Skip to content

ECS Deferred Commands in C++: Execution Order and Flush Boundaries

Record structural changes during a decision phase, then apply them at an explicit boundary. This chapter adds a small FIFO command queue around the previous generational ECS, defines when changes become visible, and tests recovery without replaying already-applied commands.

The Requirement: Decide Without Changing Membership

Chapter 6 protects reused slots with generations, but it does not define an application callback boundary. Suppose a rule scans surviving entities, removes selected bodies, and creates replacements. We want the scan to observe one stable membership set, rather than seeing its own creations partway through iteration.

Copied handle lists avoid borrowing iterators, but immediate deletion still changes what later lookups observe. Stable handles alone do not define when changes become visible. We need an execution and visibility policy.

This example adds only queued spawn and destroy operations. It does not implement dynamic component attachment, a dependency graph, worker threads, or a generic game engine scheduler.

C++ ECS Execution Order and Dependencies

The frame order is fixed, not inferred from registrations:

Phase Reads Changes Visible to the next phase
Movement Position, Velocity Position values Updated positions
Lifetime Lifetime Remaining values; collected expiry IDs Expiration decisions
Intrinsic cleanup Collected expiry IDs All components, slot generation, live order Surviving entities only
Application decisions Copied survivor observations Pending command queue only World membership is unchanged
Flush FIFO commands and current handles Entity membership and receipts Applied structural changes
Observation after return Committed state No changes Final snapshot/render data

The first three phases are the unchanged generational::World::step. The decisions callback therefore sees post-movement, post-expiration survivors. An entity that expires in this frame cannot be inspected there as a live body.

These dependencies are part of our simulation contract. Cleanup must not run before movement, decisions must not scan already-expired bodies, and final rendering must see queued deletions. Moving the flush changes semantics even if no iterator becomes invalid.

The wrapper expresses that sequence directly:

void step(double dt, const Decisions& decisions = {}) {
    ready();
    validate_dt(dt);
    ActiveGuard guard(active_);
    world_.step(dt); // Movement -> lifetime -> intrinsic expiration cleanup.
    if (decisions) {
        const ReadView view(world_);
        Writer writer(buffer_);
        decisions(view, writer); // Reads and recording only; no structural playback.
    }
    buffer_.flush(world_); // FIFO structural changes at the explicit boundary.
}

ready() rejects reentrant frames and unresolved pending commands. Invalid dt rejects before simulation or callback execution. The guard restores the idle state even if a callback or playback throws; it does not roll back time.

For a library comparison, Flecs documents sync points that merge queued operations before later systems need to observe them. Our single fixed boundary demonstrates visibility, not Flecs' pipeline analysis or its automatically inserted sync points.

ReadView and Writer Have Different Jobs

ReadView offers copied handles, snapshots, and state lists, plus handle validity checks. It cannot spawn or destroy. Writer records commands and cannot directly access mutable component stores or flush the queue.

void spawn(Seed seed) { buffer_.spawn(seed); }
void destroy(Entity entity) { buffer_.destroy(std::move(entity)); }

The inputs are copied into the queue. A destruction command retains the full World/slot/generation handle, not just a diagnostic slot number. A creation command retains its seed, not a reference to a callback's local variable.

Writer is noncopyable, and both callback objects are scoped to that call. Do not retain pointers or references to them. Returned handles and snapshots are values that can be kept; they still do not own their entities.

Capturing the outer wrapper does not provide an immediate mutation shortcut: its spawn, destroy, step, retry, discard, and receipt-reset operations reject while a frame is active. Const observations remain available. This is a single-threaded API discipline, not authentication or a concurrency guard.

FIFO Playback and Creation Receipts

Commands execute in recording order, without coalescing or reordering:

Command sequence Result
Destroy A, then spawn B B may reuse A's slot with a new generation
Spawn B, then destroy A B cannot reuse A's still-live slot
Destroy A twice First succeeds; second is ignored
Destroy stale A after a replacement Replacement is protected by generation validation
Destroy foreign or empty handle Ignored; local entities are unchanged
Spawn an initially expired seed No entity and no creation receipt

The World does not issue a handle at recording time. Successful creations appear in results().created in command order after playback. There is no pending-entity placeholder or ability to target an uncreated entity within the same queue.

Results also count successful destruction, ignored destruction, and ignored creation. They accumulate until take_results() moves them to the caller and resets the report. Taking results requires no pending commands, so receipts for a partially applied batch are not silently forgotten.

A freshly created entity becomes visible after flush but does not move in the frame that created it: movement has already run. Its first step occurs in the next frame. Surviving creation order is retained by Chapter 6's live order list, including when a small-numbered slot is reused.

Failure Policy: Committed Prefix, Retained Suffix

