39 KiB
Project M — CLAUDE.md
Multiplayer game on Unity DOTS (Entities) + Netcode for Entities — server-authoritative, input-only clients, client prediction. This file is committed and is the authoritative, cross-machine source of conventions. The /dots-dev skill drives feature work; 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) |
✅ Reconciled 2026-06-02:
manifest.jsonpins aligned to the resolved Unity 6.4.7 lock (entities/entities.graphics 6.4.0, URP 17.4.0, test-framework 1.6.0, ugui 2.0.0, multiplayer.center 1.0.1) — a no-op re-resolve (lock unchanged, console clean). The values above now matchpackages-lock.json. See DR-008_M5_HomeBase_BaseLayer_Storage.
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 — seeDocs/VaultDR-002 andDocs/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 andread_consoleafter 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 =
…AuthoringMonoBehaviours +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.Transformsmust be a DIRECT asmdef reference for any assembly whose source-gen'd systems useLocalTransform/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(definesBaker<T>) andUnity.Collections(baking source-gen). A nested baker class must not be namedBaker(it shadowsBaker<T>→ CS0308/CS0246) — name itFooBaker. - Never name an
IComponentDataPlayerInput, and don'tusing UnityEngine.InputSystem;in a file that references such a component: it collides withUnityEngine.InputSystem.PlayerInput, and the Entities generator bindsRefRW<…>to the managed class → a misleading CS8377 "must be a non-nullable value type". Fully-qualify Input System types (UnityEngine.InputSystem.Keyboard.current) instead. IInputComponentDatarequires implementingFixedString512Bytes ToFixedString().- An input-gather system that reads the managed Input System belongs in
GhostInputSystemGroupas a non-BurstISystem(orSystemBase), 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 intoAssembly-CSharp, and asmdef assemblies (ProjectM.Client) cannot reference that. Fix: set the importer'swrapperCodePath(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 managedSystemBaseholding the wrapper; gatherFireas a netcodeInputEvent(reset the field each frame,.Set()on the press edge — netcode latches the absoluteCountinto 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 genericUnity.NetCode.LowLevel.SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>()trips a Burst internal compiler error (type-hash resolution). Make the classifier a plain non-BurstISystem(it only runs when spawns are received — cold path). In 1.13.2 that method takesref DynamicBuffer<SnapshotDataBuffer>(the public HelloNetcode sample's by-valuedatais 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, ~30–40s play-enter). A clean compile + green tests + working runtime confirm the code is fine. Clear it with an editor restart (or deleteLibrary/BurstCachewhile 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 driveIInputComponentData. Fix (both now set in this project):InputSettings.editorInputBehaviorInPlayMode = AllDeviceInputAlwaysGoesToGameView+Application.runInBackground = true. For deterministic, device-independent validation prefer the editor-onlyDebugInputInjectionSystem(ProjectM.Client,#if UNITY_EDITOR): poke its statics fromexecute_code—DebugInputInjectionSystem.Fire()/.SetMove(x,z)/.SetAim(x,z)/.Stop()— to drive the local player'sPlayerInputthrough the authentic command→prediction pipeline. (Validated:SetMovedrives + replicates movement. One-shotFirepropagation needs a healthy editor — tick-batching under a degraded/corrupt-Burst editor drops one-shotInputEvents while continuous values survive.) - Prototype presentation glue lives in
ProjectM.Clientas 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'sLocalTransformeach LateUpdate. Bright prototype URP-Lit materials are inAssets/_Project/Materials/(player cyan, dummy red, projectile yellow, ground grey).ProjectM.Clientnow referencesUnity.Transformsdirectly (the rig readsLocalTransform).
Build gotchas (learned — M5 physics-in-prediction, 2026-06-01)
- Editing Assets
.cswith the rawWritetool does NOT reliably trigger a Unity recompile on an unfocused editor —refresh_unitydid a domain reload without recompiling, so tests +execute_coderan a stale assembly (symptom: behaviour that exists in neither the old nor new source). Always edit Assets.csvia MCPapply_text_edits/create_script(Unity's own scripting pipeline) — neverWrite. (Write/Editare fine for non-asset files: vault, asmdef JSON, etc.) See 2026-06-01_M5_Physics_In_Prediction. - Predicted physics is implicit — there is no
PredictedPhysicstoggle. With the netcode-physics package present (Unity.NetCode.Physics,…Physics.Hybrid) and predicted ghosts carrying physics components, Netcode relocatesPhysicsSystemGroupinto thePredictedFixedStepSimulationSystemGroup(a child ofPredictedSimulationSystemGroup, marked OrderFirst).NetCodePhysicsConfigonly tunes lag-comp / run-mode / history. Put one in the gameplay subscene withPhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntitiesso the group runs whenever physics entities exist. - Unity Physics 1.x bakes built-in
UnityEnginecolliders +Rigidbody— the oldPhysicsShapeAuthoring/PhysicsBodyAuthoring(Physics 0.x) are gone (unity_reflectfinds neither). Author a dynamic body with aCapsuleCollider/BoxCollider+Rigidbody(useGravity=false→ planar/PhysicsGravityFactor=0;isKinematic=false;interpolation=Interpolate→PhysicsGraphicalSmoothing). Static colliders = collider, no Rigidbody, baked into the subscene (present identically in server + client worlds, deterministic, no replication). PhysicsVelocityauto-replicates — Netcode shipsPhysicsVelocityDefaultVariant+ a generated serializer, so a predicted-physics ghost needs no hand-written[GhostField]for velocity (LocalTransformis already replicated). Drive the character by writingPhysicsVelocity.Linear, not by teleportingLocalTransform.Rigidbody.FreezeRotationis NOT honored by the DOTS baker (bakedPhysicsMass.InverseInertiastays non-zero). Hold a top-down character's facing by zeroing angular velocity each tick + writing rotation directly (PlayerAimSystem); setPhysicsMass.InverseInertia = float3.zeroin 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 toPlayerSpawner.SpawnPoint.y+ zeroingLinear.y(PlayerPlanarConstraintSystem). - The predicted physics group is OrderFirst, so a system in
PredictedSimulationSystemGroupwith[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 insidePredictedFixedStepSimulationSystemGroup[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:PlayerControlSystemmapsPlayerInput.Move×EffectiveCharacterStats.MoveSpeed→CharacterControl(via the unit-testedCharacterControlMath.DesiredMovement);CharacterProcessor(collide-and-slide) consumes it inCharacterPhysicsUpdateSystem([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.physicsdependency can still resolve on our renumbered stack — Unity treats the dep as a SemVer floor, so Entities 6.4.0 satisfies a1.3.15requirement and is NOT downgraded. Don't trust a version-string mismatch as "incompatible": probe (add the package, confirmpackages-lock.jsonkept 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+ staticKinematicCharacterUtilities.Update_*. The legacyKinematicCharacterAspect(IAspect, instanceUpdate_*) also exists but is NOT what the 1.4.x samples use — verify the installed shape withunity_reflect, don't assume. (A sub-agent's package-cache read disagreed with reflect; reflect + first-try clean compile won.) KinematicCharacterUtilities.BakeCharacteraborts (logs an error, adds nothing) if the GameObject has aRigidbodyand requires uniform (1,1,1) scale. The player prefab keeps itsCapsuleCollider(baked intoPhysicsCollider) but the M5Rigidbodywas removed. Two bakers on one prefab GameObject (PlayerAuthoring+PlayerCharacterAuthoring) is fine — both resolve the same entity.CharacterInterpolationmust be PredictedClient-only.BakeCharacteradds it to all prefab versions; aDefaultVariantSystemBaseregistersCharacterInterpolation → [GhostComponent(PrefabType = GhostPrefabType.PredictedClient)]so it's stripped from server + interpolated-client prefabs (else double-interp on remotes). Verified: server ghost has noCharacterInterpolation, 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 stockLocalTransformreplication. Our CC character replicates position via the normal owner-predictedLocalTransformpath; only theCharacterInterpolationvariant is registered. - Top-down CC config (planar, no gravity):
AuthoringKinematicCharacterPropertieswithSnapToGround=false,InterpolateRotation=false(rotation owned byPlayerAimSystem),SimulateDynamicBody=false(players don't physically push each other); gravity is handled in the processor by feedingfloat3.zerotoUpdate_GroundPushingand never adding a gravity term. Result: stays on the spawn plane (y≈1) with no planar-pin system.
Build gotchas (learned — M5 home base + shared storage, 2026-06-02)
- Ownerless interpolated ghost ≠ owner-predicted for buffer replication. A server-spawned ownerless ghost (e.g. the shared storage chest) replicates a
[GhostField]IBufferElementDatato all clients with noOwnerSendTypeand noGhostOwner— server mutations just propagate.[GhostComponent(OwnerSendType = SendToOwnerType.All)](perStatModifier) is only for the predicting owner to recompute its own state; adding it (or aGhostOwner) to an ownerless ghost is wrong. - One-off shared-state actions belong on an
IRpcCommand, not a predictedInputEvent. RPCs are reliable, so deposit/withdraw landed even while the server tick-batched (the M2 one-shotFireInputEvent drops under batching). RPC payloads are plain blittable fields — no[GhostField]; store an op as a byte, not an enum (cross-assembly enum-Burst hazard, same one that de-BurstedProjectileClassificationSystem). For a single shared target, resolve it as a server singleton — never put anEntity(not stable cross-world) in the command; only reach for a ghost-id+spawn-tick (SpawnedGhostEntityMap) when there are many targets (and that lookup may force the handler off Burst). - Apply server-only RPC effects in the server
SimulationSystemGroup, NOT the predicted loop — the predicted loop re-runs on rollback and would double-apply. (Mutating aDynamicBufferis not a structural change, so it's safe to do while iterating a different entity query, e.g. the RPC requests.) - Build-grid math must be deterministic + integer-stable. Corner-origin + center-returning + half-open cell bounds +
math.floor(not truncation — negatives). LockCellSize/PlotSizeas a coordinate space once: M6 placement builds on it; changing them later invalidates placed structures. (BaseGridMath, unit-tested in EditMode likePlayerSpawnMath.) - Runtime-spawn shared ghosts; don't bake them into the subscene. A one-shot server spawner (mirrors
UpgradePickup/TrainingDummy) keeps the subscene ghost-free and dodges the prespawn section-ack/CRC handshake. Do not add such a ghost to a connection'sLinkedEntityGroupif it must survive that player's disconnect (the shared base is world-owned). - Build a correctly-configured ghost prefab by duplicating an existing one (
UpgradePickup.prefab→Storage.prefab, then swap the authoring MonoBehaviour viamanage_prefabs modify_contents) rather than hand-addingGhostAuthoringComponent— its ownerless/interpolated settings (HasOwner=0,DefaultGhostMode=Interpolated) +LinkedEntityGroupAuthoringcome along for free. execute_coderuns as a method body — nousingdirectives (they parse as statements → "Identifier expected"); fully-qualify every type (Unity.Entities.World,ProjectM.Simulation.BaseAnchor, …). Also: world flags overlap a sharedGamebit, so identify worlds byworld.Name == "ServerWorld"/"ClientWorld"rather than(Flags & GameServer).- An unfocused editor throttles Edit mode to near-idle → MCP pings time out and the bridge looks hung (it still queues commands —
telemetry_pingsucceeds).Application.runInBackgroundonly helps in Play mode. If it wedges, focus or restart the editor; don't pilerefresh_unitycalls onto a blocked main thread. Preferrefresh_unity scope=scriptsfor code-only changes (scope=all forceis heavy and contributed to a mid-session hang).
Build gotchas (learned — M5.5 game feel & identity, 2026-06-02)
- Move an ownerless INTERPOLATED enemy ghost SERVER-ONLY in the plain
SimulationSystemGroup, never inPredictedSimulationSystemGroup(interpolated ghosts aren't predicted; the server has no rollback). Use[UpdateAfter(PredictedSimulationSystemGroup)], NOT[UpdateBefore]— the predicted group is OrderFirst inSimulationSystemGroup, soUpdateBefore/Afterit is silently ignored (Unity logs "Ignoring invalid UpdateBefore… OrderFirst/OrderLast has higher precedence"). A plain-SimulationSystemGroupserver system therefore always runs after the predicted group, so a contactDamageEventit appends drains the following tick (~16ms, fine for melee). StockLocalTransformreplication carries position — no hand-written[GhostField]. Build the enemy ghost by duplicating an existing interpolated ghost (UpgradePickup.prefab→Enemy.prefab) so the ownerless/interpolatedGhostAuthoringComponentcomes free — the training dummy is not a ghost (server-only → invisible to clients). See DR-009_GameFeel_Identity_FirstBlood. - Derive an enableable gate from already-replicated state instead of replicating it. Player
Dead= a LOCAL enableable derived every predicted tick from the replicatedHealth<=0(PlayerDeathStateSystem, runs in both worlds, before movement/aim/fire) — the same derive-don't-replicate idiom asStatRecomputeSystem/EffectiveCharacterStats; rollback-correct on server + owner-client with no[GhostEnabledBit]. To write the bit on a currently-disabled entity the query must visit it:.WithPresent<Dead>()(write) vs.WithDisabled<Dead>()(alive-only run);.WithAll<Simulate>()ANDs independently. Bake the enableable DISABLED (AddComponent<T>(e); SetComponentEnabled<T>(e, false);in the baker) so instances spawn in the off state (instantiated entities inherit the prefab's enabled state). Respawn TIMING is server-only (SimulationSystemGroup, after the predicted group). - All juice/HUD = client-only managed
SystemBaseinPresentationSystemGroup(once per frame, no rollback double-fire) that OBSERVES replicated state — never mutates the sim. Read ECS viaSystemAPI.QueryinsideOnUpdate+EntityManager.CompleteDependencyBeforeRO<T>()— NOT from a MonoBehaviourLateUpdate(that throws the job-safety exception the camera rig hit).Entityis a stable client dict key for a ghost's lifetime — prune the cache each frame (a pruned enemy = a kill → death VFX at its last pos; neverDestroyEntitya ghost from the client —GhostDespawnSystemowns despawn). Netcode-safe "hit-stop" = a camera punch, neverTime.timeScale(it would corrupt the deterministic sim). - Asset-free presentation: procedural
AudioClip.CreateSFX; a runtimeParticleSystempool (Sprites/Default material + HDR start color so bursts bloom); a code-built uGUI HUD (RawImageoverTexture2D.whiteTexturefor anchor-driven bars + legacyTextwithResources.GetBuiltinResource<Font>("LegacyRuntime.ttf")). To edit a prefab asset's component in code:PrefabUtility.LoadPrefabContents→ modify →SaveAsPrefabAsset(root, path)→UnloadPrefabContents(SavePrefabAssetrejects the contents root — "Can't save a Prefab instance"). Watch shared-material bleed when re-tinting (M_Dummydoubled as the wall material → orange walls; Husks got their ownM_Husk). ACES tonemapping needs the URP asset color grading mode = HDR (m_ColorGradingMode = 1). - The "0 = ready" raw-
uintcooldown sentinel can collide at tick wraparound — a computedServerTick + delaycan equal 0. Route every cooldown/spawn "next tick" write throughTickUtil.NonZero(...)(coerce 0→1), and compare stored ticks withnew NetworkTick(raw).IsNewerThan(serverTick)/.TicksSince(...)— never raw</ subtraction — the HUD cooldown bar included. (Caught by the adversarial review;RespawnMathalready guarded it.) - An unfocused editor stalls EditMode test INIT ("tests did not start within timeout") and slows play-enter domain reloads — pass
run_tests(init_timeout=120000)and retry; ask the operator to focus Unity for heavy build/test sessions (Application.runInBackgroundonly helps in Play mode).
Build gotchas (learned — art import / Synty packs → URP, 2026-06-03)
The imported store art (BefourStudios; future Synty) is HDRP-authored but we run URP 17.4 + Entities Graphics → source M_* materials render magenta. The reusable converter is Assets/_Project/Scripts/Editor/EnvArtTools.cs (menu ProjectM/Art/1. Convert Curated Env Materials); it outputs stock URP/Lit to Assets/_Project/Materials/Env/. See DR-010_Art_Import_URP_Conversion_Visual_Upgrade.
- Convert, don't switch pipelines. Re-author to stock URP/Lit (
933532a4…, the same shader our prototypes use → DOTS-instancing/Entities-Graphics compatible). FBX meshes +T_*_B/_N/_ORMtextures are reusable as-is; the auto-generatedMI_*URP stubs are blank. Switching the project to HDRP is NOT an option (breaks Entities Graphics). - A dark-lit screenshot MASKS material bugs — verify material values, not just the render.
S_Generalexposes a float_BaseColorMultiplyand the real Color in_AlbedoTint.HasProperty("_BaseColorMultiply")is true butGetColor()on a float returns (0,0,0) (and logs a "doesn't have a color property" warning) → black albedo everywhere. Alwaysshader.GetPropertyType(idx)-guard beforeGetColor/GetFloat/GetTexture. - Gate source emission on the
_Emissive(0/1) flag AND a fixture name —S_Generalcarries a non-zero default_EmissiveColoreven when off; reading it unconditionally makes crates/walls/domes glow (flat color can't reproduce the source emission mask). VolumeProfile.Add<T>()does NOT persist the override — on save thecomponentslist serializes{fileID:0}nulls (works in-session, gone after reload). UseAssetDatabase.AddObjectToAsset(component, profile)per effect, thenSaveAssets; verify non-null refs on disk.LocalTransform.FromPosition()resets Scale=1, silently discarding a ghost prefab's authored scale (Scale is a replicated[GhostField]→ consistent-but-wrong, not a desync). Server spawners must read the prefab's bakedLocalTransformand override only Position (fixed inUpgradePickupSpawnSystem/SharedStorageSpawnSystem).- High metallic + no reflection probe + dark skybox = near-black. Keep converted env metallic low (0.1–0.2); rely on albedo + direct light.
- Static decor goes in the gameplay subscene (Entities Graphics renders only baked/EG-spawned entities; a SampleScene MeshRenderer renders via classic URP). Strip colliders from cosmetic props (else they bake into the static PhysicsWorld the CC sweeps) and put no GhostAuthoring on scenery. Edit the subscene via
manage_scene load … additive→ place →SaveScene→close_scene(re-bakes on Play); the baked-entity view disappears while it's open additively — verify placement viaexecute_codeover the scene roots, not the game view. - HUD-free beauty shot = a positioned
game_viewcapture (view_position/view_rotation) — direct camera rendering excludes Screen-Space-Overlay UI.scene_viewrejects positioned capture. - VFX (GabrielAguiar) is now imported (499 prefabs, ~94% Shuriken; 27 VFX-Graph). Wired into combat via DR-011_Synty_World_VFX_Integration — see the VFX gotchas below. VFX-Graph (hits/beams) packs still need separate URP setup if wanted.
Build gotchas (learned — Synty world + GabrielAguiar VFX, 2026-06-03)
The world is now a cohesive Synty sci-fi colony + GabrielAguiar combat VFX. See DR-011_Synty_World_VFX_Integration / 2026-06-03_Synty_World_And_VFX.
- Synty is URP-native — NO conversion (unlike the HDRP BefourStudios art). The grounded world is built as cosmetic classic-URP GameObjects in SampleScene (
SyntyWorldroot), NOT the DOTS subscene — the customSynty/Generic_Basicshader just renders, and you never have to verify its Entities-Graphics DOTS-instancing. Only the gameplay subscene needs Entities Graphics + the baked PhysicsWorld. - Cosmetic SampleScene colliders are inert to gameplay — classic PhysX is separate from the DOTS PhysicsWorld (baked only from the subscene); the planar-pinned CC never sees them. So a cosmetic world needs no collider stripping for gameplay.
- "Grounded" = surround + horizon, not a bigger plane — a skyline ring of tall buildings + a planet/asteroid backdrop + a
Skybox/6 Sidedspace skybox + light fog killed the floating-plane far better than extending the ground. - Swap a subscene object's VISUAL while keeping collision: disable its MeshRenderer but keep the BoxCollider — the collider still bakes to the static
PhysicsCollider, and a disabled renderer bakes no RenderMesh (invisible wall, collision intact). Used to retire the BefourStudios walls under the Synty world. - A GA "projectile" prefab is NOT a passive trail — it ships a non-kinematic
Rigidbody+ collider +ProjectileMoveScript(self-propels, collides, spawns secondary muzzle/hit VFX). Any authored VFX dropped into a cosmetic slot must be stripped to particles: destroyRigidbody/Colliderand disableProjectile/Move-named MonoBehaviours BEFORE theirStartruns (CombatFeedbackSystem.StripCosmetic). Verify a prefab's components, not its name. - VFX prefab → client SystemBase bridge: a MonoBehaviour with a static
Instance+ prefab fields in the bootstrap scene (VFXConfig, mirrorsPrototypeCameraRig) hands authored assets to the managedCombatFeedbackSystem; keep a procedural fallback so a null slot still runs. Derive VFX TTL from the prefab's longest ParticleSystem (not a blanket constant) and cap concurrent VFX to bound GC churn under swarms. - Projectile-follow VFX: query
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<Projectile>()each presentation frame; dict-by-Entityspawn/reposition/prune (same idiom as the Health FX cache). The one-shot FireInputEventstill drops under the unfocused editor, so fire-driven VFX (muzzle/trail/enemy-death) need a focused editor / real client to fire.
Build gotchas (learned — aim controls: mouse cursor + gamepad, 2026-06-03)
The KBM/gamepad aim rework is DR-012_Aim_Controls_Cursor_Gamepad / 2026-06-03_Aim_Controls_Cursor_Gamepad.
- Client-derived aim rides the EXISTING
PlayerInput.Aim[GhostField]— no new netcode surface. Mouse-cursor aim is computed client-side inPlayerInputGatherSystem(managedSystemBase,GhostInputSystemGroup, once/frame):Mouse.current.position→Camera.main.ScreenPointToRay→AimMath.PlanarAimFromRay(pure, Burst-safe, unit-tested) against the player's movement plane → write the player→cursor direction asAim. Only the resulting direction crosses the wire; predicted/server systems are unchanged. Movement (Move) is already decoupled from facing (Aim/PlayerFacing), so strafe-while-aiming is free the momentAimis the cursor. Don't add a mouse binding to theAimaction — the gather reads the device directly (no.inputactionsedit → no wrapper regen). - Active input scheme = last-meaningful-actuation-wins, replicated as a
byte(NOT an enum). Detect KBM vs gamepad each frame (stick/trigger/button past a 0.04 lengthsq deadzone vs mouse-delta/click/movement-key;InputDevice.lastUpdateTimebreaks ties; hold last when idle) and streamPlayerInput.Scheme(InputSchemeId.KeyboardMouse=0/Gamepad=1). It is a byte because it is compared inside the Burst-compiledAbilityFireSystem(the cross-assembly enum-in-Burst ICE hazard). The server gates theAutoTargetcone toapplied.InternalInput.Scheme == Gamepad(read at the fire tick from the sameGetDataAtTicklookup) → precise mouse, gamepad-only assist; mouse then predicts == server (fewer reconcile snaps). - A static presentation bridge must reset on play-enter.
AimPresentation.Scheme(mirrorsPrototypeCameraRig/VFXConfigstatics) needs[UnityEngine.RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]to reset — statics survive fast-enter-playmode domain reloads, and a stale value flashes the wrong cursor/reticle for the first frames (caught by the adversarial review). - Cursor/reticle = client
PresentationSystemGroupSystemBase(AimReticleSystem) that OBSERVES, never mutates. A flat world-space ground ring (primitive quad,Sprites/Defaultwith a null-guard fallback, procedural ring texture) is the aim indicator for BOTH schemes — KBM at the cursor's ground-projection point, gamepad a fixed distance ahead along replicatedPlayerFacing. The hardware cursor is hidden while aiming + focused (Application.isFocused-gated) and restored on focus-loss /OnDestroy. A radial dead-zone (AimMath.PlanarAimFromRaydeadZoneRadius) holds facing when the cursor is over the character. The KBM ground point is re-raycast INSIDEAimReticleSystem(PresentationSystemGroup runs after the follow-cam's LateUpdate), not latched from the gather (GhostInputSystemGroup, before the move) — latching there drifts the ring a frame behind the cursor under the moving camera. Optional camera aim look-ahead (PrototypeCameraRig.AimLeadDistance, tunable) leads the framed point towardPlayerFacing(not the live cursor projection, to avoid a feedback loop). Headless validation: driveDebugInputInjectionSystem(now stampsScheme) + forceAimPresentation.Scheme; the real cursor / live device-switch needs a focused Game view (the unfocused editor can't inject mouse position).
Bootstrap & worlds
ProjectM.Simulation.GameBootstrap : ClientServerBootstrap→ overridesInitialize, setsAutoConnectPort = 7979(in-editor auto-connect over IPC; set in M1 — was 0), callsCreateDefaultClientServerWorlds(). Entering Play Mode creates separateServerWorld(WorldFlags.GameServer) andClientWorld(WorldFlags.GameClient) — verified.Assets/_Project/Subscenes/Gameplay.unityis wired intoSampleScene(GameObjectGameplaySubScene) as a baking target. ReplaceSampleScenewith 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 : IComponentDatais the default (unmanaged, Burst/job-friendly).class : IComponentDataonly for genuine managed refs (main-thread, no Burst).IBufferElementDatafor per-entity arrays.IEnableableComponentto toggle state without a structural change.- Systems:
ISystem(struct) +[BurstCompile]is the default;SystemBaseonly when touching managed objects.SystemAPI.Query<…>()to iterate. Aspects (IAspect) are DEPRECATED (Entities 1.4+) — do not author new ones.Entities.ForEachis legacy. - Jobs:
IJobEntity/IJobChunk; threadJobHandlethroughstate.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:
…AuthoringMonoBehaviour +class FooBaker : Baker<FooAuthoring>→GetEntity(authoring, TransformUsageFlags.…)thenAddComponent. 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 inPredictedSimulationSystemGroup(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.Randomin 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 inSimulationSystemGroup, tick, assert. Public API, always green, version-independent. Example:Assets/_Project/Tests/EditMode/HeartbeatSystemTests.cs. Run via Unity Test Runner or MCPrun_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"]). NetCodeTestWorldisinternalin netcode 1.13.2 (Unity.NetCode.Tests, assemblyUnity.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. SeeDocs/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_consoleafter script changes, and run a play/tick test, not just a compile.
Guardrails
- Never edit a
.metaindependently of its asset; delete an asset and its.metatogether. - 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_toolsbefore mutating; package adds/refreshes trigger domain reloads — wait foris_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,
.slnload timeouts). Iffind_symbolerrors/stalls, fall back toGlob/Grep(or addclaude-contextwith 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:
uv/uvx, the Obsidian app +obsidian-cli. (Theunity-mcp-skilland nativememory/notes remain machine-local and do not sync — re-install / re-create them per machine if wanted.)- basic-memory project registration (machine-local config):
uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default, thenuvx basic-memory reindex --full --search --embeddings --project gamevault. - Unity 6.4 opens the project and the CoplayDev Unity-MCP bridge connects (
mcpforunity://editor/state→ready_for_tools).