DVDevournal

§2 Architecture

An Entity Component System From Scratch, Explained Simply

ECS gets explained one of two useless ways: with fluffy analogies that never touch code, or with a wall of template metaprogramming that only compiles on the author's machine. This is the middle path — a real, small ECS built up one runnable piece at a time, so you finish understanding why it's shaped the way it is.

Why not just use objects?

The classic approach is an inheritance tree: a GameObject base, then Player, Enemy, Pickup. It works until you want a pickup that also moves, or an enemy that's temporarily invulnerable. You end up with deep hierarchies, or a bloated base class where every object carries fields it never uses. The behaviour and the data are welded together, and reuse becomes copy-paste.

ECS breaks the weld. It splits your game into three plain ideas:

An entity "is" whatever components it currently has. Attach Position and Velocity to entity 7 and the movement system will move it. Remove Velocity and it stops, with no class change and no branching.

Step 1 — entities are just numbers

Start with the smallest thing that works. An entity is an ID; creating one hands you the next integer and remembers it exists.

using Entity = uint32_t;

struct World {
    Entity next = 0;
    std::vector<Entity> alive;

    Entity create() {
        Entity e = next++;
        alive.push_back(e);
        return e;
    }
};

That's it for entities. All the interesting design is in how components are stored.

Step 2 — components in tight arrays

The tempting storage is map<Entity, Component>. It's easy and it's slow: hashing per lookup and objects scattered across the heap, so every iteration thrashes the cache. The data-oriented answer is to keep each component type in a packed array and hold a small index that maps an entity to its slot. This is the heart of ECS performance.

template<typename T>
struct Store {
    std::vector<T> data;                 // packed, no gaps
    std::vector<Entity> owner;           // data[i] belongs to owner[i]
    std::unordered_map<Entity,size_t> index;

    void add(Entity e, T value) {
        index[e] = data.size();
        data.push_back(value);
        owner.push_back(e);
    }
    T* get(Entity e) {
        auto it = index.find(e);
        return it == index.end() ? nullptr : &data[it->second];
    }
};

The map is only touched on lookup by ID. When a system iterates, it walks data straight through — contiguous memory, the cache prefetcher's favourite meal.

Step 3 — removal without holes

Deleting the middle element of a packed array leaves a gap that ruins the contiguity. The trick is swap-and-pop: move the last element into the freed slot, fix its index, then shrink. Order isn't preserved, which systems never care about.

    void remove(Entity e) {
        auto it = index.find(e);
        if (it == index.end()) return;
        size_t i = it->second;
        size_t last = data.size() - 1;

        data[i]  = data[last];          // move last into the hole
        owner[i] = owner[last];
        index[owner[i]] = i;            // repoint the moved entity

        data.pop_back();
        owner.pop_back();
        index.erase(it);
    }

Step 4 — systems are plain loops

With storage in place, a system is astonishingly boring, which is the point. Movement reads Velocity and writes Position. It iterates the smaller store and looks up the matching component on each entity:

void movementSystem(Store<Velocity>& vel,
                    Store<Position>& pos,
                    float dt) {
    for (size_t i = 0; i < vel.data.size(); ++i) {
        Entity e = vel.owner[i];
        Position* p = pos.get(e);
        if (!p) continue;              // no position, skip
        p->x += vel.data[i].dx * dt;
        p->y += vel.data[i].dy * dt;
    }
}

Note there's no inheritance, no virtual calls, no if (type == ENEMY). The system operates on whatever entities happen to carry both components. Want a new behaviour? Write a new loop. Want an entity to gain that behaviour? Attach the component. That composability is the whole reason ECS exists.

Mental model: entities are rows, components are columns, systems are queries. You're building a tiny in-memory database tuned for one workload — iterating a lot, every frame.

Where to stop, and where to go next

The version above is enough to ship a small game, and it will already outrun an object hierarchy on any scene with more than a few hundred entities, purely because iteration stays in cache. Deliberately left out: archetypes (grouping entities by their exact component set so systems iterate with zero lookups), signatures (a bitset per entity for fast "has these components?" checks), and generational IDs (so a recycled entity ID can't be confused with the old one). Each is an optimisation you add when profiling — see the profiling deep-dive — tells you the lookups actually cost you, not before.

Resist the urge to build the fully generic, reflection-driven framework on day one. The most useful ECS is the smallest one that fits your game, extended only when a measurement demands it. Start with integers and arrays. The rest is earned.

Read next

Members get the full ECS project — stores, systems and a small demo scene — as a runnable download. See membership →

← Back to the notebook