Files
Project-M/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md
T
2026-06-07 22:29:25 -07:00

87 KiB
Raw Blame History

title, type, created, permalink
title type created permalink
CLAUDE.md Build-Gotchas Archive reference 2026-06-04 gamevault/meta/claude-build-gotchas-archive

CLAUDE.md Build-Gotchas Archive

This note holds the full, verbose build-gotcha entries that were condensed out of the committed CLAUDE.md to keep that file under its 40 KB context-load limit. The condensed one-liners in CLAUDE.md link here and to the per-milestone Decision Records (Docs/Vault/07_Sessions/_Decisions/DR-###). Nothing was deleted — this is the long-form source of truth for the operational lessons; the DRs carry the design rationale.

Condensation passes:

  • 2026-06-04 (first pass) — M1M6 long-form build lessons → the milestone sections below.
  • 2026-06-07 (second pass) — the newer M7+/HUD/animation distilled bullets were further tightened in CLAUDE.md; a full verbatim snapshot of the pre-2026-06-07 CLAUDE.md is appended at the very bottom of this note (## 2026-06-07 — Pre-trim CLAUDE.md snapshot) so every removed detail stays recoverable.

When a gotcha here is later proven wrong or superseded, strike it through and note the superseding DR rather than deleting it.


Version history & stack status

Reconciled 2026-06-02: manifest.json pins aligned to the resolved Unity 6.4.7 lock (entities/entities.graphics 6.4.0, URP 17.4.0, test-framework 1.6.0, ugui 2.0.0, multiplayer.center 1.0.1) — a no-op re-resolve (lock unchanged, console clean). The values in the CLAUDE.md stack table match packages-lock.json. See DR-008_M5_HomeBase_BaseLayer_Storage.

Version history & status (2026-05-30): built on 6.4.7 (6000.4.7f1; Netcode 1.13.2 / Physics 1.4.6 / Entities 6.4.0). Briefly upgraded to 6.6.0a6, where Netcode→6.6.0, Physics→6.5.0, Entities→6.5.0 all renumbered into the editor line — BUT the alpha's Netcode/Transport runtime is broken (all in-editor connections fail with "invalid wrapped network interface"; confirmed engine bug via a zero-gameplay repro — see Docs/Vault DR-002 and Docs/UnityBugReport-Netcode-Transport-6.6.0a6.md). → Reverting to Unity 6.4.7 for stable netcode runtime. If returning to 6.6 later, expect the renumber and re-test the runtime. The M1 player slice should port to 6.4 / Netcode 1.13.2 with no or minimal changes — recompile and read_console after the downgrade.


Build gotchas (learned — M1, 2026-05-30)

  • Unity.Transforms must be a DIRECT asmdef reference for any assembly whose source-gen'd systems use LocalTransform/LocalToWorld. It is its own assembly; transitive visibility compiles your hand-written code but the Entities generator emits CS0246 inside the *.g.cs.
  • Authoring asmdefs need Unity.Entities.Hybrid (defines Baker<T>) and Unity.Collections (baking source-gen). A nested baker class must not be named Baker (it shadows Baker<T> → CS0308/CS0246) — name it FooBaker.
  • Never name an IComponentData PlayerInput, and don't using UnityEngine.InputSystem; in a file that references such a component: it collides with UnityEngine.InputSystem.PlayerInput, and the Entities generator binds RefRW<…> to the managed class → a misleading CS8377 "must be a non-nullable value type". Fully-qualify Input System types (UnityEngine.InputSystem.Keyboard.current) instead.
  • IInputComponentData requires implementing FixedString512Bytes ToFixedString().
  • An input-gather system that reads the managed Input System belongs in GhostInputSystemGroup as a non-Burst ISystem (or SystemBase), never inside the prediction loop.

Build gotchas (learned — M2 combat, 2026-05-31)

  • The generated Input Actions C# wrapper must live inside an asmdef any system needs to reference. By default it generates next to the .inputactions (e.g. Assets/Settings/), which has no asmdef → it compiles into Assembly-CSharp, and asmdef assemblies (ProjectM.Client) cannot reference that. Fix: set the importer's wrapperCodePath (in the .inputactions.meta) to a path inside the consuming asmdef, e.g. Assets/_Project/Scripts/Client/Input/ProjectMInput.cs, and delete the old generated file. Read the action map via a managed SystemBase holding the wrapper; gather Fire as a netcode InputEvent (reset the field each frame, .Set() on the press edge — netcode latches the absolute Count into the command buffer; the live component value is only the per-tick delta).
  • Predicted-spawn classification cannot be [BurstCompile]d (Netcode 1.13.2). The cross-assembly generic Unity.NetCode.LowLevel.SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>() trips a Burst internal compiler error (type-hash resolution). Make the classifier a plain non-Burst ISystem (it only runs when spawns are received — cold path). In 1.13.2 that method takes ref DynamicBuffer<SnapshotDataBuffer> (the public HelloNetcode sample's by-value data is from an older version).
  • A Burst internal compiler error corrupts the editor's Burst incremental cache. After the error is fixed in code, newly-added [BurstCompile] entry points (systems and generated ghost-component serializers) keep logging "... is not a known Burst entry point" and run managed-fallback (slow → server tick-batching, ~3040s play-enter). A clean compile + green tests + working runtime confirm the code is fine. Clear it with an editor restart (or delete Library/BurstCache while closed) — a domain reload alone does not.
  • Projectile/area hit tests must be swept, not point checks. A point distance check tunnels straight through a target when the per-tick step exceeds the target radius — at high projectile speed or whenever the server tick-batches under load. Test the segment the projectile traversed this tick ([curPos - dir*speed*dt, curPos]) against each target; order the damage system [UpdateAfter(MoveSystem)]. (Caught at runtime, not by a point-based unit test — cover hit detection with a tunnelling regression test.)
  • In-editor input injection needs a focused Game view — unless you change two settings. By default the Input System ignores injected/real device input while the Game view is unfocused, so headless (MCP execute_code) keypress simulation won't drive IInputComponentData. Fix (both now set in this project): InputSettings.editorInputBehaviorInPlayMode = AllDeviceInputAlwaysGoesToGameView + Application.runInBackground = true. For deterministic, device-independent validation prefer the editor-only DebugInputInjectionSystem (ProjectM.Client, #if UNITY_EDITOR): poke its statics from execute_codeDebugInputInjectionSystem.Fire() / .SetMove(x,z) / .SetAim(x,z) / .Stop() — to drive the local player's PlayerInput through the authentic command→prediction pipeline. (Validated: SetMove drives + replicates movement. One-shot Fire propagation needs a healthy editor — tick-batching under a degraded/corrupt-Burst editor drops one-shot InputEvents while continuous values survive.)
  • Prototype presentation glue lives in ProjectM.Client as MonoBehaviours. PrototypeCameraRig (on the Main Camera) is a tunable player-following ARPG cam (default mid 3/4 ~45° perspective) that reads the local player ghost's LocalTransform each LateUpdate. Bright prototype URP-Lit materials are in Assets/_Project/Materials/ (player cyan, dummy red, projectile yellow, ground grey). ProjectM.Client now references Unity.Transforms directly (the rig reads LocalTransform).

Build gotchas (learned — M5 physics-in-prediction, 2026-06-01)

  • Editing Assets .cs with the raw Write tool does NOT reliably trigger a Unity recompile on an unfocused editor — refresh_unity did a domain reload without recompiling, so tests + execute_code ran a stale assembly (symptom: behaviour that exists in neither the old nor new source). Always edit Assets .cs via MCP apply_text_edits / create_script (Unity's own scripting pipeline) — never Write. (Write/Edit are fine for non-asset files: vault, asmdef JSON, etc.) See 2026-06-01_M5_Physics_In_Prediction.
  • Predicted physics is implicit — there is no PredictedPhysics toggle. With the netcode-physics package present (Unity.NetCode.Physics, …Physics.Hybrid) and predicted ghosts carrying physics components, Netcode relocates PhysicsSystemGroup into the PredictedFixedStepSimulationSystemGroup (a child of PredictedSimulationSystemGroup, marked OrderFirst). NetCodePhysicsConfig only tunes lag-comp / run-mode / history. Put one in the gameplay subscene with PhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntities so the group runs whenever physics entities exist.
  • Unity Physics 1.x bakes built-in UnityEngine colliders + Rigidbody — the old PhysicsShapeAuthoring/PhysicsBodyAuthoring (Physics 0.x) are gone (unity_reflect finds neither). Author a dynamic body with a CapsuleCollider/BoxCollider + Rigidbody (useGravity=false → planar/PhysicsGravityFactor=0; isKinematic=false; interpolation=InterpolatePhysicsGraphicalSmoothing). Static colliders = collider, no Rigidbody, baked into the subscene (present identically in server + client worlds, deterministic, no replication).
  • PhysicsVelocity auto-replicates — Netcode ships PhysicsVelocityDefaultVariant + a generated serializer, so a predicted-physics ghost needs no hand-written [GhostField] for velocity (LocalTransform is already replicated). Drive the character by writing PhysicsVelocity.Linear, not by teleporting LocalTransform.
  • Rigidbody.FreezeRotation is NOT honored by the DOTS baker (baked PhysicsMass.InverseInertia stays non-zero). Hold a top-down character's facing by zeroing angular velocity each tick + writing rotation directly (PlayerAimSystem); set PhysicsMass.InverseInertia = float3.zero in a baker/system if a hard lock is needed.
  • Gravity-off bodies accumulate vertical contact impulses permanently (a capsule rides up a box edge and floats away — looks like tunnelling, isn't). Pin players to the movement plane after the physics step: a system in PredictedSimulationSystemGroup [UpdateAfter(PredictedFixedStepSimulationSystemGroup)] clamping Y to PlayerSpawner.SpawnPoint.y + zeroing Linear.y (PlayerPlanarConstraintSystem).
  • The predicted physics group is OrderFirst, so a system in PredictedSimulationSystemGroup with [UpdateBefore(PredictedFixedStepSimulationSystemGroup)] is ignored (OrderFirst/OrderLast wins) → 1-tick velocity offset (consistent across server/client/rollback — prediction stays in sync). For same-tick application, put the system inside PredictedFixedStepSimulationSystemGroup [UpdateBefore(Unity.Physics.Systems.PhysicsSystemGroup)] (verified to sort before the step) — but expect cosmetic "invalid UpdateBefore" warnings from the relocation.

Note (M5b): PlayerMoveSystem + PlayerPlanarConstraintSystem from this milestone were deleted when the player became a Unity Character Controller. The predicted-physics infra (NetCodePhysicsConfig, baked static walls) is kept. See the M5b section.

Build gotchas (learned — M5b Unity Character Controller, 2026-06-01)

  • The player is now a Unity Character Controller kinematic character, NOT a dynamic Rigidbody. PlayerMoveSystem + PlayerPlanarConstraintSystem (M5) are deleted. Movement: PlayerControlSystem maps PlayerInput.Move × EffectiveCharacterStats.MoveSpeedCharacterControl (via the unit-tested CharacterControlMath.DesiredMovement); CharacterProcessor (collide-and-slide) consumes it in CharacterPhysicsUpdateSystem ([UpdateInGroup(KinematicCharacterPhysicsUpdateGroup)], relocated into the predicted loop). The DR-006 predicted-physics infra (NetCodePhysicsConfig, baked static walls) is kept — the CC character sweeps against that same PhysicsWorld.
  • A package declaring an older com.unity.entities/com.unity.physics dependency can still resolve on our renumbered stack — Unity treats the dep as a SemVer floor, so Entities 6.4.0 satisfies a 1.3.15 requirement and is NOT downgraded. Don't trust a version-string mismatch as "incompatible": probe (add the package, confirm packages-lock.json kept Entities 6.4.0 / Physics 1.4.6 / Netcode 1.13.2 + a clean compile; rollback if not). CC 1.4.2 verified this way.
  • CC 1.4.2 API shape = IKinematicCharacterProcessor<T> + KinematicCharacterDataAccess + static KinematicCharacterUtilities.Update_*. The legacy KinematicCharacterAspect (IAspect, instance Update_*) also exists but is NOT what the 1.4.x samples use — verify the installed shape with unity_reflect, don't assume. (A sub-agent's package-cache read disagreed with reflect; reflect + first-try clean compile won.)
  • KinematicCharacterUtilities.BakeCharacter aborts (logs an error, adds nothing) if the GameObject has a Rigidbody and requires uniform (1,1,1) scale. The player prefab keeps its CapsuleCollider (baked into PhysicsCollider) but the M5 Rigidbody was removed. Two bakers on one prefab GameObject (PlayerAuthoring + PlayerCharacterAuthoring) is fine — both resolve the same entity.
  • CharacterInterpolation must be PredictedClient-only. BakeCharacter adds it to all prefab versions; a DefaultVariantSystemBase registers CharacterInterpolation → [GhostComponent(PrefabType = GhostPrefabType.PredictedClient)] so it's stripped from server + interpolated-client prefabs (else double-interp on remotes). Verified: server ghost has no CharacterInterpolation, client ghost does.
  • Do NOT copy the CC sample's global LocalTransform → DontSerializeVariant. It is project-wide and would break the non-character ghosts here (projectiles/dummies/pickups) that rely on stock LocalTransform replication. Our CC character replicates position via the normal owner-predicted LocalTransform path; only the CharacterInterpolation variant is registered.
  • Top-down CC config (planar, no gravity): AuthoringKinematicCharacterProperties with SnapToGround=false, InterpolateRotation=false (rotation owned by PlayerAimSystem), SimulateDynamicBody=false (players don't physically push each other); gravity is handled in the processor by feeding float3.zero to Update_GroundPushing and never adding a gravity term. Result: stays on the spawn plane (y≈1) with no planar-pin system.

Build gotchas (learned — M5 home base + shared storage, 2026-06-02)

  • Ownerless interpolated ghost ≠ owner-predicted for buffer replication. A server-spawned ownerless ghost (e.g. the shared storage chest) replicates a [GhostField] IBufferElementData to all clients with no OwnerSendType and no GhostOwner — server mutations just propagate. [GhostComponent(OwnerSendType = SendToOwnerType.All)] (per StatModifier) is only for the predicting owner to recompute its own state; adding it (or a GhostOwner) to an ownerless ghost is wrong.
  • One-off shared-state actions belong on an IRpcCommand, not a predicted InputEvent. RPCs are reliable, so deposit/withdraw landed even while the server tick-batched (the M2 one-shot Fire InputEvent drops under batching). RPC payloads are plain blittable fields — no [GhostField]; store an op as a byte, not an enum (cross-assembly enum-Burst hazard, same one that de-Bursted ProjectileClassificationSystem). For a single shared target, resolve it as a server singleton — never put an Entity (not stable cross-world) in the command; only reach for a ghost-id+spawn-tick (SpawnedGhostEntityMap) when there are many targets (and that lookup may force the handler off Burst).
  • Apply server-only RPC effects in the server SimulationSystemGroup, NOT the predicted loop — the predicted loop re-runs on rollback and would double-apply. (Mutating a DynamicBuffer is not a structural change, so it's safe to do while iterating a different entity query, e.g. the RPC requests.)
  • Build-grid math must be deterministic + integer-stable. Corner-origin + center-returning + half-open cell bounds + math.floor (not truncation — negatives). Lock CellSize/PlotSize as a coordinate space once: M6 placement builds on it; changing them later invalidates placed structures. (BaseGridMath, unit-tested in EditMode like PlayerSpawnMath.)
  • Runtime-spawn shared ghosts; don't bake them into the subscene. A one-shot server spawner (mirrors UpgradePickup/TrainingDummy) keeps the subscene ghost-free and dodges the prespawn section-ack/CRC handshake. Do not add such a ghost to a connection's LinkedEntityGroup if it must survive that player's disconnect (the shared base is world-owned).
  • Build a correctly-configured ghost prefab by duplicating an existing one (UpgradePickup.prefabStorage.prefab, then swap the authoring MonoBehaviour via manage_prefabs modify_contents) rather than hand-adding GhostAuthoringComponent — its ownerless/interpolated settings (HasOwner=0, DefaultGhostMode=Interpolated) + LinkedEntityGroupAuthoring come along for free.
  • execute_code runs as a method bodyno using directives (they parse as statements → "Identifier expected"); fully-qualify every type (Unity.Entities.World, ProjectM.Simulation.BaseAnchor, …). Also: world flags overlap a shared Game bit, so identify worlds by world.Name == "ServerWorld"/"ClientWorld" rather than (Flags & GameServer).
  • An unfocused editor throttles Edit mode to near-idle → MCP pings time out and the bridge looks hung (it still queues commands — telemetry_ping succeeds). Application.runInBackground only helps in Play mode. If it wedges, focus or restart the editor; don't pile refresh_unity calls onto a blocked main thread. Prefer refresh_unity scope=scripts for code-only changes (scope=all force is heavy and contributed to a mid-session hang).

Build gotchas (learned — M5.5 game feel & identity, 2026-06-02)

  • Move an ownerless INTERPOLATED enemy ghost SERVER-ONLY in the plain SimulationSystemGroup, never in PredictedSimulationSystemGroup (interpolated ghosts aren't predicted; the server has no rollback). Use [UpdateAfter(PredictedSimulationSystemGroup)], NOT [UpdateBefore] — the predicted group is OrderFirst in SimulationSystemGroup, so UpdateBefore/After it is silently ignored (Unity logs "Ignoring invalid UpdateBefore… OrderFirst/OrderLast has higher precedence"). A plain-SimulationSystemGroup server system therefore always runs after the predicted group, so a contact DamageEvent it appends drains the following tick (~16ms, fine for melee). Stock LocalTransform replication carries position — no hand-written [GhostField]. Build the enemy ghost by duplicating an existing interpolated ghost (UpgradePickup.prefabEnemy.prefab) so the ownerless/interpolated GhostAuthoringComponent comes free — the training dummy is not a ghost (server-only → invisible to clients). See DR-009_GameFeel_Identity_FirstBlood.
  • Derive an enableable gate from already-replicated state instead of replicating it. Player Dead = a LOCAL enableable derived every predicted tick from the replicated Health<=0 (PlayerDeathStateSystem, runs in both worlds, before movement/aim/fire) — the same derive-don't-replicate idiom as StatRecomputeSystem/EffectiveCharacterStats; rollback-correct on server + owner-client with no [GhostEnabledBit]. To write the bit on a currently-disabled entity the query must visit it: .WithPresent<Dead>() (write) vs .WithDisabled<Dead>() (alive-only run); .WithAll<Simulate>() ANDs independently. Bake the enableable DISABLED (AddComponent<T>(e); SetComponentEnabled<T>(e, false); in the baker) so instances spawn in the off state (instantiated entities inherit the prefab's enabled state). Respawn TIMING is server-only (SimulationSystemGroup, after the predicted group).
  • All juice/HUD = client-only managed SystemBase in PresentationSystemGroup (once per frame, no rollback double-fire) that OBSERVES replicated state — never mutates the sim. Read ECS via SystemAPI.Query inside OnUpdate + EntityManager.CompleteDependencyBeforeRO<T>() — NOT from a MonoBehaviour LateUpdate (that throws the job-safety exception the camera rig hit). Entity is a stable client dict key for a ghost's lifetime — prune the cache each frame (a pruned enemy = a kill → death VFX at its last pos; never DestroyEntity a ghost from the clientGhostDespawnSystem owns despawn). Netcode-safe "hit-stop" = a camera punch, never Time.timeScale (it would corrupt the deterministic sim).
  • Asset-free presentation: procedural AudioClip.Create SFX; a runtime ParticleSystem pool (Sprites/Default material + HDR start color so bursts bloom); a code-built uGUI HUD (RawImage over Texture2D.whiteTexture for anchor-driven bars + legacy Text with Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf")). To edit a prefab asset's component in code: PrefabUtility.LoadPrefabContents → modify → SaveAsPrefabAsset(root, path)UnloadPrefabContents (SavePrefabAsset rejects the contents root — "Can't save a Prefab instance"). Watch shared-material bleed when re-tinting (M_Dummy doubled as the wall material → orange walls; Husks got their own M_Husk). ACES tonemapping needs the URP asset color grading mode = HDR (m_ColorGradingMode = 1).
  • The "0 = ready" raw-uint cooldown sentinel can collide at tick wraparound — a computed ServerTick + delay can equal 0. Route every cooldown/spawn "next tick" write through TickUtil.NonZero(...) (coerce 0→1), and compare stored ticks with new NetworkTick(raw).IsNewerThan(serverTick) / .TicksSince(...)never raw < / subtraction — the HUD cooldown bar included. (Caught by the adversarial review; RespawnMath already guarded it.)
  • An unfocused editor stalls EditMode test INIT ("tests did not start within timeout") and slows play-enter domain reloads — pass run_tests(init_timeout=120000) and retry; ask the operator to focus Unity for heavy build/test sessions (Application.runInBackground only helps in Play mode).

Build gotchas (learned — art import / Synty packs → URP, 2026-06-03)

The imported store art (BefourStudios; future Synty) is HDRP-authored but we run URP 17.4 + Entities Graphics → source M_* materials render magenta. The reusable converter is Assets/_Project/Scripts/Editor/EnvArtTools.cs (menu ProjectM/Art/1. Convert Curated Env Materials); it outputs stock URP/Lit to Assets/_Project/Materials/Env/. See DR-010_Art_Import_URP_Conversion_Visual_Upgrade.

  • Convert, don't switch pipelines. Re-author to stock URP/Lit (933532a4…, the same shader our prototypes use → DOTS-instancing/Entities-Graphics compatible). FBX meshes + T_*_B/_N/_ORM textures are reusable as-is; the auto-generated MI_* URP stubs are blank. Switching the project to HDRP is NOT an option (breaks Entities Graphics).
  • A dark-lit screenshot MASKS material bugs — verify material values, not just the render. S_General exposes a float _BaseColorMultiply and the real Color in _AlbedoTint. HasProperty("_BaseColorMultiply") is true but GetColor() on a float returns (0,0,0) (and logs a "doesn't have a color property" warning) → black albedo everywhere. Always shader.GetPropertyType(idx)-guard before GetColor/GetFloat/GetTexture.
  • Gate source emission on the _Emissive (0/1) flag AND a fixture nameS_General carries a non-zero default _EmissiveColor even when off; reading it unconditionally makes crates/walls/domes glow (flat color can't reproduce the source emission mask).
  • VolumeProfile.Add<T>() does NOT persist the override — on save the components list serializes {fileID:0} nulls (works in-session, gone after reload). Use AssetDatabase.AddObjectToAsset(component, profile) per effect, then SaveAssets; verify non-null refs on disk.
  • LocalTransform.FromPosition() resets Scale=1, silently discarding a ghost prefab's authored scale (Scale is a replicated [GhostField] → consistent-but-wrong, not a desync). Server spawners must read the prefab's baked LocalTransform and override only Position (fixed in UpgradePickupSpawnSystem/SharedStorageSpawnSystem).
  • High metallic + no reflection probe + dark skybox = near-black. Keep converted env metallic low (0.10.2); rely on albedo + direct light.
  • Static decor goes in the gameplay subscene (Entities Graphics renders only baked/EG-spawned entities; a SampleScene MeshRenderer renders via classic URP). Strip colliders from cosmetic props (else they bake into the static PhysicsWorld the CC sweeps) and put no GhostAuthoring on scenery. Edit the subscene via manage_scene load … additive → place → SaveSceneclose_scene (re-bakes on Play); the baked-entity view disappears while it's open additively — verify placement via execute_code over the scene roots, not the game view.
  • HUD-free beauty shot = a positioned game_view capture (view_position/view_rotation) — direct camera rendering excludes Screen-Space-Overlay UI. scene_view rejects positioned capture.
  • VFX (GabrielAguiar) is now imported (499 prefabs, ~94% Shuriken; 27 VFX-Graph). Wired into combat via DR-011_Synty_World_VFX_Integration — see the VFX gotchas below. VFX-Graph (hits/beams) packs still need separate URP setup if wanted.

Build gotchas (learned — Synty world + GabrielAguiar VFX, 2026-06-03)

The world is now a cohesive Synty sci-fi colony + GabrielAguiar combat VFX. See DR-011_Synty_World_VFX_Integration / 2026-06-03_Synty_World_And_VFX.

  • Synty is URP-native — NO conversion (unlike the HDRP BefourStudios art). The grounded world is built as cosmetic classic-URP GameObjects in SampleScene (SyntyWorld root), NOT the DOTS subscene — the custom Synty/Generic_Basic shader just renders, and you never have to verify its Entities-Graphics DOTS-instancing. Only the gameplay subscene needs Entities Graphics + the baked PhysicsWorld.
  • Cosmetic SampleScene colliders are inert to gameplay — classic PhysX is separate from the DOTS PhysicsWorld (baked only from the subscene); the planar-pinned CC never sees them. So a cosmetic world needs no collider stripping for gameplay.
  • "Grounded" = surround + horizon, not a bigger plane — a skyline ring of tall buildings + a planet/asteroid backdrop + a Skybox/6 Sided space skybox + light fog killed the floating-plane far better than extending the ground.
  • Swap a subscene object's VISUAL while keeping collision: disable its MeshRenderer but keep the BoxCollider — the collider still bakes to the static PhysicsCollider, and a disabled renderer bakes no RenderMesh (invisible wall, collision intact). Used to retire the BefourStudios walls under the Synty world.
  • A GA "projectile" prefab is NOT a passive trail — it ships a non-kinematic Rigidbody + collider + ProjectileMoveScript (self-propels, collides, spawns secondary muzzle/hit VFX). Any authored VFX dropped into a cosmetic slot must be stripped to particles: destroy Rigidbody/Collider and disable Projectile/Move-named MonoBehaviours BEFORE their Start runs (CombatFeedbackSystem.StripCosmetic). Verify a prefab's components, not its name.
  • VFX prefab → client SystemBase bridge: a MonoBehaviour with a static Instance + prefab fields in the bootstrap scene (VFXConfig, mirrors PrototypeCameraRig) hands authored assets to the managed CombatFeedbackSystem; keep a procedural fallback so a null slot still runs. Derive VFX TTL from the prefab's longest ParticleSystem (not a blanket constant) and cap concurrent VFX to bound GC churn under swarms.
  • Projectile-follow VFX: query SystemAPI.Query<RefRO<LocalTransform>>().WithAll<Projectile>() each presentation frame; dict-by-Entity spawn/reposition/prune (same idiom as the Health FX cache). The one-shot Fire InputEvent still drops under the unfocused editor, so fire-driven VFX (muzzle/trail/enemy-death) need a focused editor / real client to fire.

Build gotchas (learned — aim controls: mouse cursor + gamepad, 2026-06-03)

The KBM/gamepad aim rework is DR-012_Aim_Controls_Cursor_Gamepad / 2026-06-03_Aim_Controls_Cursor_Gamepad.

  • Client-derived aim rides the EXISTING PlayerInput.Aim [GhostField] — no new netcode surface. Mouse-cursor aim is computed client-side in PlayerInputGatherSystem (managed SystemBase, GhostInputSystemGroup, once/frame): Mouse.current.positionCamera.main.ScreenPointToRayAimMath.PlanarAimFromRay (pure, Burst-safe, unit-tested) against the player's movement plane → write the player→cursor direction as Aim. Only the resulting direction crosses the wire; predicted/server systems are unchanged. Movement (Move) is already decoupled from facing (Aim/PlayerFacing), so strafe-while-aiming is free the moment Aim is the cursor. Don't add a mouse binding to the Aim action — the gather reads the device directly (no .inputactions edit → no wrapper regen).
  • Active input scheme = last-meaningful-actuation-wins, replicated as a byte (NOT an enum). Detect KBM vs gamepad each frame (stick/trigger/button past a 0.04 lengthsq deadzone vs mouse-delta/click/movement-key; InputDevice.lastUpdateTime breaks ties; hold last when idle) and stream PlayerInput.Scheme (InputSchemeId.KeyboardMouse=0/Gamepad=1). It is a byte because it is compared inside the Burst-compiled AbilityFireSystem (the cross-assembly enum-in-Burst ICE hazard). The server gates the AutoTarget cone to applied.InternalInput.Scheme == Gamepad (read at the fire tick from the same GetDataAtTick lookup) → precise mouse, gamepad-only assist; mouse then predicts == server (fewer reconcile snaps).
  • A static presentation bridge must reset on play-enter. AimPresentation.Scheme (mirrors PrototypeCameraRig/VFXConfig statics) needs [UnityEngine.RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] to reset — statics survive fast-enter-playmode domain reloads, and a stale value flashes the wrong cursor/reticle for the first frames (caught by the adversarial review).
  • Cursor/reticle = client PresentationSystemGroup SystemBase (AimReticleSystem) that OBSERVES, never mutates. A flat world-space ground ring (primitive quad, Sprites/Default with a null-guard fallback, procedural ring texture) is the aim indicator for BOTH schemes — KBM at the cursor's ground-projection point, gamepad a fixed distance ahead along replicated PlayerFacing. The hardware cursor is hidden while aiming + focused (Application.isFocused-gated) and restored on focus-loss / OnDestroy. A radial dead-zone (AimMath.PlanarAimFromRay deadZoneRadius) holds facing when the cursor is over the character. The KBM ground point is re-raycast INSIDE AimReticleSystem (PresentationSystemGroup runs after the follow-cam's LateUpdate), not latched from the gather (GhostInputSystemGroup, before the move) — latching there drifts the ring a frame behind the cursor under the moving camera. Optional camera aim look-ahead (PrototypeCameraRig.AimLeadDistance, tunable) leads the framed point toward PlayerFacing (not the live cursor projection, to avoid a feedback loop). Headless validation: drive DebugInputInjectionSystem (now stamps Scheme) + force AimPresentation.Scheme; the real cursor / live device-switch needs a focused Game view (the unfocused editor can't inject mouse position).

Build gotchas (learned — M6 Aether Cycle core loop, 2026-06-03)

The M6 core-loop slice (Expedition→Defend→Build) + the base/expedition world split. See DR-013_M6_Aether_Cycle_Region_Split / 2026-06-03_M6_Aether_Cycle_CoreLoop. Stages 01 done; 24 are the continuation.

  • Base/expedition split = coordinate-region + per-connection GhostRelevancy, NOT SceneSystem streaming (supersedes DR-008's framing). One server world; the expedition lives at base + (1000,0,0); a server RegionRelevancySystem in GhostSimulationSystemGroup (before GhostSendSystem) sets GhostRelevancyMode.SetIsIrrelevant and each tick marks region-tagged ghosts irrelevant to connections whose player is in a different region. Use SetIsIrrelevant (not SetIsRelevant) so untagged/global ghosts (the future cycle director) stay relevant to everyone for free — you only enumerate cross-region ghosts to hide. Verify the API on the installed Netcode (1.13.2) with unity_reflect: GhostRelevancy singleton has GhostRelevancyMode + NativeParallelHashMap<RelevantGhostForConnection,int> GhostRelevancySet; RelevantGhostForConnection{ int Connection; int Ghost } (Connection=NetworkId.Value, Ghost=GhostInstance.ghostId). RegionTag{byte Region} is server-only (NOT a [GhostField]) — the server makes all relevancy decisions; the client just gains/loses ghosts. Reuses the runtime-ghost spawn path verbatim (no baked ghosts → no prespawn handshake), no async-load race; co-op drop-in is free.
  • Region transit + cycle phase use the established byte-RPC + tick-safe + server-SimulationSystemGroup patterns. RegionTransitRequest{byte TargetRegion} (resolve sender via ReceiveRpcCommandRequest.SourceConnectionNetworkIdGhostOwner, flip RegionTag, teleport LocalTransform). The macro loop is a server-only CycleState singleton ([GhostField]-pre-annotated for the later CycleDirector ghost) driven by CyclePhaseSystem ([UpdateBefore(WaveSystem)]); it gates WaveSystem with a one-line if (TryGetSingleton<CycleState>(out var c) && c.Phase != CyclePhase.Defend) return;. All phase timers are wrap-safe NetworkTick (TickUtil.NonZero + IsNewerThan), never raw uint <.
  • Editing an existing [BurstCompile] ISystem's SystemAPI query set on an UNFOCUSED editor can leave a STALE Burst binary (managed assembly recompiles with shifted source-gen query indices, Burst's async recompile doesn't finish) → runtime InvalidOperationException: "… required component type was not declared in the EntityQuery" thrown from an unrelated GetSingleton<T> in that system. Tell: the Burst stack reports the old line number for the failing call. Same family as the M2 Burst-cache gotcha. Workaround: Jobs ▸ Burst ▸ Enable Compilation OFF for the session (verify BurstCompiler.Options.EnableBurstCompilation==false) — everything runs the fresh managed source-gen. Permanent fix = restart Unity to clear the cache, then re-enable Burst. Prefer a focused editor for Burst-affecting edits.
  • Shared GLOBAL game-state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost, never a region-tagged oneSetIsIrrelevant hides a region-tagged ghost (e.g. the base storage) from players in the other region. The M6 resource ledger is a StorageEntry buffer on the global CycleDirector ghost, resolved via a distinct ResourceLedger tag — never GetSingleton<StorageEntry> (the base storage container owns a second StorageEntry buffer → "multiple instances" throw). Runtime-proven: the director stays relevant to an expedition player while the base storage despawns.
  • A hit/area sweep that runs in the PLAIN SimulationSystemGroup must NOT use SystemAPI.Time.DeltaTime — that group sees the variable wall-frame delta, not the fixed tick step, so a cur - dir*Speed*dt segment is wrong. Store the per-tick step on the projectile (Projectile.LastStep, written by ProjectileMoveSystem in the fixed-step predicted group) and reconstruct the swept segment as cur - dir*LastStep — tunnel-safe with zero dependence on the consuming system's clock. ResourceHarvestSystem runs [UpdateAfter(PredictedSimulationSystemGroup)] so it only sees projectiles that survived ProjectileDamageSystem (relies on the ~1000u base/expedition coordinate gap so a base shot can't reach a node). A node hit by N projectiles in one tick: deposit per hit but ecb.DestroyEntity at-most-once (destroyed-bitset + local Remaining copy — a double destroy throws at Playback); persist the decremented [GhostField] Remaining via SetComponent so depletion carries across ticks.
  • New ghost prefab recipe (proven M6): manage_asset duplicate UpgradePickup.prefab → manage_prefabs modify_contents (swap the authoring MonoBehaviour; strip MeshFilter+MeshRenderer for an invisible state-holder, keep them for a visible node). Wire the baked spawner into the gameplay subscene: manage_scene load additiveset_active_scene Gameplaymanage_gameobject create (+ manage_components set_property for the prefab ref, verify via mcpforunity://scene/gameobject/{id}/component/...) → saveset_active_scene SampleSceneclose_scene (re-bakes on Play).
  • Run an adversarial design-review Workflow (3 critics: netcode/relevancy, determinism/prediction, reuse/scope → synthesize) BEFORE coding a netcode-heavy slice — for M6 Stage 2 it caught every one of the above pre-implementation (relevancy trap, singleton collision, dt-trap, double-destroy, lazy-create hazard).
  • manage_gameobject create component_properties SILENTLY DROPS enum + Vector3 fields (it set object-refs and simple scalars, but baked authoring enums/Vector3 stayed at their C# defaults — two gates baked identical, one worked only by coincidence). Always set those via a follow-up manage_components set_property (with a properties dict) and VERIFY through the mcpforunity://scene/gameobject/{id}/component/{Type} resource (or, for a ghost, by reading the baked component in execute_code after Play). Same caveat applies to manage_prefabs modify_contents component_properties. Per-renderer color via manage_material set_renderer_color defaults to a runtime PropertyBlock that does NOT persist into Play — create a material asset (manage_material create) and assign_material_to_renderer, or use a prefab-stage assign, for colors that survive a domain reload.
  • Walk-in region gates (M6 visibility pass): a baked ExpeditionGate{FromRegion,ToRegion,Radius,ArrivalPos} entity (visible primitive, collider stripped so you pass through) + a server ExpeditionGateSystem (plain group, [UpdateAfter(CyclePhaseSystem)]) proximity-transits a player whose RegionTag matches FromRegion (flip RegionTag + teleport to ArrivalPos, offset from the destination gate so no re-trigger). Returning to base mid-Expedition expires the cycle timer → Defend ("timer cap + early return"). The expedition is a place = cosmetic ground/pillars in SampleScene at the +1000 offset (classic URP, like SyntyWorld), not the DOTS subscene; gameplay nodes/gates are the baked subscene entities.
  • Build/automation foundation (M6 Stage 3, the M7 contract): generic PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick} on an ownerless interpolated ghost (RegionTag{Base}, world-owned, runtime-spawned). Bake the two tick fields NOW — the turret reuses NextTick as its fire cooldown, and they are the deterministic-offline-catch-up linchpin M7 needs and that can't be reconstructed retroactively. Only Type replicates (client derives Cell from LocalTransform via BaseGridMath.WorldToCell). Data-driven StructureCatalog buffer ({byte Type; Entity Prefab; byte CostResourceId; int CostAmount}, modeled on AbilityPrefabElement); M7 adds a recipe column additively. Occupancy is DERIVED by scanning live structure ghosts into a Temp NativeHashSet<int2> (structures are the source of truth — restart/replay-safe), NEVER a mutable buffer on the immutable baked BaseAnchor.
  • Co-op placement atomicity: BuildPlaceSystem commits the StorageMath.Withdraw + cell-reservation IN-PLACE inside the RPC foreach (only the Instantiate goes through the ECB) — the StorageOpReceiveSystem idiom — so two same-tick BuildPlaceRequests for one cell can't both pass (validated: → exactly one structure + one withdraw). RPC carries int CellX/CellZ scalars, not int2 (scalar-only RPC precedent).
  • Buildable turret = hitscan = reversed EnemyAISystem: snapshot living Husks, nearest-in-same-region-within-Range, on the NextTick cooldown append a direct DamageEvent{Damage, SourceNetworkId=-1} → reuses HealthApplyDamageSystem (despawns at HP≤0). NO projectile → no tunnelling, no friendly-fire/team model. Plain server group [UpdateAfter(PredictedSimulationSystemGroup)].
  • Resource-gated ability tiers reuse StatModifier — no new replicated component. AbilityUpgradeSystem spends Aether and grows ONE StatModifier{Target=Damage, Op=PercentAdd, SourceId=<sentinel>} on the player (replace-by-SourceId so the [InternalBufferCapacity(8)] buffer stays bounded — repeated upgrades grow one row, not append); StatRecomputeSystem folds it into EffectiveAbilityStats.Damage on both worlds (the UpgradePickup path). GoalProgress{[GhostField] int Charge, Target} lives on the global CycleDirector ghost, single-writer in CyclePhaseSystem. Disk-persistence writer is deferred to post-M7 (in-session-only state, per DR-008); freeze the save schema + bake the structure tick fields now so it's additive. See DR-014_M6_Build_Structures_Automation_Foundation.

2026-06-07 — Pre-trim CLAUDE.md snapshot

A full verbatim snapshot of CLAUDE.md as it stood immediately before the 2026-06-07 second condensation pass (git HEAD = commit 25b53cb06, 42 126 bytes). It is reproduced here in its entirety so that every line trimmed from the live file remains recoverable from the vault, not just from git history. The original headings are demoted two levels so they nest under this section instead of polluting the archive's outline. When a future condensation pass runs, append a new dated snapshot below this one rather than overwriting it.

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.

Build-gotcha archive: the full, verbose per-milestone build lessons were condensed out of this file on 2026-06-04 (to stay under the 40 KB context-load limit). The distilled rules live below in Build gotchas (distilled); the long-form originals are in Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md, and the design rationale in the per-milestone DRs (Docs/Vault/07_Sessions/_Decisions/DR-###).

Stack — Unity 6.4.7 (6000.4.7f1, stable) as of 2026-05-30

Package Version Notes
com.unity.entities 6.4.0 Entities/Collections/Graphics track the Editor version (6.x).
com.unity.entities.graphics 6.4.0 Renders entities under URP 17.4.
com.unity.collections 6.4.0 (transitive)
com.unity.netcode 1.13.2 Netcode for Entities (ECS). NOT com.unity.netcode.gameobjects. Independent 1.x line.
com.unity.physics 1.4.6 Unity Physics (DOTS). Independent 1.x line.
com.unity.charactercontroller 1.4.2 DOTS kinematic collide-and-slide. Declares entities/physics 1.3.15 but resolves on 6.4.0/1.4.6 via SemVer floor (no downgrade).
com.unity.transport 2.7.2 (transitive)
com.unity.burst 1.8.29 (transitive)
com.unity.mathematics 1.3.3 (transitive)
com.rukhanka.animation 2.9.0 Local pkg (Packages/com.rukhanka.animation). ECS skeletal animation (Burst CPU/GPU skinning). Declares entities.graphics 1.4.16 → resolves on 6.4.0 via SemVer floor. Netcode replication OFF → client-derived. See DR-022_Animation_Pipeline_Rukhanka_Synty.

Values match packages-lock.json (reconciled 2026-06-02; URP 17.4.0, test-framework 1.6.0, ugui 2.0.0, multiplayer.center 1.0.1). History: 6.6.0a6 was tried + reverted (Netcode/Transport runtime engine bug "invalid wrapped network interface"); returning to 6.6 means a package renumber + runtime re-test. 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.
  • 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 fields (no [GhostField]), scalars only (int CellX/CellZ, not int2). 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 a system with cross-system [UpdateBefore/After], re-audit the EXISTING [Update*] attributes of the systems you order around (a new After(A)+Before(B) collided with B's pre-existing After(A)) and always Play-validate, not just EditMode. 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 connection handshake refuses. #if UNITY_EDITOR-gate only the send/receive SYSTEMS + overlay, never the request struct. Re-mean bytes, don't rename: an enum/const whose byte VALUES are unchanged keeps the [GhostField] serializer identical → a global-loop reframe stays 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 on server + owner, 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] (server decides relevancy; client just gains/loses ghosts). RelevantGhostForConnection{int Connection (=NetworkId.Value); int Ghost (=GhostInstance.ghostId)}.
  • 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 a ledger buffer via a DISTINCT tag (ResourceLedger), never GetSingleton<StorageEntry> when a second StorageEntry buffer exists elsewhere → "multiple instances" throw.
  • 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/overlay read); for the menu world use DefaultWorldInitialization.Initialize(name, false) (or return false from GameBootstrap.Initialize). Never dispose/create worlds inside an ECS system — do all create/dispose/scene-load on a frame-boundary coroutine (SessionRunner, DontDestroyOnLoad). The gameplay subscene streams into an on-demand world ONLY if a netcode world is the DefaultGameObjectInjectionWorld at LoadScene time (dispose the menu world → set the default to the server world → LoadScene(Game)). 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 — PlayerMoveSystem/PlayerPlanarConstraintSystem were deleted; the DR-006 predicted-physics infra is 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 shape with unity_reflect, don't assume the legacy aspect).
  • KinematicCharacterUtilities.BakeCharacter aborts if the GameObject has a Rigidbody and needs uniform (1,1,1) scale. CharacterInterpolation must be PredictedClient-only (register a DefaultVariantSystemBase stripping it from server + interpolated prefabs) — else double-interp on remotes. Do NOT copy the CC sample's global LocalTransform → DontSerializeVariant (project-wide; breaks the 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. A node hit by N projectiles in one tick: ecb.DestroyEntity at-most-once (destroyed-bitset; double destroy throws at Playback). TWO target types in one projectile pass (nodes + Blight clutter): UNIFY into one best-target loop + one shared destroyed-bitset (separate sweeps each destroy a projectile overlapping both → double-destroy, DR-018). A per-hit yield (int) cast that also gates despawn is an immortal-sink (sub-1.0 → 0 → no deposit, no Remaining decrement, 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 (not truncation — negatives). Lock CellSize/PlotSize as a coordinate space once (BaseGridMath, EditMode-tested) — changing them invalidates placed structures.
  • PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick} on an ownerless interpolated ghost. Bake the two tick fields now (turret reuses NextTick as fire cooldown; they're the offline-catch-up linchpin). Only Type replicates (client derives Cell via BaseGridMath.WorldToCell). Data-driven StructureCatalog buffer. Occupancy is DERIVED by scanning live structure ghosts into a Temp NativeHashSet<int2>, never a mutable buffer on the baked BaseAnchor.
  • Co-op placement atomicity: commit the StorageMath.Withdraw + cell-reservation in-place inside the RPC foreach (only Instantiate goes through the ECB) so two same-tick requests for one cell can't both pass.
  • Buildable turret = hitscan = reversed EnemyAISystem: nearest living Husk in-region within Range, on NextTick cooldown append a direct DamageEvent{Damage, SourceNetworkId=-1} → reuses HealthApplyDamageSystem. No projectile → no tunnelling, no team model.
  • Resource-gated ability tiers reuse StatModifier — grow ONE StatModifier{Target=Damage, Op=PercentAdd, SourceId=<sentinel>} (replace-by-SourceId so the buffer stays bounded); StatRecomputeSystem folds it into EffectiveAbilityStats on both worlds. GoalProgress{[GhostField] int Charge, Target} lives on the global CycleDirector ghost. Disk persistence shipped — see the Automation + persistence bullets below.
  • M7 Automation (server-only, never predicted) ★: Harvester / Conveyor / Fabricator are buildable machines on the PlacedStructure ghost; each stores PeriodTicks + server-only MachineInput / MachineOutput buffers (NOT [GhostField]). Production runs in the plain server SimulationSystemGroup [UpdateAfter(PredictedSimulationSystemGroup)] (Harvester→Conveyor→Fabricator), replicating only via the global ledger + PlacedStructure. Deterministic catch-up via ProductionMath.CyclesDue (lower-bound 0, never 1 — a 1 premature-mints a restored remaining==0 machine; period-0 guarded). Byte-only pure math (ProductionMath / ConveyorMath.ResolveMoves / MachineSlotMath) is EditMode-tested; ConveyorMath is order-independent (snapshot → stable-sort by CellKey → at-most-one destination claim → losers stall). RuntimePlacedTag marks player-built machines for the save-scan; BuildPlaceSystem stamps LastProcessedTick=0 → runtime machines hit NeedsInit. See DR-020_M7_Automation_Production_Chains.
  • Disk persistence (SaveData, single-slot atomic JSON at persistentDataPath) ★: versioned, null on bad version, schema additive (bump the version, don't break it). Born-correct loadCycleDirectorSpawnSystem applies a staged PendingSave AT SPAWN so the director ghost never replicates a default first. Autosave on the Siege→Calm checkpoint + on quit-to-menu (WorldLauncher.TrySaveFromServer, host-only); BaseRestoreSystem replays saved structures charge-free with epoch-independent REMAINING-tick cooldowns + re-tags them. Shared SaveStructureScan.Collect (autosave + quit use ONE scan path). See DR-019_Frontend_Menu_Settings_Saves_Build.
Presentation / juice / VFX
  • All juice/HUD = client-only managed SystemBase in PresentationSystemGroup (once/frame, no rollback double-fire) that OBSERVES replicated state, never mutates the sim. Read ECS via SystemAPI.Query in OnUpdate + EntityManager.CompleteDependencyBeforeRO<T>() — NOT a MonoBehaviour LateUpdate (job-safety throw). Entity is a stable client dict key for a ghost's lifetime — prune the cache each frame (a pruned enemy = a kill → death VFX); never DestroyEntity a ghost from the client (GhostDespawnSystem owns despawn). Hit-stop = a camera punch, never Time.timeScale (corrupts the deterministic sim).
  • Asset-free presentation: procedural AudioClip.Create SFX; runtime ParticleSystem pool (Sprites/Default + HDR start color); code-built UI Toolkit HUD / menus (runtime UIDocument + shared RuntimePanelSettings; see the UITK bullet below). 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 the managed 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 shared palette + element factories + PanelSettings/EventSystem plumbing + the canonical Round/Border helpers; HudUi is a thin extension (bars/labels). HudSystem is a PresentationSystemGroup observe-only SystemBase owning a runtime UIDocument (sortingOrder 50, behind the pause overlay's 100); builds the tree on the first frame rootVisualElement != null, root pickingMode = Ignore so the HUD never eats world clicks (only palette buttons opt back in). Runtime UITK needs a PanelSettings WITH a themeStyleSheet (a .tss importing unity-theme://default) AND an EventSystem + InputSystemUIInputModule or buttons are silently dead. The build palette (lazy-built from the client StructureCatalog) drives click-to-place: ground-ghost preview (green/red via BuildPreviewMath, the client mirror of the server check), left-click → BuildPlaceRequest RPC, right-click/Esc cancels, [/]/R rotates; Fire suppressed in build mode. See DR-021_HUD_UITK_BuildPalette. Synty skin via a build-safe HudTheme (DR-024): the in-game HUD is reskinned with the InterfaceSciFiSoldierHUD/Core kit — but those sprites/fonts live under Assets/Synty/… (NOT Resources), so a runtime name-string Resources.Load("Synty/…") is stripped from the build; instead a curated HudTheme : ScriptableObject at Assets/_Project/Resources/HudTheme.asset holds serialized Sprite/Font refs (dependency-walked into the build), loaded null-safe via HudTheme.Get(), and every consumer falls back to the flat look if a ref/the asset is null. unityBackgroundImageTintColor MULTIPLIES (tint white skins into the Aether palette, zero asset bleed). Fonts = cached SDF FontAsset.CreateFontAsset(Font)+FontDefinition.FromSDFFont, built once per font per session, reset (with the theme cache) on [RuntimeInitializeOnLoadMethod(SubsystemRegistration)]. Synty HUD frame/bar sprites ship authored 9-slice borders (Box_Glass=25, Bar_Angled=80/0) — setting unitySlice* in code OVERRIDES them and logs a "borders overridden by style slices" ERROR per element; let the art border drive slicing (no unitySlice*), only set slices for border-0 sprites. Some Synty sprites import as Sprite Multiple mode (e.g. Gradient_Outwards) → LoadAssetAtPath<Sprite> silently returns null; verify import mode + each theme ref non-null after authoring. 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 Entities Graphics). Re-author to stock URP/Lit via Assets/_Project/Scripts/Editor/EnvArtTools.cs (menu ProjectM/Art/1. Convert Curated Env Materials). Synty art is URP-native — no conversion.
  • A dark-lit screenshot MASKS material bugs — verify material values. Always shader.GetPropertyType(idx)-guard before GetColor/GetFloat/GetTexture (S_General's _BaseColorMultiply is a float; GetColor on it returns black). Gate source emission on the _Emissive flag AND a fixture name. Keep converted env metallic low (0.10.2).
  • VolumeProfile.Add<T>() does NOT persist (serializes {fileID:0} on save) — use AssetDatabase.AddObjectToAsset(component, profile) + SaveAssets, verify on disk.
  • A reverted engine/URP upgrade can stamp UniversalRenderPipelineGlobalSettings.asset with m_AssetVersion AHEAD of the package's k_LastVersion (here 11 > 10, from the reverted 6.6 alpha). URP migrates forward only, so URPPreprocessBuild rejects the "from-the-future" asset ("not at last version") — blocks player builds but NOT editor Play (lurks until the first build). Fix: reflection-set m_AssetVersion back to k_LastVersion, SetDirty + SaveAssets (data already renders under the current package, only the stamp is wrong).
  • LocalTransform.FromPosition() resets Scale=1 — server spawners must read the prefab's baked LocalTransform and override only Position (Scale is a replicated [GhostField] → consistent-but-wrong).
  • Static decor → gameplay subscene (Entities Graphics renders only baked/EG-spawned entities); strip colliders from cosmetic props (else they bake into the PhysicsWorld the CC sweeps), no GhostAuthoring on scenery. Cosmetic SampleScene GameObjects (classic URP, SyntyWorld root) render via classic URP and their colliders are inert to the DOTS PhysicsWorld — no stripping needed there. To swap a subscene object's visual while keeping collision: disable the MeshRenderer, keep the collider.
  • A GA "projectile" prefab self-propels (non-kinematic Rigidbody + collider + ProjectileMoveScript) — strip to particles before Start (CombatFeedbackSystem.StripCosmetic). Verify a prefab's components, not its 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 that system (PresentationSystemGroup 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/OnDestroy.
Animation (Rukhanka) ★

Full rationale: DR-022_Animation_Pipeline_Rukhanka_Synty. Skeletal animation = Rukhanka 2.9 (Entities-native; the only maintained option on 6.4 — Latios/Kinemation not 6.4-compatible, Unity's official ECS-animation is vaporware). Netcode replication OFF (RUKHANKA_WITH_NETCODE undefined) → client-derived: a client-only SystemBase (PlayerAnimationDriveSystem, [WorldSystemFilter(LocalSimulation|ClientSimulation)] + [UpdateBefore(RukhankaAnimationSystemGroup)]) reads replicated state and writes params via AnimatorParametersAspect/FastAnimatorParameter. No new [GhostField]s; no DefaultVariant strip (define off → no Rukhanka component is a ghost component → ghost hash unchanged).

  • The rig must bake on the SAME entity that holds the gameplay components the drive job reads. Rukhanka puts the param buffer + index-table on the GO with RigDefinitionAuthoring, so 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 that includes a UniversalTarget; Synty atlas → its _BaseColorMap). Stock URP/Lit renders unskinned static + a "does not support skinning" warning — NOT magenta (magenta = reusing an HDRP sample .mat).
  • Importing the Rukhanka "Animation Samples" (the only source of AnimatedLitShader) drags in 26 sample subscenes (one NRE's Rukhanka's unguarded clip bakerAnimationClipBaker.ReadCurvesFromTransform reads a null bone Transform on a missing-bone clip), sample systems that run in your worlds, and a conflicting TextMesh Pro folder. Fix: MoveAsset the 3 deformation ShaderGraphs to _Project/Shaders/ (GUID-preserving → material ref intact), then delete the samples tree.
  • First Rukhanka bake is ~60 s, synchronous on the main thread (editor telemetry 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 use [WorldSystemFilter(Default)] (Default ⊇ ServerSimulation) so they run on the server's baked bones/meshes (the animation group is created server-side too but left empty by the bootstrap). ServerStripAnimationSystem (server-only one-shot, [WorldSystemFilter(ServerSimulation)]) disables every Rukhanka.Runtime system on the server (disabling a group cascades to its children; matched by assembly name → no type ref). Only Play-validation caught this — the static WorldFlags read said the server was clean; it wasn't.
  • Build the controller via the AnimatorController API (manage_animation silently drops enum/Vector blend-tree fields). Skeleton-root detection = walk up from a bone to the soldier's direct child, NOT SkinnedMeshRenderer.rootBone (that's 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; SciFiSpace was already so). Entity origin = capsule center (~1 m up) → offset the un-keyed Root bone local Y (clips key no Root/Hips position → Rukhanka bakes the offset as a constant → it persists through animation). Root motion OFF (the CC owns the transform; the blend tree is velocity-driven).
  • Unity.Physics must be a DIRECT asmdef ref for any system whose source-gen touches KinematicCharacterBody (it nests Unity.Physics.ColliderKey) → else CS8377/CS0012 in *.g.cs (same class as the Unity.Transforms direct-ref rule).
  • ENEMIES reuse the player pipeline — a Husk is an ownerless interpolated ghost = a remote player, so EnemyAnimationDriveSystem mirrors PlayerAnimationDriveSystem's REMOTE path (LocalTransform delta velocity + prevPos cache; facing via AnimParamMath.PlanarForward; maxSpeed from baked EnemyStats; IsAttacking = AttackWindup != 0). Drop [RequireMatchingQueriesForUpdate] so the prune runs every frame (Husks die often → else a cache entry leaks per kill). No server/asmdef/ghost-hash change. Build enemy prefabs via the EnemyRigTools editor tool (real PrefabUtility; RigDefinitionAuthoring by reflection), GUID-preserving (DeleteAsset+CopyAsset orphans subscene refs). WaveSystem uses baked.WithPosition (not FromPosition → resets Scale, a [GhostField]). See DR-023_Enemy_Animation_MonsterMash + Synty_Asset_Inventory.
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 is worse — it gets no .meta / no test-discovery until refresh_unity scope=all mode=force (it compiles, but EditMode silently won't run it). (Write/Edit are fine for non-asset files: this vault, asmdef JSON, etc.) For comment/string-precise edits to existing scripts, script_apply_edits anchor_replace (regex anchor) + delete_method work cleanly even on a struct : ISystem (unlike replace_method).
  • apply_text_edits with MULTIPLE non-adjacent edits in one call can MISALIGN (a paired replace+delete hit the line above the target). One edit per call (or strict bottom-first), always with precondition_sha256 (it returns the current SHA on mismatch). create_script won't overwrite an existing path; 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 still can't target a struct : ISystem (use whole-span). DR-017_Persistent_Base_Player_Driven_Pacing
  • execute_code runs as a method body — no using directives (parse 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; don't bake them into the subscene (dodges the prespawn handshake). Wire a baked spawner into the subscene: manage_scene load additiveset_active_scene Gameplay → create + set props + verify → saveset_active_scene SampleSceneclose_scene (re-bakes on Play).
  • 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. Don't pile refresh_unity onto a blocked main thread; 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 + the per-world ConnectionControlSystems, no auto-connect). 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, no netcode); 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).
  • Region split: one server world; the expedition lives at base + (1000,0,0), hidden per-connection via GhostRelevancy (Netcode gotchas). Place = cosmetic ground/pillars at the +1000 offset; nodes/gates are baked subscene entities. See 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)

Layer Use for Crosses machines?
In-repo vault Docs/Vault/ Design docs, decision records (DR-###), session logs, roadmap — human-facing truth Yes (git)
basic-memory MCP Semantic/wikilink recall over those vault files Yes (indexes the vault)
serena MCP C# symbol nav (find_symbol, references) of Assets/_Project/ N/A (from code)
Native Claude memory (memory/, MEMORY.md) Machine-local facts, working-style, preferences No
  • 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 → Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md.
  • 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).

2026-06-08 — World environment redo to Synty nature biomes (DR-025_World_Environment_Redo_Natural_Frontier)

Long-form lessons from the off-world-natural-frontier world redo (Meadow_Forest base + Arid_Desert expedition as cosmetic classic-URP biomes in Game.unity; region-aware WorldAtmosphereSystem fog/ambient cross-fade).

  • Synty PNB_Core fog-ring / cloud-ring PREFABS are MESHES that render as a bright white torus band at gameplay scale — they do NOT read as soft haze. Do not place them for atmosphere; the global RenderSettings fog (exp-squared, low density) gives the haze, and a Skybox/Procedural material gives the sky. (All 4 ring instances were deleted in review.)
  • Two regions, one scene → use a Skybox/Procedural MATERIAL on RenderSettings.skybox, never a Synty skydome MESH. All Synty biome "sky" materials use one SkyDome.shadergraph authored for a dome MESH centred at origin — a single dome can't cover the expedition at x=1000, and per-region domes double the cost. The procedural skybox is infinite and region-agnostic; per-biome mood comes from the cross-faded fog/ambient instead.
  • Edit-mode screenshots show the BASE region's BAKED RenderSettings over the +1000 expedition, because the runtime cross-fade (WorldAtmosphereSystem, a ClientSimulation presentation SystemBase) only runs in Play. The desert therefore looks washed/cool in an edit-mode capture. To preview a region's TRUE in-Play look without entering Play: temporarily bake that region's RenderSettings (fog colour/density + ambientSkyColor) via execute_code, screenshot, then revert. The desert read correctly the moment its warm-orange fog was baked.
  • A cyan capsule at each region centre is a baked Gameplay-SUBSCENE entity (rendered by Entities Graphics with the Aether material), NOT cosmetic dressing and NOT removable in an art pass. Object.FindObjectsByType<MeshRenderer> returns nothing for it — EG entities use BatchRendererGroup, not MeshRenderer. Confirms the cosmetic/gameplay split: an art pass touches only Game.unity GameObjects, never the subscene.
  • execute_code safety-checks BLOCK AssetDatabase.DeleteAsset (and File.Delete, Process.Start, etc.) — the whole snippet is rejected pre-execution (nothing runs, so no partial state). For idempotent asset clones, rely on AssetDatabase.CopyAsset over an absent destination (it no-ops/false on an existing one and you load that), or pass safety_checks=false deliberately.
  • Shadow-cast hygiene for hundreds of cosmetic props: nothing in the project set ShadowCastingMode.Off, so all ~377 placed meshes + the 2 ground planes cast realtime soft directional shadows (4-cascade, soft-quality-3) — a large depth-pass bill for sub-pixel foliage shadows that read as noise top-down. Fix: walk the biome roots and set Renderer.shadowCastingMode = Off on small props (name heuristic + combined-AABB extent < ~1.6m) and the flat ground planes, keeping it ON for trees/cliffs/landmarks (254 off / 149 kept here). Forward+ clustered lighting makes 4 simultaneous additional point lights/region a non-issue (the per-object AdditionalLightsPerObjectLimit is ignored under RenderingMode: 2); non-static cosmetics + SRP Batcher is the right batching choice (no static combine, re-tint-friendly).
  • Region-aware presentation cross-fade pattern: a client-only SystemBase in PresentationSystemGroup that reads Camera.main.transform.position.x and lerps RenderSettings (fog/ambient) across the X>500 boundary (mirrors HudSystem.ExpeditionRegionXMin). No ECS query, observe-only, "Game"-scene guarded so the menu isn't restyled; knobs in a WorldFeelConfig-style MonoBehaviour with SubsystemRegistration static reset + null-safe fallbacks. Zero sim/netcode impact (writes only global managed RenderSettings; ClientSimulation filter keeps it off the server/headless world).