---
title: "ECS core mechanics"
slug: /workflows/ecs-core/
markdown: /workflows/ecs-core.md
section: dots-ecs
tier: 2
summary: "System shape, component granularity, structural changes, lookups, jobs and baking."
sources: ['dots/ecs-core.md']
canonical: https://unityworkflowlibrary.dev/workflows/ecs-core/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# ECS core mechanics

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

<!-- The block below + the system-ordering block were distilled from Unity's own
     Unite24 "Connecting the DOTS" Entities 1.2 sample (the DOTS team's endorsed patterns). -->

- **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`/`RemoveComponent`s 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 `Instantiate`d 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 `AddBuffer`s.)

- **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 `Instantiate`s 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 `DynamicBuffer`s 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
  `Dispose`d 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 `Instantiate`d 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.

<!-- NetCode wire / world / authority — distilled from multi-project production burns (2026). -->

- **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](/workflows/networking-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](/workflows/networking-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](/workflows/vfx-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 -

```csharp
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.
