DOTS//CORE local · not synced

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 structural change — add or remove a component, create or destroy an entity — physically relocates entity data between archetypes and can move other entities within a chunk to fill the gap. Second, a system iterating a query is holding pointers into chunk memory and walking them as a stream.

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

cache line · 64 B fetched 64 B useful · 100% of the bandwidth you paid for during iteration the chunk stays intact — commands accumulate elsewhere; the memory you're streaming is never disturbed until playback

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.

m02.l03