Entities and component storage¶
Status: Baseline implemented. Part of the game world architecture. See the reference guide for the concrete API, bounds and policies; conditional extensions in this document remain proposed.
An entity is identity. Its state lives in explicitly named component pools owned by a game world. Systems are ordinary functions over that state. The first implementation uses one small registry and typed sparse component pools, with structural changes committed at known boundaries.
Identity and validity¶
EntityId contains World (uint64), Slot (uint32), and
Generation (uint32). It is a trivially copyable value. World zero and generation
zero are reserved as invalid. Do not pack it or expose bit arithmetic before a
measurement justifies changing the representation.
Each constructed world receives a process-local, monotonically issued nonzero identity. Restart and level replacement never reuse one. Within a world, the registry owns slots with live state and a nonzero generation. Destruction advances the generation before making a slot reusable. A slot whose generation would wrap is permanently retired; world-identity exhaustion returns an error rather than wrapping. New process runs may reuse numbers because runtime handles are never serialized or accepted from outside that process. The world identity and generation rules come from FoundationIdentity; see object handles, stage 2 for the reload limitation of a registry linked into a gameplay module.
IsAlive checks the world identity, slot range, live bit, and exact generation.
Release builds perform these checks for externally held handles too; assertions
alone cannot protect delayed results. A handle copied into another world's API
is rejected. Pools record their owning world identity and apply the same checks.
Authored IDs, debug names, entity handles, asset IDs, and network/save identities are different types with different lifetimes. Only authored IDs are stored in the level file. The Flecs generation mechanism is evidence for detecting recycled identities; the world field and exhaustion policy here are Ludus choices.
Registry and component pools¶
The registry owns slot metadata and a reusable-slot list. It does not own GPU objects, physics objects, or game state. Slot metadata also includes a creation sequence issued monotonically within the world, and a pending-destroy bit. Creation-sequence exhaustion returns an error rather than wrapping. The game owns the component roster:
GameWorld
EntityRegistry
ComponentPool<Transform2>
ComponentPool<Motion2>
ComponentPool<Collider2>
ComponentPool<SpriteVisual>
ComponentPool<PlayerState>
ComponentPool<EnemyState>
ComponentPool<ExitState>
Random streams
Tick commands and events
Asset leases and authored identity table
This example is one game's aggregate. There is no type-erased component registry, runtime reflection, or automatic discovery. The destruction function explicitly removes an entity from every owned pool. Adding a pool requires adding that removal and its invariant tests in the same change. Reviewable repetition is acceptable for the initial small roster.
Each ComponentPool<T> stores three arrays:
| Storage | Purpose |
|---|---|
| Dense owners | Full entity IDs corresponding to values. |
| Dense values | Contiguous T values for iteration. |
| Sparse indices | Registry slot to dense index, with an explicit missing sentinel. |
Lookup checks pool/world identity and slot bounds, then the sparse index, dense bounds, and exact dense owner. A stale generation cannot return another entity's component. Only a live registry entity may receive a component. Each entity has at most one component of a given type.
Initialization reserves storage for the full registry capacity with fallible operations. Insertion then publishes the owner, value, and sparse entry together. A failed insertion leaves membership and values unchanged, without growing storage. Components in the initial pool facility must be trivially copyable and trivially destructible plain state, without hidden allocation or external resource creation. Component construction must not invoke gameplay or arbitrary user callbacks.
Removal swaps the last dense value/owner into the hole, updates the moved owner's sparse entry, clears the removed slot's sparse entry, and shrinks the dense arrays. Moving a component changes its address and iteration position, not its entity ID. Sparse-array memory covers the configured registry capacity for each pool; record that cost. This is a chosen simplicity tradeoff, not an unmeasured claim of the best possible storage density.
Systems iterate one relevant pool and look up additional components explicitly.
For example, motion iterates Motion2 owners and accesses their Transform2.
Recipe validation ensures required bundles exist. Missing optional components
are ordinary branches. Do not introduce a query DSL, dynamic dispatch, or a
multicomponent cache before profiling establishes a useful need.
Initial API vocabulary¶
The identity/storage operations below are implemented by Ludus::GameplayWorld.
The reference application supplies the game-owned command operations. Other games
may adjust those signatures while preserving the visibility contracts.
| Operation | Contract |
|---|---|
EntityRegistry::TryCreate |
Create one live identity during construction/commit. On failure leave the output handle unchanged. |
EntityRegistry::IsAlive |
Validate world, slot, live state, and generation without allocation. |
EntityRegistry::IsActive |
Require a live identity whose pending-destroy bit is clear. |
ComponentPool<T>::Find |
Return a phase-local pointer or null; provide mutable and const overloads. |
ComponentPool<T>::TryInsert |
Require a live identity from the owning registry and no existing component; publish a complete value or report failure. |
ComponentPool<T>::TryRemove |
Remove an existing component only during construction/commit; invalid identity and absent component have distinct statuses. |
GameWorld::QueueSpawn |
Own a complete recipe payload and return a request ID or recording failure. |
GameWorld::QueueDestroy |
Validate queue space, record once, then mark pending destruction. |
GameWorld::Commit |
Preflight the batch and publish its structural changes with reserved storage. |
Fallible mutating operations return [[nodiscard]] statuses and are noexcept.
The initial status vocabulary includes success, invalid entity, duplicate/missing
component, invalid phase, capacity exceeded, and allocation failure. Borrowed
iteration exposes read-only owner spans and matching value spans within the phase;
value mutability follows the system's write contract. The registry's
slot release and pool structural primitives remain inaccessible to ordinary
gameplay systems; a world construction/commit facade supplies that access.
Borrowing and ordering¶
Component pointers and spans are borrowed within one phase. They must not survive
a commit, pool growth, destruction, world replacement, asynchronous dispatch, or
an API call that can perform structural mutation. Persist handles instead. Debug
inspection can read the registry's structural revision. Structural APIs return
InvalidPhase in every build when mutation is disabled. An optional view wrapper can capture that revision; raw pointers
cannot be made safe after escaping their documented lifetime.
Dense iteration order is an implementation detail. Independent per-entity updates may use it. Rules whose outcome depends on ordering must state a policy: collect intent first, reduce explicitly, and use a stable key such as authored identity or a documented creation sequence for ties. Do not use addresses, hash iteration, or worker arrival order as gameplay priority.
A Transform2 initially stores world position, rotation, and scale. There is no
mandatory parent relationship. Previous transforms needed for rendering are
presentation history, not another authoritative movement state. Adding local
transforms later requires cycle validation and an explicit propagation phase.
Value writes and structural writes¶
Updating an existing velocity, timer, health value, or AI state is a value write. The owning phase may perform it directly. Spawning/destroying an entity or adding/removing a component is a structural write and goes through the world command buffer during play. Direct structural APIs are restricted to candidate construction and commit code.
Each system documents the state it reads, writes, and produces. UpdatePlayer
reads tick input and player state and writes movement intent. MoveBodies consumes
intent and writes transforms and collision results. ResolveGameplay consumes
collision results and writes health, game outcomes, and structural commands.
These descriptions remain useful if jobs are introduced; initially the call
sequence enforces them without runtime dependency analysis.
Components describe specific capabilities and state. Keep gameplay eligibility
explicit: health/team data and InvulnerableUntilTick, for example, describe
damage rules; a sprite's animation frame does not. The authored spawn kind is a
construction choice, not a required switch in every interaction. Common immutable
configuration can be referenced by a typed definition ID, while each component
keeps its own mutable values. See definition ownership.
Entity lifecycle and presentation tails¶
The observable lifecycle is prepared, active, pending destruction, then absent. Prepared entities are private to candidate construction or commit; systems cannot see incomplete bundles. Activation publishes a complete world or successful spawn. Pending destruction suppresses further gameplay immediately, and commit removes storage. These are visibility rules, not a mandatory virtual lifecycle interface.
Resource cleanup is owner-local and cannot execute gameplay callbacks. Entity death consequences are explicit gameplay work before removal; activation effects are explicit work after successful publication. A cancelled candidate or failed spawn cannot play a birth sound. Systems may retain target handles, but validate activity when using them; a death notification is a useful typed fact, not a replacement for handle checks on delayed work.
A death animation or smoke trail may outlive the gameplay entity. Capture its position, appearance, and required asset leases into bounded presentation-owned state with a finite lifetime. That state does not borrow the removed component or retain a gameplay entity solely to keep a visual effect alive. If the dying entity must still affect gameplay, represent that as an explicit active gameplay state with documented collision and update behavior instead.
Inspectable waiting behavior¶
Start AI sequences as small, named state machines. An illustrative attack action
moves through SelectTarget, Approach, Attack, and Recover. State records
the action kind/phase, full target handle, wake/deadline tick, pending request ID,
and action revision where required. Step functions return Running, Succeeded,
Failed, or Cancelled; they do bounded work and return to the normal tick flow.
Revalidate the target and pending request on each resumption. Cancelling an action
revokes its request/revision and clears its owned intent once; it does not leave
a suspended stack pointing into the world.
This applies the readability and interruption lessons from the micro-thread chapters. It does not require one OS thread or fiber per entity. If a long scripted sequence later warrants a coroutine, its frame ownership, fallible creation, cancellation, and debugging behavior need a separate contract. It still cannot carry borrowed component pointers across suspension. Tick deadlines and decision cadence are specified in Frame updates.
Command semantics¶
The initial command vocabulary is intentionally small: spawn a complete recipe, destroy an entity, and any concrete component-bundle change the game actually needs. There is no arbitrary closure command or string-based method dispatch. Payloads own their values; they never borrow caller stack data.
All gameplay structural commands recorded in tick N commit at its end. A spawned entity can appear in render extraction after that commit and receives its first simulation update in tick N+1. If a projectile must move/collide immediately, implement an explicit same-tick attack query; add a named earlier spawn boundary only when that game's requirements justify changing the baseline.
Spawn recording returns a SpawnRequestId, not a usable entity handle. A complete
recipe supplies initial state, including any existing-entity references. Commit
publishes a bounded outcome record mapping request ID to new entity ID for next
tick's BeginTick phase. Version 1 does not support references between several
uncommitted spawns; assemble such a group as one validated recipe later if needed.
Destroy recording validates the handle and queue space before marking the entity
pending destruction. From that point, IsAlive remains true until commit, but
IsActive is false; subsequent gameplay phases and render extraction skip it.
Recorded collision/event payloads remain values and may describe that entity's
past action. Consumers check activity before applying new effects to it. This
distinction prevents duplicate rewards and accidental further updates while
storage is still present.
Record commands in sequence order. Duplicate destroy is idempotent and consumes
no extra command. Invalid/stale targets return an explicit status. Commands after
an accepted destroy cannot modify that entity. Destruction clears all components
and authored mappings before releasing the slot. Ordinary removal does not call
gameplay; death rewards and sounds belong to ResolveGameplay before commit.
Capacity and failure behavior¶
Set world limits during session configuration: entity slots, each component pool,
commands, events, spawn outcomes, and pending background requests. Preallocate at
load and reuse during ticks. Entity creation checks the declared limits even if
a container happens to have extra capacity. Use usize for counts/indices and
checked conversion for the bounded uint32 slot representation.
Before command playback, preflight the entire batch against identities, complete recipes, entity/pool capacities, asset readiness, and outcome capacity. The first implementation conservatively counts spawns against currently free slots; it does not rely on same-batch destruction to make a spawn fit. After successful preflight, playback uses reserved storage and operations that cannot fail under the validated invariants. This avoids publishing partial component bundles.
If recording or preflight fails for authoritative gameplay, enter a reported simulation fault and stop further ticks. Leave the world inspectable and offer restart in the game UI. Do not silently omit a required spawn, damage event, or destroy. The tick may already have performed value writes: this is not full tick rollback. Rejected external commands leave state unchanged; expected capacity limits may be surfaced as a gameplay choice before a tick emits a command.
Optional presentation effects can be dropped with a counter. Reserve required presentation capacity separately so particle pressure cannot erase the player. No hot-path operation invokes a fatal container growth method on ordinary resource exhaustion. Debug assertions detect programming invariant violations; runtime statuses report content, handle, and capacity failures.
Minimum inspection and tests¶
Inspect by full handle or authored ID, never dense array offset. Report present components, active/pending-destroy state, creation sequence, and storage occupancy. An invariant check verifies that every dense entry belongs to a live entity, sparse/dense mappings agree, no retired slot is reused, and required recipe components exist.
Test stale reuse, wrong-world IDs, zero/out-of-range IDs, generation exhaustion through a test seam, swap removal, duplicate components, failure at each insertion reserve, destruction across all pools, duplicate destruction, and queued spawn visibility. Test that a restart invalidates previous IDs even if it allocates the same slot/generation pattern. Run lifetime cases under ASan/UBSan.
Verify shared definitions never share current health or action state. Cancel an AI action while its target is destroyed or its result is pending. Let a death effect continue after slot reuse and prove it contains no borrowed entity data.