Files
Project-M/CLAUDE.md
T
2026-06-04 13:45:46 -07:00

160 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) |
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:** briefly tried **6.6.0a6** (renumbers Netcode→6.6.0/Physics→6.5.0/Entities→6.5.0) but its Netcode/Transport runtime is a **confirmed engine bug** ("invalid wrapped network interface") → reverted. If returning to 6.6, expect the renumber + re-test runtime. See [[DR-002_Unity66_Alpha_Netcode_Transport]] and `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md`.
## 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 |
| `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/`.
## 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 `InputEvent`s — 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.
### 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 distance check tunnels through a target 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`** (it's the 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; a double destroy throws at Playback).
### 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 deferred to post-M7** — freeze the save schema + bake structure tick fields now so it's additive.
### 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 uGUI HUD (`RawImage` over `Texture2D.whiteTexture`, legacy `Text` + `LegacyRuntime.ttf`). 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).
### 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.
- **`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.position``Camera.main.ScreenPointToRay``AimMath.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`.
### 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**. (`Write`/`Edit` are fine for non-asset files: this vault, asmdef JSON, etc.)
- **`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 additive``set_active_scene Gameplay` → create + set props + verify → `save``set_active_scene SampleScene``close_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`, sets `AutoConnectPort = 7979` (in-editor auto-connect over IPC), calls `CreateDefaultClientServerWorlds()`. Entering Play Mode creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`).
- `Assets/_Project/Subscenes/Gameplay.unity` is wired into `SampleScene` (GameObject `GameplaySubScene`) as a baking target. Replace `SampleScene` with a dedicated bootstrap scene when building for real.
- **Region split:** one server world; the expedition lives at `base + (1000,0,0)`, hidden per-connection via `GhostRelevancy` (see the Netcode gotchas). Expedition *place* = cosmetic ground/pillars in SampleScene at the +1000 offset; gameplay 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/End`Simulation`EntityCommandBufferSystem; `.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 goes in the **vault** or **this file** (both committed). Native `memory/` is local-only and does NOT sync — never the sole home of a decision.
- **serena C# caveat:** its language server is flaky on Unity. If `find_symbol` errors/stalls, **fall back to `Glob`/`Grep`**.
## Per-machine setup (NOT in git — redo on each machine)
`.mcp.json` is committed and portable (`${CLAUDE_PROJECT_DIR}` only). The **`dots-dev` skill travels with the repo** at `.claude/skills/dots-dev/` (auto-discovered on clone). Each machine still needs:
1. `uv`/`uvx`, the Obsidian app + `obsidian-cli`. (The `unity-mcp-skill` and native `memory/` notes are machine-local and do **not** sync.)
2. **basic-memory project 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 opens the project and the CoplayDev Unity-MCP bridge connects (`mcpforunity://editor/state``ready_for_tools`).