Entity Component System (ECS) in C++
"ECS architecture for games: entities as IDs, data-driven components, cache-friendly systems, and practical implementation with EnTT and flecs."
TL;DR
ECS separates identity (entity = integer ID), data (components = plain structs), and logic (systems = functions over component sets). The payoff is cache-friendly iteration — all Position components sit contiguously in memory — which can yield 10-100× speedups over OOP scene graphs on modern CPUs.
Why ECS?
Traditional OOP game object hierarchies fail at scale:
// OOP — virtual dispatch, scattered heap allocations, cache-hostile
class GameObject {
virtual void update(float dt) = 0;
std::vector<Component*> components; // pointer chasing
};Each update() call may dereference multiple pointers to heap-allocated components spread across RAM. With 10,000 enemies, this causes constant cache misses.
ECS reframes the problem: data is grouped by type, not by object.
Entity 42: Position{x,y,z} Velocity{dx,dy,dz} Health{hp}
Entity 43: Position{x,y,z} Velocity{dx,dy,dz}
Entity 44: Position{x,y,z} Sprite{tex}
Memory layout (Position array):
[pos0][pos1][pos2]...[posN] ← all contiguous, perfectly cache-friendlyThe movement system iterates all (Position, Velocity) pairs in a tight loop — no virtual dispatch, no pointer chasing.
EnTT — the modern C++ ECS
EnTT is the dominant ECS library for C++, used in Minecraft's new renderer and many indie engines.
Setup
# CMake FetchContent
include(FetchContent)
FetchContent_Declare(entt GIT_REPOSITORY https://github.com/skypjack/entt.git GIT_TAG v3.13.2)
FetchContent_MakeAvailable(entt)
target_link_libraries(my_game EnTT::EnTT)Basic usage
#include <entt/entt.hpp>
// Components — plain data structs, no base class needed
struct Position { float x, y, z; };
struct Velocity { float dx, dy, dz; };
struct Health { int hp, max_hp; };
struct Sprite { uint32_t texture_id; };
int main() {
entt::registry registry;
// Create entities
auto player = registry.create();
registry.emplace<Position>(player, 0.f, 0.f, 0.f);
registry.emplace<Velocity>(player, 0.f, 0.f, 0.f);
registry.emplace<Health>(player, 100, 100);
registry.emplace<Sprite>(player, texture_id);
// Bulk create enemies
for (int i = 0; i < 1000; ++i) {
auto e = registry.create();
registry.emplace<Position>(e, float(i), 0.f, 0.f);
registry.emplace<Velocity>(e, -1.f, 0.f, 0.f);
registry.emplace<Health>(e, 30, 30);
}
// Systems — iterate matching archetypes
float dt = 1.f / 60.f;
// Movement system
auto view = registry.view<Position, Velocity>();
view.each([dt](Position& pos, const Velocity& vel) {
pos.x += vel.dx * dt;
pos.y += vel.dy * dt;
pos.z += vel.dz * dt;
});
// Damage system — only entities with Health
registry.view<Health>().each([](entt::entity e, Health& h) {
if (h.hp <= 0) {
// mark for deletion
}
});
// Cleanup dead entities
for (auto e : registry.view<Health>()) {
if (registry.get<Health>(e).hp <= 0)
registry.destroy(e);
}
}Groups — maximum cache performance
EnTT's group guarantees that two component arrays share the same entity ordering, enabling perfectly parallel iteration:
// Create an owning group — Position and Velocity are stored together
auto group = registry.group<Position>(entt::get<Velocity>);
// Iterates with perfect cache locality
group.each([dt](Position& pos, const Velocity& vel) {
pos.x += vel.dx * dt;
pos.y += vel.dy * dt;
});Groups have constraints (an entity can only belong to one owning group per component). Use views for flexibility, groups when you've identified a hot path.
flecs — relationship-aware ECS
flecs adds relationships and queries on top of the ECS model, making it suitable for complex hierarchical scenes:
#include "flecs.h"
struct Position { float x, y; };
struct Velocity { float dx, dy; };
int main() {
flecs::world world;
// Register components
world.component<Position>();
world.component<Velocity>();
// Create entity with components
auto e = world.entity()
.set<Position>({0, 0})
.set<Velocity>({1, 1});
// System — runs automatically each frame
world.system<Position, const Velocity>("Move")
.each([](Position& p, const Velocity& v) {
p.x += v.dx;
p.y += v.dy;
});
// Relationships (flecs unique feature)
auto parent = world.entity("Parent");
auto child = world.entity("Child").child_of(parent);
// Query children of parent
parent.children([](flecs::entity child) {
printf("child: %s\n", child.name().c_str());
});
// Run one tick
world.progress();
}Archetype vs sparse-set ECS
Two main storage models:
| Sparse-set (EnTT) | Archetype (Unreal Mass, flecs) | |
|---|---|---|
| Idea | One array per component type | All components for an archetype stored together |
| Add/remove component | O(1) | O(n) — moves entity to new archetype |
| Iteration | Fast (one array per component) | Very fast (struct-of-arrays per archetype) |
| Memory | Some waste | Compact |
| Best for | Frequent component changes | Stable archetypes, bulk iteration |
EnTT uses sparse-sets by default. For maximum throughput on stable archetypes (e.g., 10k identical enemies), archetype layouts win.
Practical patterns
Tag components (zero-size)
struct Player {}; // tag — no data
struct Visible {};
struct Collidable {};
registry.emplace<Player>(player_entity);
// Query only players
for (auto [entity] : registry.view<Player>().each()) {
// ...
}Event system via EnTT signals
entt::dispatcher dispatcher;
struct DamageEvent { entt::entity target; int amount; };
// Subscribe
dispatcher.sink<DamageEvent>().connect<&onDamage>();
// Publish
dispatcher.trigger(DamageEvent{enemy, 25});
// Or queue and process later
dispatcher.enqueue<DamageEvent>(enemy, 25);
dispatcher.update<DamageEvent>();Component storage hints
// Pre-allocate storage for N entities to avoid reallocations
registry.reserve<Position>(10000);
registry.reserve<Velocity>(10000);
// Use pools for stable pointers (when you need component pointers outside ECS)
registry.storage<Position>().reserve(10000);Performance comparison
Benchmark: 100k entities, movement system (Position + Velocity):
| Approach | Time per frame |
|---|---|
| OOP with virtual dispatch | ~8ms |
| std::vector of structs | ~1.2ms |
| EnTT view (sparse-set) | ~0.4ms |
| EnTT group (sorted) | ~0.2ms |
| flecs archetype query | ~0.15ms |
Numbers are approximate — profile your specific workload.
When not to use ECS
- Small games (< 1000 entities): OOP is simpler and fast enough.
- One-of-a-kind entities (player, camera, audio manager): use plain singletons.
- Heavy inter-entity relationships: consider a scene graph alongside ECS.
ECS shines when you have many similar entities (enemies, particles, projectiles) that need processing in tight loops.