Files
Project-M/CLAUDE.md
T
kronic f271d5f47f Docs: Geyser Build Spec + session log Part Q + roadmap tick + CLAUDE.md gotcha — Phase 1.5b bundle 3
Blight geyser (bundle 3) shipped review-first. Records the design-review fold
(2 HIGH: no edge-detect on the replicated tick; invert the invalid-guard for a
baked scheduled-tick so a born-0 can't storm) and the new CLAUDE.md tick-sentinel
corollary for baked [GhostField] scheduled-ticks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:53:22 -07:00

38 KiB
Raw Blame History

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.md under 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 (detail → archive dated headings): 06-17 · 07-04 · 07-04b · 07-04c · 07-06 · 07-07b (pays the attribute-swallow gotcha) · 07-07c (game-state narrative → invariants+DR pointers; ~3 KB freed, operator-ordered).

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 2026-06-17; URP 17.5.0, test-framework 1.7.0, ugui 2.5.0, multiplayer.center 1.0.1; com.unity.ai.assistant REMOVED 07-04 — console noise). 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 = …Authoring MonoBehaviours + 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.Transforms must be a DIRECT asmdef reference for any assembly whose source-gen'd systems touch LocalTransform/LocalToWorld — transitive visibility compiles hand-written code but the generator emits CS0246 in *.g.cs — SAME failure if the consuming FILE omits using Unity.Transforms; (source-gen copies the file's usings into *.g.cs; adding a LocalTransform query to a system that lacked the using breaks only *.g.cs).
  • Unity.Physics must ALSO be a DIRECT asmdef ref for any assembly whose source-gen touches KinematicCharacterBody (it nests Unity.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 baker Baker (shadows Baker<T>) — use FooBaker.
  • Never name an IComponentData PlayerInput and don't using UnityEngine.InputSystem; in a file referencing such a component — collides with the managed UnityEngine.InputSystem.PlayerInput, generator binds RefRW<…> 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 into Assembly-CSharp which asmdefs can't reference. No .inputactions edit unless you intend a wrapper regen.
  • IInputComponentData requires implementing FixedString512Bytes 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-Burst ISystem, and store ops/schemes/region ids as byte, never enum in 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 delete Library/BurstCache while 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 unrelated GetSingleton<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 ★

  • PredictedSimulationSystemGroup runs 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 PhysicsSystemGroup into PredictedFixedStepSimulationSystemGroup (child of the predicted group, OrderFirst). NetCodePhysicsConfig only tunes lag-comp/run-mode/history; put one in the gameplay subscene with PhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntities.
  • The predicted physics group is OrderFirst, so [UpdateBefore/After(PredictedFixedStepSimulationSystemGroup)] from the parent predicted group sorts oddly: UpdateBefore is ignored (1-tick offset, still in-sync); for same-tick put the system inside the fixed-step group [UpdateBefore(PhysicsSystemGroup)]. OrderFirst/OrderLast ALSO wins against [UpdateBefore/After] the predicted group from the plain SimulationSystemGroup — a server-only system there always runs after the predicted group → use [UpdateAfter(PredictedSimulationSystemGroup)], never UpdateBefore (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. Stock LocalTransform replication carries position (no hand-written [GhostField]). A contact DamageEvent appended there drains the following tick (~16ms, fine for melee).
  • PhysicsVelocity auto-replicates (Netcode ships the default variant + serializer) — drive a predicted-physics body by writing PhysicsVelocity.Linear, not by teleporting LocalTransform.
  • Ownerless interpolated ghost ≠ owner-predicted for buffer replication. A server-spawned ownerless ghost replicates a [GhostField] IBufferElementData to all clients with no OwnerSendType / no GhostOwner — server mutations just propagate. OwnerSendType.All + GhostOwner are only for a predicting owner to recompute its own state.
  • One-off shared-state actions belong on an IRpcCommand, not a predicted InputEvent (RPCs are reliable; one-shot InputEvents — like Fire — drop under server tick-batching). RPC payloads are plain blittable scalars (int CellX/CellZ, not int2; no [GhostField]). For a SINGLE shared target resolve a server singleton — never put an Entity in 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 a DynamicBuffer is 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 IRpcCommand wire 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 replicated Health<=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 computed ServerTick+delay can wrap to 0, the "ready" sentinel) and compare with NetworkTick.IsNewerThan / .TicksSince, never raw uint < / 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 fails IsValid, 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 on 0→stamp + relevancy re-entry). See Geyser_Build_Spec.
  • GhostRelevancy for region splits: use GhostRelevancyMode.SetIsIrrelevant (not SetIsRelevant) 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 (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost, never a region-tagged one (SetIsIrrelevant would hide it cross-region). Resolve the ledger via its DISTINCT ResourceLedger tag (the multi-StorageEntry "multiple instances" rule — EB-2 line).
  • Frontend world lifecycle (menu → on-demand worlds) ★: use CreateClientWorld/CreateServerWorld (they register the ServerWorld/ClientWorld statics the UI reads; CreateLocalWorld was internal pre-6.5, PUBLIC on 6.5.0); menu world via DefaultWorldInitialization.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 the DefaultGameObjectInjectionWorld at LoadScene time. See DR-019_Frontend_Menu_Settings_Saves_Build.

Physics & character controller

  • Unity Physics 1.x bakes built-in UnityEngine colliders + Rigidbody (the Physics-0.x PhysicsShapeAuthoring/PhysicsBodyAuthoring are gone). Static collider (no Rigidbody) → baked into the subscene PhysicsWorld, deterministic, no replication. Rigidbody.FreezeRotation is NOT honored by the baker — zero angular velocity + write rotation each tick, or set PhysicsMass.InverseInertia = float3.zero. A MeshCollider bakes ONLY if the mesh has Read/Write enabled — else InvalidOperationException per bake and NO baked shape (classic scene view still shows the collider; only a baked-CollisionWorld probe catches it); flip the ModelImporter isReadable. 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/PlayerPlanarConstraintSystem deleted, predicted-physics infra kept). PlayerControlSystem maps input → CharacterControl; CharacterProcessor collide-and-slides in the relocated KinematicCharacterPhysicsUpdateGroup. CC 1.4.2 API = IKinematicCharacterProcessor<T> + KinematicCharacterDataAccess + static KinematicCharacterUtilities.Update_* (verify with unity_reflect).
  • KinematicCharacterUtilities.BakeCharacter aborts with a Rigidbody and needs uniform (1,1,1) scale. CharacterInterpolation must be PredictedClient-only (a DefaultVariantSystemBase strips it from server + interpolated prefabs) — else double-interp on remotes. Do NOT copy the CC sample's global LocalTransform → DontSerializeVariant (project-wide; breaks non-character ghosts that rely on stock LocalTransform replication).
  • Top-down CC config: SnapToGround=false, InterpolateRotation=false (rotation owned by PlayerAimSystem), SimulateDynamicBody=false; gravity handled by feeding float3.zero to Update_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 SimulationSystemGroup system do NOT use SystemAPI.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 as cur - dir*LastStep. ecb.DestroyEntity at-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): guard math.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. Lock CellSize/PlotSize as a coordinate space once (BaseGridMath) — changing them invalidates placed structures.
  • Structures: bake the tick fields (catch-up linchpin); only Type replicates (client derives Cell); 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 (only Instantiate via ECB) so two same-tick requests for one cell can't both pass.
  • Siege-era systems ★ (EB-1/2 · END-1/2 — mothball-bound at Phase 2; these INVARIANTS outlive them): ★ a Health-less machine silently drops OUT of the aggro snapshot (snapshot ABOVE the early-return; aggro weight <1 gets SQUARED) · spend from the ONE GetSingletonEntity<ResourceLedger>, afford→act else SOFT-FAIL (no cooldown-burn), read LIVE in-loop (no hoist) · CyclePhaseSystem = SOLE Phase writer (loss edges live there; transient flags, NOT latching) · RunOutcome{[GhostField] byte} is REPLICATED — never client-derive the win/lose banner. DR-032_EB1_Machines_Can_Die · DR-033_EB2_Felt_Spend_Charge_Economy · DR-034_END1_Losable_Core · DR-036_END2_Final_Siege_Win_Lose.
  • GoalProgress{[GhostField] int Charge,Target} (win meter — ≠ the ResourceId.Charge ammo) rides the CycleDirector ghost; buffs/tiers reuse StatModifierEffectiveAbilityStats.
  • Automation (server-only): catch-up ProductionMath.CyclesDue lower-bound 0; RuntimePlacedTag=player-built. DR-020_M7_Automation_Production_Chains.
  • 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/additive) ★: born-correct loadCycleDirectorSpawnSystem stages PendingSave AT SPAWN; BaseRestoreSystem replays structures charge-free + REMAINING-tick cooldowns + HP. SaveService.Load = additive floor [MinLoadableVersion=2, Current] (old saves load; missing field 0-defaults). See DR-019_Frontend_Menu_Settings_Saves_Build.

