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

28 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.

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 InputEvents — like Fire — drop under server tick-batching). RPC payloads are plain blittable fields (no [GhostField]), scalars only (int CellX/CellZ, not int2). For a SINGLE shared target resolve a server singleton — never put an Entity in the command; use ghost-id+spawn-tick (SpawnedGhostEntityMap) only for many targets.
  • Apply server-only RPC effects in the server SimulationSystemGroup, NOT the predicted loop (rollback would double-apply). Mutating a DynamicBuffer is not a structural change, so it's safe while iterating a different query.
  • 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.positionCamera.main.ScreenPointToRayAimMath.PlanarAimFromRay (pure, unit-tested) → player→cursor direction. Only the direction crosses the wire; strafe-while-aiming is free (Move already decoupled from Aim).
  • Active scheme = last-meaningful-actuation-wins, replicated as byte (PlayerInput.Scheme, KBM=0/Gamepad=1 — byte because compared in Bursted AbilityFireSystem). Server gates the AutoTarget cone to gamepad only → precise mouse, gamepad-only assist.
  • Cursor/reticle = client PresentationSystemGroup SystemBase (AimReticleSystem) that OBSERVES. Re-raycast the KBM ground point INSIDE that system (PresentationSystemGroup runs after the follow-cam's LateUpdate) — latching from the gather drifts a frame behind. Hardware cursor hidden while aiming + focused, restored on focus-loss/OnDestroy.

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.)
  • execute_code runs as a method body — no using directives (parse as statements); fully-qualify every type. Identify worlds by world.Name == "ServerWorld"/"ClientWorld" (flags overlap a shared Game bit).
  • manage_gameobject create / manage_prefabs modify_contents component_properties SILENTLY DROP enum + Vector3 fields — set those via a follow-up manage_components set_property and VERIFY through mcpforunity://scene/gameobject/{id}/component/{Type} (or read the baked component in execute_code after Play). manage_material set_renderer_color uses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.
  • New ghost prefab recipe: manage_asset duplicate an existing correctly-configured ghost (e.g. UpgradePickup.prefab) → manage_prefabs modify_contents to swap the authoring MonoBehaviour (strip MeshFilter+MeshRenderer for an invisible state-holder) — its ownerless/interpolated GhostAuthoringComponent + LinkedEntityGroupAuthoring come free. Runtime-spawn shared ghosts via a one-shot server spawner; don't bake them into the subscene (dodges the prespawn handshake). Wire a baked spawner into the subscene: manage_scene load additiveset_active_scene Gameplay → create + set props + verify → saveset_active_scene SampleSceneclose_scene (re-bakes on Play).
  • An UNFOCUSED editor throttles Edit mode to near-idle (MCP pings time out, bridge looks hung — it still queues; telemetry_ping succeeds) and stalls EditMode test INIT (pass run_tests(init_timeout=120000), retry). Application.runInBackground only helps in Play mode. Don't pile refresh_unity onto a blocked main thread; prefer refresh_unity scope=scripts for code-only changes. Ask the operator to focus Unity for heavy build/test/Burst sessions.
  • Run an adversarial design-review Workflow (netcode/relevancy · determinism/prediction · reuse/scope → synthesize) BEFORE coding a netcode-heavy slice — it has pre-caught relevancy traps, singleton collisions, dt-traps, double-destroys.

Bootstrap & worlds

  • ProjectM.Simulation.GameBootstrap : ClientServerBootstrap overrides Initialize, 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/EndSimulationEntityCommandBufferSystem; .AsParallelWriter() in parallel jobs).
  • Baking: …Authoring MonoBehaviour + class FooBaker : Baker<FooAuthoring>GetEntity(authoring, TransformUsageFlags.…) then AddComponent. Subscenes stream async — entities aren't present the instant a reference exists.
  • Netcode: ghosts = replicated entities (GhostAuthoringComponent + [GhostField]); predicted (player-controlled, rolled back) vs interpolated. Core sim runs in PredictedSimulationSystemGroup (fixed step, runs multiple times per frame on rollback → deterministic/idempotent; filter with .WithAll<Simulate>()). Server-authoritative: clients send input (IInputComponentData), not state. RPCs (IRpcCommand) for one-off events. No wall-clock/Time.deltaTime/System.Random in predicted sim.
  • Always verify volatile DOTS/Netcode API shape via context7 at code-time — do not trust memory. Pinned IDs: Entities → /websites/unity3d_packages_com_unity_entities_6_5_manual; Netcode → /websites/unity3d_packages_com_unity_netcode_1_10_api (closest published; we run 1.13.2 — re-resolve if a 1.13 set appears); ECS samples → /unity-technologies/entitycomponentsystemsamples.

Testing

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

Guardrails

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

Memory — four layers (which tool when)

Layer Use for Crosses machines?
In-repo vault Docs/Vault/ Design docs, decision records (DR-###), session logs, roadmap — human-facing truth Yes (git)
basic-memory MCP Semantic/wikilink recall over those vault files Yes (indexes the vault)
serena MCP C# symbol nav (find_symbol, references) of Assets/_Project/ N/A (from code)
Native Claude memory (memory/, MEMORY.md) Machine-local facts, working-style, preferences No
  • Where is X / who calls it → serena (fallback Grep/Glob). What did we decide / how does Z work → basic-memory → read the vault note. Literal string / asset GUID → Grep/Glob. Current DOTS API → context7. Conventions → this file. Long-form build lessons → Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md.
  • Cross-machine rule: durable truth 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/stateready_for_tools).