Skip to content

ECS Generational Handles in C++: Safe Entity Slot Reuse

An ECS can reuse a deleted entity's slot only if an old handle cannot resolve to its replacement. This chapter adds a generation and World namespace check, retires slots before generation wraparound, and preserves the simulation's existing creation-order observations.

Why Reusing a Number Changes the Contract

Chapter 5 never reuses an issued number. That is a valid and simpler identity policy. Now suppose we want to recycle entity slots after deletion instead of always allocating another metadata slot.

If a selection retains only “slot 1”, it could silently select a new occupant after the old body expires. Copying that old selection or deleting through it would make the problem worse. Slot reuse needs a new identity contract, not just a free list.

Event Slot 1 metadata Old handle (1, 1) New handle (1, 2)
First creation Alive, generation 1 Resolves Not issued
First deletion Free, generation 2 Rejected Not issued
Replacement creation Alive, generation 2 Rejected Resolves

The World namespace is a third part, omitted from the table for readability. Two Worlds can both have a live slot 1 at generation 1; they still must reject each other's handles.

C++ ECS Generational Handles

The new generational::Entity stores:

  • A one-based slot number; zero denotes an empty handle.
  • A nonzero 64-bit generation identifying an occupant of that slot.
  • A shared marker identifying the issuing World.

The marker owns neither World nor components. Handles that outlive World retain only the marker, preventing namespace confusion with a later World. As before, World is noncopyable and nonmovable, and observations are copied snapshots. The handle is a separate teaching type from Chapters 4 and 5.

Only World can issue a nonempty handle. Slot and generation getters are for diagnostics, not instructions to reconstruct a handle from numbers. The public validity check is:

1
2
3
4
5
6
bool contains(const Entity& entity) const {
    if (entity.owner_ != owner_ || entity.slot_ == 0 ||
        entity.slot_ > slots_.size()) return false;
    const auto& slot = slots_[static_cast<std::size_t>(entity.slot_ - 1)];
    return slot.alive && slot.generation == entity.generation_;
}

Check namespace and range before indexing. Then require both a live slot and a matching generation. inspect and destroy use this same check; they do not accept a slot number alone. A rejected deletion changes nothing about the replacement entity.

Metadata, Components, and the Free List

World keeps a vector of slot metadata, separate from the Chapter 5 component stores. Each record contains its generation, live/retired status, and the next free slot number. The free list is a last-in-first-out linked chain stored in these records, not another growing vector.

On creation, World first rejects an initially expired seed. It then takes the free-list head if available, or appends a new metadata slot starting at generation 1. Position is always present for this simulation; Velocity, Lifetime, and Visible membership still depend on the seed.

The free-list head is not removed, and the slot is not marked live, until component insertion succeeds. Partial insertion is rolled back if it throws; an appended but unpublished metadata slot is removed too. The live-order list reserves space before creation so its final append does not allocate. The tests do not inject allocation failures or claim to exercise every allocator failure path.

Components use slot numbers as internal keys. That is safe only under World's invariant: one live occupant per slot, complete cleanup before reuse, and validation of every external handle. Do not turn the private stores into an unchecked public slot-number API.

Deletion Invalidates the Occupant Before Reuse

Explicit deletion and lifetime expiration call the same release operation:

void release(Number number) {
    erase_components(number);
    order_.erase(std::find(order_.begin(), order_.end(), number));
    auto& slot = slot_at(number);
    slot.alive = false;
    if (slot.generation == Limit) {
        slot.retired = true; // Never wrap and resurrect an old identity.
    } else {
        ++slot.generation;
        slot.next_free = free_head_;
        free_head_ = number;
    }
}

erase_components removes Position, Velocity, Lifetime, and Visible membership. The slot's generation advances on deletion, not after the replacement becomes live. Every copied handle for the old generation is therefore stale, even while the slot is free.

The free-list link update itself needs no allocation. This does not make the whole deletion constant-time: component trees must be updated and the live order vector must find and erase the slot.

The step still validates dt, moves matching Position/Velocity pairs, updates lifetimes, and then releases collected expired slots. No store is structurally changed while its system is traversing it. The collected list is reserved before systems change state, as in Chapter 5.

Generation Exhaustion: Retire, Do Not Wrap

A finite generation counter cannot guarantee permanent stale-handle rejection if it wraps and an old pair is reissued. A larger counter delays that event; it is not an overflow policy.

