Files
Project-M/CLAUDE.md
T
kronic 3109b86d71 Slice 3: Expedition Combat Spine — epoch-seeded zone waves (DR-040)
Reactivate the dormant Expedition region as a procedural combat venue.
v1 loop: walk the gate -> fight an epoch-seeded enemy wave in the
expedition -> clear -> return -> flat Ore reward (once per epoch) ->
escalated retaliation base siege.

- New sim types: ZoneEnemyTag, ZoneEnemyDirector (+ ZoneEnemyPrefab
  buffer), ZoneEnemyState, ZoneEnemyMath (grunt->charger composition
  by epoch). ZoneEnemyDirectorSystem (server, Burst): drip-spawns the
  wave at a deterministic ring under a MaxAlive cap while a player is
  out and the base is Calm; marks ClearedThisEpoch on a real clear.
  [UpdateAfter(ExpeditionFieldSystem)] only (avoids a sort cycle).
- BLOCKER 1: EnemyAISystem region-filters target selection (player +
  structure snapshots gain parallel region lists; no base structures /
  no Core fallback for expedition husks).
- BLOCKER 3: WaveSystem, ThreatDirectorSystem timeout cull, and
  CyclePhaseSystem DefendCleared + Core-breach cull all count/cull
  RegionTag{Base} husks only (the breach cull was caught region-blind
  by the post-impl review: a base breach wiped the live expedition
  wave and spuriously paid the reward).
- BLOCKER 4: reward de-duped via CycleRuntime.LastRewardedEpoch +
  ClearedThisEpoch; ExpeditionGateSystem deposits RewardOre once/epoch.
- ExpeditionFieldSystem teardown also culls zone enemies + region-
  guards the clutter loop. Subscene wired with the director + roster.

368/368 EditMode green + clean netcode Play smoke. Docs: DR-040 ->
built, session log, CLAUDE.md cross-region tag-reaudit rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:58:26 -07:00

40 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. Design rationale already lives in the per-milestone DRs (Docs/Vault/07_Sessions/_Decisions/DR-###).
  • 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: 06-04 → 06-17 (M1END-2 long-form + 6.5 stack swap → archive).

Stack — Unity 6.5.0 (6000.5.0f1, stable) as of 2026-06-17

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). History: 6.4.7→6.5.0 (2026-06-17) put DOTS on unified 6.5 versioning (netcode/physics/transport left their old 1.x/2.x lines) — validated green (342/342 EditMode + a clean netcode Play boot: connect/ghost-sync/player-spawn), so the 6.6.0a6 "invalid wrapped network interface" transport bug does NOT hit 6.5-stable. See DR-002_Unity66_Alpha_Netcode_Transport + the gotchas 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.
  • 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>, takes ref DynamicBuffer<SnapshotDataBuffer> in 1.13.2) 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.
  • 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) ★: CreateLocalWorld is internal in 1.13.2 — use public CreateClientWorld/CreateServerWorld (they register the ServerWorld/ClientWorld statics the UI reads); 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.
  • 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.
  • PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick} on an ownerless interpolated ghost. Bake the tick fields (catch-up linchpin); only Type replicates (client derives Cell). Occupancy is DERIVED by scanning live ghosts into a Temp NativeHashSet<int2>, 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.
  • EB-1 machines can die ★ (DR-032): structures bake Health([GhostField])+DamageEvent+a Destructible tag; HealthApplyDamageSystem destroys a Destructible at 0 (NOT bare PlacedStructure; occupancy auto-frees). EnemyAISystem fortress-targets weighted-nearest players+structures (EnemyAIMath.PickWeightedNearest; snapshot ABOVE the early-return; StructureAggroWeight<1, SQUARED). See DR-032_EB1_Machines_Can_Die.
  • EB-2 felt spend ★ (DR-033): turret ammo = shared Charge(ResourceId 4) on the [GhostField] StorageEntry ledger. TurretFireSystem spends from the ONE GetSingletonEntity<ResourceLedger> (NEVER GetSingleton<StorageEntry>): afford→fire+cooldown, else SOFT-FAIL (no cooldown-burn). Fabricator.InputFromLedger reads the ledger LIVE in-loop (no hoist → machines split a finite pool). See DR-033_EB2_Felt_Spend_Charge_Economy.
  • END-1 losable Core ★ (DR-034): CoreIntegrity{[GhostField] int Current,Max; uint OverrunTick} on the GLOBAL CycleDirector ghost. CoreDamageSystem/CoreRestoreSystem (server): a Husk near PlotCenter drains+despawns; regen ONLY in Calm. SOFT-loss edge IN CyclePhaseSystem (sole Phase writer): Current<=0 in Siege → Calm (NO reward; drain+despawn; transient OverrunTick, NOT latching). Core = EnemyAISystem FALLBACK target. SaveData v4. See DR-034_END1_Losable_Core.
  • END-2 win/lose ★ (DR-036): terminal run on the CycleDirector — server-only RunPhase (writer GoalReachedSystem, after CyclePhase) + REPLICATED RunOutcome{[GhostField] byte} (writer CyclePhaseSystem; replicate for the banner, do NOT client-derive). GoalReached arms a final siege ×FinalSiegeMultiplier at Charge>=Target (once)+FinalDefense; latch Victory/Loss+halt; SiegeTimeout OFF in the final; SaveData v5. See DR-036_END2_Final_Siege_Win_Lose.
  • GoalProgress{[GhostField] int Charge,Target} (the goal meter — ≠ EB-2 ResourceId.Charge ammo) rides the CycleDirector ghost. Resource-gated ability tiers/buffs reuse StatModifier (StatRecomputeSystemEffectiveAbilityStats).
  • M7 Automation (server-only) ★: Harvester/Conveyor TRIMMED from the palette (code intact), Fabricator LIVE (EB-2); plain server group; catch-up ProductionMath.CyclesDue (lower-bound 0); RuntimePlacedTag=player-built. See DR-020_M7_Automation_Production_Chains.
  • Harvest routes by node region (DR-031) ★; inventory/equipment PAUSED: BASE→shared ResourceLedger, Expedition/un-tagged→PERSONAL InventorySlot ([GhostField] OwnerSendType.All, spill→ledger); G=deposit. Items/equip (ItemDatabase blob, EquipSystem) — full detail in the gotchas archive (2026-06-12). See DR-026_Inventory_Equipment_Progression_Foundation · DR-031_Base_Mining_Loop_Cohesion.
  • Disk persistence (SaveData, single-slot atomic JSON, versioned/additive) ★: born-correct loadCycleDirectorSpawnSystem stages PendingSave AT SPAWN; BaseRestoreSystem replays structures charge-free + REMAINING-tick cooldowns + per-structure 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.
  • Synty HUD skin via a build-safe HudTheme ★ (DR-024): a runtime name-string Resources.Load of Synty sprites is build-stripped → use a curated HudTheme : ScriptableObject of serialized refs (HudTheme.Get() null-safe, flat fallback). unityBackgroundImageTintColor MULTIPLIES; don't set unitySlice* on 9-slice sprites (per-element ERROR); Synty sprites may import as MultipleLoadAssetAtPath<Sprite> null. See DR-024_HUD_Synty_Skin_Theme.

