Introduction

This guide introduces developers to Bevy's ECS component, by porting the game written for Hands-on Rust: Effective Learning through 2D Game Development and Play to Bevy's ECS.

How to approach this book

The book, and the source project, are structured as a series of incremental steps, leading to a full game.

In the code repository, I've made minor cleanups and restructurings to the source project; the locations of the workspaces are:

The steps have been numbered (and have matching names in the source/port), in order to facilitate sequential examination and comparison; each step is an independent workspace.

This book presents multiple chapters for each step, explaining the concepts involved. When reading a chapter, one can compare the source step (workspace) with the ported one, and/or with the previous step in the respective project.

The starting chapter is 06.01, where an ECS is introduced in the design. Steps that don't introduce any new ECS concept, or don't significantly extend an introduced one, are skipped.

It's technically possible to read this book standalone, without the source one, but for newcomers to ECS, it's not advised, as in this context I'm not explaining the ECS concepts (only their Bevy API implementation).

References

The Bevy used version is 0.7.0; all the API links refer to the tag v0.7.0 in Bevy's repository.

Port style

Since comparison is a foundation of this book, I've maintained high and low-level structures as similar as I could; the most radical divergence is due to Bevy's state management. There are still a few differences, but they're minor.

Thanks

This project has been kindly sponsored by Ticketsolve; naturally, it wouldn't have been possible without the great work of Herbert Wolverson, and the Bevy community.

Introduction to step 06.01

In this chapter, the basic elements of the ECS system are introduced.

The directory name is 06_EntitiesComponentsAndSystems_01_playerecs.

Base structure

The very first step in the port is plugging Bevy's ECS. The source project has a mixed, game engine-wise, design:

  • the game loop is managed by the game engine (bracket-lib);
  • for each cycle (frame), an update is invoked on the ECS (Legion).

This is bare-bones structure of the source project:

// source: main.rs

struct State {
    ecs: World,
    resources: Resources,
    systems: Schedule,
}

impl State {
    fn new() -> Self {
        // [...]
        Self {
            ecs: World::default(),
            resources: Resources::default(),
            systems: build_scheduler(),
        }
    }
}

impl GameState for State {
    fn tick(&mut self, ctx: &mut BTerm) {
        // [...]
        self.systems.execute(&mut self.ecs, &mut self.resources);
        render_draw_buffer(ctx).expect("Render error");
    }
}

fn main() -> BError {
    // [...]
    main_loop(context, State::new())
}

The translation is straightforward:

  • World and Resources map to App (which also owns the resources)
  • Legion's systems update for each tick maps to the App#update() API.

leading to:

// port: main.rs

struct State {
    ecs: App,
}

impl State {
    fn new() -> Self {
        // [...]
        Self { ecs: App::new() }
    }
}

impl GameState for State {
    fn tick(&mut self, ctx: &mut BTerm) {
        // [...]
        self.ecs.update();
        render_draw_buffer(ctx).expect("Render error");
    }
}

World and systems

World

In Legion, we work directly on World and Resources:

// source: main.rs

let mut ecs = World::default();
let mut resources = Resources::default();

// [...]

self.systems.execute(&mut self.ecs, &mut self.resources);

In Bevy, the semantics are similar, although the idea is to use systems without interacting with the underlying data structures; this is because the engine manages them, and accessing them directly limits the (parallel) scheduling.

We still can manipulate World, just as long as we're aware that accessing it mutably, prevents systems from running. In the ported project, in order to maintain the structure similar to the source, I've intentionally done it:

// port: spawner.rs

// The world instance is accessible via App#world.
//
world.spawn().insert_bundle((
    Player,
    PointC(pos),
    Render {
        color: ColorPair::new(WHITE, BLACK),
        glyph: to_cp437('@'),
    },
));

Generally speaking, while one can use this approach, in particular in stages where performance is not a concern, using systems should be preferred.

Systems

In Legion, systems require attributes, and queries are manually invoked:

// source: entity_render.rs

#[system]
#[read_component(Point)]
#[read_component(Render)]
pub fn entity_render(ecs: &SubWorld, #[resource] camera: &Camera) {
    // ...

    <(&Point, &Render)>::query()
        .iter(ecs)
        .for_each(|(pos, render)| {
            draw_batch.set(*pos - offset, render.color, render.glyph);
        });

    // ...
}

In Bevy, systems are simpler; they don't require attributes, and the queries are declared as function parameters:

// port: entity_render.rs

pub fn entity_render(query: Query<(&PointC, &Render)>, camera: Res<Camera>) {
    // ...

    for (pos, render) in query.iter() {
        draw_batch.set(pos.0 - offset, render.color, render.glyph);
    }

    // ...
}

In this example, we perform two accesses:

  • we query entities, via Query type; in this case, entities with PointC and Render components
  • we retrieve a Camera resource, via Res (readonly access) type

Another fundamental API component is Commands, which is used primarly to add/remove entities and resources.

The following example shows other functionalities:

// port: player_input.rs

