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