Art import (HDRP store packs → URP)

  • BefourStudios art is HDRP-authored → magenta under URP 17.4 + Entities Graphics. Convert, don't switch pipelines (HDRP breaks EG): re-author to stock URP/Lit via EnvArtTools.cs (menu ProjectM/Art/1. Convert Curated Env Materials). Synty art is URP-native — no conversion.
  • 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).
  • VolumeProfile.Add<T>() does NOT persist (serializes {fileID:0}) — use AssetDatabase.AddObjectToAsset(comp, profile) + SaveAssets, verify on disk.
  • A reverted engine/URP upgrade can stamp URPGlobalSettings.asset m_AssetVersion AHEAD of the package's k_LastVersion (11>10, from the reverted 6.6 alpha); URP migrates forward-only so URPPreprocessBuild rejects it ("not at last version") — blocks player builds, not editor Play. Fix: reflection-set m_AssetVersion back to k_LastVersion + SaveAssets.
  • 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 cosmetic colliders are inert to the DOTS PhysicsWorld). World collision = subscene-only ★: Environment-layer boundary ring + landmark box colliders (player blocked via the default layer matrix); enemies slide via a server CollisionWorld.SphereCast in EnemyAISystem (filter=WorldCollisionConfig.EnvironmentMask). Boundary = a height-gated SM_Env_Rock_Cliff bowl rim, flat walkable interior. See 2026-06-08_World_Collision_HUD_Scaling.
  • A GA "projectile" prefab self-propels (non-kinematic Rigidbody+collider+ProjectileMoveScript) — strip to particles before Start (CombatFeedbackSystem.StripCosmetic). Verify components, not the name.

