Skip to content
rusthacks.dev
Systems

Game Development in Rust With Bevy and Its ECS Model

Bevy organizes a game as data and functions. Entities are IDs, components are plain structs and systems are ordinary functions that query for the components they need.

17 min read
Summarize in AI
Flat illustration for the post “Game Development in Rust With Bevy and Its ECS Model”, composed around database.

Bevy is a game engine, and the part of it you can use on its own is its entity component system: bevy_ecs, a library that stores your data in a way that makes queries fast and lets independent functions run in parallel without locks. Games are the obvious customer. Simulations, schedulers, editors and anything with thousands of things that each have a different mix of properties are the others. This guide teaches the model with programs that run headless, with no window and no renderer: entities, components, systems and queries; filters, resources and change detection; messages and observers; how the scheduler decides what can run in parallel (with a measurement); the cost of structural changes; and the errors you will meet. Everything compiled and ran on Rust 1.99 with bevy_ecs 0.20.1 (Bevy 0.20.1 is the current release as of this writing). Bevy's API moves between releases, so the version matters: pin it, and read the migration guide before upgrading.

The model in one paragraph

An entity is an id and nothing else. A component is a piece of plain data attached to an entity (a position, a health value, a marker). A system is an ordinary function whose arguments say what data it needs; the scheduler inspects the arguments to learn which components each system reads and writes. A query is how a system asks for "every entity that has a Position and a Velocity". Contrast this with object-oriented design, where a Ship class owns its position and a method moves it: in an ECS a ship is just whichever entity happens to have the components that movement, rendering and AI systems look for. Behaviour is added by adding a component, not by subclassing. The storage turns "all positions" into a contiguous column, which is what makes iteration over a million entities fast and what lets the scheduler prove that two systems touch different data.

Set up

Cargo.tomlTOML
[package]
name = "ecs-lab"
version = "0.1.0"
edition = "2024"

[dependencies]
bevy_ecs = "0.20"

That is the whole dependency. Full Bevy adds windowing, rendering, assets and input as plugins; they all sit on top of this crate. Starting with bevy_ecs alone keeps compile times short (a few seconds for an incremental build) and keeps examples testable in plain cargo test, with no GPU.

Your first world

src/main.rsrust
use bevy_ecs::prelude::*;

#[derive(Component, Debug)]
struct Position { x: f32, y: f32 }

#[derive(Component, Debug)]
struct Velocity { x: f32, y: f32 }

