Entity-Component-System
An ECS splits a game object into identity, data and behaviour, so behaviour can be written once and applied to every object that has the right data.
The concept
- Entity - just an id. It carries no data and no code.
- Component - a plain bundle of data (a position, a sprite, a collider) attached to an entity.
- System - a function that walks every entity owning a given set of components and acts on them.
Storing each component type in its own densely packed array keeps iteration cache-friendly, and composing objects from components avoids deep inheritance hierarchies.
Why not inheritance?
The classic alternative is a class tree: Enemy derives from Actor, a flying enemy from
Enemy, and so on. It works until a new object needs a mix of branches (a flying, shooting, destructible
crate) and the tree has to be reshuffled. With components, that crate is just an entity carrying the right bundle of
data; no class had to change.
Data-oriented layout
CPUs are fast at reading memory in order and slow at chasing pointers. A system that touches only
Position and Velocity reads two tightly packed arrays and never loads the sprite, health or
audio data of the same entities. That is the main performance argument for an ECS, and it grows with the entity count.
Systems and ordering
Systems hold no state of their own; everything they need is in components or shared resources. That makes them easy to test and to reason about, but it means order matters: gravity must run before movement, and movement before collision, or objects react to last frame's positions. Engines therefore fix the system order in one place.
Trade-offs
The price is indirection. Finding "the one entity with component X" means a lookup, behaviour is spread across systems instead of living in one class, and removing an entity while iterating needs care. For small, singular objects (the player controller, say) plain code is often clearer; the ECS earns its keep for the many similar things in a scene.
How ASGE does it
Generational entity handles
An asge::ecs::Entity is an index plus a generation counter. When an entity is destroyed its
slot is recycled and its generation is bumped, so any stale handle still held elsewhere fails
IsAlive() instead of silently pointing at an unrelated new entity. A slot whose generation
reaches its maximum is retired rather than reused. Indices come from a fixed-capacity
EntityAllocator built on mem::FreeList (see
Memory & threading).
Sparse-set component pools
Each component type lives in its own ComponentPool<T>, a sparse set: a sparse array maps an
entity index to a slot in a dense array of components. Insert, Get, Remove
and Contains are O(1), and iteration only touches live components. Removal is swap-and-pop, so
iteration order may change after a removal.
The Registry
asge::ecs::Registry is the single entry point. It owns the allocator and one pool per component
type, created lazily on the first AddComponent<T>. DestroyEntity() strips the entity
from every pool. The registry also holds per-registry resources (SetResource<T> /
GetResource<T>): at most one value of a type that is not tied to any entity.
Views
Registry::View<Ts...>() returns a lazy, non-owning View over every entity that has
all of Ts. It iterates the smallest contributing pool and filters the rest. Entities carrying the
Disable marker, or whose ancestor in the Hierarchy does, are skipped unless you call
IncludeDisabled(). Use View<T const> for read-only access.
struct Health { int m_Hp{ 100 }; };
asge::ecs::Registry registry;
auto entity = registry.CreateEntity(); // Result<Entity>
if ( !entity ) return;
registry.AddComponent( entity.Value(), Health{ 50 } );
// Every entity that has a Health; components arrive as std::reference_wrapper<T>.
for ( auto [e, health] : registry.View<Health>() )
{
health.get().m_Hp -= 1;
}
Systems in the Game layer
Systems are plain functions over a Registry, such as MovementSystem,
PathFollowingSystem and TransformPropagationSystem. Game runs them in one
fixed order every frame - see Game loop & timing.
Things to know
- A view built for a component type that has never been used is permanently empty, even if the type is added later.
- Views hold raw pointers into the pools and must not outlive the
Registry. - A pool is not told when an entity dies;
Registry::DestroyEntityhandles that, so destroy through the registry.
See also
Source: Core/ECS/Registry.hpp, Core/ECS/View.hpp, Core/ECS/ComponentPool.hpp.
ECS demo · API reference