CLAUDE.md was 42126 bytes (over its own 40960-byte context-load limit). Trimmed to 39437 (1523B headroom): kept the stack/assembly tables, all high-recurrence hazards, bootstrap/DOTS/testing/guardrails; condensed the verbose mega-bullets to their essence + existing [[DR-###]] pointers; collapsed the redundant Memory table into a link to [[Documentation_Protocol]]. Added a 'Maintaining this file (size budget)' section so future edits self-enforce the limit (size-check commands, archive-don't-delete rule, net-zero rule). Appended a dated, byte-faithful verbatim snapshot of the pre-trim CLAUDE.md to Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md (headings demoted to nest) so nothing is lost from the vault. Verified via a 4-auditor workflow: archive complete + integrity pass; 1 condensation distortion + 2 nuance losses caught and fixed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
38 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 live underAssets/Synty/…(NOT Resources) → a runtime name-stringResources.Loadis stripped from the build; instead a curatedHudTheme : ScriptableObjectatAssets/_Project/Resources/HudTheme.assetholds serialized Sprite/Font refs (dependency-walked in), loaded null-safe viaHudTheme.Get()with every consumer falling back to the flat look on a null ref.unityBackgroundImageTintColorMULTIPLIES (tint white skins, zero bleed); fonts = cached SDFFontAsset, reset onSubsystemRegistration. Don't setunitySlice*on Synty frame/bar sprites — they ship authored 9-slice borders and overriding logs a per-element ERROR (but DO setunitySlice*for border-0 sprites that ship no authored border). Some Synty sprites import as Multiple mode →LoadAssetAtPath<Sprite>returns null; verify import mode + each ref non-null. 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 Entities Graphics). Re-author to stock URP/Lit via
EnvArtTools.cs(menuProjectM/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 beforeGetColor/GetFloat/GetTexture(S_General's_BaseColorMultiplyis a float;GetColoron it returns black). Gate source emission on the_Emissiveflag AND a fixture name. Keep converted env metallic low (0.1–0.2). VolumeProfile.Add<T>()does NOT persist (serializes{fileID:0}on save) — useAssetDatabase.AddObjectToAsset(component, profile)+SaveAssets, verify on disk.- A reverted engine/URP upgrade can stamp
UniversalRenderPipelineGlobalSettings.assetm_AssetVersionAHEAD of the package'sk_LastVersion(here 11 > 10, from the reverted 6.6 alpha). URP migrates forward only, soURPPreprocessBuildrejects the "from-the-future" asset ("not at last version") — blocks player builds but NOT editor Play. Fix: reflection-setm_AssetVersionback tok_LastVersion,SetDirty+SaveAssets. LocalTransform.FromPosition()resets Scale=1 — server spawners must read the prefab's bakedLocalTransformand 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
GhostAuthoringon scenery. Cosmetic SampleScene GameObjects (SyntyWorldroot) render via classic URP and their colliders are inert to the DOTS PhysicsWorld. 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 beforeStart(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 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).