Skip to content
C++
Domain Deep-Dive
Advanced

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:

cpp
// 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.

cpp
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-friendly

The 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
# 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

cpp
#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:

cpp
// 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:

cpp
#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)
IdeaOne array per component typeAll components for an archetype stored together
Add/remove componentO(1)O(n) — moves entity to new archetype
IterationFast (one array per component)Very fast (struct-of-arrays per archetype)
MemorySome wasteCompact
Best forFrequent component changesStable 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)

cpp
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

cpp
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

cpp
// 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):

ApproachTime 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.

Edit on GitHubUpdated 2026-05-01T00:00:00.000Z