Presentation / juice / VFX

  • All juice/HUD = client-only observe-only SystemBase in PresentationSystemGroup (once/frame, no rollback double-fire), never mutates the sim. Read ECS via SystemAPI.Query + EntityManager.CompleteDependencyBeforeRO<T>() — NOT MonoBehaviour LateUpdate (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); never DestroyEntity a ghost client-side (GhostDespawnSystem owns despawn). Hit-stop = camera punch, never Time.timeScale.
  • Asset-free presentation: procedural AudioClip.Create SFX; runtime ParticleSystem pool (Sprites/Default + HDR start color); code-built UI Toolkit HUD/menus. Edit a prefab asset's component in code via PrefabUtility.LoadPrefabContents → modify → SaveAsPrefabAsset(root, path)UnloadPrefabContents. Watch shared-material bleed when re-tinting. ACES tonemapping needs URP color grading mode = HDR (m_ColorGradingMode=1).
  • Prototype glue lives in ProjectM.Client as MonoBehaviours: PrototypeCameraRig (player-following ARPG cam), VFXConfig (static Instance + prefab fields bridging authored VFX to CombatFeedbackSystem; 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 ★: MenuUi owns the palette/factories/PanelSettings/EventSystem plumbing; HudSystem = a PresentationSystemGroup observe-only SystemBase owning a runtime UIDocument (sortingOrder 50, root pickingMode = Ignore, tree built once rootVisualElement != null). Runtime UITK needs PanelSettings WITH a themeStyleSheet AND an EventSystem + InputSystemUIInputModule or buttons are silently dead. The build palette (lazy from the client StructureCatalog) drives click-to-place: green/red BuildPreviewMath ghost → BuildPlaceRequest RPC, right-click/Esc cancel, [/]/R rotate. See DR-021_HUD_UITK_BuildPalette.
  • HUD skin = build-safe HudTheme SO of serialized sprite refs (runtime Resources.Load by name is build-stripped); tint MULTIPLIES, never set unitySlice* 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 = cosmetic Synty nature biomes ★ (DR-025): Game.unity roots BaseBiome(Meadow_Forest)@origin + ExpeditionBiome(Arid_Desert)@+1000; ground = stock URP/Lit (NOT prop-atlas S_General). Per-region fog/ambient cross-fade via client WorldAtmosphereSystem (camera X>500). PNB fog/cloud-ring prefabs = white torus — don't place. See DR-025_World_Environment_Redo_Natural_Frontier.
  • A dark-lit screenshot MASKS material bugs — verify material values. shader.GetPropertyType(idx)-guard before GetColor/GetFloat/GetTexture (S_General's _BaseColorMultiply is a float → GetColor returns black). Gate emission on the _Emissive flag + a fixture name; keep converted env metallic low (0.10.2).
  • An EG per-instance tint (URPMaterialPropertyBaseColor) darkens a ghost ONLY if the shader's _BaseColor is Hybrid-Per-Instance (ShaderGraph overrideHLSLDeclaration:true+hlslDeclarationOverride:2). Enemies flash because AnimatedLitShader is DOTS-authored; a stock Synty prop graph (Generic_Basic) is Unity-Per-Material → the override RENDERS but silently no-ops. Check the graph before planning a per-instance tint on a baked/prop mesh; else use procedural decal quads (07-12 cover damage-cracks, Part P).
  • VolumeProfile.Add persistence + the URP m_AssetVersion build blocker → archive 2026-07-06 (+ native memory urp-global-settings-version-blocks-build).
  • LocalTransform.FromPosition() resets Scale=1 — server spawners read the prefab's baked LocalTransform, 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 GhostAuthoring on 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 server CollisionWorld.SphereCast in EnemyAISystem. ★ 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 via EnemyMoveUtil.Depenetrate + tangent-slide + an EnemyNavState nudge backstop. Re-validate movers aren't frozen when adding Environment cover. 07-10: the nudge is COVER-AWARE — a live destructible-cover ghost (BlightClutter carrier) SUPPRESSES the phase-through (breakable ⇒ no soft-lock); static wedges still nudge. Boundary = SM_Env_Rock_Cliff rim. 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 controls

  • Client-derived aim on PlayerInput.Aim; scheme byte KBM=0/Gamepad=1; reticle re-raycasts inside AimReticleSystem → full detail: archive 2026-07-06 section + the source files.

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 readsAnimator + RigDefinitionAuthoring on 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-preserved MoveAsset out 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 every Rukhanka.Runtime system on the server (group-disable cascades; matched by assembly name → no type ref). Only Play-validation caught this.
  • Build the controller via the AnimatorController API (manage_animation drops enum/Vector blend-tree fields). Skeleton-root = walk up from a bone to the soldier's direct child, NOT SkinnedMeshRenderer.rootBone (the bounds root — head SMR's is Spine_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 extra Armature node); bake root motion into pose. Optimize Game Objects OFF; root motion OFF (CC owns the transform).
  • ENEMIES reuse the player pipeline — a Husk = ownerless interpolated ghost = a remote player, so EnemyAnimationDriveSystem mirrors the REMOTE path (LocalTransform delta velocity + prevPos cache; IsAttacking = AttackWindup != 0). Drop [RequireMatchingQueriesForUpdate] so the prune runs every frame (else a cache entry leaks per kill). Build enemy prefabs via EnemyRigTools, GUID-preserving (DeleteAsset+CopyAsset orphans subscene refs); WaveSystem uses baked.WithPosition (not FromPosition → Scale reset). See DR-023_Enemy_Animation_MonsterMash.

MCP / editor workflow ★

  • Edit Assets .cs ONLY via MCP apply_text_edits / create_script (Unity's scripting pipeline) — the raw Write tool does NOT reliably trigger a recompile on an unfocused editor → tests/execute_code run a stale assembly; a raw-Write-created NEW .cs gets no .meta / no test-discovery until refresh_unity scope=all mode=force. (Write/Edit are fine for non-asset files: this vault, asmdef JSON, etc.) script_apply_edits anchor_replace (regex) + delete_method work even on a struct : ISystem.
  • apply_text_edits with MULTIPLE non-adjacent edits in one call can MISALIGN — one edit per call (or strict bottom-first), always with precondition_sha256 (it returns the current SHA on mismatch). ★ One edit can SWALLOW an adjacent attribute/comment line (07-06 _portalMat NRE · 07-07 [RuntimeInitializeOnLoadMethod] off WorldFeelConfig.ResetDefaults → feedback slice silently dead) — re-read neighbors after editing beside attributes; silent presentation slice → probe its config's Enabled in-play. create_script won't overwrite; full-file rewrites = whole-span apply_text_edits (its brace-balance validator guards botched spans) or manage_script delete+create_script (NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours). script_apply_edits replace_method is safe for class methods but can't target a struct : ISystem. DR-017_Persistent_Base_Player_Driven_Pacing
  • execute_code runs as a method body — no using directives (parsed as statements); fully-qualify every type. Identify worlds by world.Name == "ServerWorld"/"ClientWorld" (flags overlap a shared Game bit).
  • manage_gameobject create / manage_prefabs modify_contents component_properties SILENTLY DROP enum + Vector3 fields — set those via a follow-up manage_components set_property and VERIFY through mcpforunity://scene/gameobject/{id}/component/{Type} (or read the baked component in execute_code after Play). manage_material set_renderer_color uses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.
  • New ghost prefab recipe: manage_asset duplicate an existing correctly-configured ghost (e.g. UpgradePickup.prefab) → manage_prefabs modify_contents to swap the authoring MonoBehaviour (strip MeshFilter+MeshRenderer for an invisible state-holder) — its ownerless/interpolated GhostAuthoringComponent + LinkedEntityGroupAuthoring come free. Runtime-spawn shared ghosts via a one-shot server spawner (dodges the prespawn handshake); wire a baked spawner into the subscene via manage_scene load additiveset_active_scene Gameplay → create+verify → saveclose_scene.
  • An UNFOCUSED editor throttles Edit mode to near-idle (MCP pings time out, bridge looks hung — it still queues; telemetry_ping succeeds) and stalls EditMode test INIT (pass run_tests(init_timeout=120000), retry). Application.runInBackground only helps in Play mode. Prefer refresh_unity scope=scripts for 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 : ClientServerBootstrap overrides Initialize with AutoConnectPort = 0 (M4 — listen/connect is explicit via the ConnectionConfig singleton + per-world ConnectionControlSystems). Editor default = instant-into-game + MPPM (creates ServerWorld (WorldFlags.GameServer) + ClientWorld (WorldFlags.GameClient)); the ProjectM/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: Assets/Scenes/MainMenu.unity (build index 0) boots the UITK frontend (menu world only); Assets/Scenes/Game.unity (index 1) holds gameplay with Assets/_Project/Subscenes/Gameplay.unity wired in as the baked subscene (GameObject GameplaySubScene). SampleScene/DevSandbox are kept as reference/dev scenes. The on-demand lifecycle (WorldLauncher/SessionRunner/MainMenuController) creates the right worlds per menu choice (Single/Host/Join), THEN LoadScene(Game) (subscene-streaming rule above).
  • Core loop = ready-check multi-room RUNS ★ (DR-044/045/046): base = pure SPEND hub. RunDirectorSystem = sole RunInfo writer ([UpdateBefore(CyclePhaseSystem)]; nothing else in the room chain touches CyclePhase); banks Charge+retaliation once-per-RunEpoch on boss-clear ONLY; strips the boon band + BoonOffers on exit; teleports ONLY launch-stamped RunParticipants (released on Returning). Rooms spawn per RoomEpoch, RoomTag-torn-down, gate on a PORTAL (RunLifecycle.RoomExplore=6 loot window; PortalInteractRequest→server PortalCommand). Class + per-run PREP spent at base (Staging-gated RPCs via shared ClassSwapUtil; prep StatModifier band stripped on Returning). Health.Max is a [GhostField]. Boss = server-only BossState/BossAISystem (sole boss mover). ★ a serialized prefab bool ignores the C# initializer — flip the value in the prefab. Feel/tuning detail → DR-044_Expedition_Redesign_Shipped_Demo_Polish · DR-045_Combat_Demo_Feel_Boss_Fight · DR-046_Base_Expedition_Ties_Portal_Class_Prep.

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 : IComponentData is the default (unmanaged, Burst/job-friendly). class : IComponentData only for genuine managed refs (main-thread, no Burst). IBufferElementData for per-entity arrays. IEnableableComponent to toggle state without a structural change.
  • Systems: ISystem (struct) + [BurstCompile] is the default; SystemBase only when touching managed objects. SystemAPI.Query<…>() to iterate. Aspects (IAspect) are DEPRECATED (Entities 1.4+) — do not author new ones. Entities.ForEach is legacy.
  • Jobs: IJobEntity / IJobChunk; thread JobHandle through state.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: …Authoring MonoBehaviour + class FooBaker : Baker<FooAuthoring>GetEntity(authoring, TransformUsageFlags.…) then AddComponent. 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 in PredictedSimulationSystemGroup (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.Random in 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 in SimulationSystemGroup, tick, assert. Public API, version-independent. Example: Assets/_Project/Tests/EditMode/HealthApplyDamageSystemTests.cs. Run via run_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"]).
  • NetCodeTestWorld is internal (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_console after 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 .meta independently of its asset; delete an asset and its .meta together.
  • 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_tools before mutating; package adds/refreshes trigger domain reloads — wait for is_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 2026-07-07 — unused in practice + flaky on Unity.)

  • 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/stateready_for_tools).