This implementation permits the largest configured generation to be live. Deleting that occupant retires the slot permanently instead of incrementing or adding it to the free list. The metadata remains so the slot number is never silently reintroduced as a brand-new slot.

World<> uses the maximum 64-bit generation. World<2> and World<1> use the same lifecycle code with tiny limits to test the retirement boundary without billions of creations. These are test configurations, not recommended production widths.

Retirement trades available slots and retained metadata for the no-wrap guarantee. New slot numbers use Chapter 4's checked successor helper; if that number space or container capacity is exhausted, creation fails rather than wrapping. This is a policy to explain and test, not an unlimited-resource claim.

EnTT's registry documentation describes identifiers with version information, reuse after release, and registry validity checks. The permanent-retirement rule here is our teaching policy; do not assume it describes another library's overflow behaviour.

Preserve Creation Order Separately

With monotonically increasing IDs, Chapter 5's sorted maps also represented creation order. Reused slots break that equivalence:

1
2
3
4
Create A in slot 1, then B in slot 2.
Delete A, then create C in reused slot 1.
Creation order of survivors: B, C.
Sorted slot order: C, B.

World keeps order_, a list of live slots in creation order. Deletion removes the old occupant; creation appends the new one, even when its number is small. states, entities, and render follow that list. Rendering additionally checks Visible membership before reading Position.

Movement can still use the Chapter 5 store join because bodies move independently in this simulation. Reusing its sorted rendering helper would change observable order, so this chapter deliberately does not do that. Future interactions between bodies need an explicit execution-order contract.

Build and Run the Generational Example

Use the Chapter 2 repository and tool setup and current checkout, or download the complete file list from that page:

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

Use another build directory if that path belongs to a different project. For downloaded files, point cmake -S at their local folder instead. New sources are generational_ecs.hpp, generation_demo.cpp, and generation_tests.cpp. They reuse the previous headers and need the updated CMakeLists.txt. No third-party ECS library is required.

Expected demo output:

1
2
3
4
5
6
7
old: slot=1 generation=1
new: slot=1 generation=2
old handle resolves=0
stale delete succeeds=0
visible position: 20,0
visible position: 30,0
after retirement: slot=2 generation=1 retired=1

The supplied project is verified on the development host with GNU C++ 15.2.0 in Debug and Release. This is not a Raspberry Pi hardware run or performance measurement; record your environment and test results when running it on a Pi.

Tests and Remaining Boundaries

The suite checks all eight capability combinations against the plain and Chapter 5 implementations, independent trajectories, invalid time steps, initial expiration, and rendering values/order. Reuse-specific tests cover:

  • Explicit deletion and expiration cleaning up every component store.
  • Reused slots receiving a new generation; all old handle copies staying stale.
  • A stale deletion being unable to delete the replacement.
  • Old velocity and lifetime data not leaking into a new occupant.
  • Creation-order observations when free-list reuse differs from slot order.
  • Forced metadata-vector reallocation and bulk creation without losing identity.
  • Foreign handles with the same slot and generation as a live local entity.
  • Handles from destroyed Worlds being rejected in another World.
  • Tiny-limit retirement through explicit and automatic deletion, including generation 1 as the configured maximum.
  • Complete bulk cleanup and stepping an empty World.
  • Repeated full free-list reuse without duplicate live slots or extra metadata.

Release tests throw on failed checks rather than relying on assert. Allocation-failure injection, concurrent access, persistent/network identity, dynamic component edits, and user-defined callbacks are outside this chapter.

Generation checks protect handles, not borrowed component pointers or references. This public API returns copies, but a future borrowing API still needs its own invalidation rules. A namespace marker is not authentication, and generation validation does not make concurrent mutation safe.

Costs and the Simpler Alternative

The implementation adds slot metadata, a free-list head, a live-order vector, and validation. It retains the teaching example's tree-based component stores and shared namespace marker. Linear order-vector deletion and creation-time capacity reservation can be expensive under churn. No speed or total-memory improvement over Chapter 5 is claimed.

Keep non-reused IDs if they meet your requirements. Add recycling only when its lifecycle policy, costs, and failure modes are worth managing. A real ECS library should be evaluated on its documented guarantees, not just the presence of a generation field.

Exercise

Using World<2>, keep both old handles for a slot. Delete its second occupant, then create another entity. Predict the new slot and generation, and explain why neither old handle can delete it. Repeat with two free slots and predict the difference between creation order and sorted slot order.

Previous: Component stores and matching systems. Next: Execution order and deferred commands. Return to the course overview.

Donate