pub fn player_input(
    mut commands: Commands,
    mut player_query: Query<&mut PointC, With<Player>>,
    (map, key, mut camera): (Res<Map>, Option<Res<VirtualKeyCode>>, ResMut<Camera>),
) {
  // ...
}

There are a few new concepts here (besides Commands):

  1. With: query entities with a certain component, without retrieving it
  2. ResMut: mutably access a resource
  3. Option<Res>: access a resource that may not be existing

Note how Option<Res<T>> is semantically different from Res<Option<T>>:

  1. Option<Res<T>> is a resource of type T that may not be stored
  2. Res<Option<T>> is a resource of type <Option<T>> that is assumed to be stored

if a resource is not stored, the system will panic in the second case, but not the first.

Querying will be explored more in detail in the next chapters.

Components, entities and Resources

Components

In Bevy, components must derive Component:

// port: component.rs

#[derive(Component)]
pub struct Render {
    pub color: ColorPair,
    pub glyph: FontCharType,
}

#[derive(Component)]
pub struct Player;

#[derive(Component)]
pub struct PointC(pub Point);

There's something interesting here: PointC, which is a tuple struct, unlike the other (regular) structs.

Why so? Because the type Point is not defined in our crate, and therefore, can't derive Component; in order to work this problem around, we wrap it in our type (PointC), and derive the wrapping type.

Entities

Adding entities to the ECS is trivial both in Legion and Bevy.

The simplest possible form that we can adopt to create an entity (in Bevy lexicon, a "Bundle"), is to insert a tuple:

// port: spawner.rs

// Note that we generally use systems to manage entities, but in this case, we're strictly following
// the source project's design.
//
pub fn spawn_player(world: &mut World, pos: Point) {
    world.spawn().insert_bundle((
        Player,
        PointC(pos),
        Render {
            color: ColorPair::new(WHITE, BLACK),
            glyph: to_cp437('@'),
        },
    ));
}

The entity inserted above has three components (Player, PointC and Render), which are bundled together as tuple.

We can use a more declarative approach, and use an annotated struct:

#[derive(Bundle)]
struct PlayerBundle {
  player: Player,
  pos: PointC,
  render: Render,
}

It's crucial, in this case, to annotate the struct with #[derive(Bundle)]; if one forgets this, and accidentally uses a bundle as component, no error will be raised!

