§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:
- Entity — an identity. Just an integer ID. It owns nothing.
- Component — pure data with no behaviour: a
Position, aVelocity, aHealth. - System — behaviour with no data of its own: a loop that reads and writes components.
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
- Profiling Your Game: Finding the 3% of Code Eating 90% of Your Frame →
- Writing a Fixed-Timestep Game Loop That Doesn't Drift →
Members get the full ECS project — stores, systems and a small demo scene — as a runnable download. See membership →