Skip to content
Comfy Engine

ECS for 2D games: what it is, how it works, when to skip it

ECS for 2D games: what it is, how it works, when to skip it

An entity component system, or ECS, is the most argued-about pattern in hobby game development. Some people treat it as the only sane way to structure a game. Others see it as ceremony that turns a 300-line jam game into a database. For 2D games the honest answer sits in between: an ECS solves specific problems very well, and if you do not have those problems, a plain Vec of structs is easier to write, read and debug.

This piece explains what an ECS actually is, how the two main storage designs (archetypes and sparse sets) differ in plain terms, what the hecs and bevy_ecs style of code feels like, and how to tell which side of the line your game is on.

The three words

Entities are identifiers. Nothing more. An entity is usually a pair of integers: an index and a generation counter, so that a stale reference to a destroyed entity can be detected.

Components are plain data attached to entities. Position, Velocity, Sprite, Health, PlayerControlled. A component has no behaviour of its own.

Systems are functions that run over every entity holding a certain set of components. "For every entity with Position and Velocity, add velocity to position." The system does not care whether the entity is a bullet, an enemy or a falling leaf.

That last part is the whole appeal. Behaviour is defined by which components an entity has, not by which type it is. Add Velocity to a crate and it moves. Remove Health from a boss during a cutscene and nothing can damage it. You compose objects at runtime instead of designing a type hierarchy up front.

What it looks like in code

Here is the shape of ECS code in the hecs and bevy_ecs style. This is a sketch of the idea, not the exact API of either crate, which you should check in their own documentation.

world = World::new()
player = world.spawn((Position(0, 0), Velocity(0, 0), Sprite(PLAYER), Health(3)))
world.spawn((Position(50, 10), Velocity(-20, 0), Sprite(BAT), Health(1)))
world.spawn((Position(30, 0), Sprite(ROCK)))   // no Velocity, never moves

system movement(query: (&mut Position, &Velocity), dt):
    for (pos, vel) in query:
        pos += vel * dt

system damage(query: (Entity, &Health)):
    for (e, hp) in query:
        if hp <= 0: commands.despawn(e)

Spawning takes a bundle of components. Queries ask for a tuple of component types, with &mut for those the system writes. In hecs you call queries on the world directly from your own functions. In bevy_ecs systems are ordinary functions whose parameters describe what they need, and a scheduler works out when to call them and which can run in parallel.

The rock never moves because it has no Velocity, so the movement query never sees it. Nobody had to write if self.can_move.

How storage works, in plain words

The interesting engineering in an ECS is where component data lives in memory. Queries need to find "all entities with A and B" quickly and walk through their data with good cache behaviour. There are two main designs.

Archetypes

An archetype is a unique combination of component types. Every entity with exactly Position, Velocity, Sprite and Health lives in the same archetype. Inside it, each component type is stored in its own tightly packed column, like a table:

Archetype [Position, Velocity, Sprite, Health]
  row:      0        1        2
  Position  (0,0)    (50,10)  (80,40)
  Velocity  (0,0)    (-20,0)  (5,5)
  Sprite    PLAYER   BAT      BAT
  Health    3        1        1

Archetype [Position, Sprite]
  Position  (30,0)   (60,0)
  Sprite    ROCK     ROCK

A query for Position and Velocity finds every archetype that contains both columns and walks straight down them. Memory access is sequential, which is what CPUs like best. hecs and bevy_ecs both use archetype storage as their main approach (bevy_ecs also offers sparse set storage for components you mark that way).

The cost is in changing an entity's components. Adding Velocity to a rock moves its whole row from one archetype to another, copying every component. If you add and remove components every frame, for example a Stunned marker that flickers on and off, that copying adds up.

Sparse sets

A sparse set stores each component type on its own, in a packed array, plus an index from entity id to position in that array. Adding or removing a component touches only that one array, so it is cheap.

The cost moves to queries. To find entities with both Position and Velocity, the ECS walks the smaller of the two arrays and looks each entity up in the other. That lookup is a jump in memory, so iteration over several components together is slower than walking archetype columns.

Put simply: archetypes make reading fast and changing shape slow. Sparse sets make changing shape fast and multi-component reading a little slower. For most 2D games, both are far faster than they need to be.

A worked comparison

Consider a bullet-heavy 2D shooter: 2,000 bullets, 150 enemies, one player, and a few hundred particles.

Task Archetype ECS Sparse set ECS Vec of structs
Move 2,500 objects Walks packed columns Walks one array, looks up the other Walks three Vecs
Spawn 50 bullets a frame Append rows Append to each array push
Toggle Stunned on 20 enemies 20 row moves 20 small inserts or removes Flip a bool
Find what a bullet hit Query plus lookup Query plus lookup Direct index

