m02 · Systems · reading · 9 min
The command buffer — deferring the move you can't make now
The impossibility, stated precisely
Recall two facts you already own. First, a
Put those together. If a structural change happened in the middle of iteration, it could move the very entities you’re iterating — invalidate the pointer your loop is advancing, shift entities you’ve already visited or are about to, resize the arrays out from under you. This isn’t a rule Unity imposes for tidiness; it’s a memory-safety impossibility. You cannot mutate the shape of the collection you’re currently streaming through and expect the stream to stay valid. In a parallel context it’s worse: one worker’s structural change would invalidate chunk pointers other worker threads are reading right now.
So the runtime forbids it: you may not perform a structural change while a query that could be affected is being iterated. Which raises the obvious question — what do you do when your loop needs to spawn a bullet or destroy a dead enemy?
Record now, replay at a safe point
The answer is to not do the structural change now at all. You record
the intent into an EntityCommandBuffer (ECB) — a list of “please do
this later” operations — and the runtime replays that list at a
designated sync point, a moment between systems where nothing is
iterating and it’s safe to move memory.
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.TempJob);
foreach (var (health, entity) in
SystemAPI.Query<RefRO<Health>>().WithEntityAccess())
{
if (health.ValueRO.Value <= 0)
ecb.DestroyEntity(entity); // recorded, not executed
}
ecb.Playback(state.EntityManager); // executed here, at a safe point
ecb.Dispose();
}
During the loop, DestroyEntity does nothing to memory — it appends a
command. The chunk you’re iterating is untouched, your pointers stay
valid, the sweep completes. Only at Playback, after iteration is over,
does the runtime actually destroy the recorded entities, moving memory
freely because nothing is reading it. The command buffer is the seam
between “the fast, immutable-shape iteration” and “the structural
mutations that iteration made necessary.”
In practice you rarely allocate your own ECB. You request one from a
built-in ECB system (e.g. BeginSimulationEntityCommandBufferSystem
or EndSimulation...), whose entire job is to provide a buffer and play
it back at its point in the frame. Choosing which ECB system you record
into is choosing when your deferred changes land — begin-of-group vs
end-of-group vs the next frame’s initialization.
Sync points are not free — and each one is a wall
Playback is where the deferred cost comes due, all at once. Every structural change you recorded happens at the sync point, and a sync point is also a hard synchronization barrier: outstanding jobs must complete before structural changes can safely apply, so the sync point drains the job system. Two consequences follow directly.
First, batching helps: recording 5,000 destroys and playing them at one sync point pays the barrier once, whereas 5,000 immediate structural changes would each be their own stall. The ECB pattern doesn’t make structural change cheap — nothing does — but it collapses many barriers into one.
Second, more sync points is worse. Each distinct ECB system that
actually has commands to play is another barrier draining the jobs
around it. Scattering structural changes across many different ECB
systems, or forcing immediate structural changes with
EntityManager mid-frame, multiplies the walls your frame hits.
Parallel recording needs a deterministic sortKey
When you record from a parallel job (many worker threads appending to
one buffer at once), replay must still produce a deterministic
result — the same outcome every run, regardless of which thread happened
to append first. Threads don’t finish in a fixed order, so the buffer
can’t just replay in append order. It replays in the order of a
sortKey you provide per command, typically the chunk index within
the query:
// inside a parallel IJobEntity, ecb is an EntityCommandBuffer.ParallelWriter
ecb.DestroyEntity(sortKey, entity); // sortKey orders playback deterministically
The sortKey exists precisely because parallelism removed the natural
ordering. Without it, two runs of the same frame could apply structural
changes in different orders and diverge — fatal for anything
deterministic (networking, replays). With it, playback is sorted into a
stable sequence no matter the thread timing.
What this buys you
You can now explain, from memory safety alone, why structural changes
can’t happen during iteration, why the record-then-replay pattern is
forced rather than chosen, and why sync points are both necessary and
individually expensive. You also know the parallel gotcha — sortKey
for deterministic playback — before you’ve written a parallel job. The
lab makes you build both a naive immediate-structural-change system and
its ECB equivalent, and measure the frame-time gap the sync-point
collapse actually produces.