Skip to content

C++ Entity IDs: Stable Identity Before Building an ECS

An Entity ID should identify the body you meant, not whichever body currently occupies a vector slot. This chapter adds owner-scoped handles to our simulation, preserves its existing behaviour, and tests lookup after storage changes and deletion.

The New Requirement

The previous chapter compared three representations using ordered snapshots. Now imagine a selection tool:

Select a body, add other bodies, delete an earlier body, and later inspect the same selected body. If it has expired or been deleted, report that it no longer exists.

Selecting “element 1” does not meet that requirement:

State Slot 0 Slot 1 Selection meaning
Before deletion Body A, ID 1 Body B, ID 2 Slot 1 happens to contain B
After deleting A Body B, ID 2 Empty ID 2 still means B; slot 1 does not

The C++ vector specification describes invalidation after erasure and reallocation. A pointer or reference into storage is therefore not an identity policy. Nor does reserving capacity solve deletion or the meaning of a slot.

This is a reason to introduce an ID, not yet a reason to introduce a complete ECS.

C++ Entity IDs and Their World

We use two parts:

  • A nonzero, monotonically increasing 64-bit number within one World.
  • A namespace token identifying the World that issued it.

The number is never reused by that World, including after deletion or expiry. Zero is reserved for a default, empty handle. A different World can issue the same number, so comparing diagnostic numbers alone is insufficient.

The handle carries no position, velocity, or component data:

class Entity {
    std::uint64_t number_ = 0;
    std::shared_ptr<const OwnerTag> owner_;
    Entity(std::uint64_t number, std::shared_ptr<const OwnerTag> owner)
        : number_(number), owner_(std::move(owner)) {}
    friend class World;
public:
    Entity() = default;
    // Diagnostic number, not a globally unique or serialisable identity.
    std::uint64_t number() const { return number_; }
};

Only World can issue a nonempty handle. Copies of a handle name the same identity, but do not keep the corresponding body alive.

The shared pointer owns a tiny namespace marker, not the World or body. If handles outlive a World, they retain that marker so it cannot accidentally be mistaken for a new World's marker. This is a teaching choice with reference counting and handle-size costs, not a compact production ECS identifier.

The World is noncopyable and nonmovable here. Cloning or transferring a World requires an explicit identity policy, which this chapter does not implement.

Lookup Checks Identity, Then Existence

The World owns a vector of entries, each containing a number and the same Seed body data used by the baseline. It first rejects empty or foreign handles:

1
2
3
bool owns(const Entity& entity) const {
    return entity.number_ != 0 && entity.owner_ == owner_;
}

Then it searches the current entries by number. Ownership alone is not enough: a correctly issued handle can refer to a body that is now gone.

Operation Result
spawn(seed) New handle; empty handle for an initially expired body
contains(entity) Whether this World currently contains that identity
inspect(entity) A copied snapshot, or std::nullopt
destroy(entity) true if removed; false for empty, foreign, or stale handles
step(dt) Same movement and expiration contract as Chapter 2

The implementation uses linear lookup, so contains, inspect, and explicit deletion search take O(n) time. Storage remains one record per body. Separate component stores and matching queries belong to Chapter 5.

Observe State Without Borrowing Storage

1
2
3
4
5
6
7
std::optional<Snapshot> inspect(const Entity& entity) const {
    const auto it = find(entity);
    if (it == entries_.end()) return std::nullopt;
    const auto& body = it->body;
    return Snapshot{body.position, body.visible, body.expiring,
                    body.expiring ? body.remaining : 0};
}

The returned value is safe to keep as a historical snapshot, but it is not a live view. Inspect again when you need current state. An empty result means that this World cannot resolve the handle; callers must handle that case.

The API does not expose a writable pointer into the vector. If a future API returns borrowed component references, the lifetime rules for those references still apply even when its Entity ID remains valid.

Run the Identity Example

Use the Chapter 2 setup and repository instructions, then build the current example project:

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

If using an existing checkout, update it through your usual Git workflow without discarding local changes. The new source files are entity_identity.hpp, identity_demo.cpp, and identity_tests.cpp. They also need the original simulation.hpp and the project's CMakeLists.txt; see Chapter 2 for the complete download list.

Expected demo output:

1
2
3
4
tracked number=2 position=11,0
deleted handle resolves=0
replacement number=3
foreign handle resolves=0

The selected body is found after an earlier body is removed and storage is reallocated. Its old handle no longer resolves after destruction, and a replacement gets a different number.

Test the Failure Cases

The identity test suite checks:

  • Empty handles and initially expired seeds.
  • A copied handle across deletion of another body and forced reallocation.
  • Lookup after many subsequent creations and movement.
  • Historical snapshots remaining unchanged after a step.
  • Repeated deletion and all copies of a deleted handle becoming stale.
  • Foreign handles, including a collision of diagnostic numbers.
  • A handle from a destroyed World being rejected by a newly created World.
  • Automatic expiration, invalid time steps, and all eight capability combinations.
  • State equivalence with the plain simulation and independent expected positions.
  • The counter helper rejecting exhaustion rather than wrapping to zero.

To force the reallocation case, the test calls reserve with a capacity larger than the old one. The vector capacity specification defines that condition. We never dereference an invalid pointer to demonstrate it.

Debug and Release CMake/CTest runs are used for host verification with GNU C++ 15.2.0. No Raspberry Pi hardware measurement is claimed.

Costs, Limits, and the Next Design Decision

The counter accepts its largest representable nonzero value and throws std::overflow_error on the next issuance. A failed allocation does not advance the counter: insertion succeeds before the counter is committed. The exhaustion test checks the helper's boundary, not billions of creations.

This code is single-threaded. A namespace token is not an authentication mechanism, and shared-pointer reference counting does not make World mutation thread-safe. Handles are process-local, not save-file or network identifiers.

Do not add a free list merely to reuse deleted numbers. Reuse introduces another problem: how does an old handle distinguish the old occupant from a new one? Generation counters and slot reuse are a later correctness lesson. For comparison, EnTT documents versioned identifiers and registry validity; its implementation is not the one shown here.

Keep the simpler snapshot-only design if no caller needs persistent selection. If you do need stable identity, this small layer can be useful without ECS. The next step is separating capability data into stores, not adding unrelated features to the handle.

Exercise

Select a body that expires at 0.5 seconds. Inspect it after one 0.25 second step and again after the second. Specify what the selection tool should display when the second lookup returns no value. It must not silently select another body.

Previous: Inheritance and composition. Next: Component stores and matching systems. Return to the course overview.

Donate