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