#[derive(Component)]
struct Name(&'static str);

fn movement(mut query: Query<(&mut Position, &Velocity)>) {
    for (mut pos, vel) in &mut query {
        pos.x += vel.x;
        pos.y += vel.y;
    }
}

fn report(query: Query<(&Name, &Position)>) {
    for (name, pos) in &query {
        println!("{} at ({:.1}, {:.1})", name.0, pos.x, pos.y);
    }
}

fn main() {
    let mut world = World::new();
    world.spawn((Name("ship"), Position { x: 0.0, y: 0.0 }, Velocity { x: 1.0, y: 0.5 }));
    world.spawn((Name("rock"), Position { x: 5.0, y: 5.0 }));

    let mut schedule = Schedule::default();
    schedule.add_systems((movement, report).chain());

    for tick in 1..=2 {
        println!("-- tick {tick}");
        schedule.run(&mut world);
    }
}
Text
-- tick 1
ship at (1.0, 0.5)
rock at (5.0, 5.0)
-- tick 2
ship at (2.0, 1.0)
rock at (5.0, 5.0)

Everything in an ECS program fits this shape. A World owns all the data. world.spawn(( ... )) creates an entity from a tuple of components, called a bundle: the ship has three components, the rock only two, and no class declares that. The movement system takes Query<(&mut Position, &Velocity)>, so it visits every entity that has both, and the rock, which has no Velocity, is skipped. The report system asks for a name and a position and sees both entities. A Schedule runs the systems; .chain() forces them to run in order, so that the report always sees the moved positions. Without it the scheduler may run them in either order or in parallel, because nothing says one depends on the other.

One practical warning from running this code: the order in which a query returns entities is not specified. When the same program was built against bevy_ecs 0.18 the rock was reported before the ship, and against 0.20 the ship came first. If your output depends on the order, sort explicitly or carry an index component.

Filters, resources, commands and change detection

Real programs need more than "every entity with these components". Filters narrow a query without borrowing the data: With<T> and Without<T> test for presence, Changed<T> matches components modified since the system last ran. Resources are singletons stored in the world, such as a score or a clock. Commands queue structural changes (spawn, despawn, insert, remove) to be applied safely after the system finishes, since changing which entities exist while iterating would invalidate the iterators. This program wires them together: poison drains health, a reaper despawns dead enemies and adds to the score, and a reporter prints the state.

src/main.rsrust
use bevy_ecs::prelude::*;

#[derive(Component, Debug)]
struct Health(i32);

#[derive(Component)]
struct Enemy;

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Poisoned;

#[derive(Resource, Default)]
struct Score(u32);

#[derive(Resource)]
struct Tick(u32);

fn setup(mut commands: Commands) {
    commands.spawn((Player, Health(100)));
    commands.spawn((Enemy, Health(30)));
    commands.spawn((Enemy, Health(50), Poisoned));
}

fn poison(mut query: Query<&mut Health, With<Poisoned>>) {
    for mut hp in &mut query {
        hp.0 -= 20;
    }
}

fn reap(mut commands: Commands, mut score: ResMut<Score>, query: Query<(Entity, &Health), (With<Enemy>, Changed<Health>)>) {
    for (entity, hp) in &query {
        if hp.0 <= 0 {
            score.0 += 10;
            commands.entity(entity).despawn();
        }
    }
}

fn describe(tick: Res<Tick>, score: Res<Score>, players: Query<&Health, With<Player>>, enemies: Query<&Health, (With<Enemy>, Without<Poisoned>)>, poisoned: Query<&Health, With<Poisoned>>) {
    println!(
        "tick {}: score {}, player {:?}, healthy enemies {}, poisoned {:?}",
        tick.0,
        score.0,
        players.iter().map(|h| h.0).collect::<Vec<_>>(),
        enemies.iter().count(),
        poisoned.iter().map(|h| h.0).collect::<Vec<_>>()
    );
}

fn advance(mut tick: ResMut<Tick>) {
    tick.0 += 1;
}

fn main() {
    let mut world = World::new();
    world.insert_resource(Score::default());
    world.insert_resource(Tick(0));

    let mut startup = Schedule::default();
    startup.add_systems(setup);
    startup.run(&mut world);

    let mut frame = Schedule::default();
    frame.add_systems((advance, poison, reap, describe).chain());
    for _ in 0..4 {
        frame.run(&mut world);
    }
}
Text
tick 1: score 0, player [100], healthy enemies 1, poisoned [30]
tick 2: score 0, player [100], healthy enemies 1, poisoned [10]
tick 3: score 10, player [100], healthy enemies 1, poisoned []
tick 4: score 10, player [100], healthy enemies 1, poisoned []

Follow the poisoned enemy, which started with 50 health and lost 20 per tick: 30 after tick 1, 10 after tick 2, and at tick 3 it reached -10, the reaper saw the change, despawned it and added 10 points. The reaper's query uses Changed<Health>, so it only examines enemies whose health was written this frame, which matters when thousands of enemies exist and few are being hurt. The "healthy enemies" count stayed at one throughout: the first enemy has no Poisoned marker and is never touched. The marker pattern is the ECS way to express state: instead of a boolean field checked in every system, poisoning is a component, and systems select by presence. Adding or removing it with commands.entity(e).insert(Poisoned) changes which systems apply to that entity from the next frame.

Messages and observers

Systems often need to tell each other that something happened. Bevy has two mechanisms, and choosing between them is the main design decision after components. Messages are buffered: a system writes them, and later systems in the same or the next frame read them in order. They suit high-volume, batch-processed signals such as damage events. Observers are triggered immediately: an event is fired and the observer function runs right then, with access to the entity it targets. They suit rare, reactive things such as "this entity died, play the animation, drop the loot". The program uses both: the damage message carries an amount to a target, and when health reaches zero the system triggers a Died event on the entity, handled by an observer that despawns it.

src/main.rsrust
use bevy_ecs::prelude::*;

#[derive(Component, Debug)]
struct Health(i32);

#[derive(Component)]
struct Name(&'static str);

#[derive(Message)]
struct Damage {
    target: Entity,
    amount: i32,
}

#[derive(EntityEvent)]
struct Died {
    entity: Entity,
}

fn attack(mut writer: MessageWriter<Damage>, query: Query<(Entity, &Name)>) {
    for (entity, name) in &query {
        if name.0 == "goblin" {
            writer.write(Damage { target: entity, amount: 40 });
        }
    }
}

fn apply_damage(mut reader: MessageReader<Damage>, mut query: Query<&mut Health>, mut commands: Commands) {
    for dmg in reader.read() {
        if let Ok(mut hp) = query.get_mut(dmg.target) {
            hp.0 -= dmg.amount;
            if hp.0 <= 0 {
                commands.trigger(Died { entity: dmg.target });
            }
        }
    }
}

fn main() {
    let mut world = World::new();
    world.init_resource::<Messages<Damage>>();

    world.add_observer(|died: On<Died>, names: Query<&Name>, mut commands: Commands| {
        println!("observer: {} died", names.get(died.entity).map(|n| n.0).unwrap_or("?"));
        commands.entity(died.entity).despawn();
    });

    world.spawn((Name("goblin"), Health(70)));
    world.spawn((Name("knight"), Health(200)));

    let mut schedule = Schedule::default();
    schedule.add_systems((attack, apply_damage).chain());

    for round in 1..=3 {
        schedule.run(&mut world);
        world.resource_mut::<Messages<Damage>>().update();
        let alive = world.query::<&Name>().iter(&world).map(|n| n.0).collect::<Vec<_>>();
        println!("round {round}: alive = {alive:?}");
    }
}
Text
round 1: alive = ["goblin", "knight"]
observer: goblin died
round 2: alive = ["knight"]
round 3: alive = ["knight"]

Round one: the goblin took 40 of its 70 health, and both survive. Round two: it took 40 more, health reached -10, the system triggered Died, and the observer printed and despawned it before the round ended. Round three: only the knight is left. The line world.resource_mut::<Messages<Damage>>().update() advances the message buffers, which full Bevy does for you every frame; a standalone world has to do it itself or messages pile up. Note that in earlier releases buffered events were called Event and read with EventReader; if you copy older code, expect to rename those to Message, MessageReader and MessageWriter, and to use On<E> for observers.

How the scheduler finds parallelism

Each system declares its data access through its arguments, so the scheduler knows before running anything which components each one reads and writes. Two systems can run at the same time if neither writes something the other touches. You can see it with a deliberate experiment: five arrangements of systems that each sleep for 100 ms. If the scheduler overlaps them, the total is about 100 ms; if it serialises them, about 200 ms.

src/main.rsrust
fn run(label: &str, build: impl FnOnce(&mut Schedule)) {
    let mut world = World::new();
    world.spawn((A(0), B(0)));
    let mut schedule = Schedule::default();
    schedule.set_executor(bevy_ecs::schedule::MultiThreadedExecutor::new());
    build(&mut schedule);
    let start = Instant::now();
    schedule.run(&mut world);
    println!("{label:<44} {:>4} ms", start.elapsed().as_millis());
}

fn main() {
    bevy_tasks::ComputeTaskPool::get_or_init(bevy_tasks::TaskPool::default);
    run("slow_a + slow_b (different components)", |s| {
        s.add_systems((slow_a, slow_b));
    });
    run("slow_a + slow_a_again (both write A)", |s| {
        s.add_systems((slow_a, slow_a_again));
    });
    run("read_a + read_a_too (both only read A)", |s| {
        s.add_systems((read_a, read_a_too));
    });
    run("slow_a + read_a (one writes, one reads A)", |s| {
        s.add_systems((slow_a, read_a));
    });
    run("slow_a + slow_b chained", |s| {
        s.add_systems((slow_a, slow_b).chain());
    });
}
Text
slow_a + slow_b (different components)        110 ms
slow_a + slow_a_again (both write A)          216 ms
read_a + read_a_too (both only read A)        100 ms
slow_a + read_a (one writes, one reads A)     200 ms
slow_a + slow_b chained                       213 ms

The results follow the rules exactly. Two systems that write different components ran together (109 ms). Two that both write A were serialised (211 ms), as were one that writes and one that reads the same component (216 ms), since a reader must not see a half-written value. Two read-only systems overlapped (109 ms). And .chain() serialises by request (209 ms). One setup detail: a standalone bevy_ecs schedule used the single-threaded executor until it was given MultiThreadedExecutor and the multi_threaded feature was enabled with a task pool created; full Bevy does all of that for you. You never write a lock, and the guarantee comes from the borrow rules applied at the system-parameter level, which is the closest thing to "Rust's ownership model, but across functions" in an engine.

The system parameters you will use every day

A system's arguments are its whole interface, so it pays to know the common ones. Each states what the system needs and, by doing so, what it conflicts with.

ParameterGives the systemNotes
Query<D, F>Every entity matching data D and filter FThe workhorse; &T reads, &mut T writes
Single<D, F>Exactly one matching entityThe system is skipped if there are zero or more than one
Res<T>, ResMut<T>A resource, read or writeWriters to the same resource cannot overlap
CommandsA queue of structural changesApplied after the system ends, before later systems that depend on it
Local<T>State private to this systemPersists between runs; no sharing and no conflicts
MessageReader<T>, MessageWriter<T>Buffered messagesEach reader tracks its own position
ParamSet<(A, B)>Several conflicting parameters, used one at a timeThe escape hatch for error B0001

This program uses four of them, plus two features of the scheduler that real projects need immediately: system sets, which name a phase so ordering can be stated once for many systems, and run conditions, which skip a system based on a function. The three phases run in order (input, simulate, report) because configure_sets chains them; earn only runs while a not_paused condition holds; Local counts how many times earn has run without a resource; and Single expresses "the player" without a loop.

src/main.rsrust
use bevy_ecs::prelude::*;

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Gold(u32);

#[derive(Resource, Default)]
struct Paused(bool);

#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
enum Phase {
    Input,
    Simulate,
    Report,
}

fn earn(mut gold: Single<&mut Gold, With<Player>>, mut calls: Local<u32>) {
    *calls += 1;
    gold.0 += 5 * *calls;
}

fn pause_on_three(mut paused: ResMut<Paused>, gold: Single<&Gold, With<Player>>) {
    if gold.0 >= 30 {
        paused.0 = true;
    }
}

fn report(gold: Single<&Gold, With<Player>>) {
    println!("gold = {}", gold.0);
}

fn not_paused(paused: Res<Paused>) -> bool {
    !paused.0
}

fn main() {
    let mut world = World::new();
    world.init_resource::<Paused>();
    world.spawn((Player, Gold(0)));

    let mut schedule = Schedule::default();
    schedule.configure_sets((Phase::Input, Phase::Simulate, Phase::Report).chain());
    schedule.add_systems(pause_on_three.in_set(Phase::Input));
    schedule.add_systems(earn.in_set(Phase::Simulate).run_if(not_paused));
    schedule.add_systems(report.in_set(Phase::Report));

    for frame in 1..=6 {
        print!("frame {frame}: ");
        schedule.run(&mut world);
    }
}
Text
frame 1: gold = 5
frame 2: gold = 15
frame 3: gold = 30
frame 4: gold = 30
frame 5: gold = 30
frame 6: gold = 30

Gold grew by 5, then 10, then 15 on the first three frames (the local call counter makes each award larger), reached 30 on frame 3, and then the input phase set the pause flag at the start of frame 4. The run condition then skipped earn from frame 4 on, while the report still ran every frame. Notice what the sets bought: pause_on_three is declared in a different add_systems call from earn, and the order between them is guaranteed by the phases, not by the order of the calls. In a real game this is how input, physics, AI and rendering are stacked into frames, and it scales to hundreds of systems because each system names its phase and nothing else about the others. A stricter question than "which order" is "which data": the scheduler orders only by what you state, and overlaps everything else, so a missing ordering between two systems that share data on purpose is a bug that appears as flicker, not as a compile error. State the dependency (before, after, in_set, chain) the moment one system relies on another's results.

Testing systems

A system is a function over a world, so a test builds the world it needs and runs the schedule once. This is the strongest argument for the architecture: no engine, no window, no fixtures beyond a few spawned entities. A test for the reaper might spawn an enemy with health 0, run the frame schedule, and assert that world.query::<&Enemy>().iter(&world).count() == 0 and that the score resource is 10. Because the scheduler is deterministic for systems that are chained, tests that need exact order chain them; tests of independent systems run each one alone, with world.run_system_once(system) (import bevy_ecs::system::RunSystemOnce) as a shorthand when you do not want to build a schedule; it returns a Result, and running heal on a world with one entity of health 5 left it at 15. Keep time out of the systems by passing a Time-like resource that tests control, the same injection idea as in dependency injection.

Performance: what an ECS buys and what it does not

ECS is often sold as fast, and the honest answer depends on what you compare against. This benchmark updates one million entities' positions from their velocities, in a release build on an Apple M1 Max, best of seven runs, against two hand-written alternatives, then measures adding and removing a component on 100,000 entities:

Text
1M entities, one movement update:
  bevy_ecs query                1.07 ms (1.07 ns/entity)
  bevy_ecs, no change tracking   1.10 ms (1.10 ns/entity)
  Vec of 64-byte structs        1.13 ms (1.13 ns/entity)
  two Vecs (struct of arrays)   0.25 ms (0.25 ns/entity)
structural change on 100k entities: insert 6.1 ms (61 ns each), remove 4.9 ms (49 ns each)

Read the numbers carefully. The query matched a plain Vec of 64-byte structs (1.07 ms, about one nanosecond per entity), which is the layout a straightforward game loop would use, and was about four times slower than a hand-written struct-of-arrays loop over two tight vectors (0.25 ms). Turning off change detection for the writes made no difference, so that bookkeeping was not the gap; I did not profile further, and the likely causes are the generality of a query (it must work for any component set and handles access checks and ticks) and a loop the compiler cannot vectorise. The lesson is not that ECS is slow: 1 ns per entity is about 900 updates per second with a million moving things on one core. The lesson is that ECS is a flexibility tool that is fast enough, not a replacement for a hand-tuned data layout in the one loop that dominates your frame. If a single hot loop is the whole program, write the arrays. If you have dozens of systems over many overlapping groups of entities, the ECS gives you that layout for all of them without writing it.

The second result is the one that bites in practice. Adding a component to an entity moves it to a different archetype (a table holding all entities with exactly that set of components), which costs about 60 ns for an insert and 45 ns for a remove here: a copy of the entity's row. That is cheap for an occasional change and expensive if every entity toggles a flag every frame. For flags that flip often, Bevy offers sparse-set storage, declared on the component: #[component(storage = "SparseSet")]. Adds and removes become cheaper, and iteration over entities with that component becomes slower, so use it for transient markers like Stunned and keep ordinary data in tables.

The error you will meet first

A system that asks for conflicting access to the same data cannot run, and Bevy refuses at the moment it builds the schedule. Here a system holds a mutable query over all Health and a shared query over the players' health at once:

src/main.rsrust
use bevy_ecs::prelude::*;

#[derive(Component)]
struct Health(i32);

#[derive(Component)]
struct Player;

fn heal_others(mut all: Query<&mut Health>, players: Query<&Health, With<Player>>) {
    let _ = players.iter().count();
    for mut h in &mut all {
        h.0 += 1;
    }
}

fn main() {
    let mut world = World::new();
    world.spawn((Player, Health(10)));
    let mut schedule = Schedule::default();
    schedule.add_systems(heal_others);
    schedule.run(&mut world);
    println!("ran");
}
Text
thread 'main' panicked at bevy_ecs-0.20.1/src/query/state.rs:216:13:
error[B0001]: <Enable the debug feature to see the name> in system <Enable the debug feature to see the name> accesses component(s) <Enable the debug feature to see the name> in a way that conflicts with a previous system parameter. Consider using `Without<T>` to create disjoint Queries or merging conflicting Queries into a `ParamSet`. See: https://bevy.org/learn/errors/b0001
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

This is error B0001. The message hides the names unless you enable the crate's debug feature; do that in development, because "accesses component(s) Health in a way that conflicts" is the whole diagnosis. Rust's borrow checker would reject the equivalent code; in an ECS the check happens when the system parameters are validated. There are two fixes. If the queries should select disjoint entities, say so with filters: Query<&mut Health, Without<Player>> and Query<&Health, With<Player>>. If you really need both views of the same data, wrap the queries in a ParamSet and use them one at a time.

Using it outside a game

The same structure suits any program with many entities and cross-cutting behaviours: a discrete-event simulation (agents with different traits), a build system or scheduler (tasks with dependencies and states as components), an editor (document nodes with optional properties), a network simulator. The tests are easy to write: build a world, spawn entities, run a schedule once, assert on queries. That is one of the strongest reasons to use bevy_ecs directly. Alternatives exist with different trade-offs: hecs is smaller and has no scheduler, specs and shipyard take different approaches to scheduling. The related theory, laying data out for the cache, is in data-oriented design, and the system-and-query machinery in full Bevy is in systems, queries and resources.

When not to use an ECS

Skip it when the program has a handful of distinct objects with fixed behaviour; a few structs and traits are simpler and faster to write. Skip it when the dominant cost is one numeric loop, as the benchmark shows. And be wary of it when your data is a graph or tree with strong parent-child ownership and few entities per type: an ECS can model hierarchy (Bevy has parent and child components), but it is no longer the natural fit. Use it where composition and many entities are the problem, and measure before assuming the layout makes the difference.

Taggedbevy
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.