Files
Project-M/CLAUDE.md
T
kronic c87dd04e87 Docs: truth pass over CLAUDE.md, DRs and the MOCs (audit M13/M14/M15/M16/M19/L11/L12)
CLAUDE.md — it is loaded every session, so its errors cost the most:
- M13: it still said "No world code until the world-model spike passes
  its design review" while DR-049 has been `accepted` since 07-13 and the
  roadmap says PASSED. A hard contradiction between the two authority
  docs, with CLAUDE.md winning by being always-loaded — plausibly part of
  why Phase 2 never started. Now points at the build spec, warns it needs
  re-anchoring, and links the parked engine fork.
- Records the H2 hazard as a rule: a socket whose SparkId is missing from
  the baked AbilityDatabase silently reads Damage/Range/Cooldown 0.
- L12: the asmdef table was stale on all four rows; also notes the fifth
  compilation unit (Scripts/Editor has no asmdef) and that tests now
  reference Authoring.
- M19: per-machine setup — the machine-loss recovery checklist — was
  missing Blender + blendermcp (which the committed /art-dev skill
  hard-requires) and ctx7, and claimed a ${CLAUDE_PROJECT_DIR} in
  .mcp.json that isn't there.
- Structures / harvest / persistence / BefourStudios bullets rewritten:
  they described deleted subsystems.
- Net-zero rule honoured: the additions pushed it to 41,057 (over the
  40,960 hard limit), so two long art-pipeline bullets moved verbatim to
  the gotchas archive under a dated heading. 39,661 now — inside the
  file's own 39,936 soft ceiling.

- M14: DR-053 files the Bevy fork as PARKED. The guide was reachable from
  NOTHING — one file, zero inbound links, and its gating instruction
  lived only in machine-local memory, which CLAUDE.md explicitly forbids
  as a sole home. It now has a committed home and inbound links.
- DR-054 records this purge, including the three files that were in the
  delete set until their consumers were checked and turned out to be
  load-bearing.
- M15: 13 superseded DRs re-statused with `superseded_by`. Only DR-002
  carried the field before, though the project's own template prescribes
  it.
- M19: Home.md stated three wrong facts (latest DR, latest session, date)
  — refreshed. Pillars.md and Systems_Index.md now carry warning banners:
  both assert things the purges falsified, and Systems_Index still claims
  to be "the accurate map of what exists in code today".
- L11: the 4 genuinely-broken wikilinks fixed (typo'd DR filenames). Zero
  broken vault-internal links now.
- Session log written — the last three commits before today had none,
  breaking the protocol's own bookend rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:44:18 -07:00

39 KiB

Project M — CLAUDE.md

Multiplayer game on Unity DOTS (Entities) + Netcode for Entities — server-authoritative, input-only clients, client prediction. This file is committed and is the authoritative, cross-machine source of conventions. The /dots-dev skill drives feature work; one-time stack setup lives in Docs/dots-setup-task.md.

Maintaining this file (size budget — read before editing) ★

Hard limit: 40 KB (40 960 bytes). This file is context-loaded every session — over-budget gets it truncated. Keep ≥1 KB of headroom below it (target ≤ ~39 KB). After any edit, keep it under budget:

  • Size check — bash: wc -c CLAUDE.md · PowerShell: (Get-Item CLAUDE.md).Length. Must be < 40960.
  • Archive, don't delete. When trimming, append the verbose / least-hot detail to the obsidian reference note Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md under a new dated heading (never overwrite an older snapshot), and leave a one-line pointer + the relevant [[DR-###]] link here.
  • Net-zero rule: every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them ); depth lives in the archive + DRs.
  • Condensation history → the archive's dated headings (each edit's payment noted there).

Stack — Unity 6.5.1 (6000.5.1f1, stable) as of 2026-06-27

Package Version Notes
com.unity.entities 6.5.0 Entities/Collections/Graphics track the Editor version (6.x).
com.unity.entities.graphics 6.5.0 Renders entities under URP 17.5.
com.unity.collections 6.5.0 (transitive)
com.unity.netcode 6.5.0 Netcode for Entities (ECS). NOT com.unity.netcode.gameobjects. Unified 6.x since 6.5 (was 1.x).
com.unity.physics 6.5.0 Unity Physics (DOTS). Unified 6.x since 6.5 (was 1.x).
com.unity.charactercontroller 1.4.2 DOTS kinematic collide-and-slide. Declares entities/physics 1.3.15, resolves on 6.5.0 via SemVer floor; compiles+bakes on 6.5.
com.unity.transport 6.5.0 (transitive)
com.unity.burst 1.8.29 (transitive)
com.unity.mathematics 1.4.0 (transitive)
com.rukhanka.animation 2.9.0 Local pkg (Packages/com.rukhanka.animation). ECS skeletal animation (Burst CPU/GPU skinning). Resolves on 6.5.0 via SemVer floor. Netcode replication OFF → client-derived. See DR-022_Animation_Pipeline_Rukhanka_Synty.

