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; one-time stack setup lives in Docs/dots-setup-task.md.
Maintaining this file (size budget — read before editing) ★
Hard limit: 40 KB (40 960 bytes). This file is context-loaded every session — over-budget gets it truncated. Keep ≥1 KB of headroom below it (target ≤ ~39 KB). After any edit, keep it under budget:
- Size check — bash:
wc -c CLAUDE.md· PowerShell:(Get-Item CLAUDE.md).Length. Must be< 40960. - Archive, don't delete. When trimming, append the verbose / least-hot detail to the obsidian reference note
Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.mdunder a new dated heading (never overwrite an older snapshot), and leave a one-line pointer + the relevant[[DR-###]]link here. Design rationale already lives in the per-milestone DRs (Docs/Vault/07_Sessions/_Decisions/DR-###). - Net-zero rule: every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them ★); depth lives in the archive + DRs.
- Condensation history: 2026-06-04 (first pass, M1–M6 long-form → archive) · 2026-06-07 (second pass, M7+/HUD/animation tightened; full pre-trim snapshot saved at the archive's tail).
Stack — Unity 6.4.7 (6000.4.7f1, stable) as of 2026-05-30
| Package | Version | Notes |
|---|---|---|
com.unity.entities |
6.4.0 | Entities/Collections/Graphics track the Editor version (6.x). |
com.unity.entities.graphics |
6.4.0 | Renders entities under URP 17.4. |
com.unity.collections |
6.4.0 | (transitive) |
com.unity.netcode |
1.13.2 | Netcode for Entities (ECS). NOT com.unity.netcode.gameobjects. Independent 1.x line. |
com.unity.physics |
1.4.6 | Unity Physics (DOTS). Independent 1.x line. |
com.unity.charactercontroller |
1.4.2 | DOTS kinematic collide-and-slide. Declares entities/physics 1.3.15 but resolves on 6.4.0/1.4.6 via SemVer floor (no downgrade). |
com.unity.transport |
2.7.2 | (transitive) |
com.unity.burst |
1.8.29 | (transitive) |
com.unity.mathematics |
1.3.3 | (transitive) |
com.rukhanka.animation |
2.9.0 | Local pkg (Packages/com.rukhanka.animation). ECS skeletal animation (Burst CPU/GPU skinning). Resolves on 6.4.0 via SemVer floor. Netcode replication OFF → client-derived. See DR-022_Animation_Pipeline_Rukhanka_Synty. |
Values match packages-lock.json (reconciled 2026-06-02; URP 17.4.0, test-framework 1.6.0, ugui 2.0.0, multiplayer.center 1.0.1). History: 6.6.0a6 was tried + reverted (Netcode/Transport runtime engine bug "invalid wrapped network interface"); returning to 6.6 means a package renumber + runtime re-test. See DR-002_Unity66_Alpha_Netcode_Transport + the gotchas archive.
Namespaces & assembly split
Root namespace: ProjectM. Code lives under Assets/_Project/Scripts/ in four asmdefs (never create/edit .csproj/.sln; only .asmdef):
| Assembly | Namespace | Runs in | References |
|---|---|---|---|
ProjectM.Simulation |
ProjectM.Simulation |
client + server worlds | Entities, Unity.Transforms, Collections, Mathematics, Burst, Unity.Physics, Unity.NetCode |
ProjectM.Client |
ProjectM.Client |
client world only | + Simulation, Unity.Entities.Graphics, Unity.InputSystem, Unity.Transforms, Unity.NetCode, Unity.Physics + Unity.CharacterController (KinematicCharacterBody source-gen), Rukhanka.Runtime (animation) |
ProjectM.Server |
ProjectM.Server |
server world only | + Simulation, Unity.Transforms, Unity.NetCode |
ProjectM.Authoring |
ProjectM.Authoring |
bake time (+ scene runtime) | Simulation, Entities, Unity.Entities.Hybrid, Collections, Mathematics, Unity.NetCode |
- Simulation = components + systems shared by both worlds (most gameplay). Client/Server = world-specific. Authoring =
…AuthoringMonoBehaviours +Baker<T>. - Other folders:
Assets/_Project/Subscenes/(baked entity subscenes),Assets/_Project/Prefabs/,Assets/_Project/Tests/EditMode/. - Feature folders added since (
Client/UI,Client/Settings,Server/Automation,Server/Persistence,Simulation/Automation,Simulation/Persistence) live inside the existing four asmdefs — no new assemblies.
Build gotchas (distilled)
Long-form originals + the milestone each came from: Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md. The highest-recurrence hazards are flagged ★.
Assemblies, asmdefs & source-gen
Unity.Transformsmust be a DIRECT asmdef reference for any assembly whose source-gen'd systems touchLocalTransform/LocalToWorld— transitive visibility compiles hand-written code but the generator emits CS0246 in*.g.cs.Unity.Physicsmust ALSO be a DIRECT asmdef ref for any assembly whose source-gen touchesKinematicCharacterBody(it nestsUnity.Physics.ColliderKey) → else CS8377/CS0012 in*.g.cs(same class as the Transforms rule).- Authoring asmdefs need
Unity.Entities.Hybrid(Baker<T>) +Unity.Collections(baking source-gen). Never name a nested bakerBaker(shadowsBaker<T>) — useFooBaker. - Never name an
IComponentDataPlayerInputand don'tusing UnityEngine.InputSystem;in a file referencing such a component — collides with the managedUnityEngine.InputSystem.PlayerInput, generator bindsRefRW<…>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 intoAssembly-CSharpwhich asmdefs can't reference. No.inputactionsedit unless you intend a wrapper regen. IInputComponentDatarequires implementingFixedString512Bytes ToFixedString().
Burst hazards ★
- Cross-assembly generics + enums trip Burst internal compiler errors. Predicted-spawn classification (
SnapshotDataBufferComponentLookup.TryGetComponentDataFromSnapshotHistory<T>, takesref DynamicBuffer<SnapshotDataBuffer>in 1.13.2) and any enum compared inside a Bursted system are the known offenders. Make such systems plain non-BurstISystem, and store ops/schemes/region ids asbyte, neverenumin 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 deleteLibrary/BurstCachewhile 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 unrelatedGetSingleton<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 ★
PredictedSimulationSystemGroupruns 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
PhysicsSystemGroupintoPredictedFixedStepSimulationSystemGroup(child of the predicted group, OrderFirst).NetCodePhysicsConfigonly tunes lag-comp/run-mode/history; put one in the gameplay subscene withPhysicGroupRunMode = LagCompensationEnabledOrAnyPhysicsEntities. - The predicted physics group is OrderFirst, so
[UpdateBefore/After(PredictedFixedStepSimulationSystemGroup)]from the parent predicted group sorts oddly:UpdateBeforeis ignored (1-tick offset, still in-sync); for same-tick put the system inside the fixed-step group[UpdateBefore(PhysicsSystemGroup)].OrderFirst/OrderLastALSO wins against[UpdateBefore/After]the predicted group from the plainSimulationSystemGroup— a server-only system there always runs after the predicted group → use[UpdateAfter(PredictedSimulationSystemGroup)], neverUpdateBefore(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. StockLocalTransformreplication carries position (no hand-written[GhostField]). A contactDamageEventappended there drains the following tick (~16ms, fine for melee). PhysicsVelocityauto-replicates (Netcode ships the default variant + serializer) — drive a predicted-physics body by writingPhysicsVelocity.Linear, not by teleportingLocalTransform.- Ownerless interpolated ghost ≠ owner-predicted for buffer replication. A server-spawned ownerless ghost replicates a
[GhostField] IBufferElementDatato all clients with noOwnerSendType/ noGhostOwner— server mutations just propagate.OwnerSendType.All+GhostOwnerare only for a predicting owner to recompute its own state. - One-off shared-state actions belong on an
IRpcCommand, not a predictedInputEvent(RPCs are reliable; one-shotInputEvents — likeFire— drop under server tick-batching). RPC payloads are plain blittable scalars (int CellX/CellZ, notint2; no[GhostField]). For a SINGLE shared target resolve a server singleton — never put anEntityin 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 aDynamicBufferis not a structural change, so it's safe while iterating a different query. - A system-ordering CYCLE is INVISIBLE to plain-Entities EditMode tests (they register systems individually, unsorted) — it only throws
ComponentSystemSorter"circular dependency cycle" at world creation (Play). When you add cross-system[UpdateBefore/After], re-audit the EXISTING[Update*]attributes of the systems you order around and always Play-validate. DR-017_Persistent_Base_Player_Driven_Pacing - A dev/debug
IRpcCommandwire TYPE must be UNCONDITIONAL (no#if) — the reflection-built RpcCollection hash must match across release/dev peers or the handshake refuses;#if UNITY_EDITOR-gate only the send/receive SYSTEMS, never the request struct. Re-mean bytes, don't rename: unchanged byte VALUES keep the[GhostField]serializer identical → re-bake-free (only authoring default-value edits re-bake the subscene). - Derive enableable gates instead of replicating them. e.g. player
Dead= a LOCAL enableable derived every predicted tick from replicatedHealth<=0(rollback-correct, no[GhostEnabledBit]). To write the bit on a disabled entity the query must visit it (.WithPresent<Dead>()); bake the enableable DISABLED so instances spawn off. Respawn/death timing is server-only. - Cooldown/spawn "next tick" sentinels: route every stored tick through
TickUtil.NonZero(...)(a computedServerTick+delaycan wrap to 0, the "ready" sentinel) and compare withNetworkTick.IsNewerThan/.TicksSince, never rawuint </ subtraction. GhostRelevancyfor region splits: useGhostRelevancyMode.SetIsIrrelevant(notSetIsRelevant) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide.RegionTag{byte Region}is server-only, NOT a[GhostField].RelevantGhostForConnection{int Connection (=NetworkId.Value); int Ghost (=GhostInstance.ghostId)}. See DR-013_M6_Aether_Cycle_Region_Split.- Shared GLOBAL state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost, never a region-tagged one (
SetIsIrrelevantwould hide it cross-region). Resolve a ledger buffer via a DISTINCT tag (ResourceLedger), neverGetSingleton<StorageEntry>when a secondStorageEntrybuffer exists elsewhere → "multiple instances" throw. - Frontend world lifecycle (menu → on-demand worlds) ★:
CreateLocalWorldisinternalin 1.13.2 — use publicCreateClientWorld/CreateServerWorld(they register theServerWorld/ClientWorldstatics the UI reads); menu world viaDefaultWorldInitialization.Initialize(name, false). Never dispose/create worlds inside an ECS system — do it on a frame-boundary coroutine (SessionRunner,DontDestroyOnLoad). The gameplay subscene streams in ONLY if a netcode world is theDefaultGameObjectInjectionWorldatLoadScenetime. See DR-019_Frontend_Menu_Settings_Saves_Build.
Physics & character controller
- Unity Physics 1.x bakes built-in
UnityEnginecolliders +Rigidbody(the Physics-0.xPhysicsShapeAuthoring/PhysicsBodyAuthoringare gone). Static collider (no Rigidbody) → baked into the subscene PhysicsWorld, deterministic, no replication.Rigidbody.FreezeRotationis NOT honored by the baker — zero angular velocity + write rotation each tick, or setPhysicsMass.InverseInertia = float3.zero. - The player is a Unity Character Controller kinematic character (NOT a dynamic Rigidbody; M5's
PlayerMoveSystem/PlayerPlanarConstraintSystemdeleted, predicted-physics infra kept).PlayerControlSystemmaps input →CharacterControl;CharacterProcessorcollide-and-slides in the relocatedKinematicCharacterPhysicsUpdateGroup. CC 1.4.2 API =IKinematicCharacterProcessor<T>+KinematicCharacterDataAccess+ staticKinematicCharacterUtilities.Update_*(verify withunity_reflect). KinematicCharacterUtilities.BakeCharacteraborts with aRigidbodyand needs uniform (1,1,1) scale.CharacterInterpolationmust be PredictedClient-only (aDefaultVariantSystemBasestrips it from server + interpolated prefabs) — else double-interp on remotes. Do NOT copy the CC sample's globalLocalTransform → DontSerializeVariant(project-wide; breaks non-character ghosts that rely on stockLocalTransformreplication).- Top-down CC config:
SnapToGround=false,InterpolateRotation=false(rotation owned byPlayerAimSystem),SimulateDynamicBody=false; gravity handled by feedingfloat3.zerotoUpdate_GroundPushing. - Hit/area tests must be SWEPT, not point checks — a point check tunnels when the per-tick step exceeds the target radius (high speed or tick-batching); test the segment traversed this tick. In a PLAIN
SimulationSystemGroupsystem do NOT useSystemAPI.Time.DeltaTime(wall-frame delta, not the fixed step) — store the per-tick step on the projectile (Projectile.LastStep, written in the fixed-step group) and rebuild the segment ascur - dir*LastStep.ecb.DestroyEntityat-most-once per tick (destroyed-bitset; double destroy throws at Playback). TWO target types in one pass: UNIFY into one best-target loop + one shared bitset (separate sweeps double-destroy a projectile overlapping both — DR-018). A per-hit yield(int)cast that also gates despawn is an immortal-sink (sub-1.0→0→no deposit, shot still consumed): guardmath.max(1,(int)yield)+[Min(1f)]authoring.
Build / structures / grid
- Build-grid math must be deterministic + integer-stable: corner-origin, center-returning, half-open cell bounds,
math.floor(not truncation — negatives). LockCellSize/PlotSizeas a coordinate space once (BaseGridMath, EditMode-tested) — changing them invalidates placed structures. PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick}on an ownerless interpolated ghost. Bake the two tick fields (turret reusesNextTickas fire cooldown; they're the offline-catch-up linchpin). OnlyTypereplicates (client derivesCellviaBaseGridMath.WorldToCell). Data-drivenStructureCatalogbuffer. Occupancy is DERIVED by scanning live structure ghosts into a TempNativeHashSet<int2>, never a mutable buffer on the bakedBaseAnchor. See DR-014_M6_Build_Structures_Automation_Foundation.- Co-op placement atomicity: commit the
StorageMath.Withdraw+ cell-reservation in-place inside the RPC foreach (onlyInstantiategoes 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, onNextTickcooldown append a directDamageEvent{Damage, SourceNetworkId=-1}→ reusesHealthApplyDamageSystem. No projectile → no tunnelling, no team model. - Resource-gated ability tiers reuse
StatModifier— grow ONEStatModifier{Target=Damage, Op=PercentAdd, SourceId=<sentinel>}(replace-by-SourceId → bounded buffer);StatRecomputeSystemfolds it intoEffectiveAbilityStatson both worlds.GoalProgress{[GhostField] int Charge, Target}rides the global CycleDirector ghost. - M7 Automation (server-only, never predicted) ★:
Harvester/Conveyor/Fabricatorare buildable machines on thePlacedStructureghost storingPeriodTicks+ server-onlyMachineInput/MachineOutputbuffers (NOT[GhostField]). Production runs in the plain server group[UpdateAfter(PredictedSimulationSystemGroup)](Harvester→Conveyor→Fabricator), replicating only via the global ledger +PlacedStructure. Deterministic catch-up viaProductionMath.CyclesDue(lower-bound 0, never 1; period-0 guarded). Byte-only pure math is EditMode-tested;ConveyorMathis order-independent (stable-sort byCellKey→ at-most-one claim → losers stall).RuntimePlacedTagmarks player-built machines for the save-scan. See DR-020_M7_Automation_Production_Chains. - Disk persistence (
SaveData, single-slot atomic JSON atpersistentDataPath) ★: versioned (null on bad version), schema additive. Born-correct load —CycleDirectorSpawnSystemapplies a stagedPendingSaveAT SPAWN so the director ghost never replicates a default first. Autosave on the Siege→Calm checkpoint + quit-to-menu (WorldLauncher.TrySaveFromServer, host-only);BaseRestoreSystemreplays saved structures charge-free with epoch-independent REMAINING-tick cooldowns; sharedSaveStructureScan.Collect(one scan path). See DR-019_Frontend_Menu_Settings_Saves_Build.
Presentation / juice / VFX
- All juice/HUD = client-only managed
SystemBaseinPresentationSystemGroup(once/frame, no rollback double-fire) that OBSERVES replicated state, never mutates the sim. Read ECS viaSystemAPI.Query+EntityManager.CompleteDependencyBeforeRO<T>()— NOT a MonoBehaviourLateUpdate(job-safety throw).Entityis a stable client dict key for a ghost's lifetime — prune the cache each frame (a pruned enemy = a kill → death VFX); neverDestroyEntitya ghost from the client (GhostDespawnSystemowns despawn). Hit-stop = a camera punch, neverTime.timeScale(corrupts the sim). - Asset-free presentation: procedural
AudioClip.CreateSFX; runtimeParticleSystempool (Sprites/Default + HDR start color); code-built UI Toolkit HUD/menus. Edit a prefab asset's component in code viaPrefabUtility.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.Clientas MonoBehaviours:PrototypeCameraRig(player-following ARPG cam),VFXConfig(staticInstance+ prefab fields bridging authored VFX toCombatFeedbackSystem; keep a procedural fallback). A static presentation bridge must reset on play-enter via[RuntimeInitializeOnLoadMethod(SubsystemRegistration)](statics survive fast-enter-playmode reloads → stale flash). - UITK HUD + menus ★:
MenuUiowns the shared palette + element factories +PanelSettings/EventSystemplumbing + the canonicalRound/Borderhelpers;HudUiis a thin extension.HudSystemis aPresentationSystemGroupobserve-onlySystemBaseowning a runtimeUIDocument(sortingOrder 50, behind the pause overlay's 100); builds the tree on the first framerootVisualElement != null, rootpickingMode = Ignoreso the HUD never eats world clicks (only palette buttons opt back in). Runtime UITK needs aPanelSettingsWITH athemeStyleSheetAND anEventSystem+InputSystemUIInputModuleor buttons are silently dead. The build palette (lazy-built from the clientStructureCatalog) drives click-to-place: green/red ground-ghost preview (BuildPreviewMath, the client mirror of the server check), left-click →BuildPlaceRequestRPC, right-click/Esc cancel,[/]/R rotate,Firesuppressed in build mode. See DR-021_HUD_UITK_BuildPalette. - Synty HUD skin via a build-safe
HudTheme★ (DR-024): Synty sprites/fonts underAssets/Synty/…(NOT Resources) → a runtime name-stringResources.Loadis build-stripped; use a curatedHudTheme : ScriptableObject(Assets/_Project/Resources/HudTheme.asset) holding serialized refs, loaded null-safe viaHudTheme.Get()(every consumer falls back to flat on a null ref).unityBackgroundImageTintColorMULTIPLIES (tint white skins); fonts = cached SDF, reset onSubsystemRegistration. Don't setunitySlice*on Synty 9-slice frame/bar sprites (per-element ERROR; DO set it for border-0 sprites). Some Synty sprites import as Multiple →LoadAssetAtPath<Sprite>null; verify. See DR-024_HUD_Synty_Skin_Theme.
Art import (HDRP store packs → URP)
- BefourStudios art is HDRP-authored → magenta under URP 17.4 + Entities Graphics. Convert, don't switch pipelines (HDRP breaks EG): re-author to stock URP/Lit via
EnvArtTools.cs(menuProjectM/Art/1. Convert Curated Env Materials). Synty art is URP-native — no conversion. - World = cosmetic Synty nature biomes ★ (DR-025):
Game.unityrootsBaseBiome(Meadow_Forest)@origin +ExpeditionBiome(Arid_Desert)@+1000 — classic-URP cosmetics; ground = stock URP/LitMat_Grass_Textures_01/sand 1(NOT prop-atlasS_Generalmats). GlobalSkybox/Procedural(skydome MESH @origin can't span both regions); per-region fog/ambient cross-fade via clientWorldAtmosphereSystem(camera X>500). PNB fog/cloud-ring PREFABS = white torus — don't place. See DR-025_World_Environment_Redo_Natural_Frontier. - A dark-lit screenshot MASKS material bugs — verify material values.
shader.GetPropertyType(idx)-guard beforeGetColor/GetFloat/GetTexture(S_General's_BaseColorMultiplyis a float →GetColorreturns black). Gate emission on the_Emissiveflag + a fixture name; keep converted env metallic low (0.1–0.2). VolumeProfile.Add<T>()does NOT persist (serializes{fileID:0}) — useAssetDatabase.AddObjectToAsset(comp, profile)+SaveAssets, verify on disk.- A reverted engine/URP upgrade can stamp
URPGlobalSettings.assetm_AssetVersionAHEAD of the package'sk_LastVersion(11>10, from the reverted 6.6 alpha); URP migrates forward-only soURPPreprocessBuildrejects it ("not at last version") — blocks player builds, not editor Play. Fix: reflection-setm_AssetVersionback tok_LastVersion+SaveAssets. LocalTransform.FromPosition()resets Scale=1 — server spawners read the prefab's bakedLocalTransform, override only Position (Scale is a[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
GhostAuthoringon scenery (classic-URP cosmetic colliders are inert to the DOTS PhysicsWorld). World collision = subscene-only ★:Environment-layer boundary ring + landmark box colliders (player blocked via the default layer matrix); enemies slide via a serverCollisionWorld.SphereCastinEnemyAISystem(filter=WorldCollisionConfig.EnvironmentMask). Boundaries read as a raised rock-cliff bowl rim (SM_Env_Rock_Cliffring ground-snapped at the collider radius, flat walkable interior) — top-down gates height as a hard vertical wall, never traversable slopes. See 2026-06-08_World_Collision_HUD_Scaling. - A GA "projectile" prefab self-propels (non-kinematic
Rigidbody+collider+ProjectileMoveScript) — strip to particles beforeStart(CombatFeedbackSystem.StripCosmetic). Verify components, not the name.
Aim controls
- Client-derived aim rides the EXISTING
PlayerInput.Aim[GhostField]— mouse-cursor aim computed inPlayerInputGatherSystem(managedSystemBase,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 (Movealready decoupled fromAim). - Active scheme = last-meaningful-actuation-wins, replicated as
byte(PlayerInput.Scheme, KBM=0/Gamepad=1 — byte because compared in BurstedAbilityFireSystem). Server gates theAutoTargetcone to gamepad only → precise mouse, gamepad-only assist. - Cursor/reticle = client
PresentationSystemGroupSystemBase(AimReticleSystem) that OBSERVES. Re-raycast the KBM ground point INSIDE it (it runs after the follow-cam's LateUpdate; latching from the gather drifts a frame behind). Hardware cursor hidden while aiming + focused, restored on focus-loss.
Animation (Rukhanka) ★
Full rationale: DR-022_Animation_Pipeline_Rukhanka_Synty · DR-023_Enemy_Animation_MonsterMash · Synty_Asset_Inventory. Skeletal animation = Rukhanka 2.9 (the only maintained Entities-native option on 6.4). Netcode replication OFF (RUKHANKA_WITH_NETCODE undefined) → client-derived: PlayerAnimationDriveSystem (client-only SystemBase, [WorldSystemFilter(LocalSimulation|ClientSimulation)] + [UpdateBefore(RukhankaAnimationSystemGroup)]) reads replicated state and writes params via AnimatorParametersAspect/FastAnimatorParameter. No new [GhostField]s; no DefaultVariant strip (define off → ghost hash unchanged).
- The rig must bake on the SAME entity that holds the gameplay components the drive job reads — put
Animator+RigDefinitionAuthoringon the player root (not a child) and flatten the skeleton + SMRs under it, else the single-entity drive query matches nothing. - CPU engine still skins via Entities-Graphics GPU deformation → needs a deformation-aware material (
AnimatedLitShader, a multi-target ShaderGraph with aUniversalTarget; Synty atlas → its_BaseColorMap). Stock URP/Lit renders unskinned static + a"does not support skinning"warning (NOT magenta — that's a reused HDRP sample.mat). - Importing the Rukhanka "Animation Samples" (the only source of
AnimatedLitShader) drags in 26 sample subscenes (one NRE's the unguarded clip baker), sample systems that run in your worlds, and a conflicting TextMesh Pro folder. Fix:MoveAssetthe 3 deformation ShaderGraphs to_Project/Shaders/(GUID-preserving), then delete the samples tree. - First Rukhanka bake is ~60 s, synchronous on the main thread (editor freezes → looks like a hang, isn't); the animation blob is cached after → fast re-plays.
- The server runs Rukhanka unless you strip it — its deformation systems are
[WorldSystemFilter(Default)](⊇ ServerSimulation).ServerStripAnimationSystem(server-only one-shot) disables everyRukhanka.Runtimesystem on the server (group-disable cascades; matched by assembly name → no type ref). Only Play-validation caught this. - Build the controller via the
AnimatorControllerAPI (manage_animationdrops enum/Vector blend-tree fields). Skeleton-root = walk up from a bone to the soldier's direct child, NOTSkinnedMeshRenderer.rootBone(the bounds root — the head SMR's isSpine_03; using it destroys the lower skeleton). - Synty Polygon characters share one Generic skeleton; the FBX needs Optimize Game Objects OFF (Rukhanka requirement). Entity origin = capsule center (~1 m up) → offset the un-keyed
Rootbone local Y (Rukhanka bakes it as a constant → it persists through clips). Root motion OFF (the CC owns the transform; blend tree is velocity-driven). - ENEMIES reuse the player pipeline — a Husk is an ownerless interpolated ghost = a remote player, so
EnemyAnimationDriveSystemmirrors the REMOTE path (LocalTransformdelta velocity + prevPos cache; facing viaAnimParamMath.PlanarForward; maxSpeed fromEnemyStats;IsAttacking = AttackWindup != 0). Drop[RequireMatchingQueriesForUpdate]so the prune runs every frame (else a cache entry leaks per kill). Build enemy prefabs via theEnemyRigToolseditor tool, GUID-preserving (DeleteAsset+CopyAssetorphans subscene refs);WaveSystemusesbaked.WithPosition(notFromPosition→ resets Scale).
MCP / editor workflow ★
- Edit Assets
.csONLY via MCPapply_text_edits/create_script(Unity's scripting pipeline) — the rawWritetool does NOT reliably trigger a recompile on an unfocused editor → tests/execute_coderun a stale assembly; a raw-Write-created NEW.csgets no.meta/ no test-discovery untilrefresh_unity scope=all mode=force. (Write/Editare fine for non-asset files: this vault, asmdef JSON, etc.)script_apply_editsanchor_replace(regex) +delete_methodwork even on astruct : ISystem. apply_text_editswith MULTIPLE non-adjacent edits in one call can MISALIGN — one edit per call (or strict bottom-first), always withprecondition_sha256(it returns the current SHA on mismatch).create_scriptwon't overwrite; full-file rewrites = whole-spanapply_text_edits(its brace-balance validator guards botched spans) ormanage_script delete+create_script(NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours).script_apply_edits replace_methodis safe for class methods but can't target astruct : ISystem. DR-017_Persistent_Base_Player_Driven_Pacingexecute_coderuns as a method body — nousingdirectives (parsed as statements); fully-qualify every type. Identify worlds byworld.Name == "ServerWorld"/"ClientWorld"(flags overlap a sharedGamebit).manage_gameobject create/manage_prefabs modify_contentscomponent_propertiesSILENTLY DROP enum + Vector3 fields — set those via a follow-upmanage_components set_propertyand VERIFY throughmcpforunity://scene/gameobject/{id}/component/{Type}(or read the baked component inexecute_codeafter Play).manage_material set_renderer_coloruses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.- New ghost prefab recipe:
manage_asset duplicatean existing correctly-configured ghost (e.g.UpgradePickup.prefab) →manage_prefabs modify_contentsto swap the authoring MonoBehaviour (strip MeshFilter+MeshRenderer for an invisible state-holder) — its ownerless/interpolatedGhostAuthoringComponent+LinkedEntityGroupAuthoringcome free. Runtime-spawn shared ghosts via a one-shot server spawner (dodges the prespawn handshake); wire a baked spawner into the subscene viamanage_scene load additive→set_active_scene Gameplay→ create+verify →save→close_scene. - An UNFOCUSED editor throttles Edit mode to near-idle (MCP pings time out, bridge looks hung — it still queues;
telemetry_pingsucceeds) and stalls EditMode test INIT (passrun_tests(init_timeout=120000), retry).Application.runInBackgroundonly helps in Play mode. Preferrefresh_unity scope=scriptsfor 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 : ClientServerBootstrapoverridesInitializewithAutoConnectPort = 0(M4 — listen/connect is explicit via theConnectionConfigsingleton + per-world ConnectionControlSystems). Editor default = instant-into-game + MPPM (createsServerWorld(WorldFlags.GameServer) +ClientWorld(WorldFlags.GameClient)); theProjectM/Boot Into Menu (Editor)EditorPref flips the MAIN editor to the frontend path. Player builds boot the UITK frontend menu (return false→ one menu world, no netcode worlds until a menu choice). See DR-019_Frontend_Menu_Settings_Saves_Build.- Scenes:
Assets/Scenes/MainMenu.unity(build index 0) boots the UITK frontend (menu world only);Assets/Scenes/Game.unity(index 1) holds gameplay withAssets/_Project/Subscenes/Gameplay.unitywired in as the baked subscene (GameObjectGameplaySubScene).SampleScene/DevSandboxare kept as reference/dev scenes. The on-demand lifecycle (WorldLauncher/SessionRunner/MainMenuController) creates the right worlds per menu choice (Single/Host/Join), THENLoadScene(Game)(subscene-streaming rule above). - Region split: one server world; the expedition lives at
base + (1000,0,0), hidden per-connection viaGhostRelevancy(Netcode gotchas). Place = cosmetic ground/pillars at the +1000 offset; nodes/gates are baked subscene entities. See DR-013_M6_Aether_Cycle_Region_Split.
DOTS / ECS conventions (authoritative summary)
Full rules: ~/.claude/skills/dots-dev/references/dots-conventions.md (Windows: %USERPROFILE%\.claude\skills\dots-dev\references\). These replace classic MonoBehaviour/GameObject patterns.
struct : 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 → 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. 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 inSimulationSystemGroup, tick, assert. Public API, version-independent. Example:Assets/_Project/Tests/EditMode/HeartbeatSystemTests.cs. Run viarun_tests(mode="EditMode", assembly_names=["ProjectM.Tests.EditMode"]). NetCodeTestWorldisinternalin 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_consoleafter 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
.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. - 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)
Full protocol + per-layer detail: Documentation_Protocol (Docs/Vault/_Meta/Documentation_Protocol.md). The four layers: in-repo vault Docs/Vault/ (design docs, DRs, session logs — committed) · basic-memory MCP (semantic/wikilink recall over the vault) · serena MCP (C# symbol nav of Assets/_Project/) · native Claude memory (memory/, MEMORY.md — machine-local).
- Where is X / who calls it → serena (fallback
Grep/Glob). What did we decide / how does Z work → basic-memory → read the vault note. Literal string / asset GUID → Grep/Glob. Current DOTS API → context7. Conventions → this file. Long-form build lessons → the gotchas archive. - Cross-machine rule: durable truth → the vault or this file (both committed); native
memory/is local-only, never the sole home of a decision. serena C# caveat: flaky on Unity — iffind_symbolstalls, fall back toGlob/Grep.
Per-machine setup (NOT in git — redo on each machine)
.mcp.json is committed + portable (${CLAUDE_PROJECT_DIR}); the dots-dev skill travels with the repo (.claude/skills/dots-dev/). Each machine still needs: (1) uv/uvx + Obsidian app + obsidian-cli (the unity-mcp-skill + native memory/ are machine-local, don't sync); (2) basic-memory registration — uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default then uvx basic-memory reindex --full --search --embeddings --project gamevault; (3) Unity 6.4 open + the Unity-MCP bridge connected (mcpforunity://editor/state → ready_for_tools).