Each of these is microseconds at this scale. A 60 Hz frame is 16.7 milliseconds. Moving 2,500 positions, at maybe a couple of nanoseconds each in a tight loop, costs around 5 microseconds whichever way you store them. The decision is about code shape and convenience, not speed, until you are into tens of thousands of entities.

When a plain Vec of structs is better

Here is the alternative, the boring layout:

struct Enemy { pos: [f32; 2], vel: [f32; 2], hp: i32, kind: EnemyKind, stunned: f32 }
struct Bullet { pos: [f32; 2], vel: [f32; 2], from_player: bool }

struct World {
    player: Player,
    enemies: Vec<Enemy>,
    bullets: Vec<Bullet>,
}

fn move_all(w: &mut World, dt: f32) {
    for e in w.enemies.iter_mut().filter(|e| e.stunned <= 0.0) {
        e.pos[0] += e.vel[0] * dt;
        e.pos[1] += e.vel[1] * dt;
    }
    for b in &mut w.bullets {
        b.pos[0] += b.vel[0] * dt;
        b.pos[1] += b.vel[1] * dt;
    }
}

There is some repetition in the two loops, and that repetition is the price. What you get back is a lot:

  • The debugger shows you everything. Print an Enemy and you see all of it. With an ECS you see an id and have to ask the world for each component.
  • Types say what things are. A function that takes &Enemy cannot be given a rock.
  • The borrow checker is easy. Separate Vecs borrow separately. Systems that need two components from the same entity in an ECS rely on the library's query machinery to keep borrows apart.
  • Saving is a derive. Put serde derives on the structs and the whole game state serialises.

I reach for the plain layout when a game has a small, fixed set of object kinds, say under ten, that do not share behaviour in shifting combinations. That describes most jam games, most puzzle games and a good number of arcade games. The walkthrough on how to make a game engine uses exactly this layout for that reason.

When an ECS earns its place

The signs that an ECS will pay off:

  1. Many kinds of objects share overlapping behaviour. Some things burn, some things float, some things are pushable, and designers keep inventing new combinations.
  2. Behaviour changes at runtime. Status effects, possession, picking things up and throwing them, objects that become platforms.
  3. Lots of tiny systems. If you find yourself with a dozen loops that each touch two fields across several object types, systems are a natural fit.
  4. You want parallel systems for free. bevy_ecs can run systems with non-overlapping data access on different threads, without you writing any threading code.
  5. You are using an engine built on it. In Bevy, the ECS is the engine. Fighting it is harder than learning it.

A good middle road for 2D is a small library like hecs used inside your own loop, with only the parts of the game that are genuinely compositional (pickups, hazards, props) living in the ECS, and the player and level kept as plain structs. Nothing forces an all-or-nothing choice.

Fitting an ECS into the loop

An ECS lives entirely in the game layer. The renderer should not query the world directly. Instead, a final "draw" system walks Position and Sprite and fills a draw list, exactly as a plain layout would. The six-layer picture in game engine architecture applies unchanged, and the order of systems maps neatly onto the fixed and variable updates in a fixed timestep game loop: movement, collision and damage in the fixed step, animation and draw list building once per frame.

One gotcha: destroying entities during a query. Most ECS libraries do not let you despawn while iterating, because it would change the storage under your feet. Collect the ids into a Vec, or use the library's deferred command buffer, and apply the changes after the system finishes. A plain layout has the same rule, which is why retain at the end of the frame is such a common pattern.

Questions people ask

Is an ECS faster than object-oriented code?

It can be, for many thousands of similar objects, because packed columns use the cache well. A Vec of plain structs is also cache friendly, so for small 2D games the difference is usually too small to measure.

Should I write my own ECS?

As a learning project, yes, it is a great weekend: entities with generations, one sparse set per component, a two-component query. For a game, use an existing crate.

Can I switch later?

Yes, if your renderer and audio only see draw and sound commands. Moving from structs to an ECS then touches only game code.

A good exercise: take one object type in your current game, say pickups, and move only that into a small ECS. Give coins Position, Sprite and Collectable, give a magnet power-up a Magnet component that pulls Collectable things towards the player, and see whether the result reads better or worse than the struct version. That one experiment tells you more than any argument about the pattern.

Filed in Engines & Architecture · Rust Gamedev

Written by Tendai Mukwena

Tendai writes about architecture: game loops, entity component systems, collision, pathfinding and procedural generation, usually with a profiler open. He works on simulation tools by day and builds roguelikes at night in Harare.

Keep reading