Father-Daughter Challenge: the Rust-Bevy Study - Part 1
Father-Daughter Challenge: the Rust-Bevy Study - Part 1
Series Introduction
I have recently given myself the challenge to faithfully reproduce every single game my 10-year old daughter creates in school. I am genuinely impressed at how easy game creation has become and children can be exposed to the basic of programming in a fun-filled way. That said, I still try to teach her the basics programming logic.
This is a five-part tutorial series for programmers who are new to Bevy and want to understand the ideas the engine is built on, not just the API surface. The project that runs through all five parts is a two-player Road Runner game: you control a car on a scrolling road, avoiding cats that dart across your path. One player uses two lanes; two players share four lanes with independent speed controls. The game is small enough to hold in your head but large enough to generate real architectural questions as it grows.
Part 1 introduces the three primitives of the Entity-Component-System pattern and the Bevy constructs that implement them. Part 2 explains the Plugin system and the Resource type, and begins refactoring the project to exploit them. Part 3 introduces Bundles and shows how plugins and bundles together produce a module structure that maps cleanly to the architecture diagram on your whiteboard. Part 4 covers Bevy’s UI layer, which is itself an ECS sub-system, and assembles the complete game from the plugins built across the previous parts. Part 5 shows how the game could have been built incrementally using test-driven development, with the Bevy App as the test harness.
All code in this series targets Bevy 0.10. If you have never written Rust before, you will find the code readable with some effort; wherever the series introduces a Rust-specific feature such as a derive macro or a trait implementation, it explains the concept as it appears. You do not need to understand the Rust borrow checker in depth to follow Parts 1 and 2. Part 3 explains the ownership concepts that arise naturally in the spawning systems.
Introduction
Every game engine has an opinion about how to model the objects in your game world. The traditional object-oriented opinion is inheritance: a Vehicle class, a Car subclass, a PlayerCar sub-subclass that adds an input handler. That chain works for small projects and then breaks. Add a second player type that shares the movement physics but not the input. Add an AI car that shares the input abstraction but not the physics. The inheritance tree branches and the branches tangle, and before long the code you want to reuse is locked inside a base class you cannot safely modify [5].
The Entity-Component-System pattern, known as ECS, takes a different position. It deconstructs the game object entirely, breaking it into three separate concepts: an identity with no data and no behaviour, data with no identity and no behaviour, and behaviour with no data and no identity. The engine, in this case Bevy, wires these three concepts together at runtime using a data structure called the World. Understanding why ECS separates these concerns, and how Bevy’s World reunites them at query time, is the subject of Part 1 [1].
Bevy was chosen for this series because it commits to ECS more thoroughly than most engines. The renderer, the asset server, the audio system, and the UI layout engine are all implemented as plugins that register components and systems into the same World your game code uses. There is no hidden global state and no special object hierarchy that the engine uses internally but withholds from you [2]. What you learn about ECS in these five parts applies everywhere in the engine, including the parts you did not write.
Entities: Identity Without Data or Behaviour
In Bevy, an Entity is an integer. Specifically, it is a 64-bit value that encodes an index into the World’s storage arrays plus a generation counter that prevents stale references [2]. That is all an Entity is. It carries no data, no methods, no components of its own. It is a key, and the World’s component storage is the table that key indexes into.
In the Road Runner game, the player’s car is an Entity. Each cat that crosses the road is a separate Entity. Each tile of the scrolling road surface is an Entity. The score counter displayed on screen is an Entity. Nothing about those objects is encoded in the Entity value itself; the index just uniquely identifies which row in the World’s table belongs to that game object. You obtain a reference to an Entity when you spawn it, and Bevy’s query system lets you look up which components are attached to any given Entity by that reference.
Spawning a bare Entity in Bevy takes one line inside any system that receives the Commands parameter:
/* Rust -- spawning a bare entity and reading its ID */
fn spawn_bare_entity(mut commands: Commands) {
let entity = commands.spawn_empty().id();
println!("Spawned entity with id: {:?}", entity);
}
The spawn_empty call allocates a new Entity ID in the deferred command buffer. On the next frame boundary, Bevy flushes that buffer and the Entity appears in the World. You will almost never spawn a bare Entity in practice; you will spawn an Entity with a bundle of components attached, which Part 3 covers in detail.
Components: Data Without Identity or Behaviour
A Component in Bevy is any Rust struct or enum that derives the Component trait. In Rust, derive is a compile-time annotation that tells the compiler to generate an implementation of a named trait for your type automatically [3]. The Component derive generates the plumbing Bevy needs to store instances of your type in the World’s component arrays and to include your type in query results.
Here is the component that represents the player’s car in the Road Runner game:
/* Rust -- PlayerCar component carrying per-entity game data */
use bevy::prelude::*;
#[derive(Component)]
pub struct PlayerCar {
pub id: u8, // 1 or 2
pub lane: i32, // current lane index, zero-based
pub lane_count: i32, // 2 for one-player, 4 for two-player
}
Notice that PlayerCar contains only data fields. There are no methods for moving the car, no methods for handling input, and no reference to any other game object. A Component in ECS is a pure data record. The behaviour that reads and writes those fields lives in Systems, which the next section introduces. This separation is the central discipline of ECS: you maintain it by keeping your component structs free of logic.
A single Entity can have many Components attached to it simultaneously. The Road Runner player entity will eventually carry PlayerCar, a SpriteBundle (which is itself a group of components, as Part 3 explains), and a Transform that tracks its position in 2D space. The World stores each component type in separate contiguous arrays. That layout is what makes ECS queries fast: reading all PlayerCar components is a single pass over a tight array with no pointer chasing, because the values were never stored inside heterogeneous objects [4].
Systems: Behaviour Without Data
A System in Bevy is a Rust function. What makes it a system rather than a regular function is its parameter list: Bevy’s scheduler inspects those parameters at startup, determines which parts of the World each system reads or writes, and uses that information to decide which systems can run in parallel [1]. You register systems onto the App builder, and Bevy calls them on each frame or according to a schedule you specify.
The following system reads the PlayerCar component from every entity that has one and prints the player’s current lane to the console:
/* Rust -- a system that queries PlayerCar components and logs lane positions */
fn print_player_info(query: Query<&PlayerCar>) {
for car in &query {
println!("Player {} is in lane {}", car.id, car.lane);
}
}
The parameter Query<&PlayerCar> tells Bevy: “when this system runs, give me read-only access to every PlayerCar component in the World.” Bevy satisfies that request by providing an iterator over the contiguous storage array. The system function has no idea which Entity IDs it is iterating over; it sees only the component data. If you need the Entity ID, the Transform, or the ability to mutate the component, you change the query type, which the next section covers.
A system is registered onto the App in main:
/* Rust -- registering a system to run every frame while in the Playing state */
app.add_system(print_player_info.in_set(OnUpdate(GameState::Playing)));
The .in_set(OnUpdate(GameState::Playing)) call scopes this system to run only when the game is in the Playing state. You will use this scheduling mechanism throughout the series; the States section below explains how it works.
Queries: Asking the World for Data
The Query parameter type is the primary way systems access component data. Its type parameter is a tuple of component references, and Bevy resolves that tuple against the World’s storage at call time [2]. The following four signatures illustrate the range of what a query can express:
/* Rust -- four query signatures showing read, write, paired, and filtered variants */
// Read-only access to one component type
fn read_cars(q: Query<&PlayerCar>) { }
// Mutable access to Transform paired with immutable PlayerCar
fn move_cars(q: Query<(&mut Transform, &PlayerCar)>) { }
// Entity ID plus a component, useful when you need to despawn
fn find_cats(q: Query<(Entity, &Cat)>) { }
// Filter: only entities that also have RoadTile but without reading its data
fn road_tiles_only(q: Query<&Transform, With<RoadTile>>) { }
The With<RoadTile> filter in the last example means “only entities that have a RoadTile component attached, but I do not need to read its value.” The corresponding filter Without<T> excludes entities that carry a given component. These filters are how you distinguish the player car entity from the traffic car entities when both carry a Transform but only one carries a PlayerCar.
Two systems can run in parallel if their queries do not conflict. Bevy’s scheduler classifies a query as a shared reader if it contains only &T references and as an exclusive writer if it contains any &mut T. Two shared readers for the same component type can run simultaneously; a writer and any other accessor for the same type cannot. You do not need to manage this locking yourself. The scheduler derives the conflict graph from the query signatures at startup and makes parallel execution decisions automatically [1].
The World: Where Everything Lives
The World is Bevy’s central data structure. It stores every Entity ID, every Component array, every Resource, and the schedule of Systems. When you call app.run(), Bevy creates a World, populates it with everything you registered on the App builder, and begins calling systems on each frame. From that point forward, the World owns the game.
You rarely interact with the World directly in application code. Systems receive the World implicitly through their parameter list: a Query is a view into the World’s component storage, a Res<T> is a reference to a World-level resource, and Commands is a deferred mutation buffer that applies changes to the World between system runs [2]. The only time you access the World directly is when you need to perform structural operations that cannot be expressed through normal system parameters, such as saving a complete game state snapshot. For the Road Runner game, you will never need direct World access.
Bevy stores components using an archetype model. An archetype is a unique combination of component types; every entity that carries exactly the same set of component types belongs to the same archetype, and those components are stored in contiguous arrays within that archetype [4]. When you spawn a PlayerCar entity, Bevy looks up the archetype for that component set and appends the new values to its arrays. This is why adding or removing a component at runtime is more expensive than mutating a component value: the entity must move between archetype arrays.
The App Builder: Composing the World
The App struct is the entry point to Bevy. Before calling run(), you use it as a builder to declare everything the engine needs to know: which plugins to load, which resources to initialise, which systems to schedule, and which state machines to create. Think of it as the composition root [1], the single place in the program where all the moving parts are wired together. In a well-structured Bevy project, main.rs contains almost nothing except the App builder chain and the main function.
An alternative mental model, useful if you come from a server or web background, is the dependency injection container. You register components, resources, and systems the way you would register services in a DI framework: you declare what exists and what depends on what, and the engine resolves the order. Bevy’s scheduler reads the query signatures of every registered system and builds a dependency graph that determines which systems can run in parallel and which must be sequenced. You express constraints declaratively through the scheduling API, not imperatively through locks or callbacks.
Here is the Part 1 App builder for Road Runner, registering the minimum needed to demonstrate ECS queries:
/* Rust -- Part 1 main.rs: App builder with DefaultPlugins, game states, and one query system */
mod components;
mod config;
use bevy::prelude::*;
use components::PlayerCar;
#[derive(States, Debug, Clone, Eq, PartialEq, Hash, Default)]
pub enum GameState {
#[default]
Menu,
Playing,
GameOver,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Road Runner".into(),
resolution: (480.0, 640.0).into(),
..default()
}),
..default()
}))
.add_state::<GameState>()
.add_startup_system(setup_camera)
.add_system(print_player_info.in_set(OnUpdate(GameState::Playing)))
.run();
}
fn setup_camera(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
}
fn print_player_info(query: Query<&PlayerCar>) {
for car in &query {
println!("Player {} is in lane {}", car.id, car.lane);
}
}
The DefaultPlugins line is significant and the next section explains what is inside it. Each .add_system(...) call appends a system to the schedule. The .run() call creates the World, starts the event loop, and begins calling systems on each frame.
States and Schedules: Controlling When Systems Run
A game is not a single uniform loop. A menu screen, a playing screen, and a game-over screen each need different systems running at different times. Bevy models this with States: a type-safe enum that the engine uses to filter which systems execute on a given frame [1].
The GameState enum in the Road Runner project has three variants:
/* Rust -- GameState enum defining the three phases of the Road Runner game */
#[derive(States, Debug, Clone, Eq, PartialEq, Hash, Default)]
pub enum GameState {
#[default]
Menu,
Playing,
GameOver,
}
The #[derive(States)] annotation is what makes this enum a Bevy state type, and #[default] on Menu tells Bevy which variant to start in. Once you register this enum with app.add_state::<GameState>(), you can scope any system to a specific phase using three schedule sets. OnEnter(GameState::Playing) runs systems once when the state transitions to Playing, making it the right place for spawn systems that populate the game world. OnUpdate(GameState::Playing) runs systems every frame while in Playing, which is where per-frame logic such as input handling and collision detection belong. OnExit(GameState::Playing) runs systems once when leaving Playing, providing a cleanup hook for despawning entities or resetting resources.
You transition between states by injecting ResMut<NextState<GameState>> as a system parameter and calling next_state.set(GameState::GameOver). Bevy flushes the transition at the end of the frame, triggering the OnExit systems for the current state and the OnEnter systems for the new one.
Resources: World-Level Singletons (Preview)
Not all game data belongs to a specific entity. The current game mode (one player or two), the scores for each player, and the road scroll speeds are shared across many systems but are not attached to any particular entity. Bevy models this kind of data as a Resource: a type that derives Resource and of which there is exactly one instance in the World at any time [2].
/* Rust -- three Resource types carrying game-wide state in Road Runner */
#[derive(Resource, Default)]
pub struct GameMode {
pub players: u8,
}
#[derive(Resource, Default)]
pub struct Scores {
pub p1: u32,
pub p2: u32,
}
#[derive(Resource, Default)]
pub struct ScrollSpeeds {
pub p1: f32,
pub p2: f32,
}
Systems access resources through Res<T> for read-only access and ResMut<T> for mutable access, and Bevy enforces the same exclusivity rules that apply to component queries. Part 2 covers the full Resource philosophy, including how resources are registered inside plugins and when to prefer a resource over a component.
Part 1 Project Code
The Part 1 project contains four files. Create a fresh workspace with cargo new road_runner and replace the generated files with the following.
Cargo.toml:
# Road Runner Part 1 dependencies
[package]
name = "road_runner"
version = "0.1.0"
edition = "2021"
[dependencies]
bevy = "0.10"
rand = "0.8"
src/config.rs:
/* Rust -- all tunable game constants in one place */
pub const LANE_WIDTH: f32 = 80.0;
pub const ROAD_LEFT: f32 = -160.0;
pub const SCROLL_SPEED_MIN: f32 = 0.0;
pub const SCROLL_SPEED_MAX: f32 = 400.0;
pub const SCROLL_ACCEL: f32 = 150.0;
pub const SCROLL_DECEL: f32 = 200.0;
pub const CAT_SPEED: f32 = 120.0;
pub const TRAFFIC_BASE_SPEED: f32 = 180.0;
pub const WINDOW_HEIGHT: f32 = 640.0;
pub const WINDOW_WIDTH: f32 = 480.0;
pub const TILE_HEIGHT: f32 = 64.0;
All magic numbers in the Road Runner codebase live in this one file. When you want to change the lane width or the maximum scroll speed, you come here and nowhere else.
src/components.rs:
/* Rust -- all ECS component types for the Road Runner game */
use bevy::prelude::*;
#[derive(Component)]
pub struct PlayerCar {
pub id: u8,
pub lane: i32,
pub lane_count: i32,
}
#[derive(Component)]
pub struct TrafficCar {
pub lane: i32,
pub speed: f32,
}
#[derive(Component)]
pub struct Cat {
pub direction: f32, // +1.0 left-to-right, -1.0 right-to-left
}
#[derive(Component)]
pub struct RoadTile;
#[derive(Component)]
pub struct Verge;
#[derive(Component)]
pub struct ScoreText(pub u8); // holds the player id whose score this text displays
Notice that RoadTile and Verge are zero-sized structs. A zero-sized component carries no data; it is a pure label used as a filter in queries. Query<&mut Transform, With<RoadTile>> iterates only the road tile transforms, not the player or cat transforms. This marker pattern is idiomatic Bevy and replaces the string tags or type flags you might use in other engines [6].
src/main.rs (Part 1):
/* Rust -- Part 1 main.rs: App skeleton demonstrating ECS registration and queries */
mod components;
mod config;
use bevy::prelude::*;
use components::PlayerCar;
#[derive(States, Debug, Clone, Eq, PartialEq, Hash, Default)]
pub enum GameState {
#[default]
Menu,
Playing,
GameOver,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Road Runner".into(),
resolution: (480.0, 640.0).into(),
..default()
}),
..default()
}))
.add_state::<GameState>()
.add_startup_system(setup_camera)
.add_system(print_player_info.in_set(OnUpdate(GameState::Playing)))
.run();
}
fn setup_camera(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
}
fn print_player_info(query: Query<&PlayerCar>) {
for car in &query {
println!("Player {} is in lane {}", car.id, car.lane);
}
}
Running cargo run at this stage opens a blank black window. The print_player_info system runs every frame in the Playing state but finds no entities to iterate, because no spawn system exists yet. Bevy never requires a query to return at least one result. This is the correct Part 1 baseline; Part 2 adds plugins that populate the World.
Coming Up in Part 2
Part 2 introduces the Plugin trait: the mechanism by which Bevy, and your own code, packages groups of systems and resources into named, reusable units. You will see what is inside DefaultPlugins, implement a RoadPlugin from scratch, and refactor the growing main.rs into a file that reads like a table of contents for the game rather than a list of system registrations. Part 2 also gives Resources their full treatment: the question of when to put data in a Resource versus a Component has a concrete, rule-based answer, and understanding that rule changes how you design every new feature.
References
[1] Bevy Foundation. The Bevy Book. Available at bevyengine.org/learn/quick-start/introduction. Cited for the ECS model, World architecture, App builder pattern, Plugin trait, States and schedule sets, and composition root concept.
[2] Bevy Contributors. Bevy API Documentation 0.10.0. Available at docs.rs/bevy/0.10.0/bevy. Cited for the Entity integer representation, Query parameter types, Res and ResMut semantics, Commands deferred buffer, and the archetype storage model.
[3] Klabnik, S., and Nichols, C. The Rust Programming Language, 2nd ed. No Starch Press, 2023. Available at doc.rust-lang.org/book. Cited for the derive macro mechanism and trait implementation syntax as applied to the Component derive.
[4] skypjack. “ECS back and forth, Part 1.” Personal blog, 2019. Available at skypjack.github.io/2019-02-14-ecs-baf-part-1. Cited for the design rationale of separating identity, data, and behaviour in ECS, and the archetype storage model that underlies Bevy’s component arrays.
[5] Nystrom, R. Game Programming Patterns. Genever Benning, 2014. Available at gameprogrammingpatterns.com/component.html. Cited for the Component design pattern and the problems with deep inheritance hierarchies in game objects.
[6] Bevy Contributors. Bevy Examples Repository, v0.10.0. Available at github.com/bevyengine/bevy/tree/v0.10.0/examples. Cited for the marker component pattern as idiomatic Bevy practice.