Minimal ECS in C++: Component Stores and Matching Systems¶
A minimal ECS separates capability data from the Entity that identifies it, then runs systems on matching components. This chapter builds that boundary with ordinary C++17 containers, keeps the previous simulation's behaviour, and tests the result before considering storage optimisations.
What Changes After Entity Identity?¶
In Chapter 4, each entry still owns an entire body record. Its stable handle answers “which body?”, but processing still reads flags inside each record.
Now give movement access to positions and velocities, and lifetime processing access to lifetimes. A system should not need the complete body layout or a different subclass for every capability combination.
The new requirement is an access boundary, not a speed target. We retain the same simulation contract: move first, decrement lifetime, remove expired entities, then observe surviving state. Invalid time steps must reject before mutation.
Minimal C++ ECS Component Stores¶
The World owns one store per data type. Internal keys are numbers issued by that World, not unchecked handles received from callers.
| Store | Membership | Used by |
|---|---|---|
| Position | Every live entity in this simulation | Movement, rendering, snapshots |
| Velocity | Moving entities only | Movement |
| Lifetime | Expiring entities only | Lifetime |
| Visible tag | Visible entities only | Rendering |
Removing unused velocity and lifetime values is a representation change. It is not evidence of lower total memory use: tree nodes, keys, and allocations have their own costs.
The example uses fixed component types and explicit systems. It is a minimal Entity Component System, not a general-purpose registry, sparse set, archetype engine, or array-based structure-of-arrays layout.
Systems Match Component Membership¶
Movement needs the intersection of Position and Velocity. It iterates the velocity store and looks up the corresponding position:
The const velocity input cannot be changed through this parameter. The
mutable position input can be changed. Neither input gives this function
access to visibility, lifetimes, or World operations.
Every live entity currently has Position, but the intersection is still explicit. A unit fixture with a velocity-only entry checks that the system skips it rather than inventing a position. A position-only entry stays still. These synthetic stores test matching; the public World API does not create positionless entities.
Rendering similarly matches Position and the Visible tag. It returns copied
coordinates, not a window, borrowed components, or a graphics backend.
Lifetime processing visits Lifetime alone and collects expired numbers.
The functions in detail are implementation helpers; World is the validated
public entry point, not a callback interface for arbitrary structural edits.
For comparison, EnTT's views documentation describes multi-component iteration over entities containing the requested components. Our hand-written map join demonstrates that matching idea; it does not reproduce EnTT's storage or view implementation.
Keep Identity Separate from Components¶
ecs::Entity repeats the owner-scoped policy from Chapter 4: a monotonically
increasing nonzero number plus a shared namespace marker. It is a separate
teaching type from identity::Entity; the two World implementations do not
exchange handles. The original Chapter 4 header and tests remain unchanged.
The marker owns neither the World nor its components. Copies of a handle become stale together when the entity is destroyed. Empty and foreign handles are rejected before their numbers are used for lookup. A diagnostic number alone remains insufficient, even if two Worlds both contain number 1.
World is noncopyable and nonmovable, and IDs are not reused. The counter uses Chapter 4's checked successor helper. Generation counters and slot reuse remain Chapter 6 topics, not features hidden in this implementation.
For this particular simulation, Position membership also records existence.
spawn(seed) always adds Position, then conditionally adds the other
components. If an insertion throws, it removes the partially inserted
components and does not commit the new number. The suite checks ordinary
creation and deletion; it does not inject allocation failures.
If your domain permits entities without Position, introduce separate live-ID tracking instead of treating this simulation-specific rule as a universal ECS requirement.
Preserve Execution Order and Delete All Components¶
The removal list reserves enough space before any system changes state.
Lifetime processing subtracts dt and records numbers whose remaining value
is at most zero. Only after that traversal does World remove their Position,
Velocity, Lifetime, and Visible membership. Explicit destroy(entity) uses
the same all-component cleanup path.
An expiring moving entity still participates in its final movement step, then disappears from all stores. Forgetting to erase just one component would leave orphan data and could corrupt future processing or storage counts.
This is a fixed, single-threaded sequence with one collected removal list. It is not a general command buffer, scheduler, or permission system. Runtime component attachment/removal and structural changes from user callbacks are not supported here; scheduling and broader deferral belong to Chapter 7.
Build and Run the ECS Example¶
Follow the Chapter 2 repository and build setup. Use the current checkout or download its complete file list, then run:
Use another build directory if that path belongs to a different project.
For a downloaded folder, point cmake -S at that folder instead.
The new files are minimal_ecs.hpp,
ecs_demo.cpp, and ecs_tests.cpp.
They need the shared headers and the updated CMakeLists.txt.
No engine or third-party ECS library is required.
Expected demo output:
Debug and Release CMake/CTest runs verify the project on the development host with GNU C++ 15.2.0. No Raspberry Pi hardware run or performance result is claimed. On a Pi, record your compiler, OS, build mode, and test results.
What the Tests Establish¶
- All eight moving/visible/expiring combinations match the plain and Chapter 4 implementations across multiple steps, with independently expected positions.
- Initially expired seeds add no components and consume no ID.
- Invalid time steps change neither snapshots nor component counts.
- Movement and rendering skip unmatched members in synthetic store fixtures.
- The system fixture observes final movement before lifetime collection; World results and source order are checked against the shared contract.
- Rendering preserves the expected visible order and values.
- Explicit deletion and expiration remove all four kinds of membership.
- Selection survives other deletion and bulk creation; snapshots remain copies.
- Empty, stale, copied, foreign, and destroyed-World handles are handled safely, including a foreign number colliding with a live entity.
- Bulk deletion leaves every store empty, and stepping an empty World is safe.
The checks throw on failure instead of relying on assert, so Release builds
still execute them. Keep baseline equivalence tests when changing storage.
Costs and When to Keep the Simpler Design¶
With P positions and V velocities, this movement join performs V tree lookups, giving O(V log P) lookup work for nonempty stores. Tree-based storage has node allocation and pointer-traversal costs; this chapter has no timing comparison. It is not automatically faster than scanning the original vector.
Ordered keys plus monotonically issued numbers preserve creation order for our snapshots and rendering. That is this implementation's observable policy, not a guarantee for all ECS libraries. Changing store layout must either retain that policy or explicitly change the contract and tests.
World owns the stores and exposes copied observations, not writable borrowed components. It is single-threaded and process-local; its marker is not authentication. Component separation does not make concurrent mutation safe.
Keep the plain or per-body composition design when a direct loop still meets your requirements. Separate stores are useful here because they make system inputs and capability membership explicit, not because all classes or object-oriented designs are wrong.
Exercise¶
Add an invisible moving body and a visible stationary body to the demo. Predict all four store counts, positions, and rendering output after a step. Explain why movement must not depend on Visible membership.
Previous: Entity identity and IDs. Next: Generational handles and safe slot reuse. Return to the course overview.