All tutorials Mighty Professional
Tutorial 14 ยท Engine Architecture

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.

Time~40 min LevelMid to senior PrereqsYou can read C++ and Rust. Basic memory model awareness (cache lines, sequential vs random access). Comfortable with bitwise operations. HardwareNone. A feel for cache hierarchy helps in sections 5 through 7.

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.

What you'll have by the end

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:

2002
Scott Bilas, "A Data-Driven Game Object System," GDC 2002. Built for Dungeon Siege at Gas Powered Games. Over 7,300 unique object types, 100,000+ placed objects across a continuous world. Bilas proposed assembling game objects from data-driven components instead of inheriting from a class hierarchy. No engineer required to create a new object type.[1] This is the earliest widely cited talk on component-based game objects.
2007
Adam Martin, "Entity Systems are the future of MMOG development," T-Machine blog. A five-part blog series that named the pattern and argued for strict separation: entities hold no data, components hold no behavior, systems hold no state.[3] Martin's taxonomy (entity as ID, component as data, system as logic) became the canonical definition the community adopted.
2017
Michele Caini releases EnTT. A header-only C++ ECS built on sparse sets rather than archetypes. Each component type gets its own sparse-set pool. Adding and removing components is O(1) with no table migration. Used in Minecraft (Bedrock Edition) by Mojang.[12]
2017
Timothy Ford, "Overwatch Gameplay Architecture and Netcode," GDC 2017. Blizzard's Overwatch shipped on a custom ECS. Ford described how the ECS curtails complexity even as the team adds new heroes with radically different abilities. The deterministic simulation is built on the ECS tick model: systems run in a fixed order, each reading and writing declared component sets.[7]
2018
Catherine West, RustConf 2018 closing keynote: "Using Rust for Game Development." Walked through the OOP-to-ECS transition in Rust, showing how the borrow checker makes traditional mutable-object-graph architectures painful and ECS natural. Widely credited with sparking the Rust gamedev ECS wave that produced Bevy, Hecs, and Legion.[2]
2018
Unity ships its archetype ECS. Previewed at Unite Austin in October 2017 and shipped in Unity 2018.1; the "DOTS" branding for the wider stack followed in 2019. Chunk-allocated archetype tables at 16 KiB[8], the Burst compiler for auto-vectorized system code, and the C# Job System for multi-threaded scheduling. Shipped iteratively through the Entities 1.0 release in 2023, and folded into the editor as a core package in Unity 6.4[11].[8]
2019
Sander Mertens releases Flecs v1.0. A C/C++ ECS with first-class entity relationships, query caching, and an archetype storage backend. Mertens' "Building an ECS" blog series[15] is the most detailed public documentation of archetype-storage internals, covering table layout, edge graphs for archetype transitions, and query optimization.
2020
Bevy 0.1 released by Cart (Carter Anderson). A Rust game engine with an archetype-based ECS at its core. Bevy's scheduler automatically parallelizes systems based on declared read/write access to component types. The ECS design draws from prior Rust crates (Legion, Hecs) but integrates scheduling, resources, and change detection into one system.[18]
2022
Unreal Engine 5.0 ships Mass Entity. An archetype-based framework Epic uses for crowd and traffic simulation, including the City Sample and The Matrix Awakens[24]. Epic's documentation describes the model (entities, fragments, archetypes, and "memory Chunks") but publishes no chunk size[23]. Integrated with Unreal's actor/component model via Mass traits.

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.

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:

  1. 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.
  2. No virtual dispatch. A system is a single function pointer. It runs in a tight loop over flat data. No indirect call per entity.
  3. 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:

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.

Live ยท Sparse set operations
dense count
0
sparse size
16
utilization
0%
The sparse array is indexed by entity ID. The dense array is packed: no gaps, no holes. Removal swaps the target with the last element and pops, keeping the dense array contiguous in O(1). The cost is that iteration order changes on every removal. EnTT provides a sort() operation to restore order when needed (useful for render-order-dependent iteration).
Sparse set allocator
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])
    }
}
What's intentionally missing

This sparse set is written to be read, not shipped. A production pool adds:

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

Live ยท Archetype table visualizer
archetypes
0
entities
0
selected
none
Each archetype table stores entities that share exactly the same component set. Click the canvas to cycle through entities. Adding a component to the selected entity moves it to a different archetype (or creates a new one if no archetype with that component set exists yet). Removing a component does the same in reverse. This table-migration cost is the fundamental price of archetype storage.

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.