Values match packages-lock.json (URP 17.5.0). History: 6.6.0a6 transport bug — DR-002_Unity66_Alpha_Netcode_Transport + archive.

Namespaces & assembly split

Root namespace: ProjectM. Code lives under Assets/_Project/Scripts/ in four asmdefs (never create/edit .csproj/.sln; only .asmdef):

Assembly Namespace Runs in References
ProjectM.Simulation ProjectM.Simulation client + server worlds Entities, Unity.Transforms, Collections, Mathematics, Burst, Unity.Physics, Unity.NetCode, Unity.CharacterController
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), Unity.Networking.Transport, UnityEngine.UI, Rukhanka.Toolbox
ProjectM.Server ProjectM.Server server world only + Simulation, Unity.Transforms, Unity.NetCode, Unity.Networking.Transport, Unity.Physics
ProjectM.Authoring ProjectM.Authoring bake time (+ scene runtime) Simulation, Entities, Unity.Entities.Hybrid, Collections, Mathematics, Unity.NetCode, Unity.Physics, Unity.CharacterController
  • Simulation = components + systems shared by both worlds (most gameplay). Client/Server = world-specific. Authoring = …Authoring MonoBehaviours + Baker<T>.
  • A fifth compilation unit exists by omission: Scripts/Editor/ has NO asmdef, so it compiles into Assembly-CSharp-Editor. ProjectM.Tests.EditMode references all four asmdefs incl. Authoring (so bakers are testable).
  • Other folders: Assets/_Project/Subscenes/ (baked entity subscenes), Assets/_Project/Prefabs/, Assets/_Project/Tests/EditMode/.
  • Feature folders added since (Client/UI, Client/Settings, Server/Automation, Server/Persistence, Simulation/Automation, Simulation/Persistence) live inside the existing four asmdefs — no new assemblies.

Build gotchas (distilled)

Long-form originals + the milestone each came from: Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md. The highest-recurrence hazards are flagged .

Assemblies, asmdefs & source-gen

  • Unity.Transforms must be a DIRECT asmdef reference for any assembly whose source-gen'd systems touch LocalTransform/LocalToWorld — transitive visibility compiles hand-written code but the generator emits CS0246 in *.g.cs — SAME failure if the consuming FILE omits using Unity.Transforms; (source-gen copies the file's usings into *.g.cs; adding a LocalTransform query to a system that lacked the using breaks only *.g.cs).
  • Unity.Physics must ALSO be a DIRECT asmdef ref for any assembly whose source-gen touches KinematicCharacterBody (it nests Unity.Physics.ColliderKey) → else CS8377/CS0012 in *.g.cs (same class as the Transforms rule).
  • Authoring asmdefs need Unity.Entities.Hybrid (Baker<T>) + Unity.Collections (baking source-gen). Never name a nested baker Baker (shadows Baker<T>) — use FooBaker.
  • Never name an IComponentData PlayerInput and don't using UnityEngine.InputSystem; in a file referencing such a component — collides with the managed UnityEngine.InputSystem.PlayerInput, generator binds RefRW<…> to the class → misleading CS8377. Fully-qualify Input System types instead.
  • The generated Input Actions C# wrapper must live inside the consuming asmdef — set the importer's wrapperCodePath (in .inputactions.meta) to e.g. Assets/_Project/Scripts/Client/Input/ProjectMInput.cs; the default location compiles into Assembly-CSharp which asmdefs can't reference. No .inputactions edit unless you intend a wrapper regen.
  • IInputComponentData requires implementing FixedString512Bytes ToFixedString().

Burst hazards ★

  • Cross-assembly generics + enums trip Burst internal compiler errors. Predicted-spawn classification (SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>) and any enum compared inside a Bursted system are the known offenders. Make such systems plain non-Burst ISystem, and store ops/schemes/region ids as byte, never enum in anything Bursted or in RPC payloads.
  • A Burst ICE corrupts the editor's incremental cache → afterward, valid [BurstCompile] entry points log "… is not a known Burst entry point" + run slow managed-fallback. A clean compile + green tests + working runtime confirm the code is fine. Fix = editor restart (or delete Library/BurstCache while closed); a domain reload alone does NOT clear it.
  • Editing a Bursted ISystem's SystemAPI query set on an UNFOCUSED editor can leave a STALE binary → runtime InvalidOperationException: "required component type was not declared in the EntityQuery" from an unrelated GetSingleton<T> (Burst stack reports the OLD line number). Workaround: Burst compilation OFF for the session; permanent fix = restart. Prefer a focused editor for Burst-affecting edits.

