Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
39 KiB
Project M — CLAUDE.md
Multiplayer game on Unity DOTS (Entities) + Netcode for Entities — server-authoritative, input-only clients, client prediction. This file is committed and is the authoritative, cross-machine source of conventions. The /dots-dev skill drives feature work; one-time stack setup lives in Docs/dots-setup-task.md.
Maintaining this file (size budget — read before editing) ★
Hard limit: 40 KB (40 960 bytes). This file is context-loaded every session — over-budget gets it truncated. Keep ≥1 KB of headroom below it (target ≤ ~39 KB). After any edit, keep it under budget:
- Size check — bash:
wc -c CLAUDE.md· PowerShell:(Get-Item CLAUDE.md).Length. Must be< 40960. - Archive, don't delete. When trimming, append the verbose / least-hot detail to the obsidian reference note
Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.mdunder a new dated heading (never overwrite an older snapshot), and leave a one-line pointer + the relevant[[DR-###]]link here. - Net-zero rule: every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them ★); depth lives in the archive + DRs.
- Condensation history → archive dated headings: 06-17 · 07-04(a–c) · 07-06 · 07-07(b,c) · 07-13 · 07-15 · 07-16 (what each paid is noted in the archive).
Stack — Unity 6.5.1 (6000.5.1f1, stable) as of 2026-06-27
| Package | Version | Notes |
|---|---|---|
com.unity.entities |
6.5.0 | Entities/Collections/Graphics track the Editor version (6.x). |
com.unity.entities.graphics |
6.5.0 | Renders entities under URP 17.5. |
com.unity.collections |
6.5.0 | (transitive) |
com.unity.netcode |
6.5.0 | Netcode for Entities (ECS). NOT com.unity.netcode.gameobjects. Unified 6.x since 6.5 (was 1.x). |
com.unity.physics |
6.5.0 | Unity Physics (DOTS). Unified 6.x since 6.5 (was 1.x). |
com.unity.charactercontroller |
1.4.2 | DOTS kinematic collide-and-slide. Declares entities/physics 1.3.15, resolves on 6.5.0 via SemVer floor; compiles+bakes on 6.5. |
com.unity.transport |
6.5.0 | (transitive) |
com.unity.burst |
1.8.29 | (transitive) |
com.unity.mathematics |
1.4.0 | (transitive) |
com.rukhanka.animation |
2.9.0 | Local pkg (Packages/com.rukhanka.animation). ECS skeletal animation (Burst CPU/GPU skinning). Resolves on 6.5.0 via SemVer floor. Netcode replication OFF → client-derived. See DR-022_Animation_Pipeline_Rukhanka_Synty. |
Values match packages-lock.json (reconciled 06-17; URP 17.5.0; ai.assistant removed 07-04). History: 6.6.0a6 transport bug — DR-002_Unity66_Alpha_Netcode_Transport + archive.
Namespaces & assembly split
Root namespace: ProjectM. Code lives under Assets/_Project/Scripts/ in four asmdefs (never create/edit .csproj/.sln; only .asmdef):
| Assembly | Namespace | Runs in | References |
|---|---|---|---|
ProjectM.Simulation |
ProjectM.Simulation |
client + server worlds | Entities, Unity.Transforms, Collections, Mathematics, Burst, Unity.Physics, Unity.NetCode |
ProjectM.Client |
ProjectM.Client |
client world only | + Simulation, Unity.Entities.Graphics, Unity.InputSystem, Unity.Transforms, Unity.NetCode, Unity.Physics + Unity.CharacterController (KinematicCharacterBody source-gen), Rukhanka.Runtime (animation) |
ProjectM.Server |
ProjectM.Server |
server world only | + Simulation, Unity.Transforms, Unity.NetCode |
ProjectM.Authoring |
ProjectM.Authoring |
bake time (+ scene runtime) | Simulation, Entities, Unity.Entities.Hybrid, Collections, Mathematics, Unity.NetCode |
- Simulation = components + systems shared by both worlds (most gameplay). Client/Server = world-specific. Authoring =
…AuthoringMonoBehaviours +Baker<T>. - Other folders:
Assets/_Project/Subscenes/(baked entity subscenes),Assets/_Project/Prefabs/,Assets/_Project/Tests/EditMode/. - Feature folders added since (
Client/UI,Client/Settings,Server/Automation,Server/Persistence,Simulation/Automation,Simulation/Persistence) live inside the existing four asmdefs — no new assemblies.
Build gotchas (distilled)
Long-form originals + the milestone each came from: Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md. The highest-recurrence hazards are flagged ★.
Assemblies, asmdefs & source-gen
Unity.Transformsmust be a DIRECT asmdef reference for any assembly whose source-gen'd systems touchLocalTransform/LocalToWorld— transitive visibility compiles hand-written code but the generator emits CS0246 in*.g.cs— SAME failure if the consuming FILE omitsusing Unity.Transforms;(source-gen copies the file's usings into*.g.cs; adding aLocalTransformquery to a system that lacked the using breaks only*.g.cs).Unity.Physicsmust ALSO be a DIRECT asmdef ref for any assembly whose source-gen touchesKinematicCharacterBody(it nestsUnity.Physics.ColliderKey) → else CS8377/CS0012 in*.g.cs(same class as the Transforms rule).- Authoring asmdefs need
Unity.Entities.Hybrid(Baker<T>) +Unity.Collections(baking source-gen). Never name a nested bakerBaker(shadowsBaker<T>) — useFooBaker. - Never name an
IComponentDataPlayerInputand don'tusing UnityEngine.InputSystem;in a file referencing such a component — collides with the managedUnityEngine.InputSystem.PlayerInput, generator bindsRefRW<…>to the class → misleading CS8377. Fully-qualify Input System types instead. - The generated Input Actions C# wrapper must live inside the consuming asmdef — set the importer's
wrapperCodePath(in.inputactions.meta) to e.g.Assets/_Project/Scripts/Client/Input/ProjectMInput.cs; the default location compiles intoAssembly-CSharpwhich asmdefs can't reference. No.inputactionsedit unless you intend a wrapper regen. IInputComponentDatarequires implementingFixedString512Bytes ToFixedString().
Burst hazards ★
- Cross-assembly generics + enums trip Burst internal compiler errors. Predicted-spawn classification (
SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>) and any enum compared inside a Bursted system are the known offenders. Make such systems plain non-BurstISystem, and store ops/schemes/region ids asbyte, neverenumin anything Bursted or in RPC payloads. - A Burst ICE corrupts the editor's incremental cache → afterward, valid
[BurstCompile]entry points log"… is not a known Burst entry point"+ run slow managed-fallback. A clean compile + green tests + working runtime confirm the code is fine. Fix = editor restart (or deleteLibrary/BurstCachewhile closed); a domain reload alone does NOT clear it. - Editing a Bursted ISystem's SystemAPI query set on an UNFOCUSED editor can leave a STALE binary → runtime
InvalidOperationException: "required component type was not declared in the EntityQuery"from an unrelatedGetSingleton<T>(Burst stack reports the OLD line number). Workaround: Burst compilation OFF for the session; permanent fix = restart. Prefer a focused editor for Burst-affecting edits.
Netcode / prediction ★
PredictedSimulationSystemGroupruns multiple times per frame on rollback → predicted systems must be deterministic/idempotent, filter with.WithAll<Simulate>(), and use no wall-clock /Time.deltaTime/System.Random.- Predicted physics is implicit — with the netcode-physics package present, Netcode relocates
PhysicsSystemGroupintoPredictedFixedStepSimulationSystemGroup(child of the predicted group, OrderFirst).NetCodePhysicsConfigonly tunes lag-comp/run-mode/history; put one in the gameplay subscene withPhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntities. - The predicted physics group is OrderFirst, so
[UpdateBefore/After(PredictedFixedStepSimulationSystemGroup)]from the parent predicted group sorts oddly:UpdateBeforeis ignored (1-tick offset, still in-sync); for same-tick put the system inside the fixed-step group[UpdateBefore(PhysicsSystemGroup)].OrderFirst/OrderLastALSO wins against[UpdateBefore/After]the predicted group from the plainSimulationSystemGroup— a server-only system there always runs after the predicted group → use[UpdateAfter(PredictedSimulationSystemGroup)], neverUpdateBefore(Unity logs "Ignoring invalid UpdateBefore…"). - Move ownerless INTERPOLATED ghosts (enemies, pickups) SERVER-ONLY in the plain
SimulationSystemGroup— they aren't predicted; the server has no rollback. StockLocalTransformreplication carries position (no hand-written[GhostField]). A contactDamageEventappended there drains the following tick (~16ms, fine for melee). PhysicsVelocityauto-replicates (Netcode ships the default variant + serializer) — drive a predicted-physics body by writingPhysicsVelocity.Linear, not by teleportingLocalTransform.- Ownerless interpolated ghost ≠ owner-predicted for buffer replication. A server-spawned ownerless ghost replicates a
[GhostField] IBufferElementDatato all clients with noOwnerSendType/ noGhostOwner— server mutations just propagate.OwnerSendType.All+GhostOwnerare only for a predicting owner to recompute its own state. - One-off shared-state actions belong on an
IRpcCommand, not a predictedInputEvent(RPCs are reliable; one-shotInputEvents — likeFire— drop under server tick-batching). RPC payloads are plain blittable scalars (int CellX/CellZ, notint2; no[GhostField]). For a SINGLE shared target resolve a server singleton — never put anEntityin the command; use ghost-id+spawn-tick (SpawnedGhostEntityMap) only for many targets. - Apply server-only RPC effects in the server
SimulationSystemGroup, NOT the predicted loop (rollback would double-apply). Mutating aDynamicBufferis not a structural change, so it's safe while iterating a different query. - A system-ordering CYCLE is INVISIBLE to plain-Entities EditMode tests (they register systems individually, unsorted) — it only throws
ComponentSystemSorter"circular dependency cycle" at world creation (Play). When you add cross-system[UpdateBefore/After], re-audit the EXISTING[Update*]attributes of the systems you order around and always Play-validate. DR-017_Persistent_Base_Player_Driven_Pacing - A dev/debug
IRpcCommandwire TYPE must be UNCONDITIONAL (no#if) — the reflection-built RpcCollection hash must match across release/dev peers or the handshake refuses;#if UNITY_EDITOR-gate only the send/receive SYSTEMS, never the request struct. Re-mean bytes, don't rename: unchanged byte VALUES keep the[GhostField]serializer identical → re-bake-free (only authoring default-value edits re-bake the subscene). - Derive enableable gates instead of replicating them. e.g. player
Dead= a LOCAL enableable derived every predicted tick from replicatedHealth<=0(rollback-correct, no[GhostEnabledBit]). To write the bit on a disabled entity the query must visit it (.WithPresent<Dead>()); bake the enableable DISABLED so instances spawn off. Respawn/death timing is server-only. - Cooldown/spawn "next tick" sentinels: route every stored tick through
TickUtil.NonZero(...)(a computedServerTick+delaycan wrap to 0, the "ready" sentinel) and compare withNetworkTick.IsNewerThan/.TicksSince, never rawuint </ subtraction. ★ A BAKED[GhostField]scheduled-tick defaults to 0 → the "invalid-tick ⇒ fire" guard that's safe for a runtime-ADDED fuse STORMS it (0 failsIsValid, falls through, fires every tick); for a baked/periodic tick INVERT it (0 = not-ready → skip/lazy-stamp) + stamp born-correct at spawn. Client cues off a periodic tick ride the ABSOLUTE tick + a value-latch + a was-counting-down arm-guard — never edge-detect the field increment (phantom-fires on0→stamp+ relevancy re-entry). See Geyser_Build_Spec. GhostRelevancyfor region splits: useGhostRelevancyMode.SetIsIrrelevant(notSetIsRelevant) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide.RegionTag{byte Region}is server-only, NOT a[GhostField]. ★ A 2nd region sharing an EXISTING tag (EnemyTag) → re-audit every query/cull over it: once-safe global despawns/cleared-checks then wipe or block cross-region (DR-031, DR-040).RelevantGhostForConnection={int Connection=NetworkId.Value; int Ghost=ghostId}. See DR-013_M6_Aether_Cycle_Region_Split.- Shared GLOBAL state (resource ledger,
RunInfo, meta tiers) rides the UNTAGGED director ghost, never a region-tagged one (SetIsIrrelevantwould hide it cross-region). Resolve the ledger via its DISTINCTResourceLedgertag (the multi-StorageEntry"multiple instances" rule). - Frontend world lifecycle (menu → on-demand worlds) ★: use
CreateClientWorld/CreateServerWorld(they register theServerWorld/ClientWorldstatics the UI reads;CreateLocalWorldwas internal pre-6.5, PUBLIC on 6.5.0); menu world viaDefaultWorldInitialization.Initialize(name, false). Never dispose/create worlds inside an ECS system — do it on a frame-boundary coroutine (SessionRunner,DontDestroyOnLoad). The gameplay subscene streams in ONLY if a netcode world is theDefaultGameObjectInjectionWorldatLoadScenetime. See DR-019_Frontend_Menu_Settings_Saves_Build.
Physics & character controller
- Unity Physics 1.x bakes built-in
UnityEnginecolliders +Rigidbody(the Physics-0.xPhysicsShapeAuthoring/PhysicsBodyAuthoringare gone). Static collider (no Rigidbody) → baked into the subscene PhysicsWorld, deterministic, no replication.Rigidbody.FreezeRotationis NOT honored by the baker — zero angular velocity + write rotation each tick, or setPhysicsMass.InverseInertia = float3.zero. AMeshColliderbakes ONLY if the mesh has Read/Write enabled — elseInvalidOperationExceptionper bake and NO baked shape (classic scene view still shows the collider; only a baked-CollisionWorldprobe catches it); flip the ModelImporterisReadable. Env-collider fidelity is tool-driven:ColliderFitTools(audit/apply) refits subscene walls/cover/landmarks to the Game.unity visuals. - The player is a Unity Character Controller kinematic character (NOT a dynamic Rigidbody; M5's
PlayerMoveSystem/PlayerPlanarConstraintSystemdeleted, predicted-physics infra kept).PlayerControlSystemmaps input →CharacterControl;CharacterProcessorcollide-and-slides in the relocatedKinematicCharacterPhysicsUpdateGroup. CC 1.4.2 API =IKinematicCharacterProcessor<T>+KinematicCharacterDataAccess+ staticKinematicCharacterUtilities.Update_*(verify withunity_reflect). KinematicCharacterUtilities.BakeCharacteraborts with aRigidbodyand needs uniform (1,1,1) scale.CharacterInterpolationmust be PredictedClient-only (aDefaultVariantSystemBasestrips it from server + interpolated prefabs) — else double-interp on remotes. Do NOT copy the CC sample's globalLocalTransform → DontSerializeVariant(project-wide; breaks non-character ghosts that rely on stockLocalTransformreplication).- Top-down CC config:
SnapToGround=false,InterpolateRotation=false(rotation owned byPlayerAimSystem),SimulateDynamicBody=false; gravity handled by feedingfloat3.zerotoUpdate_GroundPushing. - Hit/area tests must be SWEPT, not point checks — a point check tunnels when the per-tick step exceeds the target radius (high speed or tick-batching); test the segment traversed this tick. In a PLAIN
SimulationSystemGroupsystem do NOT useSystemAPI.Time.DeltaTime(wall-frame delta, not the fixed step) — store the per-tick step on the projectile (Projectile.LastStep, written in the fixed-step group) and rebuild the segment ascur - dir*LastStep.ecb.DestroyEntityat-most-once per tick (destroyed-bitset; double destroy throws at Playback). TWO target types in one pass: UNIFY into one best-target loop + one shared bitset (separate sweeps double-destroy a projectile overlapping both — DR-018). A per-hit yield(int)cast that also gates despawn is an immortal-sink (sub-1.0→0→no deposit, shot still consumed): guardmath.max(1,(int)yield)+[Min(1f)]authoring.
Build / structures / grid
- Build-grid math must be deterministic + integer-stable: corner-origin, center-returning, half-open cell bounds,
math.floor. LockCellSize/PlotSizeas a coordinate space once (BaseGridMath) — changing them invalidates placed structures. - Structures: only
Typereplicates (client derivesCell); occupancy is DERIVED from live ghosts, never baked. See DR-014_M6_Build_Structures_Automation_Foundation. - Co-op placement atomicity: commit
StorageMath.Withdraw+ cell-reservation in-place in the RPC foreach (onlyInstantiatevia ECB) so two same-tick requests for one cell can't both pass. Ledger spends generally: afford→act else SOFT-FAIL (no cooldown-burn), read LIVE in-loop (no hoist); a Health-less entity silently drops OUT of an aggro snapshot (snapshot ABOVE the early-return). - DR-051 purge (07-15) ★: siege/cycle/core/turret/automation + legacy
AbilityRefpath + onboarding DELETED (git = the archive; retired bullets → gotchas archive 07-15). Retired byte VALUES stay reserved, never renumbered (StructureType1-4,ResourceId.Charge,DebugOp3/10/11,TuningKnob20-23);DebugOp.SpawnWave/EndSiegeRE-MEANT (force-wave / quiet-arena). Waves UNGATED — a bakedWaveDirectorAuthoringdecides by placement. Sockets are THE ability model (frame loadout seeded unconditionally at spawn);CharacterId→FrameKind. DR-051_Lantern_Realignment_Purge. - Harvest: in-run nodes→PERSONAL
InventorySlot([GhostField] OwnerSendType.All, spill→ledger);G=deposit at base. Inventory/equipment PAUSED → archive 2026-06-12 + DR-026_Inventory_Equipment_Progression_Foundation. - Disk persistence (
SaveData, single-slot atomic JSON, versioned) ★: born-correct load —CycleDirectorSpawnSystem(the ledger/RunInfo/meta host) applies the menu-stagedPendingSaveAT SPAWN;BaseRestoreSystemreplays structures charge-free + HP. v7 = a FRESH EPOCH:MinLoadableVersion = CurrentVersion = 7(older saves rejected → New Game); additive again going forward.RunDirectorSystem's terminal bank is the sole autosave trigger. See DR-019_Frontend_Menu_Settings_Saves_Build + DR-051_Lantern_Realignment_Purge.
Presentation / juice / VFX
- All juice/HUD = client-only observe-only
SystemBaseinPresentationSystemGroup(once/frame, no rollback double-fire), never mutates the sim. Read ECS viaSystemAPI.Query+EntityManager.CompleteDependencyBeforeRO<T>()— NOT MonoBehaviourLateUpdate(job-safety throw).Entity= a stable client dict key per ghost lifetime — prune the cache each frame (a pruned ghost = a kill/loss → death VFX); neverDestroyEntitya ghost client-side (GhostDespawnSystemowns despawn). Hit-stop = camera punch, neverTime.timeScale. - Asset-free presentation: procedural
AudioClip.CreateSFX; runtimeParticleSystempool; code-built UI Toolkit. Prefab-asset edits:LoadPrefabContents→modify→SaveAsPrefabAsset→Unload. Watch shared-material bleed on re-tint; ACES needs URP grading mode HDR. Detail → archive 07-16. - Prototype glue lives in
ProjectM.Clientas MonoBehaviours:PrototypeCameraRig(player-following ARPG cam),VFXConfig(staticInstance+ prefab fields bridging authored VFX toCombatFeedbackSystem; keep a procedural fallback). A static presentation bridge must reset on play-enter via[RuntimeInitializeOnLoadMethod(SubsystemRegistration)](statics survive fast-enter-playmode reloads → stale flash). - UITK HUD + menus ★:
MenuUiowns the palette/factories/PanelSettings/EventSystemplumbing;HudSystem= aPresentationSystemGroupobserve-onlySystemBaseowning a runtimeUIDocument(sortingOrder 50, rootpickingMode = Ignore, tree built oncerootVisualElement != null). Runtime UITK needsPanelSettingsWITH athemeStyleSheetAND anEventSystem+InputSystemUIInputModuleor buttons are silently dead. The build palette (lazy from the clientStructureCatalog) drives click-to-place: green/redBuildPreviewMathghost →BuildPlaceRequestRPC, right-click/Esc cancel,[/]/R rotate. See DR-021_HUD_UITK_BuildPalette. - HUD skin = build-safe
HudThemeSO of serialized sprite refs (runtimeResources.Loadby name is build-stripped); tint MULTIPLIES, never setunitySlice*on 9-slices → archive 2026-07-06 + DR-024_HUD_Synty_Skin_Theme.
Art import (HDRP store packs → URP)
- BefourStudios HDRP art: convert via
EnvArtTools.cs, never switch pipelines (HDRP breaks EG); Synty = URP-native → archive 2026-07-06. - World = the LANTERN murk ★ (DR-051; Synty biomes deleted): ONE look —
PostFX_Lantern.asset(ACES; needs URP HDR grading) +Env_SeabedKit.prefab(ArtStaging-sourced) + unified RenderSettings (NO skybox; Exp² teal fog {0.02,0.10,0.12}; flat ambient {0.03,0.055,0.08}; density knob 0.035 play / 0.075 staging; camera clearFlags SolidColor deep-water — else no-skybox corners bleed blue).ScenePolicy.IsGameplayScene()gates the dynamic-look systems — never re-addscene.namestring checks.WorldAtmosphereSystem= water-column murk. - A dark-lit screenshot MASKS material bugs — verify material values (
GetPropertyType-guard beforeGetColor/GetFloat; detail → archive 07-16). - EG per-instance tint (
URPMaterialPropertyBaseColor) works ONLY on a Hybrid-Per-Instance_BaseColorgraph (AnimatedLitShader yes; stock SyntyGeneric_Basic= Unity-Per-Material → renders but silently no-ops) — check the graph first; else procedural decals. Detail → archive 07-16. - VolumeProfile.Add persistence + the URP
m_AssetVersionbuild blocker → archive 2026-07-06 (+ native memoryurp-global-settings-version-blocks-build). LocalTransform.FromPosition()resets Scale=1 — server spawners read the prefab's bakedLocalTransform, override only Position (Scale is a[GhostField]→ consistent-but-wrong).- Static decor → gameplay subscene (EG renders only baked entities); strip colliders from cosmetic props + no
GhostAuthoringon scenery (classic-URP colliders are inert to the DOTS PhysicsWorld). World collision = subscene-only ★:Environment-layer boundary ring + landmark colliders (player blocked via the layer matrix); enemies slide via a serverCollisionWorld.SphereCastinEnemyAISystem. ★ enemy slide has NO pathfinding — a near-vertical wall normal or an embedded spawn FROZE Husks on cover rocks (soft-locks a room on one leftover); fixed 07-07 viaEnemyMoveUtil.Depenetrate+ tangent-slide + anEnemyNavStatenudge backstop. Re-validate movers aren't frozen when adding Environment cover. 07-10: the nudge is COVER-AWARE — a live destructible-cover ghost (BlightCluttercarrier) SUPPRESSES the phase-through (breakable ⇒ no soft-lock); static wedges still nudge. Boundary =SM_Env_Rock_Cliffrim. See 2026-06-08_World_Collision_HUD_Scaling. - A GA "projectile" prefab self-propels — strip to particles before
Start(CombatFeedbackSystem.StripCosmetic); verify components, not the name.
Aim / facing (SoD model — DR-052) ★
PlayerFacingis body-yaw ONLY (moves→face movement; cast window→turn to aim; idle→hold; never passively track the cursor). Every gameplay direction readsFacingMath.ResolveAim(PlayerInput.Aim, facing)(all AbilityFireSystem archetypes + assist seed + melee cleave) and aim-readout presentation reads the SAME resolver; Movement-archetype sockets never open a cast window (TickWindowMath); PlayerAimSystem stays UN-gated (integrator over the snapshot-restored [GhostField]). Scheme byte KBM=0/Gamepad=1; KBM reticle re-raycasts. DR-052_SoD_Facing_Underwater_Feel + archive 2026-07-06.
Animation (Rukhanka) ★
Full rationale: DR-022_Animation_Pipeline_Rukhanka_Synty · DR-023_Enemy_Animation_MonsterMash · Synty_Asset_Inventory. Skeletal animation = Rukhanka 2.9 (the only maintained Entities-native option on 6.4). Netcode replication OFF (RUKHANKA_WITH_NETCODE undefined) → client-derived: PlayerAnimationDriveSystem (client-only SystemBase, [WorldSystemFilter(LocalSimulation|ClientSimulation)] + [UpdateBefore(RukhankaAnimationSystemGroup)]) reads replicated state and writes params via AnimatorParametersAspect/FastAnimatorParameter. No new [GhostField]s; no DefaultVariant strip (define off → ghost hash unchanged).
- The rig must bake on the SAME entity that holds the gameplay components the drive job reads —
Animator+RigDefinitionAuthoringon the player root (not a child), flatten skeleton + SMRs under it, else the drive query matches nothing. - CPU engine skins via Entities-Graphics GPU deformation → needs a deformation-aware material (
AnimatedLitShader, multi-target ShaderGraph +UniversalTarget; Synty atlas →_BaseColorMap). Stock URP/Lit renders unskinned static + a"does not support skinning"warning (NOT magenta — that's a reused HDRP sample.mat). - The 3 deformation ShaderGraphs (incl.
AnimatedLitShader) live in_Project/Shaders/— GUID-preservedMoveAssetout of the Rukhanka "Animation Samples" tree (then deleted; importing those samples drags in 26 subscenes + world-running sample systems + a TMP conflict). Detail → gotchas archive 07-04b. - First Rukhanka bake is ~60 s, main-thread-synchronous (editor freezes — not a hang; blob cached after → fast re-plays).
- The server runs Rukhanka unless you strip it — its deformation systems are
[WorldSystemFilter(Default)](⊇ ServerSimulation).ServerStripAnimationSystem(server-only one-shot) disables everyRukhanka.Runtimesystem on the server (group-disable cascades; matched by assembly name → no type ref). Only Play-validation caught this. - Build the controller via the
AnimatorControllerAPI (manage_animationdrops enum/Vector blend-tree fields). Skeleton-root = walk up from a bone to the soldier's direct child, NOTSkinnedMeshRenderer.rootBone(the bounds root — head SMR's isSpine_03→ destroys the lower skeleton). - The rig pipeline is HUMANOID (muscle clips retarget onto player + monster rigs; the old "Generic" note was WRONG). Blender clips: per-action FBX (Key All Bones + Force Start/End + FBX Units Scale); import
CreateFromThisModel(CopyFromOther FAILS on Blender's extraArmaturenode); bake root motion into pose. Optimize Game Objects OFF; root motion OFF (CC owns the transform). - ★ Skinned ATTACHMENTS onto an existing rig (07-16 suit-kit recipe): Blender rigid-skin (vgroup w=1 + armature modifier; UNHIDE the armature before selection-export or the FBX is silently skinless) →
PlayerRigTools.GraftSmrREBASES on rebind (verts→rest-world; bindposes = inverse of rigid scale-stripped rest matrices — a Blender roundtrip imports cm bones/0.01 armature, raw bindpose reuse explodes ×100;RecalculateTangents()or Rukhanka/BRG registration fails and the whole rig vanishes). Hand-written deformation shaders: DOTS-instanced_DeformedMeshIndexblock BEFORE theComputeDeformedVertex.hlslinclude + the property ALSO in the Properties block (the baker validatesHasProperty). DR-052_SoD_Facing_Underwater_Feel. - ENEMIES reuse the player pipeline — a Husk = ownerless interpolated ghost = a remote player, so
EnemyAnimationDriveSystemmirrors the REMOTE path (LocalTransformdelta velocity + prevPos cache;IsAttacking = AttackWindup != 0). Drop[RequireMatchingQueriesForUpdate]so the prune runs every frame (else a cache entry leaks per kill). Build enemy prefabs viaEnemyRigTools, GUID-preserving (DeleteAsset+CopyAssetorphans subscene refs);WaveSystemusesbaked.WithPosition(notFromPosition→ Scale reset). See DR-023_Enemy_Animation_MonsterMash.
MCP / editor workflow ★
- Edit Assets
.csONLY via MCPapply_text_edits/create_script(Unity's scripting pipeline) — the rawWritetool does NOT reliably trigger a recompile on an unfocused editor → tests/execute_coderun a stale assembly; a raw-Write-created NEW.csgets no.meta/ no test-discovery untilrefresh_unity scope=all mode=force. (Write/Editare fine for non-asset files: this vault, asmdef JSON, etc.)script_apply_editsanchor_replace(regex) +delete_methodwork even on astruct : ISystem. apply_text_editswith MULTIPLE non-adjacent edits in one call can MISALIGN — one edit per call (or strict bottom-first), always withprecondition_sha256(it returns the current SHA on mismatch). ★ One edit can SWALLOW an adjacent attribute/comment line (07-06_portalMatNRE · 07-07[RuntimeInitializeOnLoadMethod]offWorldFeelConfig.ResetDefaults→ feedback slice silently dead) — re-read neighbors after editing beside attributes; silent presentation slice → probe its config'sEnabledin-play.create_scriptwon't overwrite; full-file rewrites = whole-spanapply_text_edits(its brace-balance validator guards botched spans) ormanage_script delete+create_script(NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours).script_apply_edits replace_methodis safe for class methods but can't target astruct : ISystem. DR-017_Persistent_Base_Player_Driven_Pacingexecute_coderuns as a method body — nousingdirectives (parsed as statements); fully-qualify every type. Identify worlds byworld.Name == "ServerWorld"/"ClientWorld"(flags overlap a sharedGamebit).manage_gameobject create/manage_prefabs modify_contentscomponent_propertiesSILENTLY DROP enum + Vector3 fields — set those via a follow-upmanage_components set_propertyand VERIFY throughmcpforunity://scene/gameobject/{id}/component/{Type}(or read the baked component inexecute_codeafter Play).manage_material set_renderer_coloruses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.- New ghost prefab recipe:
manage_asset duplicatea correctly-configured ghost (UpgradePickup.prefab) → swap the authoring MB (ownerless/interpolatedGhostAuthoring+ LEG come free). Runtime-spawn shared ghosts via a one-shot server spawner (dodges the prespawn handshake); wire baked spawners viamanage_scene load additive→set_active→create→save→close_scene. Detail → archive 07-16. - An UNFOCUSED editor throttles Edit mode to near-idle (MCP pings time out, bridge looks hung — it still queues;
telemetry_pingsucceeds) and stalls EditMode test INIT (passrun_tests(init_timeout=120000), retry).Application.runInBackgroundonly helps in Play mode. Preferrefresh_unity scope=scriptsfor code-only changes. Ask the operator to focus Unity for heavy build/test/Burst sessions. - Run an adversarial design-review Workflow (netcode/relevancy · determinism/prediction · reuse/scope → synthesize) BEFORE coding a netcode-heavy slice — it has pre-caught relevancy traps, singleton collisions, dt-traps, double-destroys.
Bootstrap & worlds
ProjectM.Simulation.GameBootstrap : ClientServerBootstrapoverridesInitializewithAutoConnectPort = 0(M4 — listen/connect is explicit via theConnectionConfigsingleton + per-world ConnectionControlSystems). Editor default = instant-into-game + MPPM (createsServerWorld(WorldFlags.GameServer) +ClientWorld(WorldFlags.GameClient)); theProjectM/Boot Into Menu (Editor)EditorPref flips the MAIN editor to the frontend path. Player builds boot the UITK frontend menu (return false→ one menu world, no netcode worlds until a menu choice). See DR-019_Frontend_Menu_Settings_Saves_Build.- Scenes (the DR-051 contract — exactly these four):
MainMenu.unity(build 0, UITK frontend) ·Game.unity(build 1, the seabed arena; subsceneGameplay.unity) ·DevSandbox.unity(renamed from Gym; dev tooling + subsceneGymSub.unity; theDebugOverlay/F1-F2 dev scripts gate on this scene NAME) ·ArtStaging.unity(art viewing, no player; the look's source of truth). All share the LANTERN look (see World bullet). The on-demand lifecycle (WorldLauncher/SessionRunner/MainMenuController) creates the right worlds per menu choice (Single/Host/Join), THENLoadScene(Game)(subscene-streaming rule above). - Direction = LANTERN ★ — pivot LOCKED 2026-07-13 (DR-048_Lantern_Adoption_Full_Pivot). Co-op action-RPG, light is territory (seed-pinned pocket-graph; SoD manual-aim skillshots; suit-frames + Sparks + wild mutations). Supersedes the Awakening-Engine fiction + the Co-op Hades iteration. Operative roadmap Roadmap_Lantern_Slice; existing code (combat feel, ability/boon plumbing, run/hub lifecycle, save, regions/relevancy) is QUARRY, not foundation — keep/rework/mothball per Lantern_Strip_Mothball_Inventory. No world code until the ★review-first world-model spike (Lantern_World_Model_Spike) passes its design review. The prior co-op-Hades core-loop (ready-check multi-room RUNS;
RunDirectorSystem/BossState; DR-044/045/046) is salvage — invariants archived 07-13 in the gotchas archive. ★ general gotcha kept: a serialized prefab bool ignores the C# initializer — flip the value in the prefab.
DOTS / ECS conventions (authoritative summary)
Full rules: .claude/skills/dots-dev/references/dots-conventions.md (in-repo; travels with the repo). These replace classic MonoBehaviour/GameObject patterns.
struct : IComponentDatais the default (unmanaged, Burst/job-friendly).class : IComponentDataonly for genuine managed refs (main-thread, no Burst).IBufferElementDatafor per-entity arrays.IEnableableComponentto toggle state without a structural change.- Systems:
ISystem(struct) +[BurstCompile]is the default;SystemBaseonly when touching managed objects.SystemAPI.Query<…>()to iterate — max 7 type args; read an 8th component via aComponentLookupkeyed by the entity (hit twice in Phase 1.7 boons). Aspects (IAspect) are DEPRECATED (Entities 1.4+) — do not author new ones. - Jobs:
IJobEntity/IJobChunk; threadJobHandlethroughstate.Dependency; mark inputs[ReadOnly]. Allocators:Temp(frame),TempJob(one job),Persistent(must dispose). Burst breaks on managed types/exceptions/reflection/strings. - Structural changes (add/remove component, create/destroy entity) invalidate handles + cause sync points → batch via
EntityCommandBuffer(Begin/EndSimulationEntityCommandBufferSystem;.AsParallelWriter()in parallel jobs). - Baking:
…AuthoringMonoBehaviour +class FooBaker : Baker<FooAuthoring>→GetEntity(authoring, TransformUsageFlags.…)thenAddComponent. Subscenes stream async — entities aren't present the instant a reference exists. - Netcode: ghosts = replicated entities (
GhostAuthoringComponent+[GhostField]); predicted (player-controlled, rolled back) vs interpolated. Core sim runs inPredictedSimulationSystemGroup(fixed step, runs multiple times per frame on rollback → deterministic/idempotent; filter with.WithAll<Simulate>()). Server-authoritative: clients send input (IInputComponentData), not state. RPCs (IRpcCommand) for one-off events. No wall-clock/Time.deltaTime/System.Randomin predicted sim. - Always verify volatile DOTS/Netcode API shape via context7 at code-time — do not trust memory. Pinned IDs: Entities →
/websites/unity3d_packages_com_unity_entities_6_5_manual; Netcode →/websites/unity3d_packages_com_unity_netcode_1_10_api(closest published as of 07-07, no 6.x set yet — installed is 6.5.0; re-resolve periodically); ECS samples →/unity-technologies/entitycomponentsystemsamples.
Testing
- Default = plain-Entities EditMode test: create a
World, register the system inSimulationSystemGroup, tick, assert. Public API, version-independent. Example:Assets/_Project/Tests/EditMode/HealthApplyDamageSystemTests.cs. Run viarun_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"]). NetCodeTestWorldisinternal(6.5.0 re-check: not even loaded outside test asmdefs), exposed only to a fixed[InternalsVisibleTo]allow-list — to use it, name a test asmdef to match an entry (e.g.Unity.NetcodeSamples.EditModeTests) or vendor the test utils. Netcode world boot is covered by the Play Mode check, not a NetCodeTestWorld test. See DR-001_Netcode_Test_Harness.- Burst/source-gen errors surface at editor compile, not a plain build — always
read_consoleafter script changes, and run a play/tick test, not just a compile. Cover swept hit-detection with a tunnelling regression test (the point-check tunnel bug doesn't surface in a point-based unit test).
Guardrails
- Never edit a
.metaindependently of its asset; delete an asset and its.metatogether. - Never read/write
Library/,Temp/,obj/,Logs/,UserSettings/(generated/cache). Use MCP resources for editor state. - Never create/edit/commit
.csproj/.sln— only.asmdef. - No asset/scene edits during Play Mode. Check
editor_state.advice.ready_for_toolsbefore mutating; package adds/refreshes trigger domain reloads — wait foris_compiling=false.
Memory — three layers (which tool when)
Full protocol + per-layer detail: Documentation_Protocol (Docs/Vault/_Meta/Documentation_Protocol.md). The three layers: in-repo vault Docs/Vault/ (design docs, DRs, session logs — committed) · basic-memory MCP (semantic/wikilink recall over the vault) · native Claude memory (memory/, MEMORY.md — machine-local). (serena removed 07-07.)
- Where is X / who calls it → Grep/Glob. What did we decide / how does Z work → basic-memory → read the vault note. Current DOTS API → context7. Conventions → this file. Long-form build lessons → the gotchas archive.
- Cross-machine rule: durable truth → the vault or this file (both committed); native
memory/is local-only, never the sole home of a decision.
Per-machine setup (NOT in git — redo on each machine)
.mcp.json is committed + portable (${CLAUDE_PROJECT_DIR}); the dots-dev skill travels with the repo (.claude/skills/dots-dev/). Each machine still needs: (1) uv/uvx + Obsidian app + obsidian-cli (the unity-mcp-skill + native memory/ are machine-local, don't sync); (2) basic-memory registration — uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default then uvx basic-memory reindex --full --search --embeddings --project gamevault; (3) Unity 6.5 open + the Unity-MCP bridge connected (mcpforunity://editor/state → ready_for_tools).