Live ยท OOP pointer chase vs ECS sequential scan
OOP cache hits
0
OOP cache misses
0
ECS cache hits
0
ECS cache misses
0
The OOP side accesses entities in random order (simulating heap-allocated objects scattered across memory); each access to a new cache line is a miss. The ECS side scans sequentially, reusing each line for several entities before advancing. Both visit all 64 entities exactly once, so the only difference is memory layout. A miss stalls that side while the line is fetched, so the sequential (ECS) side finishes first while the pointer-chasing (OOP) side is still grinding through misses. The per-miss stall is set to 8ร— a hit here so the clip stays watchable; a real DRAM miss runs roughly 50โ€“200ร— an L1 hit, so the true gap is wider.

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:

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]

Live · Immediate vs deferred structural change
entities visited
0/10
entities skipped
0
migrations
0
bytes copied
0 B
archetype lookups
0
The system scans the source table and tags entities by health: zero adds Dead, under 40 adds Burning. Five of the ten always qualify. In immediate mode each tag migrates the row on the spot. Swap-and-pop drops the table's last row into the slot the cursor is sitting on (amber), the loop advances anyway, and that row is never visited; at the end of the scan the passed-over rows are outlined in red. The migration count then comes up short of five, which is the tell: entities that should have been tagged were not, so the cheaper-looking run is the broken one. Deferred mode records the same tags into the buffer, finishes the scan having visited all ten, and replays grouped by destination archetype: five migrations, and two archetype lookups instead of one per entity. Per migration the cost is the same either way, 44 bytes, the sum of the four component sizes in the header. Deferral does not move fewer bytes. It moves the right ones.

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.

Live ยท Query matcher
archetypes matched
0/0
entities matched
0
The WITH bitmask requires all marked bits to be set in the archetype. The WITHOUT bitmask requires all marked bits to be clear. Two bitwise ANDs per archetype. The entity count shows how many entities the system would iterate after matching. Toggle the component constraints to see archetypes drop in and out of the match set.

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));
Live · System dependency scheduler
conflict edges
0
longest chain
0 ms
frame
0 ms
core utilization
0%
Two systems conflict on a component if either one writes it. Read-read is not a conflict, and that is the entire reason declaring access buys parallelism. Every cell edit rebuilds the conflict graph, retakes the longest weighted chain through it (red), and repacks the systems greedily onto the lanes; nothing here is precomputed. At the default access sets the chain is movement → physics → ai, 12 ms, and it is a floor: two workers already reach it, and three or four move only the utilization number. Clear physics's Pos access and set its Vel to read: seven conflict edges drop to four and the chain drops to 8 ms, because Pos and Vel were the only two components coupling it to movement.

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.

Live ยท Generational index allocator
alive
0
free slots
12
saved refs
0
Each allocation saves a reference (index + generation). Freeing a slot bumps its generation. Resolving a saved reference checks the generation against the slot's current generation. If they disagree, the reference is stale: the entity it pointed to was destroyed, and the slot may have been reused for a different entity. This is how ECS implementations detect use-after-free without garbage collection.
Generational index allocator
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
    }
}
What's intentionally missing

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

15What's next

16Sources

  1. 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)."
  2. 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).
  3. 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.
  4. 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.
  5. 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."
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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."
  11. 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."
  12. 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.
  13. 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."
  14. 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.
  15. 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."
  16. 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.
  17. 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.
  18. 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.
  19. 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."
  20. 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.
  21. 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.
  22. 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.
  23. 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.
  24. 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.
  25. 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.
  26. 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.
  27. 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."
  28. 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.
  29. 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."
  30. Bevy contributors. bevy_ecs::schedule module documentation. docs.rs. Describes the MultiThreadedExecutor as one that "Runs the schedule using a thread pool. Non-conflicting systems can run in parallel," and defines ConflictingSystems as "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.
  31. Bevy contributors. bevy_ecs::schedule::ScheduleBuildSettings. docs.rs. Defines an ambiguity, in the documentation of the ambiguity_detection field, as the presence of "systems with conflicting access but indeterminate order"; the field defaults to logging nothing. Supports §11. docs.rs.

See also