Topic 5 · DOTS
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
LocalTransformin 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
EntityQuerymust track world re-creation.new HashSet/new List/CreateEntityQueryinsideOnUpdateallocates every tick — hoist the collection to a field (Clear+ reuse) and build the query once inOnCreate. 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 inAwake/OnCreateand 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. Usequery.ToEntityArray(WorldUpdateAllocator)(Entities wraps it) ornew 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
DynamicBufferpulled from aSystemAPI.Queryforeach tuple is a READONLY iteration variable — copy it to a local before indexed writes.foreach (var (…, buffer) in SystemAPI .Query<…, DynamicBuffer<T>>)makesbuffera foreach variable, sobuffer[i] = x/buffer.RemoveAtSwapBack(i)fail with CS1654 ("cannot modify members of iteration variable").DynamicBufferis a cheap view struct:var local = buffer;then write throughlocal— same underlying data, compiles clean. (Bit twice in one session on SC ability/status systems.) -
Frequently-toggled per-entity state is an
IEnableableComponentflipped withSetComponentEnabled, 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. TogglingIsComponentEnabled/SetComponentEnabled(writable from a job viaComponentLookup<T>) flips a per-chunk bitmask IN PLACE, andWithAll<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 systemAddComponent/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 freshlyInstantiated 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 liveComponentLookup/array/query iterator that tick; spread across N producer systems it scatters N sync points across the frame and makes the producers unparallelizable. ProducersAsParallelWriter.Enqueuea deferred record into a sharedNativeQueuesingleton (or record into anEntityCommandBuffer); ONE late consumer ordered[UpdateBefore]the rest does the batched destroy/instantiate at a single defined point. The tell: anEntityManager.DestroyEntity/Instantiateinside aforeach (… 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 allAddBuffer/AddComponentfirst, thenGetBufferand fill. EachAddBuffermoves the entity's archetype and invalidates every previously obtained buffer/handle on that entity. Writing the stale handle throwsObjectDisposedException: 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:AddBufferall kinds →GetBuffereach → fill. Same rule for holdingEntityqueries,NativeArrayfrom queries, orComponentLookupacross structural changes without.Update. (Surfaced SC:ArenaLayoutPublishheldBaseSeatacross moreAddBuffers.) -
A stored
Entityis a weak handle — validate it before every cross-entity dereference. AnEntityheld in a component does NOT keep its target alive; once destroyed the handle dangles andComponentLookup[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 onEntityStorageInfoLookup.Exists(target)orComponentLookup.TryGetComponent/HasComponentbefore reading, and clear the stale ref (Entity.Null/ disable the state component) when it fails. The tell: indexing a lookup with anEntitypulled 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 inOnCreateand refreshed with.Update(ref state)eachOnUpdate— neverSystemAPI.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 freshGetComponentLookup/GetEntityStorageInfoLookupinOnUpdatere-resolves the type handle every tick — the field+.Updateform only refreshes a cheap version stamp (same class as the cached-EntityQueryrule 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-capacityFixedList512Bytes<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.distancesqvsradius*radius), nevermath.distance(a sqrt) in the inner loop. The tell: a system doesforeach entity { foreach other entity }. (Reuse the project's per-tile/nav-cell grid as this index.) -
Sim systems are
[BurstCompile] partial struct : ISystemgated byRequireForUpdate<T>inOnCreate; a system that touches managed objects is a SEPARATE non-Burst system.ISystemis an unmanaged value type whoseOnCreate/OnUpdate/OnDestroyeach take[BurstCompile], so the whole sim tick Bursts allocation-free; a system that calls into UI /UnityEngine.Object/ManagedAPIsimply OMITS[BurstCompile](or is aSystemBase) 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 withif (!SystemAPI.HasSingleton<T>) return;at the top of a hotOnUpdate. The tell: a movement/combat/spawn system written asSystemBase, or one that null-checks its singleton insideOnUpdateinstead of declaring the dependency inOnCreate. -
A spawnable's prefab is resolved ONCE in the baker via
GetEntity(prefab, TransformUsageFlags…), stored as anEntityin anIComponentData/IBufferElementData; the runtime systemInstantiates that baked entity — neverResources.Load/instantiate-from-GameObject in a system. The bakedEntityis a Burst-friendly value the spawn job reads with zero managed load, andGetEntityregisters the bake dependency so a prefab edit re-bakes (the ECS counterpart of theResources.Loadban in unity.md). PickTransformUsageFlagsper entity by what it actually needs:.Nonefor non-spatial data/config/store/ singleton entities (the baker then attaches NOLocalTransform/LocalToWorld, so they cost nothing inTransformSystemGroup),.Dynamic(|.WorldSpaceto ignore parent hierarchy) only for entities that move and render. The tell: a spawn/streaming system holding aGameObject/asset ref or a string path instead of a bakedEntity, or a config/store entity defaulted to.Dynamic. -
One concept per
IComponentData, and sizeDynamicBuffers at bake time. Compose entities from fine-grained value structs (Health/Damage/Speed/Cooldownas 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-assignbuffer.Capacity = lengthbefore theAddloop in the baker (one allocation, not geometric regrowth). -
Persistent native data a system owns lives on
state.SystemHandle, created inOnCreateandDisposed inOnDestroy. Attaching aNativeQueue/NativeArray/NativeListas a component on the system handle gives exactly one owner and a guaranteed teardown site — never a leakedAllocator.Persistentcontainer with no disposal (same paired create/teardown discipline as event subscriptions in csharp.md). Canonical shape: a deferred-destroyNativeQueueand a broadphase-grid singleton both hang off the system handle. -
Bake-time work that must read data baked onto OTHER entities goes in a
PostBakingSystemGroupbaking-world system — not aBaker, and not a runtime first-update fixup. ABakerruns in isolation per authoring component and re-runs incrementally, so it has NO global view — aggregating across entities inside oneBakeis 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 withCreateAdditionalEntity. The tell: a baker reaching for another entity's already-baked components, or a one-shot "fix it on firstOnUpdate" 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 againstTransformSystemGroupDOES work — an unmarkedISystemdefaults intoSimulationSystemGroup, andTransformSystemGroupis a CHILD ofSimulationSystemGroup, 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
OrderFirstgroup. 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 (andGhostSimulationSystemGroup) areOrderFirstinSimulationSystemGroup, 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-tickSetComponent(LocalTransform)bakes intoLocalToWorldthis frame — no one-frame origin flicker. A freshlyInstantiated entity holds the prefab's baked pose untilTransformSystemGroupcomputes 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 areRequireForUpdate-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 (InitializationSystemGroupruns beforeSimulationSystemGroupevery frame), which the cross-group rule above means[UpdateBefore]against a sim system CANNOT express. For the streaming world, gate each consumer withstate.RequireForUpdate<T>so it doesn't tick until its tile/world/ghost-prefab singleton exists, rather than racing the loader. -
Use
OrderFirst/OrderLastfor edge-of-group systems, and order anyEntityCommandBufferwork relative to theBegin/End*EntityCommandBufferSystemthat 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 (BeginSimulationEntityCommandBufferSystemvsEndSimulation…) 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]+AssetPostprocessorhook 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 viaAssetDatabase.GetDependencies(buildScenePath)→ nested.unitydeps (SubScene components reference their SceneAsset — no YAML parsing). Under NetCode warm BOTHDotsGlobalSettings.Instance.GetClientGUIDandGetServerGUID— each world requests its own artifact viaSceneSystemData.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 opaqueEditorLoopsample 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 fullGC.CollectinsideApplication.LoadLevelAsync Integrateon 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 anEntitySceneWarmupeditor 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 isOtherManager.Instancewith 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],RequireForUpdateon 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-ticknew, 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
ISystemcannot capture instance fields (CS1673).ISystemis a struct, so this hits systems first: a nestedLaunchthat reads_transformswill 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 shadowsUnity.Entities.Baker<T>(CS0308). (Sibling of theEditornamespace rule in C# style & conventions.) - NO-SCENE-SCAN-INSIDE-SYSTEMS — the runtime scene-scan ban applies inside ECS systems too. An
ISystem'sOnCreate/OnStartRunning/OnUpdateis 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 astaticinstance/event the MB publishes or a request component a system queries, never a scan (a single one-shotFindObjectOfTypeinOnStartRunningto 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 byEntity(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)inOnCreate, thenlookup.Update(ref state)inOnUpdate- the documented purpose ofUpdateis the "minimal incremental updates necessary to make the type handle safe to use" across system updates. Unity also advisesEntityManager.GetComponentDataoutside 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.
EntityManagerinside a job, or per-entity structural changes. Sync points; batch through anEntityCommandBufferor 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 declareComponentLookupread-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.LocalSimulationalso runs your system inside CUSTOM tool worlds. A system that exists for a netcode session must filter to exactlyServerSimulation | ClientSimulation; addLocalSimulationonly 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.Tempcollection must never be assigned to a persistent system FIELD. Next frameIsCreatedstill 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. Checkshader.keywordSpace.keywordNamesagainst 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 andDependsOn(...)it; anything that must bakeGetEntity(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/GetEntityare 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 viaRenderMeshArray.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.logafter the last play-entry line instead. Three schedule-time killers from one night on a tower-defense game: a defaultNativeArrayjob field (a map without optional buffers leftCellKindsunconstructed — pass a persistent 1-length dummy when the source is absent); an embeddedIJobEntitystruct (its source-gen injects a hidden__TypeHandleonly the generated scheduler assigns — never embed one in another job; demote shared logic to a plain struct); and a main-threadComponentLookupread racing an in-flight job (callstate.CompleteDependencybefore main-thread lookup use). Corollary: a required victim/target component missing from a hand-built archetype (ArmorProfileon 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 aSubsystemRegistrationreset. A silentdefault:dropped all 64TerrainCollidertiles 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.CreateWRITES its heights input ([ReadOnly]throws) and indexesheights[z*res+x]. ALocalSimulation-filtered spawning system also instantiates inside custom TOOL worlds. (a survival game) - ISYSTEM-HELPER-TAKES-SYSTEMSTATE — a helper that runs
SystemAPI.Querytakesref SystemStateor source-gen failsSGSG0002even 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.RenderuntilPresentationSystemGroupis pumped; Entities Graphics 6.5 Mesh LOD is a BAKING-path feature, so addMeshLodInfoComponentyourself beforeRenderMeshUtility.AddComponents(whose runtime path throws);Mesh.GetLodis(subMeshIndex, levelIndex)— bothint, so swapped args compile; and a bulk mesh pass that throws mid-chunk leaves in-memory chains aforce:falsererun 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;
FindFirstObjectByTypereturns 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 stripSceneTag/SceneSectionfrom a runtime clone (different archetype, noEntitiesGraphicsChunkInfo, invisible); SMR + Animator prefabs spawn as Companion GameObjects; andDestroyEntity(EntityQuery)fails on roots whoseLinkedEntityGroupreaches outside the query — destroy per root. (a projectdocs/pitfalls/subscene-baker-gotchas.md.) - JOB-API-SHARP-EDGES.
SystemAPI.Query<…>caps at 7 type arguments (fetch the rest withSystemAPI.GetComponentin the loop); a[WriteOnly]NativeArray throws on ANY read;IJob.Runcollides withIJobEntity.Rununlessusing Unity.Jobs;is explicit (CS0315); an out-of-bandCamera.Renderduring Play needsCompleteAllTrackedJobson every live world andWaitForEndOfFrame; registrations that aSubsystemRegistrationreset clears go inAfterAssembliesLoaded; blob-stored types pass byref, neverin(EA0001/EA0009), and a blob in a baker isBlobBuilder→CreateBlobAssetReference(Persistent)→AddBlobAsset. (a projectdocs/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
DisableRenderingacross the wholeLinkedEntityGroup, not the visual root; and render-child teardown is a CLEANUP component the presentation system's dead pass drains, notOnDestroybookkeeping. A baked equip prefab's root carries markers and no renderer. (a projectdocs/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 usesCustomPhysicsSystemGroup/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 = 1while 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
IJobEntitycannot 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 itsPhysicsWorldIndexshared 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 falseof 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/CopyFromComponentDataArrayon the main thread do NOT complete a job you scheduled earlier in the sameOnUpdatethat writes that component type. They throwInvalidOperationException: The previously scheduled job <Job> writes to <T>_RW_ComponentTypeHandle. You must call JobHandle.Complete ... before you can read. Callstate.Dependency.Completebefore 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 isOrderFirstorOrderLastin the same group, from one that is not, is DISCARDED withIgnoring 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]forOrderFirst/OrderLast. Network receive and command-buffer groups are the common targets.
PARALLEL-JOB-COUNTS-ITSELF-PER-THREAD-AND-PUBLISHES-IN-A-JOB
- A parallel
IJobEntitythat needs to report how much work it did gets oneNativeArray<int>slot per worker, not an atomic and not a main-thread pass. Size itJobsUtility.ThreadIndexCount, mark it[NativeDisableParallelForRestriction], and add[NativeSetThreadIndex] public int ThreadIndex;—IJobEntitysupports that attribute, same as any other job. - Sum it in a follow-up
IJob, not on the main thread. Reading the counters inOnUpdateeither races the job you just scheduled or forces aComplete, 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
ICleanupComponentDatamustRemoveComponentit, neverDestroyEntity. 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 withDestroyEntity; it stayed pending, sealed the body, and the hits under test were ignored.
RUNTIME-COLLIDER-IS-ALREADY-UNIQUE
PhysicsCollider.MakeUniquenever clones a blob made at runtime. EveryXxxCollider.CreateandCollider.Clonesets the header'sForceUniqueBlobID, soIsUniqueis already true andMakeUniquereturns 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 YOURICleanupComponentData, so anInstantiatecopy never inherits it and a destroy still reaches it. Test by comparingColliderPtracross two instances, never byIsUnique. - Do not dispose that blob the frame its entity dies when a physics world can outlive the entity (a
CustomPhysicsSystemGroupkeeps its lastPhysicsWorlduntil 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.CreateandCompoundCollider.Createrun as plain C# when managed main-thread code calls them. None of them, norConvexHullBuilder, 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'sOnUpdateand 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 oneIJobParallelForfor the children and a second for the compounds. Dispose the child blobs afterCompoundCollider.Createcopies them. A consumer that finds the job still running waits a frame. It never builds inline and never blocks onComplete. - 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 anISystem'sOnUpdatedoes 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 .* : ISystemwhose preceding lines lack[BurstCompile]whileOnUpdatehas it. Prove the fix with the Burst Inspector or a profiler marker. A job's.Completesync 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
TransformSystemGroupkeeps its lastLocalToWorldand its last written colour. Measured: pooled health bars returned at a unit's death were parked (rootLocalTransformat 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
LocalToWorldto 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
IComponentDatacarrying[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.unscaledTimeinside aSystemBasefails to compile (CS0619, then CS1061): the inherited, error-obsoleteComponentSystemBase.TimeshadowsUnityEngine.Time. WriteUnityEngine.Time.unscaledTimefor wall-clock presentation timing andSystemAPI.Timefor 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
PhysicsColliderPOINTER 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]SystemBasewith an emptyOnUpdatein the test file, get it withWorld.GetOrCreateSystemManaged, and build the struct inside it fromCheckedStateRef, through the runtime's ownCreate(ref SystemState)/Update(ref SystemState)so the test exercises them.