ECS design¶
The game world, on the server and on the client, is an Entity-Component-System
(aegis2d::ecs). This page explains how it is built and how to use it: what an entity
is, what a component may contain, where the components are stored, how to iterate over
the entities that own one or several components, and how systems run. Why the engine
uses a sparse-set ECS rather than an inheritance tree or archetypes is in the
technical study; the list
of every header and function is in the architecture overview.
To add your own component and system, follow
Add a component and a system.
#include <aegis2d/ecs/components.hpp>
#include <aegis2d/ecs/registry.hpp>
#include <aegis2d/ecs/system.hpp>
The three parts¶
| Part | What it is | In the engine |
|---|---|---|
| Entity | A game object as a number, with no data and no behavior | ecs::Entity, created by the Registry |
| Component | Plain data attached to an entity, at most one of each type | ecs::Transform, ecs::Velocity, ecs::Health... or a struct of the game |
| System | A function run once per tick over the entities that own some components | ecs::ISystem or any (Registry&, const Tick&) function, run by an ecs::Scheduler |
A ship is not a Ship class: it is an entity that owns a Transform, a Velocity, a
shmup::PlayerShip, a shmup::Weapon... A Bydo is an entity with a Transform, a Velocity, a
Health and a ScoreValue. The movement system moves both, because both own a
Transform and a Velocity; it does not know what a ship or a Bydo is. Giving an
existing object a new behavior is adding a component to it, not writing a new class.
The Registry is the world: it creates and destroys the entities and stores their
components. Each binary has one, and the systems receive it on every tick.
flowchart LR
registry["Registry"] --> allocator["EntityAllocator<br/>alive entities and generations"]
registry --> pools["one pool per component type<br/>(created on first use)"]
pools --> transform["SparseSet<Transform>"]
pools --> velocity["SparseSet<Velocity>"]
pools --> health["SparseSet<Health>"]
scheduler["Scheduler<br/>systems in order"] -- "update(registry, step)" --> registry
Entities¶
An Entity is a 32-bit handle: a 24-bit index (the slot of the entity, up to about
16 million) and an 8-bit generation (how many times that slot was reused).
ecs::Registry registry;
const ecs::Entity missile = registry.create(); // Entity(0, 0): index 0, generation 0
registry.destroy(missile); // slot 0 is free again
const ecs::Entity bydo = registry.create(); // Entity(0, 1): same slot, new generation
registry.alive(missile); // false: stale handle, slot 0 belongs to the Bydo
registry.alive(bydo); // true
ecs::Entity nothing; // null by default, never alive
The generation is what makes keeping a handle safe. A system that remembered the missile
(an Owner, a homing target, the server's id-to-client map) and asks for it after the
slot was reused gets "not alive" or "no such component", never the Bydo's data.
Destroyed slots wait in a first-in, first-out free list, so a slot is reused only after
all the other free ones; even so, the generation wraps after 256 reuses of one slot, so
check alive() before using a handle kept for a long time.
Entities are handles, so they are cheap to copy, compare and hash, and a component may
store one to point at another entity (ecs::Owner stores the ship that fired a missile).
The server sends Entity::id() to the clients as an ecs::NetworkId; a client creates
its own entities and keeps a map from the server's id to its local entity, it never uses
the server's handle as its own.
Components¶
A component is a plain struct, usually an aggregate with default values:
struct Velocity {
Vec2 value{}; // pixels per second
};
struct Health {
std::int32_t points = 1;
[[nodiscard]] constexpr bool depleted() const noexcept { return points <= 0; }
};
struct AutoTrigger {}; // a marker: owning it is the information
The rules that keep the storage simple and the world easy to send over the network:
- Data, not behavior. A component may have small helpers (
depleted()), but the logic that changes the world lives in systems. - Movable, with a
noexceptmove assignment. Components are moved inside their pool when another entity loses the same component. Strings and vectors are fine;boolis rejected (use a marker struct instead). - No pointer or reference to another component. Components move in memory; to point
at something, store its
Entityand look it up when needed. - One per type per entity.
emplacethrowsstd::logic_errorif the entity already has one;emplace_or_replaceoverwrites it. - Positions in logical pixels, durations in seconds, as the engine's systems expect (origin top-left, y down; see the shared components).
Components that the engine's systems read, or that both binaries share, are in
aegis2d/ecs/components.hpp. Components that only one game uses are declared in the game,
in a header.
const ecs::Entity bydo = registry.create();
registry.emplace<ecs::Transform>(bydo, Vec2{1296.0F, 200.0F});
registry.emplace<ecs::Velocity>(bydo, Vec2{-120.0F, 0.0F});
registry.emplace<ecs::Health>(bydo, 2);
registry.get<ecs::Health>(bydo).points -= 1; // throws std::out_of_range if absent
if (auto health = registry.try_get<ecs::Health>(bydo)) {
health->get().points -= 1; // std::optional of a reference
}
registry.has<ecs::Transform, ecs::Velocity>(bydo); // true: owns both
registry.remove<ecs::Velocity>(bydo); // it stops moving
registry.destroy(bydo); // removes its other components too
Storage: one sparse set per component type¶
The Registry keeps one pool per component type, an ecs::SparseSet<C>, created the first
time the type is used and found again by its type (typeid). A pool has three arrays:
- dense: the entities that own the component, packed with no holes;
- components: their components, in the same order (
components[i]belongs todense[i]); - sparse: indexed by entity index, the position of that entity in
dense(or "none").
For example, after three entities got a Health (indices 4, 0 and 7) and the one at index
0 lost it:
| Array | Content |
|---|---|
| sparse | [0] none, [4] 0, [7] 1 (other indices: none) |
| dense | Entity(4, 0), Entity(7, 0) |
| components | Health{3}, Health{1} |
Every operation is O(1):
| Operation | How |
|---|---|
contains(e) |
sparse[e.index()] gives a position, and dense[position] == e (same generation, so a stale handle is not found) |
get(e) |
components[sparse[e.index()]] |
emplace(e, ...) |
append to dense and components, write the position in sparse |
remove(e) |
move the last entity and component into the hole, update their sparse entry, pop the back |
Removal is why the arrays stay packed: a loop over a pool touches only entities that own
the component, one after the other in memory, with no "is it dead?" check. It is also why
the order of a pool changes and why references are short-lived: a reference
returned by get() or passed to a callback points into components, which moves its
elements when the same pool loses one, and reallocates when it grows. Keep the Entity
and call get() again later, not the reference.
Registry::destroy(e) removes the entity from every pool, whatever its components, then
frees its slot.
Iterating¶
Every entity with one component¶
A pool can be walked directly; entities() and components() are spans of the same
length:
for (ecs::Health& health : registry.pool<ecs::Health>().components()) {
health.points = std::max(health.points, 1);
}
auto& weapons = registry.pool<shmup::Weapon>();
for (std::size_t i = 0; i < weapons.size(); ++i) {
const ecs::Entity owner = weapons.entities()[i]; // owns weapons.components()[i]
}
A one-component view does the same with the entity as a parameter:
registry.view<ecs::Health>().each([](ecs::Entity entity, ecs::Health& health) { ... }).
Every entity with two components¶
This is the usual query of a system: "every entity that owns an A and a B".
registry.view<A, B>() returns a View, and each() calls a function for each of those
entities, with references to its two components:
// Movement: every entity that owns both a Transform and a Velocity.
const float dt = tick.delta();
registry.view<ecs::Transform, ecs::Velocity>().each(
[dt](ecs::Transform& transform, const ecs::Velocity& velocity) {
transform.position += velocity.value * dt;
});
- The parameters follow the order of the template arguments. Take a component by
const&when the system only reads it. - To know which entity is visited, add an
ecs::Entityas the first parameter:
std::vector<ecs::Entity> dead;
registry.view<ecs::Health, ecs::ScoreValue>().each(
[&dead](ecs::Entity entity, const ecs::Health& health, const ecs::ScoreValue&) {
if (health.depleted()) {
dead.push_back(entity);
}
});
What each() does:
- It takes the smallest of the pools of the view. With 300 entities that own a
Transformand 12 that own aVelocity, it walks the 12Velocityentries, not the 300Transformones: the cost follows the rarest component, not the size of the world. - For each entity of that pool, it checks in O(1) (the sparse array) that the other pools contain it too, and skips it otherwise.
- It calls the function with the components of the entity, found in O(1).
It walks the smallest pool from its last entry to its first. Removing the visited entity's component moves the last entry into its place, and that entry was already visited: this is why the function may remove components from the entity it visits, or destroy it, and every other entity is still visited exactly once.
registry.view<ecs::Transform, ecs::OffscreenDespawn>().each(
[®istry](ecs::Entity entity, const ecs::Transform& transform, const ecs::OffscreenDespawn&) {
if (transform.position.x < -64.0F) {
registry.destroy(entity); // allowed: the entity being visited
}
}); // (ecs::despawn_outside() does this for real)
Views with more types work the same way (view<ecs::Transform, ecs::Collider,
ecs::Health>()); the types must be different, which is checked at compile time.
Other questions about two components¶
| Question | Code |
|---|---|
| Does this entity own both? | registry.has<A, B>(entity), or view.contains(entity) |
| How many entities own both? | count them in each(); view.size_hint() is only an upper bound (the size of the smallest pool) |
Every entity with A, and its B if it has one |
view<A>() and registry.try_get<B>(entity) inside |
Every entity with A and B but not C |
view<A, B>() and if (registry.has<C>(entity)) { return; } inside |
The first entity with A and B |
a for loop over registry.pool<A>().entities() with has<B>() and a break: each() always visits everything |
Rules while iterating¶
- Do not add or remove components of the viewed types on other entities, and do not
create entities that get them, inside
each(): the pools would move under the loop. Collect what to do in a vector and do it after the loop. The engine's systems work like this:trigger_weapons()lists the shooters and the game creates the missiles afterwards,apply_damage()records the kills andremove_casualties()destroys them later. - Changing the values of components, adding or removing components of other types, and destroying the visited entity are fine.
- The order of the visit is not specified. It depends on which pool is the smallest and on the past additions and removals. The server is deterministic because the same inputs replay the same operations, but code whose result depends on the order should sort first, as the collision grid does with its pairs.
Systems¶
A system is the code that reads and changes components, once per tick. Most systems are
functions with the signature void(ecs::Registry&, const ecs::Tick&); a system that keeps
state between ticks derives from ecs::ISystem:
void apply_velocity(ecs::Registry& registry, const ecs::Tick& tick) {
registry.view<ecs::Transform, ecs::Velocity>().each(
[dt = tick.delta()](ecs::Transform& transform, const ecs::Velocity& velocity) {
transform.position += velocity.value * dt;
});
}
class PeakEnemies final : public ecs::ISystem { // most enemies alive at once
public:
void update(ecs::Registry& registry, const ecs::Tick&) override {
peak_ = std::max(peak_, registry.count<ecs::ScoreValue>());
}
[[nodiscard]] std::size_t peak() const noexcept { return peak_; }
private:
std::size_t peak_ = 0;
};
The Scheduler runs the systems in the order they were added, once per call to
update(registry, step), which the fixed-step callback of the main loop makes 60 times a
second on the server. The Tick gives the step, the tick number and delta(), the step
in seconds: speeds are per second, so the game behaves the same at any tick rate.
ecs::Scheduler systems;
systems.add("steering", shmup::steer_player_ships); // inputs -> Velocity
systems.add("movement", apply_velocity); // Velocity -> Transform
auto& peak = systems.add<PeakEnemies>("stats"); // built in place, owned by the scheduler
systems.update(registry, core::step_from_rate(60)); // one tick: steering, movement, stats
systems.set_enabled("stats", false); // skipped, keeps its place
The order is written in one place and is the same on every tick: R-Type's server, for
example, runs steering, movement, sine motion, the play area, weapons, enemy fire, spawns,
waves, collision, damage, score, lives, cleanup and despawn, in that order. Names are
unique; adding or removing a system from inside a system throws std::logic_error; an
exception thrown by a system stops the tick, which is then not counted in ticks().
Systems do not call each other. When one system's result is needed by the next one (the
contacts found by the collision system, the kills found by the damage system), it is
passed in a vector reused from tick to tick, or as a component, or published as an event on
the core::EventBus for the modules that only react (audio, effects).
Known limits¶
- One thread. The registry is not thread-safe; the network thread never touches it and hands its messages to the game thread through queues (network module).
- No archetypes or groups. A query over several components checks the other pools for each entry of the smallest one. It is fast enough for the thousands of entities of a shoot'em up; the trade-off is explained in the technical study.
- Pools are found by
typeid. With libc++ (macOS) and hidden symbols, a component type can get a differenttypeidin the engine's shared library than in the game, and code compiled inside the library would then see empty pools. Engine code that reads components is therefore written in headers (inline), compiled in the game's code; the game keeps its own component code in a static library linked into its executables. - Handles are 32 bits. At most about 16 million entities alive at once, and the 8-bit generation of a slot wraps after 256 reuses (see Entities).