Files
Project-M/CLAUDE.md
T
kronic 23236f0fa0 Docs: trim CLAUDE.md under 40KB + self-maintenance protocol; full snapshot to vault archive
CLAUDE.md was 42126 bytes (over its own 40960-byte context-load limit). Trimmed to 39437 (1523B headroom): kept the stack/assembly tables, all high-recurrence hazards, bootstrap/DOTS/testing/guardrails; condensed the verbose mega-bullets to their essence + existing [[DR-###]] pointers; collapsed the redundant Memory table into a link to [[Documentation_Protocol]].

Added a 'Maintaining this file (size budget)' section so future edits self-enforce the limit (size-check commands, archive-don't-delete rule, net-zero rule).

Appended a dated, byte-faithful verbatim snapshot of the pre-trim CLAUDE.md to Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md (headings demoted to nest) so nothing is lost from the vault. Verified via a 4-auditor workflow: archive complete + integrity pass; 1 condensation distortion + 2 nuance losses caught and fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:34:25 -07:00

38 KiB
Raw Blame History

Project M — CLAUDE.md

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

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

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

  • Size check — bash: wc -c CLAUDE.md · PowerShell: (Get-Item CLAUDE.md).Length. Must be < 40960.
  • Archive, don't delete. When trimming, append the verbose / least-hot detail to the obsidian reference note Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md under a new dated heading (never overwrite an older snapshot), and leave a one-line pointer + the relevant [[DR-###]] link here. Design rationale already lives in the per-milestone DRs (Docs/Vault/07_Sessions/_Decisions/DR-###).
  • Net-zero rule: every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them ); depth lives in the archive + DRs.
  • Condensation history: 2026-06-04 (first pass, M1M6 long-form → archive) · 2026-06-07 (second pass, M7+/HUD/animation tightened; full pre-trim snapshot saved at the archive's tail).

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

Burst hazards ★

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

Netcode / prediction ★

  • PredictedSimulationSystemGroup runs multiple times per frame on rollback → predicted systems must be deterministic/idempotent, filter with .WithAll<Simulate>(), and use no wall-clock / Time.deltaTime / System.Random.
  • Predicted physics is implicit — with the netcode-physics package present, Netcode relocates PhysicsSystemGroup into PredictedFixedStepSimulationSystemGroup (child of the predicted group, OrderFirst). NetCodePhysicsConfig only tunes lag-comp/run-mode/history; put one in the gameplay subscene with PhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntities.
  • The predicted physics group is OrderFirst, so [UpdateBefore/After(PredictedFixedStepSimulationSystemGroup)] from the parent predicted group sorts oddly: UpdateBefore is ignored (1-tick offset, still in-sync); for same-tick put the system inside the fixed-step group [UpdateBefore(PhysicsSystemGroup)]. OrderFirst/OrderLast ALSO wins against [UpdateBefore/After] the predicted group from the plain SimulationSystemGroup — a server-only system there always runs after the predicted group → use [UpdateAfter(PredictedSimulationSystemGroup)], never UpdateBefore (Unity logs "Ignoring invalid UpdateBefore…").
  • Move ownerless INTERPOLATED ghosts (enemies, pickups) SERVER-ONLY in the plain SimulationSystemGroup — they aren't predicted; the server has no rollback. Stock LocalTransform replication carries position (no hand-written [GhostField]). A contact DamageEvent appended there drains the following tick (~16ms, fine for melee).
  • PhysicsVelocity auto-replicates (Netcode ships the default variant + serializer) — drive a predicted-physics body by writing PhysicsVelocity.Linear, not by teleporting LocalTransform.
  • Ownerless interpolated ghost ≠ owner-predicted for buffer replication. A server-spawned ownerless ghost replicates a [GhostField] IBufferElementData to all clients with no OwnerSendType / no GhostOwner — server mutations just propagate. OwnerSendType.All + GhostOwner are only for a predicting owner to recompute its own state.
  • One-off shared-state actions belong on an IRpcCommand, not a predicted InputEvent (RPCs are reliable; one-shot InputEvents — like Fire — drop under server tick-batching). RPC payloads are plain blittable scalars (int CellX/CellZ, not int2; no [GhostField]). For a SINGLE shared target resolve a server singleton — never put an Entity in the command; use ghost-id+spawn-tick (SpawnedGhostEntityMap) only for many targets.
  • Apply server-only RPC effects in the server SimulationSystemGroup, NOT the predicted loop (rollback would double-apply). Mutating a DynamicBuffer is not a structural change, so it's safe while iterating a different query.
  • A system-ordering CYCLE is INVISIBLE to plain-Entities EditMode tests (they register systems individually, unsorted) — it only throws ComponentSystemSorter "circular dependency cycle" at world creation (Play). When you add cross-system [UpdateBefore/After], re-audit the EXISTING [Update*] attributes of the systems you order around and always Play-validate. DR-017_Persistent_Base_Player_Driven_Pacing
  • A dev/debug IRpcCommand wire TYPE must be UNCONDITIONAL (no #if) — the reflection-built RpcCollection hash must match across release/dev peers or the handshake refuses; #if UNITY_EDITOR-gate only the send/receive SYSTEMS, never the request struct. Re-mean bytes, don't rename: unchanged byte VALUES keep the [GhostField] serializer identical → re-bake-free (only authoring default-value edits re-bake the subscene).
  • Derive enableable gates instead of replicating them. e.g. player Dead = a LOCAL enableable derived every predicted tick from replicated Health<=0 (rollback-correct, no [GhostEnabledBit]). To write the bit on a disabled entity the query must visit it (.WithPresent<Dead>()); bake the enableable DISABLED so instances spawn off. Respawn/death timing is server-only.
  • Cooldown/spawn "next tick" sentinels: route every stored tick through TickUtil.NonZero(...) (a computed ServerTick+delay can wrap to 0, the "ready" sentinel) and compare with NetworkTick.IsNewerThan / .TicksSince, never raw uint < / subtraction.
  • GhostRelevancy for region splits: use GhostRelevancyMode.SetIsIrrelevant (not SetIsRelevant) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. RegionTag{byte Region} is server-only, NOT a [GhostField]. RelevantGhostForConnection{int Connection (=NetworkId.Value); int Ghost (=GhostInstance.ghostId)}. See DR-013_M6_Aether_Cycle_Region_Split.
  • Shared GLOBAL state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost, never a region-tagged one (SetIsIrrelevant would hide it cross-region). Resolve 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 reads); menu world via DefaultWorldInitialization.Initialize(name, false). Never dispose/create worlds inside an ECS system — do it on a frame-boundary coroutine (SessionRunner, DontDestroyOnLoad). The gameplay subscene streams in ONLY if a netcode world is the DefaultGameObjectInjectionWorld at LoadScene time. See DR-019_Frontend_Menu_Settings_Saves_Build.

Physics & character controller

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

Build / structures / grid

  • Build-grid math must be deterministic + integer-stable: corner-origin, center-returning, half-open cell bounds, math.floor (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 (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. See DR-014_M6_Build_Structures_Automation_Foundation.
  • 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 → bounded buffer); StatRecomputeSystem folds it into EffectiveAbilityStats on both worlds. GoalProgress{[GhostField] int Charge, Target} rides the global CycleDirector ghost.
  • M7 Automation (server-only, never predicted) ★: Harvester/Conveyor/Fabricator are buildable machines on the PlacedStructure ghost storing PeriodTicks + server-only MachineInput/MachineOutput buffers (NOT [GhostField]). Production runs in the plain server group [UpdateAfter(PredictedSimulationSystemGroup)] (Harvester→Conveyor→Fabricator), replicating only via the global ledger + PlacedStructure. Deterministic catch-up via ProductionMath.CyclesDue (lower-bound 0, never 1; period-0 guarded). Byte-only pure math is EditMode-tested; ConveyorMath is order-independent (stable-sort by CellKey → at-most-one claim → losers stall). RuntimePlacedTag marks player-built machines for the save-scan. See DR-020_M7_Automation_Production_Chains.
  • Disk persistence (SaveData, single-slot atomic JSON at persistentDataPath) ★: versioned (null on bad version), schema additive. Born-correct loadCycleDirectorSpawnSystem applies a staged PendingSave AT SPAWN so the director ghost never replicates a default first. Autosave on the Siege→Calm checkpoint + quit-to-menu (WorldLauncher.TrySaveFromServer, host-only); BaseRestoreSystem replays saved structures charge-free with epoch-independent REMAINING-tick cooldowns; shared SaveStructureScan.Collect (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 + 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 sim).
  • Asset-free presentation: procedural AudioClip.Create SFX; runtime ParticleSystem pool (Sprites/Default + HDR start color); code-built UI Toolkit HUD/menus. Edit a prefab asset's component in code via PrefabUtility.LoadPrefabContents → modify → SaveAsPrefabAsset(root, path)UnloadPrefabContents. Watch shared-material bleed when re-tinting. ACES tonemapping needs URP color grading mode = HDR (m_ColorGradingMode=1).
  • Prototype glue lives in ProjectM.Client as MonoBehaviours: PrototypeCameraRig (player-following ARPG cam), VFXConfig (static Instance + prefab fields bridging authored VFX to CombatFeedbackSystem; keep a procedural fallback). A static presentation bridge must reset on play-enter via [RuntimeInitializeOnLoadMethod(SubsystemRegistration)] (statics survive fast-enter-playmode reloads → stale flash).
  • UITK HUD + menus ★: MenuUi owns the shared palette + element factories + PanelSettings/EventSystem plumbing + the canonical Round/Border helpers; HudUi is a thin extension. 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 AND an EventSystem + InputSystemUIInputModule or buttons are silently dead. The build palette (lazy-built from the client StructureCatalog) drives click-to-place: green/red ground-ghost preview (BuildPreviewMath, the client mirror of the server check), left-click → BuildPlaceRequest RPC, right-click/Esc cancel, [/]/R rotate, Fire suppressed in build mode. See DR-021_HUD_UITK_BuildPalette.
  • Synty HUD skin via a build-safe HudTheme ★ (DR-024): Synty sprites/fonts live under Assets/Synty/… (NOT Resources) → a runtime name-string Resources.Load is stripped from the build; instead a curated HudTheme : ScriptableObject at Assets/_Project/Resources/HudTheme.asset holds serialized Sprite/Font refs (dependency-walked in), loaded null-safe via HudTheme.Get() with every consumer falling back to the flat look on a null ref. unityBackgroundImageTintColor MULTIPLIES (tint white skins, zero bleed); fonts = cached SDF FontAsset, reset on SubsystemRegistration. Don't set unitySlice* on Synty frame/bar sprites — they ship authored 9-slice borders and overriding logs a per-element ERROR (but DO set unitySlice* for border-0 sprites that ship no authored border). Some Synty sprites import as Multiple mode → LoadAssetAtPath<Sprite> returns null; verify import mode + each ref non-null. See DR-024_HUD_Synty_Skin_Theme.

Art import (HDRP store packs → URP)

  • BefourStudios art is HDRP-authored → magenta under URP 17.4 + Entities Graphics. Convert, don't switch pipelines (HDRP breaks Entities Graphics). Re-author to stock URP/Lit via 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 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. Fix: reflection-set m_AssetVersion back to k_LastVersion, SetDirty + SaveAssets.
  • 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 (SyntyWorld root) render via classic URP and their colliders are inert to the DOTS PhysicsWorld. 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 it (it runs after the follow-cam's LateUpdate; latching from the gather drifts a frame behind). Hardware cursor hidden while aiming + focused, restored on focus-loss.

Animation (Rukhanka) ★

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

  • The rig must bake on the SAME entity that holds the gameplay components the drive job reads — put Animator + RigDefinitionAuthoring on the player root (not a child) and flatten the skeleton + SMRs under it, else the single-entity drive query matches nothing.
  • CPU engine still skins via Entities-Graphics GPU deformation → needs a deformation-aware material (AnimatedLitShader, a multi-target ShaderGraph with a UniversalTarget; Synty atlas → its _BaseColorMap). Stock URP/Lit renders unskinned static + a "does not support skinning" warning (NOT magenta — that's a reused HDRP sample .mat).
  • Importing the Rukhanka "Animation Samples" (the only source of AnimatedLitShader) drags in 26 sample subscenes (one NRE's the unguarded clip baker), sample systems that run in your worlds, and a conflicting TextMesh Pro folder. Fix: MoveAsset the 3 deformation ShaderGraphs to _Project/Shaders/ (GUID-preserving), then delete the samples tree.
  • First Rukhanka bake is ~60 s, synchronous on the main thread (editor freezes → looks like a hang, isn't); the animation blob is cached after → fast re-plays.
  • The server runs Rukhanka unless you strip it — its deformation systems are [WorldSystemFilter(Default)] (⊇ ServerSimulation). ServerStripAnimationSystem (server-only one-shot) disables every Rukhanka.Runtime system on the server (group-disable cascades; matched by assembly name → no type ref). Only Play-validation caught this.
  • Build the controller via the AnimatorController API (manage_animation drops enum/Vector blend-tree fields). Skeleton-root = walk up from a bone to the soldier's direct child, NOT SkinnedMeshRenderer.rootBone (the bounds root — the head SMR's is Spine_03; using it destroys the lower skeleton).
  • Synty Polygon characters share one Generic skeleton; the FBX needs Optimize Game Objects OFF (Rukhanka requirement). Entity origin = capsule center (~1 m up) → offset the un-keyed Root bone local Y (Rukhanka bakes it as a constant → it persists through clips). Root motion OFF (the CC owns the transform; blend tree is velocity-driven).
  • ENEMIES reuse the player pipeline — a Husk is an ownerless interpolated ghost = a remote player, so EnemyAnimationDriveSystem mirrors the REMOTE path (LocalTransform delta velocity + prevPos cache; facing via AnimParamMath.PlanarForward; maxSpeed from EnemyStats; IsAttacking = AttackWindup != 0). Drop [RequireMatchingQueriesForUpdate] so the prune runs every frame (else a cache entry leaks per kill). Build enemy prefabs via the EnemyRigTools editor tool, GUID-preserving (DeleteAsset+CopyAsset orphans subscene refs); WaveSystem uses baked.WithPosition (not FromPosition → resets Scale).

MCP / editor workflow ★

  • Edit Assets .cs ONLY via MCP apply_text_edits / create_script (Unity's scripting pipeline) — the raw Write tool does NOT reliably trigger a recompile on an unfocused editor → tests/execute_code run a stale assembly; a raw-Write-created NEW .cs gets no .meta / no test-discovery until refresh_unity scope=all mode=force. (Write/Edit are fine for non-asset files: this vault, asmdef JSON, etc.) script_apply_edits anchor_replace (regex) + delete_method work even on a struct : ISystem.
  • apply_text_edits with MULTIPLE non-adjacent edits in one call can MISALIGN — one edit per call (or strict bottom-first), always with precondition_sha256 (it returns the current SHA on mismatch). create_script won't overwrite; full-file rewrites = whole-span apply_text_edits (its brace-balance validator guards botched spans) or manage_script delete+create_script (NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours). script_apply_edits replace_method is safe for class methods but can't target a struct : ISystem. DR-017_Persistent_Base_Player_Driven_Pacing
  • execute_code runs as a method body — no using directives (parsed as statements); fully-qualify every type. Identify worlds by world.Name == "ServerWorld"/"ClientWorld" (flags overlap a shared Game bit).
  • manage_gameobject create / manage_prefabs modify_contents component_properties SILENTLY DROP enum + Vector3 fields — set those via a follow-up manage_components set_property and VERIFY through mcpforunity://scene/gameobject/{id}/component/{Type} (or read the baked component in execute_code after Play). manage_material set_renderer_color uses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.
  • New ghost prefab recipe: manage_asset duplicate an existing correctly-configured ghost (e.g. UpgradePickup.prefab) → manage_prefabs modify_contents to swap the authoring MonoBehaviour (strip MeshFilter+MeshRenderer for an invisible state-holder) — its ownerless/interpolated GhostAuthoringComponent + LinkedEntityGroupAuthoring come free. Runtime-spawn shared ghosts via a one-shot server spawner (dodges the prespawn handshake); wire a baked spawner into the subscene via manage_scene load additiveset_active_scene Gameplay → create+verify → saveclose_scene.
  • An UNFOCUSED editor throttles Edit mode to near-idle (MCP pings time out, bridge looks hung — it still queues; telemetry_ping succeeds) and stalls EditMode test INIT (pass run_tests(init_timeout=120000), retry). Application.runInBackground only helps in Play mode. Prefer refresh_unity scope=scripts for code-only changes. Ask the operator to focus Unity for heavy build/test/Burst sessions.
  • Run an adversarial design-review Workflow (netcode/relevancy · determinism/prediction · reuse/scope → synthesize) BEFORE coding a netcode-heavy slice — it has pre-caught relevancy traps, singleton collisions, dt-traps, double-destroys.

Bootstrap & worlds

  • ProjectM.Simulation.GameBootstrap : ClientServerBootstrap overrides Initialize with AutoConnectPort = 0 (M4 — listen/connect is explicit via the ConnectionConfig singleton + per-world ConnectionControlSystems). Editor default = instant-into-game + MPPM (creates ServerWorld (WorldFlags.GameServer) + ClientWorld (WorldFlags.GameClient)); the ProjectM/Boot Into Menu (Editor) EditorPref flips the MAIN editor to the frontend path. Player builds boot the UITK frontend menu (return false → one menu world, no netcode worlds until a menu choice). See DR-019_Frontend_Menu_Settings_Saves_Build.
  • Scenes: Assets/Scenes/MainMenu.unity (build index 0) boots the UITK frontend (menu world only); Assets/Scenes/Game.unity (index 1) holds gameplay with Assets/_Project/Subscenes/Gameplay.unity wired in as the baked subscene (GameObject GameplaySubScene). SampleScene/DevSandbox are kept as reference/dev scenes. The on-demand lifecycle (WorldLauncher/SessionRunner/MainMenuController) creates the right worlds per menu choice (Single/Host/Join), THEN LoadScene(Game) (subscene-streaming rule above).
  • 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)

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

  • Where is X / who calls it → serena (fallback Grep/Glob). What did we decide / how does Z work → basic-memory → read the vault note. Literal string / asset GUID → Grep/Glob. Current DOTS API → context7. Conventions → this file. Long-form build lessons → the gotchas archive.
  • Cross-machine rule: durable truth → the vault or this file (both committed); native memory/ is local-only, never the sole home of a decision. serena C# caveat: flaky on Unity — if find_symbol stalls, fall back to Glob/Grep.

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

.mcp.json is committed + portable (${CLAUDE_PROJECT_DIR}); the dots-dev skill travels with the repo (.claude/skills/dots-dev/). Each machine still needs: (1) uv/uvx + Obsidian app + obsidian-cli (the unity-mcp-skill + native memory/ are machine-local, don't sync); (2) basic-memory registration — uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default then uvx basic-memory reindex --full --search --embeddings --project gamevault; (3) Unity 6.4 open + the Unity-MCP bridge connected (mcpforunity://editor/stateready_for_tools).