Tier 2 · DOTS full stack

ECS core mechanics

System shape, component granularity, structural changes, lookups, jobs and baking.

System shape, component granularity, structural changes, lookups, jobs and baking.

Entities fundamentals: system shape, component granularity, structural changes, lookups, job dependencies, ordering, and baking.

  • Pure ECS for sim, movement, networking; MB only at the presentation seam (camera, UI, audio). Rule of thumb: scales with count → entity/data; one of it → MB. Cross the boundary explicitly — ECS→MB reads LocalTransform in LateUpdate; MB→ECS adds a request component a system consumes.

  • Register managed Unity-Object types queried via SystemAPI.ManagedAPI. A missing [RegisterUnityEngineComponentType(typeof(T))] throws on world creation and breaks host startup silently (looks like a connection timeout). One registrations file per asmdef.

  • Per-tick systems allocate nothing on the main thread; a cached client-world EntityQuery must track world re-creation. new HashSet/new List/CreateEntityQuery inside OnUpdate allocates every tick — hoist the collection to a field (Clear + reuse) and build the query once in OnCreate. For a managed presentation MB that resolves the client world per frame: the client world is DISPOSED + RE-CREATED on disconnect/reconnect, so a query (or any handle) cached against it goes stale and throws after a reconnect — cache the world ref alongside it and rebuild the query when the world changes (canonical: PrecipitationView), never bind it once in Awake/OnCreate and assume it lives forever.

  • new NativeArray<T>(n, state.WorldUpdateAllocator) throws every tick. WorldUpdateAllocator is a RewindableAllocator. NativeArray's constructor only accepts Temp / TempJob / Persistent. Use query.ToEntityArray(WorldUpdateAllocator) (Entities wraps it) or new NativeArray<T>(n, Allocator.Temp). Exception text: Use CollectionHelper.CreateNativeArray in com.unity.collections package for custom allocator. A system that throws here skips the rest of OnUpdate — measured: HealthBarAttach never spawned bars.

  • A DynamicBuffer pulled from a SystemAPI.Query foreach tuple is a READONLY iteration variable — copy it to a local before indexed writes. foreach (var (…, buffer) in SystemAPI .Query<…, DynamicBuffer<T>>) makes buffer a foreach variable, so buffer[i] = x / buffer.RemoveAtSwapBack(i) fail with CS1654 ("cannot modify members of iteration variable"). DynamicBuffer is a cheap view struct: var local = buffer; then write through local — same underlying data, compiles clean. (Bit twice in one session on SC ability/status systems.)

  • Frequently-toggled per-entity state is an IEnableableComponent flipped with SetComponentEnabled, never an add/remove tag. Add/remove is a STRUCTURAL change — it moves the entity to a new archetype chunk (memory copy + query-cache invalidation + a sync point); doing that every tick for a unit entering/leaving a state (idle↔combat, hauling↔idle, on-cooldown, targeted) is the dominant cost at thousands of agents. Toggling IsComponentEnabled/SetComponentEnabled (writable from a job via ComponentLookup<T>) flips a per-chunk bitmask IN PLACE, and WithAll<T>/WithNone<T> honour it for free — a disabled entity reads as absent and drops out of the downstream query at zero cost. The tell you got it wrong: a system AddComponent/RemoveComponents a marker that means "this entity is currently X". Bake it to its resting state in the baker (SetComponentEnabled<T>(entity, false)) so a freshly Instantiated entity starts already-gated with no runtime init pass.

  • Never apply a structural change (Instantiate/DestroyEntity/add/remove) inline from a sim system — record the intent and drain it in ONE ordered system. A structural change forces a sync point (drains in-flight jobs) and invalidates every live ComponentLookup/array/query iterator that tick; spread across N producer systems it scatters N sync points across the frame and makes the producers unparallelizable. Producers AsParallelWriter.Enqueue a deferred record into a shared NativeQueue singleton (or record into an EntityCommandBuffer); ONE late consumer ordered [UpdateBefore] the rest does the batched destroy/instantiate at a single defined point. The tell: an EntityManager.DestroyEntity/Instantiate inside a foreach (… SystemAPI.Query …) loop. At MMO spawn/death rates this is one sync point versus a frame full of them.

  • Never hold a DynamicBuffer<T> (or any type handle) across a structural change — finish all AddBuffer/AddComponent first, then GetBuffer and fill. Each AddBuffer moves the entity's archetype and invalidates every previously obtained buffer/handle on that entity. Writing the stale handle throws ObjectDisposedException: BufferTypeHandle<T> … invalidated by a structural change (not a real Dispose). Least-tech-debt shape: all structural adds → then get buffers → then write data. Wrong: var seats = AddBuffer<BaseSeat>(e); AddBuffer<Lane>(e); seats.Add(...). Right: AddBuffer all kinds → GetBuffer each → fill. Same rule for holding Entity queries, NativeArray from queries, or ComponentLookup across structural changes without .Update. (Surfaced SC: ArenaLayoutPublish held BaseSeat across more AddBuffers.)

  • A stored Entity is a weak handle — validate it before every cross-entity dereference. An Entity held in a component does NOT keep its target alive; once destroyed the handle dangles and ComponentLookup[target] reads garbage or throws. Worst with deferred destruction (the target may be queued-for-death this very tick) and in a streaming world (the referenced entity may have unloaded with its tile). Gate on EntityStorageInfoLookup.Exists(target) or ComponentLookup.TryGetComponent/HasComponent before reading, and clear the stale ref (Entity.Null / disable the state component) when it fails. The tell: indexing a lookup with an Entity pulled from another entity's component without a prior existence check.

  • Random cross-entity access goes through a ComponentLookup<T>/BufferLookup<T> cached as a system FIELD, built once in OnCreate and refreshed with .Update(ref state) each OnUpdate — never SystemAPI.GetComponentLookup<T> fresh per tick. Query iteration only reaches the CURRENT chunk; to touch an arbitrary OTHER entity (a target, a neighbour) you pass a lookup into the job. A fresh GetComponentLookup/GetEntityStorageInfoLookup in OnUpdate re-resolves the type handle every tick — the field+.Update form only refreshes a cheap version stamp (same class as the cached-EntityQuery rule above). Annotate for the access you perform: [ReadOnly] on every lookup you only read (the scheduler then runs them concurrently); reach for [NativeDisableParallelForRestriction] ONLY on a written lookup where you have PROVEN no two parallel-for indices touch the same entity — it's an unsafe override, not a default.

  • Proximity / collision queries bucket entities into a spatial-cell structure first — never scan the flat entity list pairwise. Pairwise distance checks are O(n²) and explode at thousands of agents; bucketing into a cell array (tile / nav-cell / grid) collapses it to local per-cell checks, one job index per cell. Hold the index as DATA — a singleton wrapping a NativeArray<Cell> of fixed-capacity FixedList512Bytes<Entity> membership lists, kept current as entities spawn/move/die — and read it from the broadphase job rather than rebuilding it each frame. Compare SQUARED distance (math.distancesq vs radius*radius), never math.distance (a sqrt) in the inner loop. The tell: a system does foreach entity { foreach other entity }. (Reuse the project's per-tile/nav-cell grid as this index.)

  • Sim systems are [BurstCompile] partial struct : ISystem gated by RequireForUpdate<T> in OnCreate; a system that touches managed objects is a SEPARATE non-Burst system. ISystem is an unmanaged value type whose OnCreate/OnUpdate/OnDestroy each take [BurstCompile], so the whole sim tick Bursts allocation-free; a system that calls into UI / UnityEngine.Object / ManagedAPI simply OMITS [BurstCompile] (or is a SystemBase) and stays at the presentation seam — never mix managed work into a Bursted sim system. State-gate on the singleton/data the system needs (state.RequireForUpdate<T>) so it is SCHEDULED OUT when absent — not branch-skipped with if (!SystemAPI.HasSingleton<T>) return; at the top of a hot OnUpdate. The tell: a movement/combat/spawn system written as SystemBase, or one that null-checks its singleton inside OnUpdate instead of declaring the dependency in OnCreate.

  • A spawnable's prefab is resolved ONCE in the baker via GetEntity(prefab, TransformUsageFlags…), stored as an Entity in an IComponentData/IBufferElementData; the runtime system Instantiates that baked entity — never Resources.Load/instantiate-from-GameObject in a system. The baked Entity is a Burst-friendly value the spawn job reads with zero managed load, and GetEntity registers the bake dependency so a prefab edit re-bakes (the ECS counterpart of the Resources.Load ban in unity.md). Pick TransformUsageFlags per entity by what it actually needs: .None for non-spatial data/config/store/ singleton entities (the baker then attaches NO LocalTransform/LocalToWorld, so they cost nothing in TransformSystemGroup), .Dynamic (|.WorldSpace to ignore parent hierarchy) only for entities that move and render. The tell: a spawn/streaming system holding a GameObject/asset ref or a string path instead of a baked Entity, or a config/store entity defaulted to .Dynamic.

  • One concept per IComponentData, and size DynamicBuffers at bake time. Compose entities from fine-grained value structs (Health/Damage/Speed/Cooldown as separate one-field components), not a god-struct: a query then declares exactly what it reads (Query<RefRO<Speed>, RefRW<LocalTransform>>), the chunk iterator streams only those fields (tight cache lines), and two systems touching DIFFERENT fields gain no false write-dependency (the test: two unrelated systems each use a different field of one component → it should have been two). For a buffer, set [InternalBufferCapacity(N)] to the typical element count (elements within N live inline in the chunk; overflow spills to an off-chunk heap block and kills iteration locality — so don't over-size N either) and pre-assign buffer.Capacity = length before the Add loop in the baker (one allocation, not geometric regrowth).

  • Persistent native data a system owns lives on state.SystemHandle, created in OnCreate and Disposed in OnDestroy. Attaching a NativeQueue/NativeArray/NativeList as a component on the system handle gives exactly one owner and a guaranteed teardown site — never a leaked Allocator.Persistent container with no disposal (same paired create/teardown discipline as event subscriptions in csharp.md). Canonical shape: a deferred-destroy NativeQueue and a broadphase-grid singleton both hang off the system handle.

  • Bake-time work that must read data baked onto OTHER entities goes in a PostBakingSystemGroup baking-world system — not a Baker, and not a runtime first-update fixup. A Baker runs in isolation per authoring component and re-runs incrementally, so it has NO global view — aggregating across entities inside one Bake is structurally impossible; and redoing it at load/first-frame wastes cost AND invents a runtime ordering dependency that needn't exist. Defer it to a system gated [UpdateInGroup(typeof(PostBakingSystemGroup))] + [WorldSystemFilter(WorldSystemFilterFlags.BakingSystem)], carrying intermediate data on a [TemporaryBakingType] component (stripped after baking, never reaches the runtime world) and spawning scratch entities with CreateAdditionalEntity. The tell: a baker reaching for another entity's already-baked components, or a one-shot "fix it on first OnUpdate" system.

  • [UpdateBefore]/[UpdateAfter] only order systems that share a parent group — a constraint against a system in a DIFFERENT group (or world) is silently ignored, not an error. Pick the GROUP first with [UpdateInGroup], then fine-order within it. The silent failure: your "runs before X" never happens, surfacing as a one-tick lag or a race that only bites under load. Why ordering against TransformSystemGroup DOES work — an unmarked ISystem defaults into SimulationSystemGroup, and TransformSystemGroup is a CHILD of SimulationSystemGroup, so the two are siblings; ordering against a non-sibling group would no-op. The tell: an [UpdateAfter] pointing at a system you didn't confirm shares the same parent group.

  • ORDERFIRST-GROUP-NEEDS-ORDERFIRST - a plain-slot system cannot order before an OrderFirst group. Measured: [UpdateInGroup(SimulationSystemGroup)] [UpdateBefore(typeof(PredictedSimulationSystemGroup))] logged "Ignoring invalid [UpdateBefore] attribute ... OrderFirst/OrderLast takes precedence" three times, and the collider swap it guarded landed AFTER the prediction replay, one tick late on every client. Prediction (and GhostSimulationSystemGroup) are OrderFirst in SimulationSystemGroup, so the constraint is dropped, not an error. Fix: [UpdateInGroup(typeof(SimulationSystemGroup), OrderFirst = true)] plus [UpdateAfter(typeof(GhostSimulationSystemGroup))] (snapshot data applied first) and [UpdateBefore(typeof(PredictedSimulationSystemGroup))]. The tell: that console warning.

  • Run spawn / transform-mutating systems [UpdateBefore(typeof(TransformSystemGroup))] so a same-tick SetComponent(LocalTransform) bakes into LocalToWorld this frame — no one-frame origin flicker. A freshly Instantiated entity holds the prefab's baked pose until TransformSystemGroup computes its matrix; set its real position AFTER that group ran and it renders one frame at the prefab/origin pose before snapping into place.

  • Pin every read-after-write system pair with explicit [UpdateAfter]/[UpdateBefore] inside one group — never lean on implicit creation order (it's a deterministic sort you don't control, and it reshuffles when systems are added). A mark→apply→drain chain pins every stage: targets resolved BEFORE damage applied BEFORE the single destroy-dead stage; put the system that performs the structural change LAST so all producers have run. On a server-authoritative shard an unordered read-after-write reads one-tick-stale data → divergent sim across shards/replays. The tell: two of your systems touch the same component in the same tick and neither declares an order relative to the other.

See also: the scene flow page - the app & scene flow hub (bootstrap -> load -> transitions/additive -> runtime lifecycle -> teardown).

  • Loading / bootstrap / tile-stream / spawn / connection-approval systems belong in InitializationSystemGroup (or are RequireForUpdate-gated on the loaded data) — not ordered by a bare attribute against a sim system. "Init-class work completes before sim consumes it" is a GROUP guarantee (InitializationSystemGroup runs before SimulationSystemGroup every frame), which the cross-group rule above means [UpdateBefore] against a sim system CANNOT express. For the streaming world, gate each consumer with state.RequireForUpdate<T> so it doesn't tick until its tile/world/ghost-prefab singleton exists, rather than racing the loader.

  • Use OrderFirst/OrderLast for edge-of-group systems, and order any EntityCommandBuffer work relative to the Begin/End*EntityCommandBufferSystem that plays it back — not relative to the consumer. A recorded ECB doesn't apply until its ECB system runs at the group boundary, so whether your deferred spawn/destroy is visible this frame or next depends on which buffer you grabbed (BeginSimulationEntityCommandBufferSystem vs EndSimulation…) and where your recorder sits around it; [UpdateBefore] against the reader won't change WHEN the structural change actually lands.

  • The editor "Loading Assetcache" stall at play-mode entry is on-demand subscene entity-scene import — pre-warm artifacts after every compile instead of paying it at play start; builds never hit it. Entering play with a stale entity-scene artifact blocks the editor (seconds to tens of seconds behind a "Loading Assetcache" dialog) while the import worker re-bakes the subscene — and ANY script compile invalidates the artifact (baking assembly hash), so active dev pays it on the first play after every edit. Builds pre-bake entity scenes at build time — pure editor friction, never a player cost. Fix shape: an [InitializeOnLoadMethod] + AssetPostprocessor hook that, when the editor is idle, calls (reflection — internal) Unity.Scenes.EntityScenesPaths.GetSubSceneArtifactHash(sceneGUID, configGUID, true, ImportMode.Asynchronous) for every subscene reachable from enabled build scenes and polls until hashes go valid; discover the subscene set via AssetDatabase.GetDependencies(buildScenePath) → nested .unity deps (SubScene components reference their SceneAsset — no YAML parsing). Under NetCode warm BOTH DotsGlobalSettings.Instance.GetClientGUID and GetServerGUID — each world requests its own artifact via SceneSystemData.BuildConfigurationGUID (client-hosted server world uses the CLIENT guid; client-only target / simulated dedicated uses the server guid). Profiling traps that hide this: the import stall reads as one opaque EditorLoop sample in a play capture (enable Profile Editor to decompose), and your own MCP/tooling calls also stall EditorLoop — don't report probe overhead as game spikes. Separately: the engine forces a full GC.Collect inside Application.LoadLevelAsync Integrate on every scene load — a ~0.7–1.2s editor-heap spike that shrinks drastically in a player build and cannot be removed from project code. (Proven a mobile strategy game: 23s play-entry stall killed by an EntitySceneWarmup editor hook; post-fix match load's only PlayerLoop spike was the engine GC at ~700ms.)

System design: Shape B — DOTS / ECS layer (from System design before code)

the ecs core page and the Netcode overview page hold the pitfalls; this is the shape. The MonoBehaviour vocabulary maps across exactly, and keeping the mapping is what preserves blast radius:

MonoBehaviour layer DOTS layer
manager owns state a component holds the data; exactly ONE system writes it
static event a component, enableable tag or DynamicBuffer entry — the event IS data
subscriber a query — a system whose query includes that component IS the subscriber
controller drives the manager an intent component the owning system consumes
view subscribes a presentation system reads and pushes outward, never reaches back
  • One writer per component, named in its doc line. Every other system declares it [ReadOnly]. This is ONE-PATH-CODE-SHAPE in ECS clothing, and it is the rule that keeps a component's meaning knowable.
  • The event is a component; the subscription is a query. System A writes a request component or enables a tag; system B queries for it and consumes it. Never a direct system→system method call, never World.GetExistingSystem<T> to reach a peer — that is OtherManager.Instance with ECS syntax and it destroys the same property.
  • Blast radius is mechanically enumerable here, and that is the point: grep the component type, and every system naming it in a query is the complete subscriber list. ECS gives you for free what the MonoBehaviour layer has to earn with discipline. A design that breaks that grep — a system reading a component it never declares, state smuggled through a static, a managed side-table — has thrown away the one property worth having.
  • A one-shot is a request that its consumer clears, not a bool poked from outside. Consume and disable/destroy in the same system that queries it, so the lifetime is one system's business.
  • Ordering is declared, never discovered. [UpdateInGroup], [UpdateBefore], [UpdateAfter], RequireForUpdate on the singleton. A readiness bool, a "wait until initialised" poll or a frame counter is the second path — it dies with the ordering attribute that replaces it.
  • Server owns outcomes; clients send tick-tagged intent. No client-authoritative writes to replicated state. → the Netcode overview page.
  • Hot-path shape is not negotiable: Burst-compiled jobs, [ReadOnly] lookups, struct data, no managed allocation per tick, structural changes batched through an ECB at a defined sync point. A per-tick new, a closure, a LINQ call or a boxed lookup is a defect at write time, not a later optimisation. → the perf memory page.

The seam — ECS state to presentation

This is where a DOTS project actually rots. Measured on one codebase's 62-hit lookup backlog, EVERY single offender was a Presentation/ file reaching sideways for something.

  • One bridge system per presentation concern, one direction. It reads ECS state and pushes to the GameObject side. Presentation NEVER queries the world, resolves an entity, or hunts a renderer.
  • The bridge pushes into a wired reference or one static event. Not into a manager it looked up, and not into an object it found.
  • Nothing crosses back. Input and intent return as a command/intent component, never as a presentation object writing simulation state.
  • A presentation type that needs to know an entity got the entity handed to it. If it had to go looking, the bridge is missing — write the bridge; do not add the lookup.

Moved here from the Unity programming pages

  • STRUCT-CAPTURE-IN-ISYSTEM — local functions and lambdas inside an ISystem cannot capture instance fields (CS1673). ISystem is a struct, so this hits systems first: a nested Launch that reads _transforms will not compile. Copy the field to a local first. Query expressions have the same ban. (General C# rule: C# style & conventions.)
  • BAKER-CLASS-NAME-SHADOWS — never name a nested baker class Baker. It shadows Unity.Entities.Baker<T> (CS0308). (Sibling of the Editor namespace rule in C# style & conventions.)
  • NO-SCENE-SCAN-INSIDE-SYSTEMS — the runtime scene-scan ban applies inside ECS systems too. An ISystem's OnCreate/OnStartRunning/OnUpdate is runtime build code, so a scene scan from a system is the same O(n) / skips-inactive trap as one from a MonoBehaviour; bridge ECS→a hybrid MB (UIDocument, camera) via a static instance/event the MB publishes or a request component a system queries, never a scan (a single one-shot FindObjectOfType in OnStartRunning to bind a startup UI document is the only narrowly-tolerable case — never per-tick). (General rule: Runtime references & object lifecycle.)
  • SUBSCENE-REIMPORT-UNBAKES-THE-SECTION — an editor pass that re-saves scenes unconditionally bites hardest under a SubScene. Re-importing the referenced scene invalidates its baked entity scene and Play can admit a player onto an unbaked section — a failure that presents as its own success, since the collider ENTITY exists and only its mesh is missing. A baker-delivered catalog also only bakes inside a SubScene, so an editor sync that writes into the wrong scene silently delivers nothing. (General rule: UNCHANGED-SAVE-STILL-REIMPORTS on the Editor tooling page.)

BURST-ISYSTEM-HELPER-QUERY-CRASHES-LLVM — nested SystemAPI in ISystem OnUpdate can kill the Editor

ISystem.OnUpdate is Burst-compiled by default. A helper that runs SystemAPI.Query (or other SystemAPI iterators) from inside that method, including a second query nested in the first, can infinite-recurse Burst LLVM 19 CloneFunctionInto / burst_TypeAllocSize during HashAndCompileMethodGroup. The Editor dies on import or Play with Crash!!! and no C# exception. Managed SystemBase skips Burst compile of that path; [BurstCompile(Disable = true)] is the smaller ISystem form. Collect query results first, then walk them — do not nest SystemAPI.Query.

Measured: two Editor deaths four minutes apart, native stack looping burst_TypeAllocSize then burst_CloneFunctionInto; Play after those systems became SystemBase stayed up at the main menu with zero console errors.

BURST-CCTOR-FOLLOWS-FIELD-READ — a Burst method reading a static field compiles that type's cctor

A class hosting a Burst-eligible method needs a Burst-safe static constructor: no managed static readonly, no new FixedStringN(stringConst). Moving the consts to a sibling class does NOT help — Burst compiles the cctor of any type whose static field a Burst method reads, and follows the field read. Build FixedStrings in managed code into a SharedStatic<T> and let Burst read them as data.

Why it is worse than it looks: BC1091 poisons every Burst caller of the class, jobs fall back to Mono (per-entity-per-tick systems → GC storm → OOM), and the "fixed" version only LOOKS fixed in the editor because JIT falls back for an uncompilable cctor — AOT cannot, so Build Player fails. Sibling: Burst keys its compiled cache on struct-layout hash, so during rapid iteration old code can run against a new struct and writes to a newly added field silently vanish — force invalidation by renaming/retyping a field, touching the [BurstCompile] line, or wiping Library/BurstCache/.

EDITOR-BURST-CAN-SKIP-A-RECOMPILE — Burst on and off disagree, so the job runs pre-change code

Burst's editor loader ignores an assembly recompile it classes as not-for-Editor. The likely trigger is a compile during or right after a player build. The changed jobs keep their old hash and run pre-change code until something dirties the assembly again. Measured: a sprint gate edited in a shared static method held aim at 0 in 42 of 42 sprint samples with Burst on, and 1 in 32 of 32 with Burst off. Toggling Enable Compilation alone reused the stale code. Detect it by A/B-ing BurstCompiler.Options.EnableBurstCompilation before debugging the logic. Fix it without a restart: typeof(BurstCompiler).GetMethod("SendCommandToCompiler", NonPublic|Static).Invoke(null, new object[] { "$dirty_all_assemblies", null }), then toggle Enable Compilation off and on. After the fix, 45 of 46 samples ran the new code. A build recipe runs this before it hands the Editor back.

Amended 0.5.86: the cache can also outlive a clean rebuild. After a large pull whose first compile failed and whose second passed, the Editor's Library/BurstCache/JIT kept code compiled from the pre-pull IL and ran it against the new, larger system structs: two ISystems threw NullReferenceException every tick and character physics stopped (7,676 NREs in one session). Signature: the Burst stack frames cite line numbers that only match the pre-pull revision (map them with git show <old>:<file>; here they were the first field reads of the old OnUpdate), and Burst logs "not a known Burst entry point" for systems the pull added. A script rebuild did not clear it. Fix: exit the Editor, move Library/BurstCache/JIT aside, relaunch; walk and fire then ran clean with Burst on. Blaming the latest change to the crashing system is the trap.

DOTS-LOOKUP-IS-A-DIFFERENT-RULE-NOT-A-FREE-PASS (HARD)

The GameObject ban rests on two failures (the references page GETCOMPONENT-IS-TWO-FAILURES): a scan, and an invisible dependency. In ECS the MECHANISM is different and the DEPENDENCY IS DECLARED - but that does not make a lookup free, and "it is DOTS, so it is fine" is the wrong conclusion. Researched against Unity's own documentati:

  • ComponentLookup<T> is a [NativeContainer] giving "array-like access to components of a specific type", indexed by Entity (Unity Entities API, ComponentLookup<T>). Its documented purpose is to "look up data associated with one entity while iterating over a different set of entities". It is not a search of a GameObject's component list, and the fix for a GameObject lookup - wire a [SerializeField] - is meaningless here. Diagnosing one as the other is wrong in both the diagnosis and the remedy.
  • But Unity frames it as the FALLBACK, not the default. The manual page is titled "Look up arbitrary data" and opens by stating that a system with an entity query and a job is the most efficient path, "with minimal memory cache misses", and that lookup is for when you need an arbitrary component of an arbitrary entity. So random access IS a cost centre - just a cache-locality one, not an allocation-and-coupling one.
  • Therefore: different rule, different remedy. The question to ask of a lookup is never "is this banned" but "could this data have been in the query?" If yes, put it in the query and iterate the chunk. A lookup earns its place only for genuinely cross-entity access (the parent/target case Unity's own example uses).

The real DOTS debt shapes, none of which is "you called a lookup":

  • A lookup constructed per frame. state.GetComponentLookup<T>(isReadOnly) in OnCreate, then lookup.Update(ref state) in OnUpdate - the documented purpose of Update is the "minimal incremental updates necessary to make the type handle safe to use" across system updates. Unity also advises EntityManager.GetComponentData outside a job "to avoid the overhead of creating a ComponentLookup object", so construction is not free by Unity's own account.
  • Random access where iteration was available - see the cache-miss framing above.
  • EntityManager inside a job, or per-entity structural changes. Sync points; batch through an EntityCommandBuffer or a query-wide operation.
  • GetComponentObject / reaching a MonoBehaviour from a system - that IS the bad kind: it re-enters managed land and reintroduces both GameObject failures.
  • Reading a component the system never declares (smuggled via a static, a managed side-table, or World.GetExistingSystem<T> to reach a peer). This breaks the grep that makes blast radius enumerable - the one property ECS gives for free (the system blueprint page Shape B).
  • A missing [ReadOnly] on a lookup you only read. Unity's note is unambiguous: always declare ComponentLookup read-only unless you intend to write. It costs scheduler parallelism, not correctness, so nothing ever tells you.

Gate scope. TryGetComponent is TWO APIs: GameObject's is TryGetComponent(out T) - out-first (Unity Scripting API) - while ComponentLookup<T>'s is TryGetComponent(Entity, out T) - entity-first. tools/rule-check.sh matched both and reported correct Burst code as a scene lookup; a false positive on valid code is worse than no check because it teaches the agent to stamp rule-check:allow and launder the signal. It now matches only the out-first form. Verified both directions: a probe of correct DOTS lookups reports clean, and TryGetComponent<T>(out var), gameObject.TryGetComponent(out T) and GetComponentInChildren<T> are all still caught. Not gated, deliberately: a ComponentLookup field without [ReadOnly]. Measured on a real DOTS project first - three such fields exist and appear to be legitimate write-lookups, and a grep cannot separate "writes to it" from "forgot the attribute". Review it by eye; do not add a check whose false-positive rate you have not measured.

Filters, lifetimes and bakers

  • WorldSystemFilterFlags.LocalSimulation also runs your system inside CUSTOM tool worlds. A system that exists for a netcode session must filter to exactly ServerSimulation | ClientSimulation; add LocalSimulation only with a positive reason. (An entity-spawning system tagged LocalSimulation gave an editor streaming world 1,500 uninvited physics entities and corrupted its shared-component refcounts into an assert avalanche at scene close.)

  • A frame-lifetime Allocator.Temp collection must never be assigned to a persistent system FIELD. Next frame IsCreated still reports true on freed memory and reads return garbage (observed: characters teleporting wildly). Copy the owning context/struct to a LOCAL before creating its temp collections, every update — a job-scheduled path hides this because the job copies the struct for you, so a main-thread port of a job-based package API silently inherits a lifetime the API never documented.

  • Entities Graphics only renders shaders that support DOTS_INSTANCING_ON. A hand-written HDRP/URP shader that does not opt in gets a garbage object-to-world matrix from the BatchRendererGroup and its entities are simply INVISIBLE — no error, shader.isSupported == true, material bound, entities resident. This is the most common "Entities Graphics doesn't work" false diagnosis. Check shader.keywordSpace.keywordNames against a shader already rendering (HDRP/Lit). Shader Graph is not an escape hatch for a custom vertex stage — render that layer outside ECS. (The vendor-variant note in The VFX with AllIn1 page is one instance of this rule.)

  • Bakers cannot Find* main-scene objects — they run in an isolated SubScene context when the SubScene is CLOSED, so it works with the SubScene open (live baking sees the whole scene) and returns null with it closed, falling back to defaults silently — i.e. the bug exists only in the configuration you ship. Put the SO/asset reference directly on the authoring MB as a serialized field and DependsOn(...) it; anything that must bake GetEntity(prefab) references belongs IN a SubScene, never a main-scene catalog.

  • The SubScene importer does not dependency-track materials/meshes swapped INSIDE a baked prefab — only authoring-component fields and GetComponent/GetEntity are auto-tracked (Entities 1.4). An open SubScene re-bakes live every Play so the editor looks right while every non-live world and every player build keeps the OLD material, and restarting the editor does not help because the artifact hash is unchanged. Register renderer materials explicitly in the baker (foreach (var r in prefab.GetComponentsInChildren<MeshRenderer>(true)) foreach (var m in r.sharedMaterials) DependsOn(m);) and verify at RUNTIME via RenderMeshArray.GetMaterial(MaterialMeshInfo), never by reading the prefab on disk.

FIXEDSTRING-BYTES-NOT-CHARS — truncate by UTF-8 byte count, never string.Length

FixedString64Bytes holds 61 UTF-8 BYTES; string.Length counts UTF-16 code units. A if (name.Length > 60) name = name.Substring(0, 60) guard therefore passes 25 CJK characters (75 bytes) or 16 emoji straight into a constructor that throws ArgumentException on truncation — and the comment beside the guard usually states the byte limit correctly, which is why it reads as already handled.

The trigger needs no attacker: a player whose real name is non-Latin hits it just by joining, and the throw lands on the HOST during match bootstrap, so it takes down everyone's match. Measure System.Text.Encoding.UTF8.GetByteCount(name) and trim whole text elements (surrogate-pair aware) until it fits. Grep the whole repo when you fix one — this is copied between call sites, so the file you would copy the correct version FROM often has the same bug.

  • A sim system that "runs but does nothing" is almost always THROWING AT SCHEDULE TIME — and the console buffer has evicted the proof. Schedule-time exceptions repeat every frame (hundreds per second), so any OTHER exception storm (a leaked BRG callback, a UI spam) rotates them out of the visible buffer and the system looks mysteriously inert. Debug order: (1) filter console to type=error FIRST, every run; (2) if flooded, grep Logs/Editor.log after the last play-entry line instead. Three schedule-time killers from one night on a tower-defense game: a default NativeArray job field (a map without optional buffers left CellKinds unconstructed — pass a persistent 1-length dummy when the source is absent); an embedded IJobEntity struct (its source-gen injects a hidden __TypeHandle only the generated scheduler assigns — never embed one in another job; demote shared logic to a plain struct); and a main-thread ComponentLookup read racing an in-flight job (call state.CompleteDependency before main-thread lookup use). Corollary: a required victim/target component missing from a hand-built archetype (ArmorProfile on damage victims) makes the drain query skip silently — mirror the reference project's full archetype, not just the components you recognise.

  • PHYSICS-MIRROR-LOUD-AND-STATIC — a PhysX → Unity.Physics mirror is LOUD on any collider type it does not convert, EXCLUDES anything under an Animator, and disposes its blob cache on a SubsystemRegistration reset. A silent default: dropped all 64 TerrainCollider tiles while every PhysX check said fine (the character fell through the rendered ground every session); a static mirror of an animated rig leaves phantom walls where a creature once stood; collider blobs are persistent allocations that leak every play cycle unless the previous cache is disposed in a [RuntimeInitializeOnLoadMethod(SubsystemRegistration)]. TerrainCollider.Create WRITES its heights input ([ReadOnly] throws) and indexes heights[z*res+x]. A LocalSimulation-filtered spawning system also instantiates inside custom TOOL worlds. (a survival game)

  • ISYSTEM-HELPER-TAKES-SYSTEMSTATE — a helper that runs SystemAPI.Query takes ref SystemState or source-gen fails SGSG0002 even though the code reads like a normal instance method. Scene object references do not survive baking as object references. (a survival game)
  • LIVE-EDIT-DRAINS-ECB-FIRST — a live edit over streamed ECS content drains pending ECBs BEFORE its structural consequences, enforces the validator's exact predicate (strict <, not <=) at edit time, and a failed tick stands down instead of storming. Structural unloads with queued fade commands stormed the editor; two equal band values bricked the next Start. (a survival game)
  • **EG-EDITMODE-AND-MESHLOD — runtime entities are INVISIBLE to an edit-mode Camera.Render until PresentationSystemGroup is pumped; Entities Graphics 6.5 Mesh LOD is a BAKING-path feature, so add MeshLodInfoComponent yourself before RenderMeshUtility.AddComponents (whose runtime path throws); Mesh.GetLod is (subMeshIndex, levelIndex) — both int, so swapped args compile; and a bulk mesh pass that throws mid-chunk leaves in-memory chains a force:false rerun skips. (a survival game)

  • ECB-LAG-DUP-BYPASS — a duplicate guard that reads LIVE state is bypassed by every duplicate that lands in the same tick, because the state change it checks for arrives via ECB playback. N pickups of one ghost in one tick each re-read the live count; N login approvals each run the full approve path. Live buffer mutations are same-tick safe; deferred destroys and adds are not — keep a processed set for the tick or apply immediately. (a project docs/pitfalls/input-hardening.md:27, storage-haul.md:31.) See also: the scene flow page - the app & scene flow hub (bootstrap -> load -> transitions/additive -> runtime lifecycle -> teardown).

  • SUBSCENE-RUNTIME-BOUNDARY. Plain MonoBehaviours inside a SubScene do not exist at runtime (only bakers survive; FindFirstObjectByType returns null); a main-scene field pointing at a SubScene authoring GO — or the reverse — breaks at play, so cross the boundary through a baked marker component + query, never a held Transform; never strip SceneTag / SceneSection from a runtime clone (different archetype, no EntitiesGraphicsChunkInfo, invisible); SMR + Animator prefabs spawn as Companion GameObjects; and DestroyEntity(EntityQuery) fails on roots whose LinkedEntityGroup reaches outside the query — destroy per root. (a project docs/pitfalls/subscene-baker-gotchas.md.)

  • JOB-API-SHARP-EDGES. SystemAPI.Query<…> caps at 7 type arguments (fetch the rest with SystemAPI.GetComponent in the loop); a [WriteOnly] NativeArray throws on ANY read; IJob.Run collides with IJobEntity.Run unless using Unity.Jobs; is explicit (CS0315); an out-of-band Camera.Render during Play needs CompleteAllTrackedJobs on every live world and WaitForEndOfFrame; registrations that a SubsystemRegistration reset clears go in AfterAssembliesLoaded; blob-stored types pass by ref, never in (EA0001/EA0009), and a blob in a baker is BlobBuilder → CreateBlobAssetReference(Persistent) → AddBlobAsset. (a project docs/pitfalls/subscene-baker-gotchas.md:12-49, ai-brains.md:165,175.)
  • RENDER-CHILDREN-ARE-LINKED-ENTITIES — the mesh is a CHILD entity, so visibility toggles DisableRendering across the whole LinkedEntityGroup, not the visual root; and render-child teardown is a CLEANUP component the presentation system's dead pass drains, not OnDestroy bookkeeping. A baked equip prefab's root carries markers and no renderer. (a project docs/pitfalls/combat-ranged- firing-pipeline.md:6, detail-spawning-grass-trees-rocks.md:36.)

MATH-MIN-MAX-DROP-NAN - a min/max sweep cannot detect a bad value

Unity.Mathematics.math.min/max return the OTHER operand when one side is NaN. Measured against 6000.5.0f1's shipped UnityEngine.MathematicsModule: math.min(3, NaN) -> 3, math.min(NaN, 3) -> 3, math.max(3, NaN) -> 3; only min(NaN, NaN) -> NaN. Same for the double overloads, the vector overloads (min(float3(3,3,3), float3(NaN,NaN,NaN)) -> (3,3,3)) and the horizontal reductions (cmin(5, NaN, 9) -> 5, cmax(5, NaN, 9) -> 9).

The BCL is the OPPOSITE: System.Math.Min(3f, float.NaN) and MathF.Min both return NaN. So the habit is backwards, and the two libraries are usually imported into the same file.

The failure this creates is a validation that reports clean. The standard shape -

for (var i = 0; i < heights.Length; i++)          // sweep
{
    grid[i]  = heights[i];
    minimum  = math.min(minimum, heights[i]);
    maximum  = math.max(maximum, heights[i]);
}
...
if (!math.isfinite(range[0]) || !math.isfinite(range[1])) Reject;   // guard
  • catches +/-Infinity (those compare normally, so they DO propagate through min/max)
  • is blind to every NaN. An all-NaN array leaves the seeds untouched and yields [float.MaxValue, float.MinValue] - both finite, so the guard passes and reports a clean range.

Fix: test the ELEMENT, not the reduction - bad |= !math.isfinite(heights[i]); in the same loop is free next to the min/max, and math.isfinite is the right predicate because it rejects NaN and both infinities. A NaN that gets past the guard usually will NOT throw downstream: TerrainCollider quantizes each sample with an unchecked (short)(h * factor + 0.5f) (com.unity.physics/.../TerrainCollider.cs:928), and an unchecked float->integer cast of NaN is UNSPECIFIED in C# (0 on x64) - so NaN lands as a wrong height, with no exception and no log.

Reviewing a diff: a math.isfinite check applied to an aggregate rather than to the samples is this bug. (a space sandbox game, found in review of a streamed-collider height reduction.)

PHYSICS-SINGLETON-IS-ONE-WORLD-OF-SEVERAL

  • SystemAPI.GetSingleton<PhysicsWorldSingleton> is physics world index 0, which in a project that runs more than one world may contain nothing you care about. Multiple worlds appear as soon as anything uses CustomPhysicsSystemGroup / PhysicsWorldIndex: per-region or per-sector collision, a separate world for a vehicle interior, a client-side prediction world. Terrain streamed into a region lives in that region's world, and the default world is EMPTY of it.
  • The failure is silent and total. Every raycast returns no hit, so a spawn gated on ground support places nothing and the feature is simply absent — no exception, no warning, no log. It is indistinguishable from "the content was never authored", which is where the time goes.
  • Measured on one lane: the same defect at two sites (activation ground probe, locomotion ground settle) against a region at PhysicsWorldIndex = 1 while the singleton was index 0. Nothing had ever spawned, and the bodies that were forced in drifted 8 to 10 metres under the surface.
  • Read through whatever the project uses to reach a world by membership, and probe ONCE PER GROUP of bodies rather than per body. A parallel IJobEntity cannot reach a managed registry, so split the pass: march in the job, then gather the group's transforms and issue one world read per world, filtering the query by its PhysicsWorldIndex shared component.
  • Audit by grepping for GetSingleton<PhysicsWorldSingleton> and asking, per hit, which world the bodies it is testing actually live in.

SUPPORT-QUERY-NAMES-EVERY-LAYER-IT-STANDS-ON

  • A ground or support query that reuses a narrower named filter finds nothing on a body built on another layer, and the refusal is silent. A terrain-only "ground" filter (CollidesWith = the terrain bit) is right for spawn placement and wrong for anything a player stands on that is not terrain: a vehicle deck, a ship interior, a building floor. The capsule itself collides with every layer, so the player visibly stands on the deck while the check that gates the handover says no.
  • Measured once: a boarding check cast the terrain-only query into an interior physics world whose 27 bodies all belonged to the structure layer. The precondition chain returned false with no log, so the only symptom was "standing on the ship does nothing". The same ray with a query that includes the structure layer hit the deck at the expected height and resolved the right vehicle.
  • Name the support query after what it must find and list every layer the target can be built on (for example: terrain, structure, scatter, vehicle; never capsules, trigger-only ramps or loose loot). Pin it with a pair-rule test (CollisionFilter.IsCollisionEnabled(query, targetLayer)) for each layer the feature stands on.
  • Debug a silent refusal by evaluating each return false of the chain live against the real world, in order; the first false is the defect. A cast that misses: dump the bodies' BelongsTo against the query's CollidesWith before looking at geometry. Sibling: PHYSICS-SINGLETON-IS-ONE-WORLD-OF-SEVERAL (the right filter against the wrong world misses the same way).

MAIN-THREAD-GATHER-DOES-NOT-WAIT-FOR-A-SCHEDULED-WRITER

  • ToComponentDataArray / CopyFromComponentDataArray on the main thread do NOT complete a job you scheduled earlier in the same OnUpdate that writes that component type. They throw InvalidOperationException: The previously scheduled job <Job> writes to <T>_RW_ComponentTypeHandle. You must call JobHandle.Complete ... before you can read. Call state.Dependency.Complete before the gather.
  • It throws EVERY tick, so the work after it never happens — and if the surrounding feature was already inert for another reason, the exception spam is the only symptom and it looks like a logging problem rather than a dead pass.
  • A main-thread pass reading a type a job also reads must still have that type in the system's declared dependencies, or you get the paired complaint reads <T> via <Job> but that type was not assigned to the Dependency property.

RECOVERY-PROBE-REACHES-FURTHER-THAN-THE-DRIFT-IT-CORRECTS

  • A symmetric probe cannot recover a body that has already drifted past its reach. A ±6 m ground ray is fine for a body sitting on the surface and useless for one 8 m above it: the ray misses, the correction is skipped, and the body is stranded permanently. The probe is both the detector and the repair, so its reach must cover the worst drift the system can produce, not the per-tick delta.
  • Split the reach: start it a little above the body (enough to clear the relief it crosses in a tick) and reach far below (enough to catch anything that has already come adrift). The asymmetry is the point — starting far above picks up ceilings and canopy.

ORDER-ATTRIBUTES-LOSE-TO-ORDERFIRST-BUCKETS

  • [UpdateAfter] / [UpdateBefore] pointing at a system or group that is OrderFirst or OrderLast in the same group, from one that is not, is DISCARDED with Ignoring invalid [UpdateAfter(X)] attribute on Y because OrderFirst/OrderLast has higher precedence. — every domain reload, forever. The sorter compares buckets first and only orders within a bucket.
  • The ordering you wanted is usually already guaranteed by the buckets, so the attribute buys nothing and costs a permanent warning. Delete it and put the reason in the type header: a blueprint's ORDER line is satisfied by a stated mechanism, not only by an attribute.
  • Check before writing the attribute: read the target's own [UpdateInGroup] for OrderFirst / OrderLast. Network receive and command-buffer groups are the common targets.

PARALLEL-JOB-COUNTS-ITSELF-PER-THREAD-AND-PUBLISHES-IN-A-JOB

  • A parallel IJobEntity that needs to report how much work it did gets one NativeArray<int> slot per worker, not an atomic and not a main-thread pass. Size it JobsUtility.ThreadIndexCount, mark it [NativeDisableParallelForRestriction], and add [NativeSetThreadIndex] public int ThreadIndex; — IJobEntity supports that attribute, same as any other job.
  • Sum it in a follow-up IJob, not on the main thread. Reading the counters in OnUpdate either races the job you just scheduled or forces a Complete, and a once-a-second readout is not worth a sync point. Let the summing job write the component and zero the slots in the same pass, so the window empties exactly once.
  • Keep the counter's meaning narrow enough to name: count the branch that does the expensive work, not every entry to Execute. A counter that includes early-outs measures the query size, which you already have.

MIRROR-SYNC-FLOWS-ONE-WAY-OR-ERASES-WRITES

  • A system that copies authoritative state onto an entity other code also writes erases every write that lands between its copy and the reader. Measured: an initialization pass copied an offline body's health onto its damage hitbox each frame. A hit written outside the simulation tick (an editor tool, a later group) was reset before the owner read it: hitbox 70 -> 45 -> 70, hit counter 0. A weapon hit inside the tick passed, which hid it.
  • Copy only in the direction the writes flow. For damage the mirror only lowers (if (inbox > state) inbox = state); an inbox below the state is an unread hit, never drift to correct.
  • Test a write from outside the tick as well as inside it.

CLEANUP-COMPONENT-OUTLIVES-DESTROY-IN-TESTS

  • A test standing in for the system that removes an ICleanupComponentData must RemoveComponent it, never DestroyEntity. Destroy leaves the entity alive with only its cleanup components, so every query on that component still matches. Measured: 4 of 18 EditMode tests "saved" a departure checkpoint with DestroyEntity; it stayed pending, sealed the body, and the hits under test were ignored.

RUNTIME-COLLIDER-IS-ALREADY-UNIQUE

  • PhysicsCollider.MakeUnique never clones a blob made at runtime. Every XxxCollider.Create and Collider.Clone sets the header's ForceUniqueBlobID, so IsUnique is already true and MakeUnique returns without copying. Only baked blobs start shared. A runtime-built prefab instantiated N times hands all N entities the SAME blob, and per-entity in-place edits (capsule resize for stance) hit everyone. Measured: two networked players, one server capsule pointer; the standing player's baseline restore reset the prone player's 0.75 m capsule to 1.80 m every tick, and the client's prediction floated and was corrected.
  • Clone explicitly (collider.Value.Value.Clone) once per entity and keep the clone in YOUR ICleanupComponentData, so an Instantiate copy never inherits it and a destroy still reaches it. Test by comparing ColliderPtr across two instances, never by IsUnique.
  • Do not dispose that blob the frame its entity dies when a physics world can outlive the entity (a CustomPhysicsSystemGroup keeps its last PhysicsWorld until it rebuilds): completing reader jobs does not stop a later query of that stale world. Pool the blob per world and reuse it; dispose the pool with the world.

RUNTIME-COLLIDER-CREATE-IS-NOT-BURST

  • ConvexCollider.Create, MeshCollider.Create and CompoundCollider.Create run as plain C# when managed main-thread code calls them. None of them, nor ConvexHullBuilder, carries [BurstCompile]. Burst compiles them only inside a Burst job. ConvexHullGenerationParameters.Default (0.015 simplification tolerance, 0.05 bevel) hulls and simplifies every point cloud, and that is the expensive part. Measured: the first retrieve of one 42-mesh vehicle (26 convex, 16 triangle) built its blobs in a system's OnUpdate and held one frame for 3528 ms. It did this in the server AND the client worlds, so every client saw the stall as a "network" freeze.
  • Build runtime collider blobs in a [BurstCompile] job scheduled when the source data arrives (catalog load), not on the frame that needs them. Flatten points and triangles into NativeArrays with per-child ranges. Use one IJobParallelFor for the children and a second for the compounds. Dispose the child blobs after CompoundCollider.Create copies them. A consumer that finds the job still running waits a frame. It never builds inline and never blocks on Complete.
  • Show the worker ms, the main-thread schedule ms and the pending count in a diagnostic, so the cost you moved stays visible. The Burst speedup over the managed path has not been measured yet: record it on the first instance.

BURST-ISYSTEM-NEEDS-THE-STRUCT-ATTRIBUTE

  • [BurstCompile] on an ISystem's OnUpdate does nothing unless the struct carries [BurstCompile] too. The method attribute alone compiles clean and looks Burst-ready, but the system runs managed every frame. Measured: a per-hit impact-spawn system had the method attribute only, and adding the struct attribute compiled with 0 Burst errors.
  • Audit: grep for partial struct .* : ISystem whose preceding lines lack [BurstCompile] while OnUpdate has it. Prove the fix with the Burst Inspector or a profiler marker. A job's .Complete sync point in the same marker can hide the gain.

PARK-POOLED-PRESENTATION-BEFORE-TRANSFORMS

  • A pooled presentation entity parked and disabled through an ECB that plays back after TransformSystemGroup keeps its last LocalToWorld and its last written colour. Measured: pooled health bars returned at a unit's death were parked (root LocalTransform at y -1000) and disabled at end of simulation. The disabled pair kept the old world pose and alpha 0.19, and the fill child was never parked. When the pool re-rented the pair to an undamaged unit, the update job's "parked and hidden, skip" early-out never rewrote pose or colour, so faint ghost bars drew at old death spots until the new owner was hit.
  • Fix: on the return frame the job itself writes alpha 0 and parks every transform of the pair (root and children) before transforms run; the ECB only disables. A re-rent then starts from a known invisible state.
  • Refined: every way out of use goes through that ONE return handler, including the session ending. After a retry, pooled bars floated into the next run: the bar job early-outs while presentation is inactive (results, restart), and no inactive path returned the rented bars, so they kept their last pose and alpha. On inactive, run the same return path with a clear-all flag, parking and resetting every rented child (fill parked, alpha 0, owner cleared) before transforms. Never add a second, root-only park for the inactive case.
  • Measure what the player sees, not the state machine. A lifecycle probe on owner and bar said "gone in 5 frames" while the screen showed ghosts. Pause on the event, capture, then query the entities under the visible pixels (project LocalToWorld to the screen) before trusting a lifecycle log.

MATERIAL-OVERRIDE-GOES-ON-THE-RENDERED-ENTITY

  • A per-instance shader value (hit flash, dissolve, tint) is an IComponentData carrying [MaterialProperty("_Name")], and it must sit on the entity that has the renderer. Measured: a hit-flash component with no [MaterialProperty] lived on the simulation entity while a separate skinned-mesh entity rendered, so the flash never showed. Copy sim to rendered entity in a Burst job for the LOD levels that draw it, and declare the property per instance in the shader (Shader Graph: override property declaration, hybrid per instance).

SYSTEMBASE-TIME-SHADOWS-UNITYENGINE-TIME

  • Time.unscaledTime inside a SystemBase fails to compile (CS0619, then CS1061): the inherited, error-obsolete ComponentSystemBase.Time shadows UnityEngine.Time. Write UnityEngine.Time.unscaledTime for wall-clock presentation timing and SystemAPI.Time for the world's own time.

DEAD-BODY-SWAPS-TO-A-LYING-COLLIDER-TWIN

  • A killed body that stays as an entity for a corpse window keeps its living collider, and players collide with it for the whole window: on the server, and in every client whose predicted physics world holds that ghost's collider as a static body. Hiding the view or ragdolling a copy in a private physics world does not remove it.
  • Give the prefab a second, same-shape blob whose filter belongs to the character layer but collides with everything except characters, and swap the PhysicsCollider POINTER to it in both worlds, keyed on the replicated health, before prediction. Character capsules pass; shot queries (belongs to all) and melee queries that ask for the character layer still hit it; ground probes ignore it. Nothing new is replicated.
  • Never edit the filter in place: the blob is shared by every instance of the prefab (RUNTIME-COLLIDER-IS-ALREADY-UNIQUE above), so one death would lay every living body down.

TEST-LOOKUPS-COME-FROM-A-SYSTEM

An EditMode test that built a job/lookup struct failed CS1061: EntityManager has no GetComponentLookup or GetBufferLookup; those live on SystemState and SystemBase.

  • Put a [DisableAutoCreation] SystemBase with an empty OnUpdate in the test file, get it with World.GetOrCreateSystemManaged, and build the struct inside it from CheckedStateRef, through the runtime's own Create(ref SystemState) / Update(ref SystemState) so the test exercises them.