Netcode / prediction ★

  • PredictedSimulationSystemGroup runs multiple times per frame on rollback → predicted systems must be deterministic/idempotent, filter with .WithAll<Simulate>(), and use no wall-clock / Time.deltaTime / System.Random.
  • Predicted physics is implicit — with the netcode-physics package present, Netcode relocates PhysicsSystemGroup into PredictedFixedStepSimulationSystemGroup (child of the predicted group, OrderFirst). NetCodePhysicsConfig only tunes lag-comp/run-mode/history; put one in the gameplay subscene with PhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntities.
  • The predicted physics group is OrderFirst, so [UpdateBefore/After(PredictedFixedStepSimulationSystemGroup)] from the parent predicted group sorts oddly: UpdateBefore is ignored (1-tick offset, still in-sync); for same-tick put the system inside the fixed-step group [UpdateBefore(PhysicsSystemGroup)]. OrderFirst/OrderLast ALSO wins against [UpdateBefore/After] the predicted group from the plain SimulationSystemGroup — a server-only system there always runs after the predicted group → use [UpdateAfter(PredictedSimulationSystemGroup)], never UpdateBefore (Unity logs "Ignoring invalid UpdateBefore…").
  • Move ownerless INTERPOLATED ghosts (enemies, pickups) SERVER-ONLY in the plain SimulationSystemGroup — they aren't predicted; the server has no rollback. Stock LocalTransform replication carries position (no hand-written [GhostField]). A contact DamageEvent appended there drains the following tick (~16ms, fine for melee).
  • PhysicsVelocity auto-replicates (Netcode ships the default variant + serializer) — drive a predicted-physics body by writing PhysicsVelocity.Linear, not by teleporting LocalTransform.
  • Ownerless interpolated ghost ≠ owner-predicted for buffer replication. A server-spawned ownerless ghost replicates a [GhostField] IBufferElementData to all clients with no OwnerSendType / no GhostOwner — server mutations just propagate. OwnerSendType.All + GhostOwner are only for a predicting owner to recompute its own state.
  • One-off shared-state actions belong on an IRpcCommand, not a predicted InputEvent (RPCs are reliable; one-shot InputEvents drop under tick-batching). ★ Raw InputBufferData reads: InputEvent.IsSet = EVER-pressed — wire counts ACCUMULATE (only the decoded component is delta-corrected); gate on a count-STEP vs the prior tick's command, else one press recasts at every cooldown reopen (07-21). 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 — the RpcCollection hash must match across release/dev peers; #if-gate only the send/receive SYSTEMS, never the struct. Re-mean bytes, don't rename: unchanged byte VALUES keep the [GhostField] serializer identical → re-bake-free (only authoring default-value edits re-bake the subscene).
  • Derive enableable gates instead of replicating them. e.g. player Dead = a LOCAL enableable derived every predicted tick from replicated Health<=0 (rollback-correct, no [GhostEnabledBit]). To write the bit on a disabled entity the query must visit it (.WithPresent<Dead>()); bake the enableable DISABLED so instances spawn off. Respawn/death timing is server-only.
  • Cooldown/spawn "next tick" sentinels: route every stored tick through TickUtil.NonZero(...) (a computed ServerTick+delay can wrap to 0, the "ready" sentinel) and compare with NetworkTick.IsNewerThan / .TicksSince, never raw uint < / subtraction. ★ A BAKED [GhostField] scheduled-tick defaults to 0 → an "invalid ⇒ fire" guard STORMS it; for a baked/periodic tick INVERT (0 = not-ready → skip/lazy-stamp) + stamp born-correct. Client cues off a periodic tick ride the ABSOLUTE tick + value-latch + was-counting-down arm-guard — never edge-detect (phantom-fires on 0→stamp / relevancy re-entry). Tick SOURCE: a predicted-player threat reads nt.ServerTick (Geyser); an INTERPOLATED ghost's own effect reads InterpolationTick (ZoneTelegraph — predicted pins the fill ~RTT wrong, invisible on loopback). See Geyser_Build_Spec.
  • GhostRelevancy for region splits: use GhostRelevancyMode.SetIsIrrelevant (not SetIsRelevant) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. RegionTag{byte Region} is server-only, NOT a [GhostField]. ★ A 2nd region sharing an EXISTING tag (EnemyTag) → re-audit every query/cull over it: once-safe global despawns/cleared-checks then wipe or block cross-region (DR-031, DR-040). RelevantGhostForConnection = {int Connection=NetworkId.Value; int Ghost=ghostId}. See DR-013_M6_Aether_Cycle_Region_Split.
  • Shared GLOBAL state (resource ledger, RunInfo, meta tiers) rides the UNTAGGED director 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).
  • Frontend world lifecycle (menu → on-demand worlds) ★: use CreateClientWorld/CreateServerWorld (they register the ServerWorld/ClientWorld statics the UI reads; CreateLocalWorld was internal pre-6.5, PUBLIC on 6.5.0); menu world via DefaultWorldInitialization.Initialize(name, false). Never dispose/create worlds inside an ECS system — do it on a frame-boundary coroutine (SessionRunner, DontDestroyOnLoad). The gameplay subscene streams in ONLY if a netcode world is the DefaultGameObjectInjectionWorld at LoadScene time. See DR-019_Frontend_Menu_Settings_Saves_Build.