Aim controls

  • Client-derived aim rides the EXISTING PlayerInput.Aim [GhostField] — mouse-cursor aim computed in PlayerInputGatherSystem (managed SystemBase, GhostInputSystemGroup): Mouse.current.positionCamera.main.ScreenPointToRayAimMath.PlanarAimFromRay (pure, unit-tested) → player→cursor direction. Only the direction crosses the wire; strafe-while-aiming is free (Move already decoupled from Aim).
  • Active scheme = last-meaningful-actuation-wins, replicated as byte (PlayerInput.Scheme, KBM=0/Gamepad=1 — byte because compared in Bursted AbilityFireSystem). Server gates the AutoTarget cone to gamepad only → precise mouse, gamepad-only assist.
  • Cursor/reticle = client PresentationSystemGroup SystemBase (AimReticleSystem) that OBSERVES. Re-raycast the KBM ground point INSIDE it (it runs after the follow-cam's LateUpdate; latching from the gather drifts a frame behind). Hardware cursor hidden while aiming + focused, restored on focus-loss.

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 — put Animator + RigDefinitionAuthoring on the player root (not a child) and flatten the skeleton + SMRs under it, else the single-entity drive query matches nothing.
  • CPU engine still skins via Entities-Graphics GPU deformation → needs a deformation-aware material (AnimatedLitShader, a multi-target ShaderGraph with a UniversalTarget; Synty atlas → its _BaseColorMap). Stock URP/Lit renders unskinned static + a "does not support skinning" warning (NOT magenta — that's a reused HDRP sample .mat).
  • Importing the Rukhanka "Animation Samples" (the only source of AnimatedLitShader) drags in 26 sample subscenes (one NRE's the unguarded clip baker), sample systems that run in your worlds, and a conflicting TextMesh Pro folder. Fix: MoveAsset the 3 deformation ShaderGraphs to _Project/Shaders/ (GUID-preserving), then delete the samples tree.
  • First Rukhanka bake is ~60 s, synchronous on the main thread (editor freezes → looks like a hang, isn't); the animation blob is 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 — the head SMR's is Spine_03; using it destroys the lower skeleton).
  • Synty Polygon characters share one Generic skeleton; the FBX needs Optimize Game Objects OFF (Rukhanka requirement). Entity origin = capsule center (~1 m up) → offset the un-keyed Root bone local Y (Rukhanka bakes it as a constant → it persists through clips). Root motion OFF (the CC owns the transform; blend tree is velocity-driven).
  • ENEMIES reuse the player pipeline — a Husk is an ownerless interpolated ghost = a remote player, so EnemyAnimationDriveSystem mirrors the REMOTE path (LocalTransform delta velocity + prevPos cache; facing via AnimParamMath.PlanarForward; maxSpeed from EnemyStats; IsAttacking = AttackWindup != 0). Drop [RequireMatchingQueriesForUpdate] so the prune runs every frame (else a cache entry leaks per kill). Build enemy prefabs via the EnemyRigTools editor tool, GUID-preserving (DeleteAsset+CopyAsset orphans subscene refs); WaveSystem uses baked.WithPosition (not FromPosition → resets Scale).

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). 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 is base-local ★ (DR-031): BaseFieldSpawnSystem (server) tops up RegionTag{Base} Ore nodes around BaseGridMath.PlotCenter (DISTINCT BaseFieldSpawner singleton; SetComponent-override Region+Ore — Add throws; Ore-only). Scheduled base sieges via ThreatDirectorSystem's reserved Schedule source (ScheduleEnabled/Interval/SizePerWave on CycleDirectorAuthoring) need NO expedition trip; ExpeditionFieldSystem teardown region-filtered Expedition-only (else it wipes the base field). The now-dormant expedition still lives at base+(1000,0,0), hidden per-connection via GhostRelevancy. See DR-031_Base_Mining_Loop_Cohesion · DR-013_M6_Aether_Cycle_Region_Split.

DOTS / ECS conventions (authoritative summary)

Full rules: ~/.claude/skills/dots-dev/references/dots-conventions.md (Windows: %USERPROFILE%\.claude\skills\dots-dev\references\). 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; we run 1.13.2 — re-resolve if a 1.13 set appears); 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/HeartbeatSystemTests.cs. Run via run_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"]).
  • NetCodeTestWorld is internal in netcode 1.13.2, 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 — four layers (which tool when)

Full protocol + per-layer detail: Documentation_Protocol (Docs/Vault/_Meta/Documentation_Protocol.md). The four layers: in-repo vault Docs/Vault/ (design docs, DRs, session logs — committed) · basic-memory MCP (semantic/wikilink recall over the vault) · serena MCP (C# symbol nav of Assets/_Project/) · native Claude memory (memory/, MEMORY.md — machine-local).

  • Where is X / who calls it → serena (fallback Grep/Glob). What did we decide / how does Z work → basic-memory → read the vault note. Literal string / asset GUID → Grep/Glob. 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. serena C# caveat: flaky on Unity — if find_symbol stalls, fall back to Glob/Grep.

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.4 open + the Unity-MCP bridge connected (mcpforunity://editor/stateready_for_tools).