Bundles can be nested (and again, we need to annotate the children, this time with #[bundle]), although in this project, this feature is not used:

#[derive(Bundle)]
struct PlayerBundle {
  player: Player,
  pos: PointC,

  #[bundle]
  sprite: SpriteSheetBundle,
}

Resources

Resources, differently from components, currently (this may change in the future!) don't need to be derived; anything inserted in Bevy's ECS is a resource.

// port: main.rs

// key is a bracket-lib `VirtualKeyCode` type
// ecs is the Bevy `App` instance
//
if let Some(key) = ctx.key {
    self.ecs.insert_resource(key);
} else {
    self.ecs.world.remove_resource::<VirtualKeyCode>();
}

Since there can be only one resource for a given type, when a resource is inserted, any existing resource with the same type is going to be overwritten.

Note how in the example above, we remove a resource through World; while this is acceptable, we should strive to use systems where possible. The technical reason for this usage is that Bevy provides resource insertion via the App instance, but not the removal. This makes sense architecturally, since in the initialization of a project, one insert resources rather than removing them.

Later, we'll see how to access resources in systems.

Keyboard input

In Bevy, keyboard input handling is performed via resources:

fn keyboard_input(keys: Res<Input<KeyCode>>) {
    if keys.just_pressed(KeyCode::W) { /* ... */ }

    // ...
}

In this context, we rely on bracket-lib for input handling, so the API above is not used.

The only thing that needs to be kept in mind is that, in the context of the port, we need to remove the key resource:

// port: main.rs

// key is a bracket-lib `VirtualKeyCode` type
//
if let Some(key) = ctx.key {
    self.ecs.insert_resource(key);
} else {
    self.ecs.world.remove_resource::<VirtualKeyCode>();
}

If we forget the remove_resource invocation, if the player doesn't press any key, the current frame will still hold the keypress resource from the previous frame!

This is not needed when using Bevy input handling (with an exception: see here); it's only necessary in the context of the port.

Introduction to System sets

One fundamental different between the architectures of the source design (Legion) and the port (Bevy) is how systems are grouped and scheduled.

In Legion, this is very simple; all the systems that belong to a (conceptual) state, are grouped together into a a "Schedule", which is sent to Legion, to be run on each update:

// source: mod.rs

Schedule::builder()
    .add_system(player_input::player_input_system())
    .add_system(map_render::map_render_system())
    .add_system(entity_render::entity_render_system())
    .build()

// source: main.rs

// `self.systems` is the Schedule instance built above.
//
self.systems.execute(&mut self.ecs, &mut self.resources);

Bevy uses similar semantics as well, however, they are more conceptually more complex; the closest concept in Bevy is the system of Stages and SystemSets. In this chapter, we'll explore the SystemSets.

Warning

As of v0.7, Bevy's states are practically unusable, except for trivial programs; this is due to the internal APIs used to implement them (see here). Trying, for example, to use the standard SystemSet/Stage Bevy APIs for this project, will cause the program to hang.

Fortunately, there is a third party crate, iyes_loopless, that solves this problem, while also maintaining the semantics identical (or very close) to Bevy's, with a clean interface.

The rest of this book assumes that the iyes_loopless crate is used:

# Cargo.toml

[dependencies]
bevy = "0.7.0"
iyes_loopless = "0.5.1"

SystemSets

A SystemSet in Bevy, is the abstraction that allows grouping and scheduling the systems, based on user-provided properties.

At this step, only the grouping functionality is used:

// port: mod.rs

pub fn build_system_set() -> SystemSet {
    SystemSet::new()
        .with_system(player_input::player_input)
        .with_system(map_render::map_render)
        .with_system(entity_render::entity_render)
}

The above is the simplest form that a system set can have: an anonymous group of systems that will run in parallel.

Don't forget the parallel - by default, Bevy runs the systems in parallel, so consider this when designing the system.

Another extremely important concept is that changes applied by a system to entities, are not seen by systems in the same system set. This doesn't matter now (the renderings are independent of the player entity properties), so we'll get back on this later.

In the most basic form, system sets are registered with Bevy directly on the App instance:

// port: main.rs

ecs.add_system_set(build_system_set());

Bevy provides APIs to organize system sets when more are added; at this stage, since there's only one, we register it directly without setting any property.

Query basics

Queries are a shiny example of Bevy's capabilities. Putting aside performance, any project will certainly benefit from their ergonomy.

Their distinctive trait is that they're entirely, and automatically, defined by the systems function signature, while allowing at the same time a great amount of flxeibility.

Basic queries definition and iteration

This is a basic (edited) example:

// port: entity_render.rs

pub fn entity_render(query: Query<(&PointC, Option<&Render>)>) {
    for (pos: &PointC, render: Option<&Render>) in query.iter() {
        if let Some(render) = render {
          // ...
        }
    }
}

Here, we query all the entities that have a PointC, and optionally a Render component, and retrieve both of them in the query.

Iteration can be mutable or immutable; in order to make the above mutable:

  • make query mutable: mut Query<(&PointC, Option<&Render>)>
  • use the mutable iterator API: query.iter_mut()

Resources and commands

In addition to Queries, systems are also typically provided access to commands and resources:

// port: player_input.rs

pub fn player_input(
    mut commands: Commands,
    mut player_query: Query<&mut PointC, With<Player>>, //(1) (2)
    (map, key, mut camera): (Res<Map>, Option<Res<VirtualKeyCode>>, ResMut<Camera>),
)

Commands are used to perform write operations, typically on entities and resources; while the details will be described later, in this context, an example invocation is:

// port: player_input.rs

commands.remove_resource::<VirtualKeyCode>();

which removes a resource from the storage.

We can observe resources access on the third set of parameters; its definition is intuitive, and the main information we need to know is:

  • if a resource may not exist, it must be accessed via Option<Res<T>>;
  • immutable/mutable access to a resource are performed via, respectively, the types Res/ResMut.

Queries resources implement Deref/DerefMut, so we can access them without any syntactic requirement, for example:

// port: player_input.rs

camera.on_player_move(destination);

which is invoking the method:

// port: camera.rs

impl Camera {
    pub fn on_player_move(&mut self, player_position: Point) { /* ... */ }
}

since access to ResMut automatically dereferences to &mut Camera.

Query conditionals

By using components directly as query parameters, we've made an implicit assumption: that we want to search entities that include all the components specified, and that we want to retrieve all the components.

A companion concept one may want to express is exclusion: query entities that do not include a component; we use the With trait for this:

// port: entity_render.rs

pub fn entity_render(query: Query<&PointC, Without<Invisibility>>) {
    for pos: &PointC in query.iter() {
      // ...
    }
}

in this case we want all the entities that have a PointC component, but not an Invisibility one.

There's also something worth mentioning: when querying a single component, we don't use a tuple as Query type parameter Query<(&Player), ...>, but the type alone; this has an effect on the iteration, which is pos: &PointC instead of (pos: &PointC).

The port does not make use of the Without conditionals, however, there is a link: the inclusion conditional - With.

What's the use, if Query implicitly perform inclusion? Convenience, and potentially performance. In some cases, we query for a component, but we don't need to actively use it; by specifying it as With parameter, it's not retrieved; an (edited) example is:

// port: player_input.rs

pub fn player_input(mut player_query: Query<&mut PointC, With<Player>>) {
    if let Ok(pos) = player_query.get_single() {
        camera.on_player_move(pos);
    }
}

In this case we want to know the position of the player, in order to process it, but since we don't process the Player component, we don't retrieve it.

There's a new API used here: get_single(); it is part of a family of APIs that allow access to a set of components in a query, without iteration:

APIChecked? (Ret. type)Access
single()N (T)immutable
single_mut()N (T)mutable
get_single()Y (Result<T>)immutable
get_single_mut()Y (Result<T>)mutable

(note that the return type is simplified)

The APIs are easy to remember:

  • the get_ prefix specifies checked access (returns None if an instance is not found, instead of panicking), similarly to HashMap;
  • the _mut suffix specifies mutable access.

Introduction to step 06.02

In this chapter, we're going to explore some other Querying concepts.

The directory name is 06_EntitiesComponentsAndSystems_02_dungeonecs.

Querying: Entities and Param sets

Querying entities

In the previous chapter about queries, we've queried, and acted upon, only components. How about we need to act entities?

Both Legion and Bevy use a very similar interface - simply, by adding Entity to the query interface.

This is a query example:

// port: collisions.rs (edited)

pub fn collisions(
    mut commands: Commands,
    enemies_query: Query<(Entity, &PointC), With<Enemy>>,
) {
    for (entity, pos) in enemies_query.iter() {
        // ... act on the entity ...
    }
}

The type Entity is a classic example of handle; it's a lightweight type that supports common operations (copy, comparison, hash...), and therefore, can be conveniently passed around.

Expanding the example above, we can observe how we actually use it:

// port: collisions.rs

pub fn collisions(
    mut commands: Commands,
    player_query: Query<&PointC, With<Player>>,
    enemies_query: Query<(Entity, &PointC), With<Enemy>>,
) {
    let player_pos = player_query.single().0;

    for (entity, pos) in enemies_query.iter() {
        if pos.0 == player_pos {
            commands.entity(entity).despawn()
        }
    }
}

specifically, we pass it to the entity despawning API, when certain component conditions are met (enemy pos matching the player pos).

Watch out! As we'll see later, despawning doesn't have an immediate effect.

Param sets

Param sets are a functionality that is not needed in the port, but it's important to know nonetheless.

Let's say, for the sake of example, that we have the following system:

// port: collisions.rs (edited)

pub fn collisions(
    player_query: Query<&PointC, With<Player>>,
    mut enemies_query: Query<&mut PointC, With<Enemy>>,
) {
    let player_pos = player_query.single().0;

    for mut pos in enemies_query.iter_mut() {
        if pos.0 == player_pos {
            pos.0.x = 0;
            pos.0.y = 0;
        }
    }
}

which model a logic where instead of despawning the enemy entities, we move them away (by changing pos).

The project compiles! But, surprise surprise... once when running, we get a crash:

thread 'main' panicked at 'error[B0001]: Query<(Entity, &mut PointC), With<Enemy>>
in system collisions accesses component(s) PointC 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`.', /home/user/.cargo/registry/src/github.com-1ecc6299db9ec823/bevy_ecs-0.7.0/src/system/system_param.rs:173:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4cbaac699c14b7ac7cc80e54823b2ef6afeb64af/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/4cbaac699c14b7ac7cc80e54823b2ef6afeb64af/library/core/src/panicking.rs:142:14
   2: bevy_ecs::system::system_param::assert_component_access_compatibility
             at /home/user/.cargo/registry/src/github.com-1ecc6299db9ec823/bevy_ecs-0.7.0/src/system/system_param.rs:173:5
   3: <bevy_ecs::query::state::QueryState<Q,F> as bevy_ecs::system::system_param::SystemParamState>::init
             at /home/user/.cargo/registry/src/github.com-1ecc6299db9ec823/bevy_ecs-0.7.0/src/system/system_param.rs:113:9
   4: <(P0,P1,P2) as bevy_ecs::system::system_param::SystemParamState>::init
             at /home/user/.cargo/registry/src/github.com-1ecc6299db9ec823/bevy_ecs-0.7.0/src/system/system_param.rs:1247:21

This is interesting; we're prevented from query PointC in two queries, with incompatible access mode. Bevy doesn't enforce incompatible access compile-time, but it does enforce it runtime.

In order to solve this problem, one of the two solutions referenced is to use ParamSet:

pub fn collisions(
    mut queries: ParamSet<(
        Query<&PointC, With<Player>>,
        Query<&mut PointC, With<Enemy>>,
    )>,
) {
    let player_pos = queries.p0().single().0;

    for mut pos in queries.p1().iter_mut() {
        if pos.0 == player_pos {
            pos.0.x = 0;
            pos.0.y = 0;
        }
    }
}

There's no significant difference; we just group the queries inside a ParamSet, and access them via pN() functions. Considering the constraint, this is impressively ergonomic!

Introduction to steps 07.01-02

This chapter covers both steps 07.01 and 07.02; we're going to explore States, SystemSets and Stages (and the iyes_loopless crate).

The Bevy concepts described here are the foundation for structuring games, and this step is the one that departs the most from the source project.

The directory names are 07_TurnBasedGames_01_wandering and 07_TurnBasedGames_02_turnbased.

A look at the states model in the source project

Source structure, and Bevy's Stages

Before getting technical, we need to have a look at how the source project models the states:

// source: mod.rs (edited)

pub fn build_input_scheduler() -> Schedule {
    Schedule::builder()
        .add_system(player_input::player_input_system())
        .add_system(map_render::map_render_system())
        .add_system(entity_render::entity_render_system())
}

pub fn build_player_scheduler() -> Schedule {
    Schedule::builder()
        .add_system(collisions::collisions_system())
        .add_system(map_render::map_render_system())
        .add_system(entity_render::entity_render_system())
        .add_system(end_turn::end_turn_system())
}

pub fn build_monster_scheduler() -> Schedule {
    Schedule::builder()
        .add_system(random_move::random_move_system())
        .add_system(collisions::collisions_system())
        .add_system(map_render::map_render_system())
        .add_system(entity_render::entity_render_system())
        .add_system(end_turn::end_turn_system())
}

We observe that there are three states:

  1. player input
  2. collision handling (following player move)
  3. monster moving, and collision handling

Every state performs rendering; state change is performed in two different ways:

  • on the player input state, performed by the player_input system, when the user presses a key;
  • on the other states, performed by an adhoc system.

As previously mentioned, Bevy does have a concept semantically similar to Legion's Schedule, however, it's more complicated, and it's better/closer modeled by a set of other types.

Commands flushing and Stages modeling

In the source schedulers, there's something interesting - the flush() commands:

// source: mod.rs

pub fn build_monster_scheduler() -> Schedule {
    Schedule::builder()
        .add_system(random_move::random_move_system())
        .flush()
        .add_system(collisions::collisions_system())
        .flush()
        .add_system(map_render::map_render_system())
        .add_system(entity_render::entity_render_system())
        .add_system(end_turn::end_turn_system())
        .build()
}

This is a crucial concept to understand!

When ECSs like Legion or Bevy modify entities/components, they don't commit the changes immediately; if one accidentally forgets flushing, it will lead to a very confusing situation, because systems won't find the expected data.

Compared to (current) Bevy, Legion has an advantage - as seen above, Legion has an API for flushing at will, while Bevy doesn't (as of Jun/2022, there's an open issue).

Fortunately, we can easily model this behavior using Stages - when a Stage is completed, the changes are committed.

Bevy's model has a disadvantage, though: we'll need to model more states than we do in Legion. In the source project, there are three schedulers, and that's all; in Bevy, we'll need:

  • three states, one for each Legion scheduler
  • seven stages, one for each commands transaction

This is not dramatic, but there is certainly an increase of the complexity that we'll need to handle; fortunately, in our case, we can reduce the number of stages.

A basic states model in Bevy

States modeling problems in Bevy v0.7

Prior to any discussion, it must be pointed out that Bevy's state modeling as a whole, as of v0.7, it's broken: using the related APIs to their full extent in a project, will cause breakages (e.g. Bevy will hang); a typical example is that one can't use FixedTimeSteps and States at the same time, or, in the case of this project, States with Stages.

Fortunately, a 3rd party developer has created a plugin that solves this problem: iyes_loopless. In order to proceed with the next steps, it's therefore necessary to add it to the Cargo configuration.

As of June 2022, it's planned for future Bevy versions to integrate the iyes_loopless logic in Bevy.

First concept: Stages

In the previous chapter, we've seen the source project's states model. In order to translate it in Bevy, we'll use three major concepts; the first one is Stages.

On each frame, Bevy's scheduler goes through a predefined set of so-called Stages, which are essentially group of operations performed on a schedule; they are the appropriate abstraction to use (for reasons we'll see in the following chapters) to model states.

By default, Bevy has several stages, however, we're interested only in one: the Update stage; the reason is that during the other stages, other operations are performed behind the scenes, and especially in a simple project, one doesn't want to touch them.

In order to accommodate the states we need, we can add new stages - the idea is that we schedule them to be run in the order we want, but always after Update and before the stage following it by default (called PostUpdate).

Designing the states

Now that we've examined all the concepts involved, let's design the Bevy's states.

We'll use the following code as reference, which is the source project as of this step:

// source: mod.rs

pub fn build_input_scheduler() -> Schedule {
    Schedule::builder()
        .add_system(player_input::player_input_system())
        .flush()
        .add_system(map_render::map_render_system())
        .add_system(entity_render::entity_render_system())
        .build()
}

pub fn build_player_scheduler() -> Schedule {
    Schedule::builder()
        .add_system(collisions::collisions_system())
        .flush()
        .add_system(map_render::map_render_system())
        .add_system(entity_render::entity_render_system())
        .add_system(end_turn::end_turn_system())
        .build()
}

pub fn build_monster_scheduler() -> Schedule {
    Schedule::builder()
        .add_system(random_move::random_move_system())
        .flush()
        .add_system(collisions::collisions_system())
        .flush()
        .add_system(map_render::map_render_system())
        .add_system(entity_render::entity_render_system())
        .add_system(end_turn::end_turn_system())
        .build()
}

A first, direct translation

In a direct transation, we create one stage for each transaction:

stage descriptionsource scheduler numberstage count
get player input11
render player input12
handle player collisions23
render player collisions24
move monsters35
handle monster collisions36
render move monsters37

All good! This can work, and it's a very straight model. Something important to keep in mind is that, in the game design, if the player doesn't press any key, the subsequent stages won't be executed!

Simplifying the source project states

Now, let's look at the rendering systems: map_render::map_render_system() and entity_render::entity_render_system(); we notice two things:

  1. they are common to all the schedulers;
  2. they need to be run in their separate transaction, otherwise, they may render a partial state (this would be the equivalent of the "read uncommitted" transaction level in database systems).

However, if we consider that this all happens over a single frame, we now see an opportunity for simplification: we just need to render only once! Let's have a look at the new table:

stage descriptionsource scheduler numberstage count
get player input11
handle player collisions22
move monsters33
handle monster collisions34
render45

We need a small modification here 😉 If the player doesn't send any input, the rendering won't be executed. Fortunately, we can shift the render:

stage descriptionsource scheduler numberstage count
render41
get player input12
handle player collisions23
move monsters34
handle monster collisions35

Excellent. From the user perspective, if we render at the beginning of a frame, or at the end of the previous one, it makes no difference - except that, with the former approach, we've solved the rendering problem.

We can go further! we don't strictly need to sequentially render and get the player input in a sequence; this is a slow game, and rendering a tiny fraction of second after won't make any difference; the new plan is therefore:

stage descriptionsource scheduler numberstage count
render+get player input4+11
handle player collisions22
move monsters33
handle monster collisions34

Notice that when we talk about a tiny fraction of second, it not one frame of delay; the input/rendering systems are rendered in parallel (as opposed to in a sequence), so there is virtually zero delay.

Summary of the design

With the design above, we now have 4 stages:

  • render+get player input
  • handle player collisions
  • move monsters
  • handle monster collisions

We'll also keep the 3 game states of the source project:

  • awaiting input
  • player turn
  • monster turn

Remember: states are used to represent high-level game states, while stages are subdivisions of the states, for transactional purposes.

Implementing the states in Bevy

In this chapter, we'll use the iyes_loopless Bevy crate!

Adding the stages

First, let's define the stages:

// Port: game_stage.rs

#[derive(Debug, Clone, Eq, PartialEq, Hash, StageLabel)]
pub enum GameStage {
    MovePlayer,
    MoveMonsters,
    MonsterCollisions,
}

Is there one missing? No! For simplicity, we're going to use Bevy's standard one, Update, for the rendering and player input. It's perfectly possible to leave Update untouched, and add an extra state for those systems; it's up to the developer's taste.

Now, we'll need to register them; this is performed via some App APIs, of which, for simplicity, we'll use just one, add_stage_after:

// Port: main.rs

ecs.add_stage_after(CoreStage::Update, MovePlayer, SystemStage::parallel())
    .add_stage_after(MovePlayer, MoveMonsters, SystemStage::parallel())
    .add_stage_after(MoveMonsters, MonsterCollisions, SystemStage::parallel());

Note that we're specifying that we want to run the systems of each stage in parallel (the alternative is SystemStage::single_threaded()), taking advantage of Bevy's ECS.

Adding the states

Now, let's define the states, which are encoded using a standard Bevy resource:

// Port: turn_state.rs

#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum TurnState {
    AwaitingInput,
    PlayerTurn,
    MonsterTurn,
}

We set the initial state by adding the resource to the ECS:

// Port: main.rs

ecs.insert_resource(TurnState::AwaitingInput);

In the next section, we'll encode the conditions.

Setting the SystemSets

Now we can add the SystemSets.

// Port: mod.rs (edited)

pub fn build_system_sets(app: &mut App) {
    app.add_system_set(
        SystemSet::new()
            .with_system(map_render::map_render)
            .with_system(entity_render::entity_render),
    );

    app.add_system(
        player_input::player_input
            .run_if_resource_equals(TurnState::AwaitingInput)
    );

    app.add_system_set_to_stage(
        GameStage::MovePlayer,
        ConditionSet::new()
            .run_if_resource_equals(TurnState::PlayerTurn)
            .with_system(collisions::collisions)
            .with_system(end_turn::end_turn)
            .into(),
    );

    app.add_system_set_to_stage(
        GameStage::MoveMonsters,
        ConditionSet::new()
            .run_if_resource_equals(TurnState::MonsterTurn)
            .with_system(random_move::random_move)
            .into(),
    );

    app.add_system_set_to_stage(
        GameStage::MonsterCollisions,
        ConditionSet::new()
            .run_if_resource_equals(TurnState::MonsterTurn)
            .with_system(collisions::collisions)
            .with_system(end_turn::end_turn)
            .into(),
    );
}

The state handling is done; let's review it.

First, rendering:

    app.add_system_set(
        SystemSet::new()
            .with_system(map_render::map_render)
            .with_system(entity_render::entity_render),
    );

By not specifying the stage, we're adding those systems to the Update stage. We're also not specifying a state, as we don't strictly need it; we could bind it to the AwaitingInput state, but conceptually speaking, there isn't such connection.

Note how there is no temporal dependency between the two rendering systems (therefore, they will run in parallel); since they can execute independently (they draw to two different planes), we take the chance to parallelize them.

Now, the player input:

    app.add_system(
        player_input::player_input
            .run_if_resource_equals(TurnState::AwaitingInput)
    );

This also goes in Update, however, we slot it in the associated game state (AwaitingInput). Note how we encode the conditional using the run_if_resource_equals() iyes_loopless API; it allows a system/set to run if the ECS includes an enum resource whose variant is the one specified.

It's extremely important not to use the iyes_loopless state management API (run_in_state()) for this purpose! While both are backed by resources, the difference is that the decision whether to run each system/set, is taken:

  • with the run_in_state() condition, once per frame, at the beginning of it;
  • with the run_if_resource_equals() condition, at the beginning of each stage.

The consequence is that if we set a new state (by updating the resource) in a given stage, systems in subsequent stages with conditions encoded via run_in_state(), will not run, even if they match the new state, because the decision not to run them was taken at the beginning of the frame!

Now, the player move:

    app.add_system_set_to_stage(
        GameStage::MovePlayer,
        ConditionSet::new()
            .run_if_resource_equals(TurnState::PlayerTurn)
            .with_system(collisions::collisions)
            .with_system(end_turn::end_turn)
            .into(),
    );

There are a few notable things here. First, we need to invoke ConditionSet#into() - this is because such type belongs to the iyes_loopless crate, and it needs to be converted to make it compatible with Bevy's add_system_set_to_stage APIs.

Then, we're now adding the system set to a stage (MovePlayer).

Like for rendering, we run the systems in parallel. Since we are sure that Bevy will wait for both systems to complete before moving to the next stage (MoveMonsters), there's no risk of a race condition.

The rest of the systems follow the same structure.

A small detail not to forget is turn state change is performed by the state machine system end_turn, but not in the AwaitingInput state - in this case, it's performed by the player_input system (since it depends on the user input):

// Port: player_input.rs (extract)

commands.insert_resource(TurnState::PlayerTurn);

as you can see, changing state is just a matter of updating the state resource.

Introduction to step 07.03

In this concept we're going to explore event passing.

The directory name is 07_TurnBasedGames_03_intent.

Managed event passing

This chapter presents an interesting departure from the source project! We're going to implement event passing; this is interesting because it's possible to observe the improvements offered compared to manual event handling (which is the implementation chosen in the source project).

A look at a manual implementation

Let's see what the source project implementation. Since the messages are regular entities, first, they need to be declared:

// Source: component.rs

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WantsToMove {
    pub entity: Entity,
    pub destination: Point,
}

Now, they're simply added to the world:

// Source: player_input.rs (edited)

pub fn player_input(
    commands: &mut CommandBuffer,
) {
    // ...

    // Legion’s push function doesn’t work with single-component insertions

    commands.push((
        (),
        WantsToMove {
            entity: *player_entity,
            destination: player_destination,
        },
    ));

    // ...
}

Here we observe a small limitation of Legion, in particular, when used for events - it doesn't accept insertion of individual components, so we need to add a phony one (the empty tuple ()).

Reading is performed via a standard query:

// Source: movement.rs (extract)

pub fn movement(
    entity: &Entity,
    want_move: &WantsToMove,
    // ...
    commands: &mut CommandBuffer,
) {
    if map.can_enter_tile(want_move.destination) {
        // ... operate on `want_move`
    }

    commands.remove(*entity);
}

Note how we have to manually remove the pseudo-message entity.

That's all. This implementation is very simple, which works well in the design of this game.

Bevy's Event passing

Bevy's managed event passing is equally simple. We define the event type, and we register it:

// Port: events.rs

pub struct WantsToMove {
    pub entity: Entity,
    pub destination: Point,
}

// Port: main.rs

ecs.add_event::<WantsToMove>();

Note that differently from entities, message types don't need to be derived as components; in the port, this gives a minor simplification - we don't need to use the wrapper component PointC - we use just Point.

Now, let's send the events:

// Port: player_input.rs (edit)

pub fn player_input(
    mut move_events: EventWriter<WantsToMove>,
) {
    // ... compute the player destination ...

    move_events.send(WantsToMove {
        entity: *player_entity,
        destination: player_destination,
    });

    // ...
}

and read them:

// Port: movement.rs (edit)

pub fn movement(
    mut move_events: EventReader<WantsToMove>,
    // ...
) {
    for &WantsToMove {
        entity,
        destination,
    } in move_events.iter()
    {
        if map.can_enter_tile(destination) {
            // ... operate on entity/destination
        }
    }
}

Very straightforward. There is a very important difference of the port: we don't need to remove the message; this is taken care of by Bevy.

Systems ordering

There are two crucial concepts to be aware of, when using Bevy's events:

  1. events persist for at most two frames - the frame where the event is sent, and the next;
  2. the developer must take care of the systems ordering, in order to read the events as soon as possible.

In some games, lag is not a problem, however, in some others, if the systems ordering is improperly designed, event reading may be lag, and the lag can propagate to other systems/frames.

It's therefore useful to review how system ordering is designed in the port, at this stage:

// Port: mod.rs (extract)

// Here we write the event; this is the default (Update) stage.
//
app.add_system(
    player_input::player_input.run_in_state(AwaitingInput)
);

// Here we read the events; the stage is the next, which guarantees the ordering.
//
app.add_system_set_to_stage(
    MovePlayer,
    ConditionSet::new()
        .run_in_state(PlayerTurn)
        .with_system(movement::movement) // reads the event
        .into(),
);

We don't need to introduce any syncing (ie. extra stages) - the project's states and actions are well-defined, so the sync points (implemented via stages) fit cleanly and, in this case, for free.

Considerations

Whether to use or not managed event passing, is a valid question; after all, one can just write entities, send them around, then remove them. In the context of this project, the advantages that managed events passing gives are:

  • it avoids manual removal of the messages;
  • the implementation is semantically more consistent with its intent;
  • as a consequence of the previous point, workarounds are not necessary.

These don't constitute a very significant improvement, however, given the simplicity of Bevy's API, in my opinion, using it is a no-brainer.

One larger projects, when there are multiple readers, the advantage of not having to manually clear the events, is definitely more significant.

Introduction to step 09.02

Before and after this step, there aren't - from an ECS perspective - new concepts, so this is the last chapter; I'm going to explain a few concepts related to Bevy's World.

The directory name is 09_WinningAndLosing_02_losing.

Bevy's World

World was briefly mentioned in the World and systems chapter. In general, there are two ways of accessing the World instance: from the App instance, and from a system.

We've seen the first case:

// port: app.rs

let mut ecs = App::new();
// ...
spawn_player(&mut ecs.world, map_builder.player_start);

// port: spawner.rs

// The world instance is accessible via App#world.
//
world.spawn().insert_bundle((
    Player,
    PointC(pos),
    Render {
        color: ColorPair::new(WHITE, BLACK),
        glyph: to_cp437('@'),
    },
));

There's nothing notable in it; we're at the initialization state of the application, so we don't have specific concerns.

In order to get access to World from a system, we just specify it as parameter:

pub fn world_read_system(world: &World) {
    if let Some(player) = world.entity(entity).get::<Player>() {
        // ...
    }
}

Exclusive systems

In the case of mutable World access, there are significant constraints.

First, we need to mark the system as "exclusive" when adding it:

pub fn world_write_system(world: &mut World) {
    if let Some(player) = world.entity(entity).get_mut::<Player>() {
        // ...
    }
}

app.add_system(world_write_system.exclusive_system())

Second: mutable access to World is incompatible with other queries, for obvious reasons:

// This is not allowed!
//
pub fn not_working_exclusive_system(
  world: &mut World,
  timer: ResMut<MyTimer>,
) {
  // in theory, here, we we'd have aliasing of the MyTimer resource
}

Finally, and very importantly, exclusive systems can't be scheduled concurrently with other systems, potentially causing bottlenecks!

Clearing the world

In some cases, we want to clear the entities in the world, for example, in order to partially perform a state cleanup (don't forget the resources!); this is easily accomplished:

world.clear_entities();

As of Jun/2022, there is no way to clear resources; however, while there's a PR open, it must be very clear that one can't just clear all the resources, since Bevy uses resources for various tasks - fully clearing the world would likely bring Bevy to an unusable state.

Conclusion

In this mini-book, we've explored how Bevy's ECS applies to a full-fledged, nontrivial, game.

Before reading Hands-on Rust, ECSs were a bit of mystery to me. After the book, basing a game on an ECS felt natural and advantageous. Nowadays, I think that a game does not need to be large in order to - overall - profit from an ECS.

I'm impressed by Bevy's ECS interface, as the querying system is very coincise, and makes for readable code. On the other hand, Bevy has a fundamental, unaddressed, problem in the state managament, however, the iyes_loopless solves it cleanly, and one can produce solid work with this engine.

Happy experimentation 😄

Exercise for the reader

I think that fully practicing the concepts studied on a book, brings understanding to another level. This book presents a fantastic opportunity!

Exercise

The source project has an uneven design. While the game itself is designed very neatly, its initialization is inconsistent with the rest, as it has a procedural design:

  • it access the World directly, and doesn't use systems;
  • it uses (local) variables, and reinstantiates them, instead of using resources.

The exercise is to redesign the game setup, so that it fully applies the ECS design, by using systems and resources. If possible, don't reinitialize resources - reuse them!

In my opinion, performing this exercise will give the reader a more solid understanding of how to work with ECSs and design around them!