Physics & character controller

  • Unity Physics 1.x bakes built-in UnityEngine colliders + Rigidbody (the Physics-0.x PhysicsShapeAuthoring/PhysicsBodyAuthoring are gone). Static collider (no Rigidbody) → baked into the subscene PhysicsWorld, deterministic, no replication. Rigidbody.FreezeRotation is NOT honored by the baker — zero angular velocity + write rotation each tick, or set PhysicsMass.InverseInertia = float3.zero. A MeshCollider bakes ONLY if the mesh has Read/Write enabled — else InvalidOperationException per bake and NO baked shape (classic scene view still shows the collider; only a baked-CollisionWorld probe catches it); flip the ModelImporter isReadable. Env-collider fidelity is tool-driven: ColliderFitTools (audit/apply) refits subscene walls/cover/landmarks to the Game.unity visuals.
  • The player is a Unity Character Controller kinematic character (NOT a dynamic Rigidbody; M5's PlayerMoveSystem/PlayerPlanarConstraintSystem deleted, predicted-physics infra kept). PlayerControlSystem maps input → CharacterControl; CharacterProcessor collide-and-slides in the relocated KinematicCharacterPhysicsUpdateGroup. CC 1.4.2 API = IKinematicCharacterProcessor<T> + KinematicCharacterDataAccess + static KinematicCharacterUtilities.Update_* (verify with unity_reflect).
  • KinematicCharacterUtilities.BakeCharacter aborts with a Rigidbody and needs uniform (1,1,1) scale. CharacterInterpolation must be PredictedClient-only (a DefaultVariantSystemBase strips it from server + interpolated prefabs) — else double-interp on remotes. Do NOT copy the CC sample's global LocalTransform → DontSerializeVariant (project-wide; breaks non-character ghosts that rely on stock LocalTransform replication).
  • Top-down CC config: SnapToGround=false, InterpolateRotation=false (rotation owned by PlayerAimSystem), SimulateDynamicBody=false; gravity handled by feeding float3.zero to Update_GroundPushing.
  • Hit/area tests must be SWEPT, not point checks — a point check tunnels when the per-tick step exceeds the target radius (high speed or tick-batching); test the segment traversed this tick. In a PLAIN SimulationSystemGroup system do NOT use SystemAPI.Time.DeltaTime (wall-frame delta, not the fixed step) — store the per-tick step on the projectile (Projectile.LastStep, written in the fixed-step group) and rebuild the segment as cur - dir*LastStep. ecb.DestroyEntity at-most-once per tick (destroyed-bitset; double destroy throws at Playback). TWO target types in one pass: UNIFY into one best-target loop + one shared bitset (separate sweeps double-destroy a projectile overlapping both — DR-018). A per-hit yield (int) cast that also gates despawn is an immortal-sink (sub-1.0→0→no deposit, shot still consumed): guard math.max(1,(int)yield) + [Min(1f)] authoring.

Build / structures / grid

  • Grid math (BaseGridMath, still live for spawn rings/respawn/lights): corner-origin, center-returning, half-open cell bounds, math.floor; lock cell size as a coordinate space once. Structures/placement themselves are deleted — recipe + atomicity rules in DR-014_M6_Build_Structures_Automation_Foundation if buildables return.
  • Ledger spends: afford→act else SOFT-FAIL (no cooldown-burn), read LIVE in-loop (no hoist); a Health-less entity silently drops OUT of an aggro snapshot (snapshot ABOVE the early-return).
  • DR-051 purge (07-15) ★: siege/cycle/core/turret/automation + legacy AbilityRef path + onboarding DELETED (git = the archive). Retired byte VALUES stay reserved, never renumbered (StructureType 1-4, ResourceId.Charge, DebugOp 3/10/11, TuningKnob 20-23); DebugOp.SpawnWave/EndSiege RE-MEANT (force-wave / quiet-arena). Waves UNGATED — a baked WaveDirectorAuthoring decides by placement. Sockets are THE ability model (frame loadout seeded unconditionally at spawn) — the Spark defs must be in EVERY gameplay subscene's AbilityDatabaseAuthoring; a socket whose SparkId is missing from the baked blob silently reads Damage/Range/Cooldown 0 (audit H2, live-proven). FrameKind = Bathynaut(2)/Harpooner(3); PlayerClass is gone (FrameId is the single frame identity). DR-051_Lantern_Realignment_Purge · 2026-08-07 audit purge deleted the whole base/expedition shell (run FSM, meta shop, prep, boons, build/structures, storage, inventory/equipment, enemy variants + boss): DR-054_Audit_Purge_2026-08.
  • Harvest is single-sink (→ the shared ledger, via HarvestMath.DepositYield). The personal-bag/equipment layer was deleted 2026-08-07; reintroduce LANTERN's carried-vs-banked split inside HarvestMath, not at its two call sites.
  • Disk persistence (SaveData, single-slot atomic JSON, versioned) ★: born-correct loadCycleDirectorSpawnSystem (now the ledger host only) applies the menu-staged PendingSave AT SPAWN. v7 = a FRESH EPOCH: MinLoadableVersion = CurrentVersion = 7; additive going forward — the save now carries only the ledger (structure/meta fields persist empty so v7 files still load). 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; code-built UI Toolkit. Prefab-asset edits: LoadPrefabContents→modify→SaveAsPrefabAssetUnload. Watch shared-material bleed on re-tint; ACES needs URP grading mode HDR. Detail → archive 07-16.
  • Prototype glue lives in ProjectM.Client as MonoBehaviours: PrototypeCameraRig (player-following ARPG cam), VFXConfig (static Instance + prefab fields bridging authored VFX to CombatFeedbackSystem; keep a procedural fallback). A static presentation bridge must reset on play-enter via [RuntimeInitializeOnLoadMethod(SubsystemRegistration)] (statics survive fast-enter-playmode reloads → stale flash).
  • UITK HUD + menus ★: MenuUi owns the palette/factories/PanelSettings/EventSystem plumbing; HudSystem = a PresentationSystemGroup observe-only SystemBase owning a runtime UIDocument (sortingOrder 50, root pickingMode = Ignore, tree built once rootVisualElement != null). Runtime UITK needs PanelSettings WITH a themeStyleSheet AND an EventSystem + InputSystemUIInputModule or buttons are silently dead. The build palette (lazy from the client StructureCatalog) drives click-to-place: green/red BuildPreviewMath ghost → BuildPlaceRequest RPC, right-click/Esc cancel, [/]/R rotate. See DR-021_HUD_UITK_BuildPalette.
  • HUD skin = build-safe HudTheme SO of serialized sprite refs (runtime Resources.Load by name is build-stripped); tint MULTIPLIES, never set unitySlice* on 9-slices → archive 2026-07-06 + DR-024_HUD_Synty_Skin_Theme.

Art import (HDRP store packs → URP)

  • Synty = URP-native. (BefourStudios HDRP pack deleted 2026-08-07 — 4 reachable textures kept in _Project/Textures/Env.)
  • World = the LANTERN murk ★ (DR-051; Synty biomes deleted): ONE look — PostFX_Lantern.asset (ACES; needs URP HDR grading) + Env_SeabedKit.prefab (ArtStaging-sourced) + unified RenderSettings (NO skybox; Exp² teal fog {0.02,0.10,0.12}; flat ambient {0.03,0.055,0.08}; density knob 0.035 play / 0.075 staging; camera clearFlags SolidColor deep-water — else no-skybox corners bleed blue). ScenePolicy.IsGameplayScene() gates the dynamic-look systems — never re-add scene.name string checks. WorldAtmosphereSystem = water-column murk.
  • A dark-lit screenshot MASKS material bugs — verify material values (GetPropertyType-guard before GetColor/GetFloat; detail → archive 07-16).
  • EG per-instance tint (URPMaterialPropertyBaseColor) works ONLY on a Hybrid-Per-Instance _BaseColor graph (AnimatedLitShader yes; stock Synty Generic_Basic = Unity-Per-Material → renders but silently no-ops) — check the graph first; else procedural decals. Detail → archive 07-16.
  • VolumeProfile.Add persistence + the URP m_AssetVersion build blocker → archive 2026-07-06 (+ native memory urp-global-settings-version-blocks-build).
  • LocalTransform.FromPosition() resets Scale=1 — server spawners read the prefab's baked LocalTransform, override only Position (Scale is a [GhostField] → consistent-but-wrong).
  • Static decor → gameplay subscene (EG renders only baked entities); strip colliders from cosmetic props + no GhostAuthoring on scenery (classic-URP colliders are inert to the DOTS PhysicsWorld). World collision = subscene-only ★: Environment-layer boundary ring + landmark colliders (player blocked via the layer matrix); enemies slide via a server CollisionWorld.SphereCast in EnemyAISystem. ★ enemy slide has NO pathfinding — a near-vertical wall normal or an embedded spawn FROZE Husks on cover rocks (soft-locks a room on one leftover); fixed 07-07 via EnemyMoveUtil.Depenetrate + tangent-slide + an EnemyNavState nudge backstop. Re-validate movers aren't frozen when adding Environment cover. 07-10: the nudge is COVER-AWARE — a live destructible-cover ghost (BlightClutter carrier) SUPPRESSES the phase-through (breakable ⇒ no soft-lock); static wedges still nudge. Boundary = SM_Env_Rock_Cliff rim. See 2026-06-08_World_Collision_HUD_Scaling.
  • A GA "projectile" prefab self-propels — strip to particles before Start (CombatFeedbackSystem.StripCosmetic); verify components, not the name.

Aim / facing (SoD model — DR-052) ★

  • PlayerFacing is body-yaw ONLY (moves→face movement; cast window→turn to aim; idle→hold; never passively track the cursor). Every gameplay direction reads FacingMath.ResolveAim(PlayerInput.Aim, facing) (all AbilityFireSystem archetypes + assist seed + melee cleave) and aim-readout presentation reads the SAME resolver; Movement-archetype sockets never open a cast window (TickWindowMath); PlayerAimSystem stays UN-gated (integrator over the snapshot-restored [GhostField]). Scheme byte KBM=0/Gamepad=1; KBM reticle re-raycasts. DR-052_SoD_Facing_Underwater_Feel + archive 2026-07-06.

Animation (Rukhanka) ★

Full rationale: DR-022_Animation_Pipeline_Rukhanka_Synty · DR-023_Enemy_Animation_MonsterMash · Synty_Asset_Inventory. Skeletal animation = Rukhanka 2.9 (the only maintained Entities-native option on 6.4). Netcode replication OFF (RUKHANKA_WITH_NETCODE undefined) → client-derived: PlayerAnimationDriveSystem (client-only SystemBase, [WorldSystemFilter(LocalSimulation|ClientSimulation)] + [UpdateBefore(RukhankaAnimationSystemGroup)]) reads replicated state and writes params via AnimatorParametersAspect/FastAnimatorParameter. No new [GhostField]s; no DefaultVariant strip (define off → ghost hash unchanged).

  • The rig must bake on the SAME entity that holds the gameplay components the drive job readsAnimator + RigDefinitionAuthoring on the player root (not a child), flatten skeleton + SMRs under it, else the drive query matches nothing.
  • CPU engine skins via Entities-Graphics GPU deformation → needs a deformation-aware material (AnimatedLitShader, multi-target ShaderGraph + UniversalTarget; Synty atlas → _BaseColorMap). Stock URP/Lit renders unskinned static + a "does not support skinning" warning (NOT magenta — that's a reused HDRP sample .mat).
  • The 3 deformation ShaderGraphs (incl. AnimatedLitShader) live in _Project/Shaders/ — GUID-preserved MoveAsset out of the Rukhanka "Animation Samples" tree (then deleted; importing those samples drags in 26 subscenes + world-running sample systems + a TMP conflict). Detail → gotchas archive 07-04b.
  • First Rukhanka bake is ~60 s, main-thread-synchronous (editor freezes — not a hang; cached after).
  • The server runs Rukhanka unless you strip it — its deformation systems are [WorldSystemFilter(Default)] (⊇ ServerSimulation). ServerStripAnimationSystem (server-only one-shot) disables every Rukhanka.Runtime system on the server (group-disable cascades; matched by assembly name → no type ref). Only Play-validation caught this.
  • Build the controller via the AnimatorController API (manage_animation drops enum/Vector blend-tree fields). Skeleton-root = walk up from a bone to the soldier's direct child, NOT SkinnedMeshRenderer.rootBone (the bounds root — head SMR's is Spine_03 → destroys the lower skeleton).
  • Skinned attachments (graft/rebase/bindpose) + the HUMANOID clip pipeline (Blender per-action FBX rules) → archive 2026-08-07 (long-form; also in the /art-dev cookbook). Rig type IS Humanoid; root motion OFF; Optimize Game Objects OFF.
  • ENEMIES reuse the player pipeline — a Husk = ownerless interpolated ghost = a remote player, so EnemyAnimationDriveSystem mirrors the REMOTE path (LocalTransform delta velocity + prevPos cache; IsAttacking = AttackWindup != 0). Drop [RequireMatchingQueriesForUpdate] so the prune runs every frame (else a cache entry leaks per kill). Build enemy prefabs via EnemyRigTools, GUID-preserving (DeleteAsset+CopyAsset orphans subscene refs); WaveSystem uses baked.WithPosition (not FromPosition → Scale reset). See DR-023_Enemy_Animation_MonsterMash.

MCP / editor workflow ★

  • Edit Assets .cs ONLY via MCP apply_text_edits / create_script (Unity's scripting pipeline) — the raw Write tool does NOT reliably trigger a recompile on an unfocused editor → tests/execute_code run a stale assembly; a raw-Write-created NEW .cs gets no .meta / no test-discovery until refresh_unity scope=all mode=force. (Write/Edit are fine for non-asset files: this vault, asmdef JSON, etc.) script_apply_edits anchor_replace (regex) + delete_method work even on a struct : ISystem.
  • apply_text_edits with MULTIPLE non-adjacent edits in one call can MISALIGN — one edit per call (or strict bottom-first), always with precondition_sha256 (it returns the current SHA on mismatch). ★ One edit can SWALLOW an adjacent attribute/comment line (07-06 _portalMat NRE · 07-07 [RuntimeInitializeOnLoadMethod] off WorldFeelConfig.ResetDefaults → feedback slice silently dead) — re-read neighbors after editing beside attributes; silent presentation slice → probe its config's Enabled in-play. create_script won't overwrite; full-file rewrites = whole-span apply_text_edits (its brace-balance validator guards botched spans) or manage_script delete+create_script (NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours). script_apply_edits replace_method is safe for class methods but can't target a struct : ISystem. DR-017_Persistent_Base_Player_Driven_Pacing
  • execute_code runs as a method body — no using directives (parsed as statements); fully-qualify every type. Identify worlds by world.Name == "ServerWorld"/"ClientWorld" (flags overlap a shared Game bit).
  • manage_gameobject create / manage_prefabs modify_contents component_properties SILENTLY DROP enum + Vector3 fields — set those via a follow-up manage_components set_property and VERIFY through mcpforunity://scene/gameobject/{id}/component/{Type} (or read the baked component in execute_code after Play). manage_material set_renderer_color uses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.
  • New ghost prefab recipe: manage_asset duplicate a correctly-configured ghost (UpgradePickup.prefab) → swap the authoring MB (ownerless/interpolated GhostAuthoring + LEG come free). Runtime-spawn shared ghosts via a one-shot server spawner (dodges the prespawn handshake); wire baked spawners via manage_scene load additiveset_active→create→saveclose_scene. Detail → archive 07-16.
  • An UNFOCUSED editor throttles Edit mode to near-idle (MCP pings time out, bridge looks hung — it still queues; telemetry_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 (the DR-051 contract — exactly these four): MainMenu.unity (build 0, UITK frontend) · Game.unity (build 1, the seabed arena; subscene Gameplay.unity) · DevSandbox.unity (renamed from Gym; dev tooling + subscene GymSub.unity; the DebugOverlay/F1-F2 dev scripts gate on this scene NAME) · ArtStaging.unity (art viewing, no player; the look's source of truth). All share the LANTERN look (see World bullet). The on-demand lifecycle (WorldLauncher/SessionRunner/MainMenuController) creates the right worlds per menu choice (Single/Host/Join), THEN LoadScene(Game) (subscene-streaming rule above).
  • Direction = LANTERN ★ — pivot LOCKED 2026-07-13 (DR-048_Lantern_Adoption_Full_Pivot). Co-op action-RPG, light is territory (seed-pinned pocket-graph; SoD manual-aim skillshots; suit-frames + Sparks + wild mutations). Supersedes the Awakening-Engine fiction + the Co-op Hades iteration. Operative roadmap Roadmap_Lantern_Slice; existing code (combat feel, ability/boon plumbing, run/hub lifecycle, save, regions/relevancy) is QUARRY, not foundation — keep/rework/mothball per Lantern_Strip_Mothball_Inventory. World-model review PASSED 2026-07-13 (DR-049_Lantern_World_Model_Design accepted) → build against World_Model_Build_Spec; re-anchor it first (its step 1 assumes SaveData MinLoadableVersion < 7, which DR-051 already shipped, and its RegionTag→PocketTag line anchors have moved). ★ The engine fork (DR-053_Engine_Fork_Bevy_Parked) is open — surface it before starting world code. The prior co-op-Hades core-loop was DELETED 2026-08-07 (audit H1), not mothballed; git is the archive. ★ general gotcha kept: a serialized prefab bool ignores the C# initializer — flip the value in the prefab.

DOTS / ECS conventions (authoritative summary)

Full rules: .claude/skills/dots-dev/references/dots-conventions.md (in-repo; travels with the repo). These replace classic MonoBehaviour/GameObject patterns.

  • struct : 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 — max 7 type args; read an 8th component via a ComponentLookup keyed by the entity (hit twice in Phase 1.7 boons). Aspects (IAspect) are DEPRECATED (Entities 1.4+) — do not author new ones.
  • Jobs: IJobEntity / IJobChunk; thread JobHandle through state.Dependency; mark inputs [ReadOnly]. Allocators: Temp (frame), TempJob (one job), Persistent (must dispose). Burst breaks on managed types/exceptions/reflection/strings.
  • Structural changes (add/remove component, create/destroy entity) invalidate handles + cause sync points → batch via EntityCommandBuffer (Begin/EndSimulationEntityCommandBufferSystem; .AsParallelWriter() in parallel jobs).
  • Baking: …Authoring MonoBehaviour + class FooBaker : Baker<FooAuthoring>GetEntity(authoring, TransformUsageFlags.…) then AddComponent. Subscenes stream async — entities aren't present the instant a reference exists.
  • Netcode: ghosts = replicated entities (GhostAuthoringComponent + [GhostField]); predicted (player-controlled, rolled back) vs interpolated. Core sim runs in PredictedSimulationSystemGroup (fixed step, runs multiple times per frame on rollback → deterministic/idempotent; filter with .WithAll<Simulate>()). Server-authoritative: clients send input (IInputComponentData), not state. RPCs (IRpcCommand) for one-off events. No wall-clock/Time.deltaTime/System.Random in predicted sim.
  • Always verify volatile DOTS/Netcode API shape via context7 at code-time — do not trust memory. Pinned IDs: Entities → /websites/unity3d_packages_com_unity_entities_6_5_manual; Netcode → /websites/unity3d_packages_com_unity_netcode_1_10_api (closest published as of 07-07, no 6.x set yet — installed is 6.5.0; re-resolve periodically); ECS samples → /unity-technologies/entitycomponentsystemsamples.

Testing

  • Default = plain-Entities EditMode test: create a World, register the system in SimulationSystemGroup, tick, assert. Public API, version-independent. e.g. HealthApplyDamageSystemTests. Run via run_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"]).
  • NetCodeTestWorld is internal (6.5.0 re-check: not even loaded outside test asmdefs), exposed only to a fixed [InternalsVisibleTo] allow-list — to use it, name a test asmdef to match an entry (e.g. Unity.NetcodeSamples.EditModeTests) or vendor the test utils. Netcode world boot is covered by the Play Mode check, not a NetCodeTestWorld test. See DR-001_Netcode_Test_Harness.
  • Burst/source-gen errors surface at editor compile, not a plain build — always read_console after script changes, and run a play/tick test, not just a compile. Cover swept hit-detection with a tunnelling regression test (the point-check tunnel bug doesn't surface in a point-based unit test).

Guardrails

  • Never edit a .meta independently of its asset; delete an asset and its .meta together.
  • Never read/write Library/, Temp/, obj/, Logs/, UserSettings/ (generated/cache). Use MCP resources for editor state.
  • Never create/edit/commit .csproj/.sln — only .asmdef.
  • No asset/scene edits during Play Mode. Check editor_state.advice.ready_for_tools before mutating; package adds/refreshes trigger domain reloads — wait for is_compiling=false.

Memory — three layers (which tool when)

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

  • Where is X / who calls it → Grep/Glob. What did we decide / how does Z work → basic-memory → read the vault note. Current DOTS API → context7. Conventions → this file. Long-form build lessons → the gotchas archive.
  • Cross-machine rule: durable truth → the vault or this file (both committed); native memory/ is local-only, never the sole home of a decision.

Per-machine setup (NOT in git — redo on each machine)

.mcp.json is committed (one server: basic-memory); the dots-dev + art-dev skills travel with the repo (.claude/skills/dots-dev/). Each machine still needs: (1) uv/uvx + Obsidian app + obsidian-cli (machine-local, don't sync); (2) basic-memory registration — uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default then uvx basic-memory reindex --full --search --embeddings --project gamevault; (3) Unity 6.5 open + the Unity-MCP bridge connected (mcpforunity://editor/stateready_for_tools); (4) Blender 5.1 + the blendermcp addon (socket 9876) — the committed /art-dev skill hard-requires it; (5) npx ctx7 available (+ CONTEXT7_API_KEY for higher limits).