Files
Project-M/CLAUDE.md
T
2026-06-02 08:56:26 -07:00

22 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; the one-time stack setup lives in Docs/dots-setup-task.md.

Stack — reverting to Unity 6.4.7 (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 on Unity 6.4.
com.unity.physics 1.4.6 Unity Physics (DOTS). Independent 1.x line on Unity 6.4.
com.unity.charactercontroller 1.4.2 Unity Character Controller (DOTS, kinematic collide-and-slide). Player movement foundation (M5b). Declares entities/physics 1.3.15 but resolves on our 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)

⚠️ The values above are the Unity 6.4.7 set being reverted to — verify against packages-lock.json after the editor downgrade re-resolves, and reconcile manifest.json if the brief 6.6 upgrade left explicit version pins.

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

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
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 (learned — M1, 2026-05-30)

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

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

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

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

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

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

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

Bootstrap & worlds

  • ProjectM.Simulation.GameBootstrap : ClientServerBootstrap → overrides Initialize, sets AutoConnectPort = 7979 (in-editor auto-connect over IPC; set in M1 — was 0), calls CreateDefaultClientServerWorlds(). Entering Play Mode creates separate ServerWorld (WorldFlags.GameServer) and ClientWorld (WorldFlags.GameClient) — verified.
  • 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.

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 → must be 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. See context7-libraries.md. Pinned IDs for our versions: 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 pattern = plain-Entities EditMode test: create a World, register the system in SimulationSystemGroup, tick, assert. Public API, always green, version-independent. Example: Assets/_Project/Tests/EditMode/HeartbeatSystemTests.cs. Run via Unity Test Runner or MCP run_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"]).
  • NetCodeTestWorld is internal in netcode 1.13.2 (Unity.NetCode.Tests, assembly Unity.NetCode.TestsUtils.Runtime.Tests), exposed only via a fixed [InternalsVisibleTo] allow-list of Unity assemblies. To use it you must name a test asmdef to match an entry (e.g. Unity.NetcodeSamples.EditModeTests) — or vendor the test utils. See Docs/Vault/07_Sessions/_Decisions/DR-001_Netcode_Test_Harness.md. This does not change on Unity 6.6. Netcode world boot is covered by the Play Mode check, not a NetCodeTestWorld test.
  • Burst/source-gen errors surface at editor compile, not a plain build — always check read_console after script changes, and run a play/tick test, not just a compile.

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 instead.
  • 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 same 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 defined / 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.
  • 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 (can auto-install the wrong .NET, .sln load timeouts). If find_symbol errors/stalls, fall back to Glob/Grep (or add claude-context with local embeddings as a code-search index). serena live-verification was deferred at setup; confirm on first use.

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

.mcp.json is committed and portable (${CLAUDE_PROJECT_DIR} only). The dots-dev skill now travels with the repo at .claude/skills/dots-dev/ (project-level, auto-discovered by Claude Code on clone — no manual ~/.claude/skills/ copy needed). But each machine still needs:

  1. uv/uvx, the Obsidian app + obsidian-cli. (The unity-mcp-skill and native memory/ notes remain machine-local and do not sync — re-install / re-create them per machine if wanted.)
  2. basic-memory project registration (machine-local config): 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/stateready_for_tools).