Deferral does not imply a transaction. This queue applies one command at a time and removes it only after application and result recording succeed:

// Pop only after application and receipt recording succeed.
commands_.pop_front();

Creation-receipt capacity is reserved before playback. Entity handle copying is checked as nonthrowing, so recording a successful creation does not allocate after that creation commits. The target's operations must fail before mutation; the supplied generational World's spawn rollback policy provides that contract. The templated target path exists for a controlled failure fixture, not a promise to support arbitrary side-effectful executors or reentrant targets.

Failure point State retained Recovery
Invalid time step No simulation or callback change Correct the input
Decision callback throws Simulation is advanced; already recorded commands remain unapplied Inspect, then retry flush or discard pending commands
Receipt reservation fails No queued command applied; simulation may already be advanced Retry flush or discard
Command application throws before mutation Successful prefix and its receipts remain; failed front and unattempted suffix stay queued Retry only the pending queue or discard it

retry_flush() does not run movement, decrement lifetimes, or call decisions again. A new frame and immediate mutation are blocked while pending commands remain. discard_pending() abandons only unapplied work; it does not undo the simulation or successful prefix. After either resolution, consume receipts with take_results() as needed.

If a callback fails before recording any command, the queue can be empty even though simulation advanced. An exception still means the frame did not finish; inspect state rather than blindly repeating the same time step.

No allocation-failure injection is claimed. The playback test uses a target that throws deliberately before mutation, exercising prefix removal, retained receipts, and retry bookkeeping. External side effects inside callbacks are outside this recovery policy.

Build and Run the Deferred Example

Use the Chapter 2 setup and complete download list, then build the current project:

1
2
3
4
5
cmake -S docs/data-oriented-design/examples -B /tmp/pi-deferred-debug \
  -DCMAKE_BUILD_TYPE=Debug
cmake --build /tmp/pi-deferred-debug
ctest --test-dir /tmp/pi-deferred-debug --output-on-failure
/tmp/pi-deferred-debug/deferred_demo

Use a different build directory if that path belongs to another project. For downloaded files, point cmake -S at their local folder. New files are deferred_ecs.hpp, deferred_demo.cpp, and deferred_tests.cpp. They use the unchanged Chapter 4–6 headers and updated CMakeLists.txt.

Expected demo output:

1
2
3
4
during decisions: old alive=1 position=1
after flush: old alive=0 replacement position=1
same slot=1 new generation=2
next frame: replacement position=2

Debug and Release verification uses GNU C++ 15.2.0 on the development host. No Raspberry Pi hardware run, parallel speedup, or performance measurement is claimed. Record your environment and results when reproducing the tests on a Pi.

What the Tests Establish

  • Without recorded changes, all eight capability combinations match the plain and Chapter 6 implementations across multiple frames and rendering outputs.
  • Invalid time steps reject before callback execution or state changes.
  • Decisions see movement results and intrinsic expiration cleanup.
  • Recorded deletion leaves the entity readable until flush; creation starts moving only in the next frame.
  • FIFO order controls whether a deleted slot is available for a creation.
  • Duplicate, stale, foreign, empty, and already-expired targets are ignored; a queued stale deletion cannot delete a replacement generation.
  • Queued deletion clears all component stores.
  • Reentrant mutation, flush, and frame calls are rejected during decisions.
  • Callback failure retains recording without playback, blocks another frame, and supports flush-only retry or explicit discard.
  • A controlled playback failure retains the failed command and suffix, preserves successful-prefix receipts, and does not replay the prefix on retry.
  • Receipt reset and empty playback have defined behaviour.

Release checks throw on failure instead of using assert. Earlier examples and their tests remain unchanged.

Costs and Next Boundaries

The queue stores copies and can allocate while recording. Receipts retain handles until consumed, including their namespace markers. ReadView list operations allocate copied observations; this is a correctness-oriented teaching boundary, not a zero-allocation hot-loop API.

Processing remains single-threaded. Read/write columns explain our dependency choices, but they are not a proof that arbitrary callbacks or systems can run in parallel. There is no automatic ordering, dynamic component-edit command, cross-queue merge policy, command cancellation by ID, or rollback transaction.

Keep Chapter 6's direct calls when all membership changes already occur at a clear safe boundary. Add deferral when observation stability and the timing of structural changes justify the extra semantics and memory.

Exercise

Queue two replacements while iterating view.entities(). Predict which bodies the callback can inspect, the order of creation receipts, and which frame first moves each replacement. Then make the callback throw after recording one replacement: explain the difference between retrying flush and running another simulation step.

Previous: Generational handles and safe slot reuse. Next: Component storage, sparse sets, and archetypes. Return to the course overview.

Donate