ECS from Scratch
The architecture that replaced deep OOP inheritance hierarchies in game engines. Entity is an ID. Component is data. System is a function that transforms matching components every tick. Flat arrays instead of pointer graphs, iteration the prefetcher can follow, and a scheduler that can prove which systems are safe to run at once. We build one from scratch, measure why it is fast, and trace the design through Overwatch, Unity DOTS, Bevy, Flecs, and EnTT.
01Why ECS
Game objects in a shipping engine carry a variable set of behaviors: this one has a transform and a mesh, that one has a transform, a mesh, a rigid body, and an AI controller. The classical OOP approach models this with inheritance. Twenty years of shipped games demonstrated that deep inheritance hierarchies produce diamond problems, fat base classes, and cache-hostile memory layouts that cost real frame time at scale. ECS is the replacement.
The core proposition: separate identity from data from behavior. An entity is a lightweight ID. A component is a plain data struct attached to that ID. A system is a function that runs over all entities matching a component query. No inheritance. No virtual dispatch. Components live in flat, typed arrays. Systems iterate those arrays sequentially. The CPU prefetcher sees a predictable stride. The scheduler sees declared read/write sets and can parallelize automatically.
The results show up in frame time. The cleanest published measurement is still Tony Albrecht's, from Sony's European R&D group: 11,111 objects in a five-level scene tree, transformed and hierarchically culled every frame, with an empty render call so only the traversal is timed. Restructuring that loop from the textbook object hierarchy toward flat, contiguous data took it from 19.6 ms to 3.3 ms on PS3-era hardware, in four steps, with the logic unchanged[4].
The profiler counters from that run are the more useful part, because they say which cost dominated. At the starting point Albrecht measured 50,421 branch mispredictions at roughly 23 cycles each (about 0.36 ms) against 36,345 L2 cache misses at roughly 400 cycles each (about 4.54 ms)[4]. Memory stalls cost more than an order of magnitude more than dispatch did. Both matter, and this tutorial covers both, but if you only fix one, fix the layout.
Working knowledge of both major ECS storage strategies (archetypes and sparse sets), when to pick each, and how to implement them. Generational indices for safe entity recycling. Query matching by bitset intersection. The structural change problem and command buffer pattern. System scheduling and automatic parallelism. And the case studies: Overwatch's gameplay ECS, Unity DOTS, Bevy's parallel executor, Flecs' relationship model, EnTT's sparse-set design, and Unreal's Mass Entity framework.
02A short history
The component pattern predates the term "ECS" by over a decade. The timeline of the ideas that converged into the architecture shipping in engines today:
03The OOP problem
The classical game-object hierarchy starts reasonable: GameObject at the root, RenderableObject inherits from it, PhysicsObject inherits from it, Character inherits from both. By the time you have 200 object types across a shipped game, the hierarchy is 6 to 12 levels deep. The problems are structural, not cosmetic.
- Diamond inheritance. A
FlyingEnemyneeds bothEnemy(AI, health) andFlyingObject(flight model). Both inherit fromPhysicsObject. C++ virtual inheritance "solves" this at the cost of extra indirection, vtable complexity, and a data layout that no one on the team can draw on a whiteboard. - Fat base classes. Every feature that "most objects need" migrates upward.
GameObjectaccumulates a transform, a bounding box, a name, a layer mask, a tag, an enable flag, a serialization hook. Objects that need none of these (a trigger zone, a sound emitter) pay for all of them in memory and initialization cost. - Virtual dispatch overhead. A per-frame
Update()call on 50,000 objects through a vtable means 50,000 indirect function calls, each one a chance for the branch predictor to miss the target. Agner Fog measures the misprediction penalty at 15 to 20 cycles on Haswell through the Skylake derivatives, and around 18 on Zen 1 to 3[5]. Size the worst case honestly: 50,000 misses at ~17 cycles is about 0.3 ms at 3 GHz, and that is the ceiling, not the expectation. Indirect branch predictors handle a loop that keeps calling the same concrete type well, so a run of same-type objects costs far less. The dispatch bill only approaches that ceiling when the type actually varies from object to object. - Cache-hostile layout. Each object is heap-allocated. The
newallocator interleaves objects of different types in address space. Iterating allRenderableObjectinstances pointer-chases through a linked list or flat pointer array, loading one cache line per object. Most of that cache line is wasted on fields the current loop does not touch.
The "everything is a GameObject" model that Unity (pre-DOTS) and many custom engines used is a partial fix. It replaces inheritance with composition at the object level, but the components themselves are still polymorphic, heap-allocated, and pointer-chased. The iteration pattern (for each entity, fetch its component by type, call a virtual method on it) is fundamentally the same pointer chase.
04Entities, Components, Systems
An ECS has three concepts and zero inheritance.
Entity: a . Typically a 32-bit or 64-bit integer split into an index (slot in an array) and a generation counter. The entity itself stores nothing. It is a key into the component tables.
Component: a plain data struct. No methods, no vtable, no inheritance. struct Position { float x, y, z; }; is a complete component. Components are stored in typed, contiguous arrays. The storage strategy (how those arrays are organized) is the subject of sections 5 and 6.
System: a function (or callable) that queries a set of component types and iterates all entities matching that query. A movement system declares "give me every entity with Position and Velocity" and runs position += velocity * dt for each one. Systems have no per-entity state. They read and write components; the ECS runtime provides the iterator.
This separation has three consequences that matter for performance:
- Homogeneous arrays. All Position components are stored in one flat array. Iteration is a sequential scan. The CPU prefetcher sees a constant stride and loads ahead.
- No virtual dispatch. A system is a single function pointer. It runs in a tight loop over flat data. No indirect call per entity.
- Declarative access. Each system declares which component types it reads and which it writes. The scheduler can run two systems in parallel if their access sets don't conflict. This is mechanical, not hand-tuned.
// Minimal ECS usage pattern (pseudocode).
// Create entities
auto player = world.spawn();
world.add<Position>(player, {0, 0, 0});
world.add<Velocity>(player, {1, 0, 0});
world.add<Health>(player, {100, 100});
auto prop = world.spawn();
world.add<Position>(prop, {5, 0, 0});
world.add<StaticTag>(prop, {});
// Movement system: iterates entities with Position AND Velocity.
// The prop (no Velocity) is excluded automatically.
world.system<Position, const Velocity>(
[](auto& position, const auto& velocity) {
position.x += velocity.dx * dt;
position.y += velocity.dy * dt;
position.z += velocity.dz * dt;
}
);
05Storage strategy 1: Sparse sets
A maps entity IDs to component data using two arrays. The sparse array is indexed by entity ID and stores the index into a dense array. The dense array stores entity IDs (and, in parallel, component values) packed contiguously with no gaps.
All three core operations are O(1) on the dense side:
- Has: check
sparse[entity]. If the value is in range anddense[sparse[entity]] == entity, the entity has this component. - Add: set
sparse[entity] = dense.length, push the entity ID onto dense, push the component value onto the parallel values array. - Remove: swap the entity's slot with the last element of dense and values, then pop. Update sparse for the swapped entity. O(1), no allocation.
One honest caveat, visible in the code above. The first insert of a high entity ID has to grow the sparse array out to that index, which is O(max entity ID), not O(1). Amortized over many inserts it disappears, but a single add() for entity 4,000,000 into an empty pool is a four-million-element allocation. Paging the sparse array is the standard fix, and it is what EnTT does[13].
EnTT[12] uses one sparse set per component type. The trade-off: the sparse array is sized to the maximum entity ID, so it can consume significant memory if entity IDs are large. Paging the sparse array (allocating it in fixed-size pages on demand) mitigates this. The swap-and-pop removal does not preserve insertion order, which matters if you need deterministic iteration order across runs. EnTT offers a way to buy back multi-pool iteration cost: an owning group takes ownership of a set of pools and keeps the entities matching the group tightly packed and identically ordered at the front of each one[14]. Iterating a group is then a parallel walk of aligned arrays, which Caini calls perfect SoA: "no jumps, no branches". Two costs come with it. The packing is maintained on component creation and destruction rather than at iteration time, so the win is paid for on the add/remove side. And an array can only be ordered one way, which caps how many groups a component can join: "One component, one group." Nested groups lift that only for groups where "one literally contains the other"; a pair like <A, B> and <A, C> is rejected, since neither extends the other.
template<typename T>
struct SparseSet {
static constexpr uint32_t INVALID = UINT32_MAX;
std::vector<uint32_t> sparse; // entity ID -> dense index
std::vector<uint32_t> dense; // packed entity IDs
std::vector<T> values; // component data, parallel to dense
void ensure_sparse(uint32_t entityId) {
if (entityId >= sparse.size())
sparse.resize(entityId + 1, INVALID);
}
bool has(uint32_t entityId) const {
return entityId < sparse.size()
&& sparse[entityId] != INVALID
&& dense[sparse[entityId]] == entityId;
}
void add(uint32_t entityId, T value) {
ensure_sparse(entityId);
sparse[entityId] = static_cast<uint32_t>(dense.size());
dense.push_back(entityId);
values.push_back(std::move(value));
}
void remove(uint32_t entityId) {
if (!has(entityId)) return;
auto idx = sparse[entityId];
auto last = static_cast<uint32_t>(dense.size() - 1);
if (idx != last) { // swap with last
dense[idx] = dense[last];
values[idx] = std::move(values[last]);
sparse[dense[idx]] = idx; // fix swapped entity's sparse entry
}
dense.pop_back();
values.pop_back();
sparse[entityId] = INVALID;
}
T& get(uint32_t entityId) { return values[sparse[entityId]]; }
const T& get(uint32_t entityId) const { return values[sparse[entityId]]; }
};
pub struct SparseSet<T> {
sparse: Vec<Option<usize>>, // entity ID -> dense index
dense: Vec<u32>, // packed entity IDs
values: Vec<T>, // component data, parallel to dense
}
impl<T> SparseSet<T> {
// The fields are private, so without this the type cannot be built
// outside this module. Deriving Default would not work: T need not be.
pub fn new() -> Self {
Self { sparse: Vec::new(), dense: Vec::new(), values: Vec::new() }
}
pub fn has(&self, entity_id: u32) -> bool {
let index = entity_id as usize;
index < self.sparse.len()
&& self.sparse[index].is_some()
&& self.dense[self.sparse[index].unwrap()] == entity_id
}
pub fn add(&mut self, entity_id: u32, value: T) {
let index = entity_id as usize;
if index >= self.sparse.len() {
self.sparse.resize_with(index + 1, || None);
}
self.sparse[index] = Some(self.dense.len());
self.dense.push(entity_id);
self.values.push(value);
}
pub fn remove(&mut self, entity_id: u32) {
if !self.has(entity_id) { return; }
let idx = self.sparse[entity_id as usize].unwrap();
let last = self.dense.len() - 1;
if idx != last {
self.dense.swap(idx, last);
self.values.swap(idx, last);
let swapped = self.dense[idx] as usize;
self.sparse[swapped] = Some(idx);
}
self.dense.pop();
self.values.pop();
self.sparse[entity_id as usize] = None;
}
// Returns None rather than indexing blind. The C++ pane's get() trusts
// the caller to have checked has() first, which is the kind of footgun
// Option exists to remove.
pub fn get(&self, entity_id: u32) -> Option<&T> {
if !self.has(entity_id) { return None; }
self.sparse[entity_id as usize].map(|idx| &self.values[idx])
}
}
This sparse set is written to be read, not shipped. A production pool adds:
- A guard in
add()against adding the same component twice. As written, a secondadd()pushes a duplicate dense entry and overwrites the sparse slot, orphaning the first one soremove()leaves a stale entity behind. The live widget above guards this; the listing does not. - Validity checking in the C++
get(). It indexesvalues[sparse[entityId]]with no check, so calling it for an entity that lacks the component reads out of bounds. The Rust pane returnsOptioninstead, which is the difference worth noticing between the two panes. - A paged sparse array, so entity ID 4,000,000 does not force a four-million-element allocation[13].
- Pointer stability across insertion, if anything outside the pool holds references into
values. Swap-and-pop moves elements, so it does not have it. - Iteration order guarantees. Swap-and-pop scrambles order, which matters the moment you need a deterministic replay.
06Storage strategy 2: Archetypes
An groups entities by their exact component set. All entities with exactly {Position, Velocity} live in one table. All entities with {Position, Velocity, Health} live in another. Each table is a set of contiguous arrays, one per component column, plus an entity ID column. Iterating all entities with Position and Velocity means finding every archetype whose component set is a superset of {Position, Velocity} and scanning each matching table sequentially.
This is the storage model Unity DOTS[8], Flecs[15], and Unreal Mass Entity[23] use. The core trade-off vs sparse sets: iteration is a pure sequential scan (the CPU prefetcher's best case), but adding or removing a component moves the entity's data from one archetype table to another. That move is the problem (section 8).
The two strategies have been compared under controlled conditions rather than by anecdote. Cox et al. built matched implementations and measured both across a range of entity counts. At 50,000 entities the archetype build's median frame latency was 7.410 ms against the sparse set's 13.819 ms; at 10,000 it was 1.721 ms against 2.945 ms (both p < .001). Entity instantiation ran the other way: a median 6,600 ns per entity for archetypes against 1,000 ns for sparse sets (p < .001). At 1,000 entities and below the frame-latency gap disappeared into the noise, reaching no significance at either 1,000 or 100 entities[26]. The paper does not publish its test hardware, so treat the absolute milliseconds as indicative and the direction of each gap as the durable result: archetypes pay at construction and win at iteration. Cross-library numbers settle no ranking, because library, configuration and workload move together: Beimler's ecs_benchmark runs a 1,000,000-entity update across 7 systems on ten library configurations on one stated machine (3.13 GHz, 12 cores, GCC 14.2.1, Linux 6.10.4) and gets 16 ms at the fast end and 102 ms at the slow end, with most configurations between 19 ms and 40 ms. The 102 ms outlier is EnTT's runtime-typed mode, not EnTT itself, which lands at 40 ms and at 35 ms with an owning group. Its README states the caveat this whole subsection rests on: "The results of these benchmarks should be used as a starting point for your own benchmarking efforts."[27]
07Iteration and cache coherence
The performance argument for ECS reduces to one claim: iterating flat, typed arrays is faster than pointer-chasing through polymorphic objects. The gap is not algorithmic (both are O(n)); it is entirely in the constant factor, dominated by cache line utilization.
In an OOP hierarchy, each game object is heap-allocated. Iterating "all objects with a physics component" dereferences a pointer per object. Each pointer leads to a different address. The CPU loads a 64-byte cache line for each dereference; if the useful data is 16 bytes (a Position struct), 48 bytes of each line are wasted. Worse, successive objects are rarely adjacent in memory, so the prefetcher cannot guess where you are going next and each dereference risks falling all the way through the hierarchy. The order-of-magnitude spread is what matters: on the Dean and Norvig figures an L1 reference is around 0.5 ns and an L2 reference around 7 ns, while going out to main memory is around 100 ns[6]. Two hundred times is the gap you are betting the frame on.
In an archetype ECS, all Position values for entities in one archetype are packed in a contiguous float[]. Iterating it is a sequential scan. The hardware prefetcher detects the stride and loads ahead. Every byte of every cache line contains useful data. For a 12-byte Position struct (3 floats), roughly 5 positions fit per 64-byte cache line. At L1 hit latency, the amortized cost per entity is a fraction of a nanosecond. The ratio between pointer-chasing and sequential access is often 50x to 200x in practice, depending on working set size and cache pressure from other systems.
08Component add/remove: the structural change problem
In archetype storage, adding a component to an entity means moving its data from the current archetype table to a different one (the archetype that has the old set plus the new component). Removing a component does the same in reverse. Each move copies every component value for that entity. If the entity has 8 components totaling 200 bytes, that is a 200-byte memcpy per .
A single move is cheap. A thousand moves per frame (spawn 500 enemies, each with an add-component-on-spawn pattern) is not. The solutions:
- Deferred commands. Systems record structural changes into a instead of executing them immediately. At the end of the system (or at an explicit sync point), the buffer replays all changes in batch. This avoids invalidating iterators mid-loop and enables batching moves by target archetype. Unity calls this an
EntityCommandBuffer. Bevy calls itCommands, documented as "A Command queue to perform structural changes to the World" whose queued commands "are automatically applied in sequence when the ApplyDeferred system runs"[20]. - Archetype edge caching. When entity e moves from archetype A to archetype A+{Health}, the ECS caches the edge "A + Health = B". The next entity that adds Health to the same archetype A skips the archetype lookup and goes straight to B. Flecs[15] stores these edges in a graph connecting archetypes.
- Chunk allocation. Unity DOTS allocates archetype tables in fixed-size chunks (16 KiB). Each chunk holds as many entities as fit. Moving an entity out of a chunk leaves a hole that is filled by swapping in the last entity from the same chunk. This keeps chunks packed without a global compaction pass.
Deferral is not only tidier. Applying a structural change while a query is iterating the table it reads is a correctness bug, not a performance one. An archetype table fills a vacated row by moving its last row into the hole, so migrating the entity at index i puts an unvisited entity at index i, and a loop that then advances to i+1 never visits it. Flecs' manual gives the same reason for deferring: "doing this without deferring an operation could modify the underlying data structure."[16]
Once both versions actually process every matched entity, deferral does not reduce the copying. The same rows migrate and the same bytes move. They move at a point where nothing is iterating, and grouped by destination archetype, so the archetype-graph lookup runs once per (source, destination) pair instead of once per entity. Playback has to be scheduled somewhere: Unity's EntityCommandBuffer "stores a queue of thread-safe commands which you can add to and later play back,"[9] and its playback resolves the temporary entity IDs that let one command reference an entity an earlier command created. Commands record entity handles rather than row indices for the reason the widget makes visible: by playback time, earlier migrations have already shuffled rows underneath any index you saved.
In sparse-set ECS (EnTT), structural changes are cheaper: adding a component inserts into a per-type sparse set (O(1)), removing swaps and pops (O(1)). No table migration. This is the primary advantage of sparse sets over archetypes for workloads with frequent component add/remove (particle systems, buff/debuff stacking, short-lived effects).
09Queries and query caching
A query is the interface between a system and the storage. A query descriptor says: "give me every entity that has all of these component types and none of those." The ECS runtime resolves this into a set of archetype tables whose component sets satisfy the constraints.
The resolution step is a bitset intersection. Assign each component type a bit index. Each archetype stores a bitmask of its component types. The query "With(Position, Velocity), Without(Static)" becomes:
// Query: With(Position, Velocity), Without(Static)
// The 'ull' suffix is load-bearing: a bare 1 is an int, so 1 << 31 is undefined
// behavior and 1 << 32 is worse. Declaring the result uint64_t does not help,
// because the shift has already happened in int by then.
uint64_t withMask = (1ull << POSITION_BIT) | (1ull << VELOCITY_BIT);
uint64_t withoutMask = (1ull << STATIC_BIT);
for (auto& archetype : allArchetypes) {
bool hasAll = (archetype.mask & withMask) == withMask;
bool hasNone = (archetype.mask & withoutMask) == 0;
if (hasAll && hasNone) {
iterateArchetype(archetype); // sequential scan of matching table
}
}
This outer loop (over archetypes) is cheap: dozens to low hundreds of archetypes in a typical game, each checked by two bitwise ANDs and two comparisons. The inner loop (over entities in each matching archetype) is the sequential scan that does the real work.
Query caching avoids re-running the archetype match every frame. On first execution, the query finds all matching archetypes and stores pointers to them. When a new archetype is created (because some entity got a novel component combination), the ECS tests it against all cached queries and adds it where it matches. Flecs[15] and Bevy[18] both cache queries this way.
10Relationships and entity references
Pure ECS stores flat data. But games need structure: parent/child hierarchies (scene graph), targeting (missile locked onto a ship), inventory (item inside a container), socket attachment (weapon in hand). These are all relationships between entities.
The simplest approach: store a Parent component containing the parent's entity ID. This works for parent/child. Flecs[15] generalizes this into first-class relationships: a component type can be parameterized by a target entity. (ChildOf, parent_entity) is a relationship pair that acts as a component. Entities with the same relationship pair land in the same archetype, so "find all children of entity X" is an archetype query. Bevy made relationships first-class in 0.16: "Adding a child is now as simple as" commands.spawn(ChildOf(some_parent)), with the reverse Children collection maintained by the engine instead of by hand[21]. Separately in the same release, transform propagation got better parallelization and a dirty bit that lets an unmoved subtree be skipped entirely, which took Bevyโs test of the 127,515-object Caldera Hotel scene from Call of Duty: Warzone from 1.1 ms to 0.1 ms on an M4 Max[21]. Hierarchy cost is a scheduling problem before it is a data-layout problem: the propagation has to respect parent-before-child order, and the fix was finding more of the tree that could be skipped or run in parallel.
The danger with entity references in components: the referenced entity may be destroyed. A Target component pointing to entity 42 is a dangling reference after entity 42 is freed. Generational indices (section 12) detect this at resolve time. Flecs additionally supports "on delete" hooks: when the target of a relationship is destroyed, the relationship component is automatically removed from all entities that reference it.
11Scheduling and parallelism
Each system declares which component types it reads and which it writes. Two systems can run in parallel if they have no write-write or read-write conflict on the same component type. A system that reads Position and writes Velocity can run in parallel with a system that reads Health and writes Damage, because the component sets are disjoint.
The scheduler builds a dependency graph of systems. Edges encode conflicts: if system A writes Position and system B reads Position, B depends on A (or vice versa, depending on declared ordering). The scheduler topologically sorts this graph and dispatches independent systems to worker threads. Bevy's multi-threaded executor[18] does this automatically each frame. The developer writes systems with declared access; the engine parallelizes them.
This only works because ECS access is declarative. In an OOP architecture, a method on a GameObject can touch any field on any other object through a pointer. The engine has no way to know what a method accesses without running it. In ECS, the query signature is the access declaration. The scheduler reads it statically.
// Bevy system declarations (Rust). The scheduler reads the type signature
// to determine access: &Position asks for read access, &mut Velocity for write.
// Written against Bevy 0.17+, where buffered events were renamed to messages
// (EventReader -> MessageReader) and Time::delta_seconds became delta_secs.
fn movement_system(
mut query: Query<(&mut Position, &Velocity)>,
time: Res<Time>,
) {
for (mut position, velocity) in &mut query {
position.x += velocity.dx * time.delta_secs();
position.y += velocity.dy * time.delta_secs();
}
}
fn damage_system(
mut query: Query<&mut Health, With<DamageReceiver>>,
mut damage_messages: MessageReader<DamageMessage>,
) {
// Reads DamageMessage, writes Health. No overlap with movement_system,
// so the scheduler runs both in parallel. The reader must be `mut`:
// iterating it advances a per-system cursor into the message buffer.
}
// Registration: the engine reads the function signatures at compile time.
app.add_systems(Update, (movement_system, damage_system));
Two engines read the same declarations and stop at different guarantees. Unity's Entities package chains job dependencies from declared component access and is explicit about the granularity it settles for: "this system dependency approach works at a system level, it can result in jobs waiting for other jobs to access components that the original jobs don't need."[10] Bevy's multi-threaded executor promises exclusion and nothing more. Its schedule module describes the executor as one where "Non-conflicting systems can run in parallel,"[30] and a conflicting pair with no .before or .after between them is an ambiguity, defined in the build settings as "systems with conflicting access but indeterminate order."[31] Ambiguity detection is off by default. The widget models the Unity reading, where declaration order fixes the direction of every conflict edge and the graph is a DAG by construction. Flecs takes a third position and pins work to threads rather than to systems: "The scheduler ensures that the same entity is always processed by the same thread, until the next sync point"[17], with the sync points themselves derived from the read/write patterns across systems.
12Generational indices
Entity IDs must be recycled. A game that spawns and destroys thousands of projectiles per second will grow the slot array unboundedly unless IDs are reused, since per-ID storage (sparse arrays, component slots) scales with the high-water mark of the index. A monotonically increasing 32-bit index would also exhaust the space in days at sustained high spawn rates. The standard solution: a .
The entity allocator maintains an array of slots. Each slot has a generation counter and an alive flag. A free list tracks available slots. Allocating pops a slot off the free list and returns {index, generation}. Freeing increments the generation and pushes the slot back onto the free list. Any saved reference that holds the old generation will fail the generation check on resolve. Weissflog states the rule in one line: "Each array slot gets its own generation counter, which is bumped when a handle is released"[28], and Gray states the matching test on the read side: "To test if a weak pointer is still valid, we check whether the generation in the weak pointer's struct matches the generation in the slot indicated by the id"[29].
This prevents the ABA problem in entity references: slot 5 held enemy A (generation 0), enemy A was destroyed (generation bumped to 1), slot 5 was reused for projectile B (generation 1). A stale reference to {index: 5, generation: 0} correctly fails to resolve, even though slot 5 is alive again.
struct Entity {
uint32_t index;
uint32_t generation;
};
struct EntityAllocator {
struct Slot { uint32_t generation; bool alive; };
std::vector<Slot> slots;
std::vector<uint32_t> freeList;
Entity allocate() {
uint32_t index;
if (!freeList.empty()) {
index = freeList.back();
freeList.pop_back();
} else {
index = static_cast<uint32_t>(slots.size());
slots.push_back({0, false});
}
slots[index].alive = true;
return { index, slots[index].generation };
}
void free(Entity entity) {
if (!isAlive(entity)) return;
slots[entity.index].alive = false;
slots[entity.index].generation++; // invalidate stale refs
freeList.push_back(entity.index);
}
bool isAlive(Entity entity) const {
return entity.index < slots.size()
&& slots[entity.index].alive
&& slots[entity.index].generation == entity.generation;
}
};
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct Entity {
pub index: u32,
pub generation: u32,
}
pub struct EntityAllocator {
slots: Vec<(u32, bool)>, // (generation, alive)
free_list: Vec<u32>,
}
impl EntityAllocator {
pub fn new() -> Self {
Self { slots: Vec::new(), free_list: Vec::new() }
}
pub fn allocate(&mut self) -> Entity {
let index = if let Some(idx) = self.free_list.pop() {
idx
} else {
let idx = self.slots.len() as u32;
self.slots.push((0, false));
idx
};
self.slots[index as usize].1 = true;
Entity { index, generation: self.slots[index as usize].0 }
}
pub fn free(&mut self, entity: Entity) {
if !self.is_alive(entity) { return; }
let slot = &mut self.slots[entity.index as usize];
slot.1 = false;
slot.0 += 1; // bump generation
self.free_list.push(entity.index);
}
pub fn is_alive(&self, entity: Entity) -> bool {
let idx = entity.index as usize;
idx < self.slots.len()
&& self.slots[idx].1
&& self.slots[idx].0 == entity.generation
}
}
The allocator above is the minimal viable version. Production implementations add: packing index and generation into a single 64-bit integer (Bevy uses 32 bits index + 32 bits generation), tombstone detection for double-free, configurable generation width (some engines use 16-bit generation + 16-bit index for tighter packing), and atomic operations for thread-safe allocation.
13Case studies
Overwatch (Blizzard, 2016)
Timothy Ford's GDC 2017 talk[7] describes a custom ECS built for Overwatch. Systems run in a fixed tick order. Each system declares its component reads and writes. The ECS enables the deterministic simulation that powers Overwatch's netcode: given the same inputs, the same sequence of system ticks produces the same game state. Hero abilities that would be nightmares in a deep inheritance hierarchy (Genji's deflect interacting with every projectile type) are implemented as systems that query component combinations rather than as method overrides on a base Projectile class.
Unity DOTS
Unity's archetype ECS stores entities in 16 KiB chunks[8]. Each chunk belongs to one archetype. Within a chunk, component arrays are laid out in order at the component-type level: all Position structs contiguous, then all Velocity structs, then all Health structs. The Burst compiler auto-vectorizes system loops over these arrays, emitting SIMD instructions without manual intrinsics. The C# Job System schedules jobs across worker threads based on declared component access, similar to Bevy's approach but in the C# / .NET runtime.
Bevy (Rust)
Bevy[18] uses archetype storage with a multi-threaded system executor. Systems are plain Rust functions. Their parameter types encode the query: Query<(&Position, &mut Velocity)> requests read access to Position and write access to Velocity for every entity that has both. The data the system wants goes in the first type parameter as a tuple; the optional second parameter is a filter, not more data, which is where With<Player> or Changed<Health> go. The executor builds a dependency graph from these signatures and dispatches non-conflicting systems to a thread pool. Storage is per-component rather than fixed: a component defaults to Table (columnar, "fast and cache-friendly iteration, but slower addition and removal") and can opt into SparseSet ("fast addition and removal of components, but slower iteration")[19]. Bevy is the clearest counterexample to treating archetype and sparse set as a whole-library choice. The engine keeps moving fast enough that version matters when you read its docs: 0.19 unified resources as singleton entity components, replaced the render graph with ordinary ECS schedules, and exposed contiguous query access as table slices so system loops can be vectorized[22].
Flecs (C/C++)
Flecs[15] is an archetype-based ECS with first-class relationships. The (ChildOf, parent) relationship pair acts as a component: entities with the same parent share an archetype, making "find all children of X" an archetype-table scan. Flecs caches query results and maintains an archetype graph where edges represent "add component C" or "remove component C" transitions, enabling O(1) archetype lookup on structural changes. Sander Mertens' "Building an ECS" blog series[15] provides the most detailed public documentation of these internals.
EnTT (C++)
EnTT[12] is the primary example of a sparse-set ECS. Each component type has its own sparse set pool. No archetype tables, no table migration on add/remove. The trade-off: iteration over multiple component types requires intersecting multiple sparse sets (iterating the smallest set and looking up each entity in the others). Used in Minecraft Bedrock Edition. Michele Caini's "ECS Back and Forth" series documents the design decisions in detail.
Unreal Mass Entity
Mass Entity[23] is Epic's archetype-based framework in Unreal Engine 5, used for crowd and traffic simulation in the City Sample and The Matrix Awakens[24]. Epic's own documentation states the model without the numbers: entities of identical composition share an archetype, and "Entities in an Archetype are organized in memory Chunks"[23]. It publishes no chunk size. The widely repeated "128 bytes per cache line, 1024 cache lines" figure comes from the community MassSample documentation, whose authors state they are not affiliated with Epic and describe it as their reading of the UE::Mass::ChunkSize constant[25]. Read literally it is a 128 KB chunk, an order of magnitude larger than Unity's 16 KiB. Treat the derivation as unofficial. Mass interoperates with Unreal's Actor/Component model through traits that bridge the ECS world and traditional UObjects.
14Pitfalls
- Over-splitting components. Splitting Position into PositionX, PositionY, PositionZ (one component per field) maximizes SoA vectorization but creates three archetype columns where one suffices. Most systems read all three fields together; the extra indirection costs more than the SIMD benefit. Split only when profiling shows a system that reads one axis and ignores the others.
- Archetype explosion. If entities carry many optional components and the combinations are diverse, the archetype count grows combinatorially. 20 optional components produce up to 2^20 possible archetypes. In practice, a few hundred archetypes cover the common cases. If the count grows past a few thousand, reconsider whether some of those "components" should be fields inside a larger component.
- Structural change storms. A system that adds and removes components every frame (toggling a buff on and off) moves entities between archetypes every tick. Use a boolean field inside the component instead of adding/removing the component, or use command buffers to batch the changes.
- Entity reference dangling. A component stores an entity ID referencing another entity. That entity is destroyed. The component now holds a dangling reference. Generational indices detect this at resolve time, but the system must handle the failure case (skip, remove the component, spawn a replacement).
- System ordering bugs. System A writes a value that system B reads. If the scheduler runs them in the wrong order (or in parallel), B sees stale data. Declare explicit ordering constraints (
.after(SystemA)in Bevy) when data dependencies exist that the component access analysis cannot capture (e.g., both systems read and write different fields of the same component type).
15What's next
- Change detection. Track which components were modified since the last frame. Bevy stores added/changed ticks per component instance, so
Changed<Health>filters per entity. That is a cheap per-entity test during iteration, not a way to skip the iteration: the query still walks every matching entity, so it stays O(n) with a smaller constant. Unity DOTS is the design that gets real skipping, because its change version is per chunk:ArchetypeChunk.DidChangelets a system reject a whole 16 KiB chunk without touching its contents. Granularity is what decides whether change detection saves work or just saves the body of the loop. - Serialization. Archetype tables are contiguous arrays of typed data. Serializing a game state is iterating each archetype and writing its arrays to disk. Deserialization reconstructs the tables. The regularity of the layout makes this simpler than serializing an arbitrary object graph.
- Networking. ECS makes delta compression straightforward: for each component type, diff the current frame's array against the previous frame's array. Send only the changed entries. The deterministic system ordering that ECS encourages (Overwatch's approach) enables lockstep and rollback netcode patterns.
- GPU-driven ECS. Store component arrays in GPU-visible buffers. Run systems as compute shaders. The flat-array layout maps directly to GPU memory models. Unity's DOTS Burst compiler and Unreal's Mass framework are steps in this direction, though fully GPU-resident ECS is still experimental in most production engines as of 2026.
16Sources
- Scott Bilas. "A Data-Driven Game Object System." GDC 2002, Gas Powered Games. gamedevs.org. The earliest widely cited talk on assembling game objects from data-driven components, built for Dungeon Siege (">7300 unique object types", ">100000 objects placed in our two maps"). Note the argument is about authoring flexibility and refactoring cost, not cache performance: "This is a database (a very well understood problem)."
- Catherine West. "Using Rust For Game Development." RustConf 2018, Closing Keynote. kyren.github.io. Walked through OOP-to-ECS in Rust; credited with catalyzing the Rust ECS ecosystem (Bevy, Hecs, Legion).
- Adam Martin. "Entity Systems are the future of MMOG development." T-Machine, Part 1: 3 September 2007; Part 2: 11 November 2007. t-machine.org (Part 2). Part 1 argues the case; the entity-as-ID, component-as-data, system-as-logic taxonomy this tutorial uses is defined in Part 2, which is why Part 2 is the link here.
- Tony Albrecht. "Pitfalls of Object Oriented Programming." GCAP 2009, Sony Computer Entertainment Europe R&D. harmful.cat-v.org (PDF). The measured OOP-to-data-oriented transformation used in §1: 11,111 nodes in a five-level tree, hierarchical culling, empty render call, taken from 19.6 ms to 3.3 ms in four steps on PS3-era hardware. Also the source of the counter split (50,421 branch mispredictions at ~23 cycles vs 36,345 L2 misses at ~400 cycles) showing memory stalls dominating dispatch by roughly 12 to 1.
- Agner Fog. "The microarchitecture of Intel, AMD, and VIA CPUs." Last updated 2026-05-23. agner.org (PDF). Supports the branch-misprediction penalty in §3: "The branch misprediction penalty varies a lot. It was measured to 15 - 20 clock cycles" for Haswell through the Skylake derivatives, and "approximately 18 clock cycles for Zen 1-3." Also notes indirect jumps and calls are predicted, "though not as efficiently as conditional jumps."
- Jeff Dean and Peter Norvig. "Latency Numbers Every Programmer Should Know." Widely circulated via Jonas Bonér's gist. gist.github.com/jboner/2841832. Gives L1 cache reference 0.5 ns, L2 cache reference 7 ns, branch mispredict 5 ns, and main memory reference 100 ns. Supports the order-of-magnitude spread across the memory hierarchy in §7, and nothing finer: these are ballparks, not measurements of a specific part.
- Timothy Ford. "Overwatch Gameplay Architecture and Netcode." GDC 2017, Blizzard Entertainment. gdcvault.com. The talk body is behind the GDC Vault paywall; the public abstract states Overwatch "uses a cutting-edge Entity Component System (ECS) architecture" and "leverages determinism to achieve responsiveness and precision." Cited here only for what that abstract supports.
- Unity Technologies. "Archetypes concepts." Entities package 6.4.0 manual. docs.unity3d.com. The source for the 16 KiB chunk figure: "Each chunk consists of 16 KiB and the number of entities that they can store depends on the number and size of the components in the chunk's archetype." Also documents the per-component-type arrays and their tight packing.
- Unity Technologies. "Entity command buffer overview." Entities 6.4.0 manual. docs.unity3d.com. "An entity command buffer (ECB) stores a queue of thread-safe commands which you can add to and later play back." Supports the deferred-structural-change discussion in §8, including temporary-entity fixup during playback.
- Unity Technologies. "Job dependencies." Entities 6.4.0 manual. docs.unity3d.com. Documents automatic dependency tracking from declared component access, and its stated coarseness: "this system dependency approach works at a system level, it can result in jobs waiting for other jobs to access components that the original jobs don't need."
- Unity Technologies. "ECS Development Status - December 2025." Unity Discussions, 11 December 2025. discussions.unity.com. Supports the currency note in §13: "The Entities, Collections, Mathematics, and Entities Graphics packages are coming to Unity 6.4 as Core Packages," and "EntityId will represent both GameObjects and Entities."
- Michele Caini (skypjack). "EnTT: Gaming meets modern C++." github.com/skypjack/entt. Sparse-set ECS, used in Minecraft Bedrock Edition. Repository created March 2017; v4.0.0 (23 July 2026) requires C++20.
- Michele Caini. EnTT entity documentation (
docs/md/entity.md). github.com/skypjack/entt. Supports the generational-index material in §12 as shipped in production: "An entity identifier contains information about the entity itself and its version." Also documents paged sparse arrays "to avoid wasting memory." - Michele Caini. "ECS back and forth, part 6: Nested groups." 19 November 2019. skypjack.github.io. The owning-group design referenced in §5 and §13: owned pools are rearranged so matching entities form a contiguous, identically ordered prefix, which the author calls perfect SoA with "no jumps, no branches" during iteration.
- Sander Mertens. "Entity Component System FAQ." flecs.dev/ecs-faq. The Flecs author's taxonomy: archetype ("fast to query and iterate"), sparse set ("fast add/remove operations"), and bitset-based. Also the hedge this tutorial follows: performance comparisons "depend on what is being measured, and the ECS implementation."
- Sander Mertens and contributors. "Flecs Manual" (deferred operations). flecs.dev. "Deferred operations are useful when an application wants to make modifications to an entity while iterating, as doing this without deferring an operation could modify the underlying data structure." Supports §8.
- Sander Mertens and contributors. "Systems" manual (multithreading). flecs.dev. Supports §11: "The scheduler ensures that the same entity is always processed by the same thread, until the next sync point," and sync points are inserted from read/write patterns across systems.
- Carter Anderson et al. "Bevy Engine." bevyengine.org. Rust game engine with automatic parallel system scheduling and change detection. Source at github.com/bevyengine/bevy.
- Bevy contributors.
bevy_ecs::component::StorageType. docs.rs. The per-component storage choice cited in §5 and §13: Table "provides fast and cache-friendly iteration, but slower addition and removal of components"; SparseSet "provides fast addition and removal of components, but slower iteration." - Bevy contributors.
bevy_ecs::system::Commands. docs.rs. "A Command queue to perform structural changes to the World... all queued commands are automatically applied in sequence when the ApplyDeferred system runs." Supports §8. - Bevy contributors. "Bevy 0.16." Release notes, 24 April 2025. bevy.org. First-class entity relationships: "Adding a child is now as simple as
commands.spawn(ChildOf(some_parent))." Supports §10, and the measured transform-propagation improvement from 1.1 ms to 0.1 ms. - Bevy contributors. "Bevy 0.19." Release notes, 19 June 2026. bevy.org. Currency for §13: resources unified as singleton entity components, the render graph replaced by ordinary ECS schedules, and contiguous query access exposing table slices for SIMD and auto-vectorization.
- Epic Games. "Overview of Mass Entity in Unreal Engine." UE 5.8 Documentation. dev.epicgames.com. Epic's own description, quoted in §2 and §13: "Archetypes are a collection of Entities of identical composition" and "Entities in an Archetype are organized in memory Chunks." Note what it does not contain: any chunk size, cache-line figure, or byte count.
- Epic Games. "City Sample Project Unreal Engine Demonstration." UE 5.8 Documentation. dev.epicgames.com. Supports the claim that Mass drives Epic's crowds and traffic: "Mass AI manages the behavior and visualization of traffic and crowds." The same page calls many of these features experimental, which is why this tutorial does not assign Mass a maturity label.
- Karl Mavko (Megafunk) and Alvaro Jover (vorixo). "MassSample" README. Community documentation, built against UE 5.6. github.com/Megafunk/MassSample. The actual origin of the widely repeated chunk figure: "The chunk size (UE::Mass::ChunkSize) has been conveniently set based on next-gen cache sizes (128 bytes per line and 1024 cache lines)." The authors state they are "not affiliated with Epic Games" and describe the write-up as their "somewhat WIP understanding," which is why §13 attributes it to them rather than to Epic.
- Louis Cox, Benjamin Williams, Jay Vickers, Davin Ward, Christopher Headleand. "Run-time Performance Comparison of Sparse-set and Archetype Entity-Component Systems." EG UK CGVC 2025, University of Staffordshire. diglib.eg.org (PDF). The controlled comparison cited in §5, §6 and §13. Median frame latency at 50,000 entities: 7.410 ms archetype vs 13.819 ms sparse set (p < .001); entity instantiation 6600 ns vs 1000 ns (p < .001). Below roughly 1,000 entities the difference is not statistically significant. The paper does not state its test hardware, which bounds how far the figures travel.
- Andreas Beimler et al. "Entity-Component-System Benchmarks" (ecs_benchmark). github.com/abeimler/ecs_benchmark. Cross-library measurements with stated hardware (3.13 GHz, 12 cores, GCC 14.2.1, Linux 6.10.4) and pinned library versions. Updating ~1M entities across 7 systems spans 16 ms (mustache) to 102 ms (EnTT runtime-typed) across ten library configurations, with most between 19 ms and 40 ms. The README's own caveat is worth repeating: results "should be used as a starting point for your own benchmarking efforts."
- Andre Weissflog (floooh). "Handles are the better pointers." 17 June 2018, updated 28 November 2018. floooh.github.io. The canonical handle argument behind §12: "Each array slot gets its own generation counter, which is bumped when a handle is released." The November update exists specifically to address slot reuse.
- Niklas Gray. "Data Structures Part 1: Bulk Data." Our Machinery, 23 July 2019 (read via the community archive; ourmachinery.com no longer resolves). ruby0x1.github.io. States the weak-pointer validity test used in §12: "To test if a weak pointer is still valid, we check whether the generation in the weak pointer's struct matches the generation in the slot indicated by the id."
- Bevy contributors.
bevy_ecs::schedulemodule documentation. docs.rs. Describes theMultiThreadedExecutoras one that "Runs the schedule using a thread pool. Non-conflicting systems can run in parallel," and definesConflictingSystemsas "Pairs of systems that conflict with each other along with the components they conflict on, which prevents them from running in parallel." Supports the exclusion-not-ordering reading of Bevy in §11. docs.rs. - Bevy contributors.
bevy_ecs::schedule::ScheduleBuildSettings. docs.rs. Defines an ambiguity, in the documentation of theambiguity_detectionfield, as the presence of "systems with conflicting access but indeterminate order"; the field defaults to logging nothing. Supports §11. docs.rs.