Hugo Peixoto

Rust, gamedev, ECS, and bevy - Part 2

Published on September 24, 2020

Table of contents

Introduction

In Part 1 of this series, I talked about what ECS is and what it’s good for. In this post, I’ll translate those concepts to bevy, with some code examples. I’ll finish with the issues I found in bevy and how ECS is kind of the opposite of how I’m used to thinking.

Bevy is a very recent ECS game engine: it was released on August 10. There are many other rust ECS engines, like Legion, hecs, shipyard, and Specs (which powers Amethyst). Bevy seems to have caught people’s attention, and I’m not really sure why, but I decided to try it because of its release timing and all the hype it received.

Bevy basics

Getting started was easy, thanks to bevy’s introduction post. Documentation is still a bit lacking, but the introduction covers the most important of concepts, and their Discord was helpful when I needed to fill in the gaps.

To get started, here’s a basic bevy app, that prints Hello and exits:

1
2
3
4
5
6
7
8
9
10
11
use bevy::prelude::*;

fn hello() {
  println!("Hello");
}

fn main() {
  App::build()
    .add_system(hello.system())
    .run();
}

App::build() returns a builder object that lets you chain methods to configure your application. The most common operation I find myself using is add_system.

add_system registers the given System and calls its run method in every game loop iteration.

The most common way to create a system is by calling system() on a rust function. system() comes from either the IntoForEachSystem trait or the IntoQuerySystem trait.

Bevy implements these traits out of the box for some rust functions, based on the function’s parameters. It uses a bunch of macro code that uses the function’s parameter list to determine the system signature to create a run method that queries the list of entities that match that signature.

In this code example, the printing is done by a system. In this case, the function has no arguments, so there’s no component matching. Bevy will call hello once per game loop iteration. In this example it only runs once though, because bevy defaults to using a scheduler that runs only once.

If you want the application to run in a loop, with some initialization code that should only run once, you can do the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use bevy::{ prelude::*, app::*};
use std::time::Duration;

fn setup() {
  println!("setup");
}

fn tick() {
  println!("tick");
}

fn main() {
  App::build()
    .add_plugin(ScheduleRunnerPlugin {
      run_mode: RunMode::Loop { wait: Some(Duration::from_secs(1)) }
    })
    .add_startup_system(setup.system())
    .add_system(tick.system())
    .run();
}

This application runs the setup system once, because it’s added via add_startup_system, and it runs tick once per game loop iteration, which in this case runs once per second. Currently, bevy only supports one global scheduler, so you can’t easily declare that you want one system to run at 16hz and another system at 60hz. There are some workarounds for this, but nothing perfect.

Moving and bobbing spheres

In Part 1, I used an example with spheres moving at a constant speed and spheres bobbing up and down. Let’s implement those components, ignoring the drawing part for now:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
use bevy::{ prelude::*, app::*};
use std::time::Duration;
use std::f32::consts::TAU;

struct Position {
  x: f32,
  y: f32,
}

struct FixedSpeedMovement {
  dx: f32,
  dy: f32,
}

struct BobbingMovement {
  dy: f32,
  amplitude: f32,
  period: f32,
}

fn fixed_speed_movement_system(
  mut position: Mut<Position>,
  movement: &FixedSpeedMovement,
) {
  let elapsed = 0.016;
  position.x += movement.dx * elapsed;
  position.y += movement.dy * elapsed;
}

fn bobbing_movement_system(
  mut position: Mut<Position>,
  mut movement: Mut<BobbingMovement>,
) {
  let elapsed = 0.016;
  let period = movement.period;

  position.y -= movement.amplitude * (movement.dy * TAU / period).sin();
  movement.dy = (movement.dy + elapsed) % period;
  position.y += movement.amplitude * (movement.dy * TAU / period).sin();
}

fn setup(mut commands: Commands) {
  commands.spawn((
    Position { x: 0.0, y: 0.0 },
    FixedSpeedMovement { dx: 0.1, dy: 0.2 },
  ));

  commands.spawn((
    Position { x: 0.0, y: 0.0 },
    BobbingMovement { dy: 0.0, amplitude: 5.0, period: 10.0 },
  ));
}

fn print(position: &Position) {
  println!("entity at {:.3}, {:.3}", position.x, position.y);
}

fn main() {
  App::build()
    .add_plugin(ScheduleRunnerPlugin {
      run_mode: RunMode::Loop { wait: Some(Duration::from_millis(16)) }
    })
    .add_startup_system(setup.system())
    .add_system(fixed_speed_movement_system.system())
    .add_system(bobbing_movement_system.system())
    .add_system(print.system())
    .run();
}

Apart from the missing DrawableSphere component, this example looks very similar to the one from Part 1. In bevy, components are just structs.

I changed the setup signature to receive a Commands object. This object lets you schedule commands to be executed at the end of the game loop iteration. I’m spawning a couple of entities in there, using Commands#spawn. spawn schedules the creation of a new entity with the given components attached. Technically it receives a bundle of components, so I’m passing a tuple of components. Bevy implements the Bundle trait for tuples out of the box.

fixed_speed_movement_system’s signature is made of two components: Position and FixedSpeedMovement. This means that bevy will call this system for every entity that has at least these two components. By wrapping Position in a Mut<>, we’re letting bevy know that this system will modify this component, so it should not run other systems that require the Position component in parallel.

bobbing_movement_system is practically the same as fixed_speed_movement_system, but it also needs to mutate the BobbingMovement component to keep track of movement state.

The print system signature matches every entity with a Position component and prints its coordinates. This is a temporary substitute for the DrawableSphere system we had in Part 1.

In main, we’re adding all the systems. Running this will print something like:

1
2
3
4
5
6
7
8
9
entity at 0.002, 0.003
entity at 0.000, 0.050
entity at 0.003, 0.006
entity at 0.000, 0.101
entity at 0.005, 0.010
entity at 0.000, 0.151
entity at 0.006, 0.013
entity at 0.000, 0.201
[...]

You’ll notice that I have let elapsed = 0.016 in the systems. This is not ideal, since the time between system calls may vary a bit, and we want to avoid drift. To figure out the actual elapsed time, bevy provides global resource Time.

Global resources

Global resources are objects that your systems can add to their signature and that will persist global state, with the benefit of bevy being able to reason about then when thinking of parallelization. Those resources are initialized in main, by calling add_resource or init_resource:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
fn fixed_speed_movement_system(
  time: Res<Time>,
  mut position: Mut<Position>,
  movement: &FixedSpeedMovement,
) {
  let elapsed = time.delta_seconds;
  position.x += movement.dx * elapsed;
  position.y += movement.dy * elapsed;
}

fn bobbing_movement_system(
  time: Res<Time>,
  mut position: Mut<Position>,
  mut movement: Mut<BobbingMovement>,
) {
  let elapsed = time.delta_seconds;
  let period = movement.period;

  position.y -= movement.amplitude * (movement.dy * TAU / period).sin();
  movement.dy = (movement.dy + elapsed) % period;
  position.y += movement.amplitude * (movement.dy * TAU / period).sin();
}

fn time_system(mut time: ResMut<Time>) {
  time.update();
}

fn main() {
  App::build()
    .init_resource::<Time>()
    .add_system_to_stage(stage::FIRST, time_system.system())
    .add_plugin(ScheduleRunnerPlugin {
      run_mode: RunMode::Loop { wait: Some(Duration::from_millis(16)) }
    })
    .add_startup_system(setup.system())
    .add_system(fixed_speed_movement_system.system())
    .add_system(bobbing_movement_system.system())
    .add_system(print.system())
    .run();
}

The systems that need to care about time now have a new parameter, Res<Time>. I’m adding the resource with init_resource (it calls Time::from_resource), and I added a system to update the time resource: time_system. I’m registering this system with add_system_to_stage(stage::FIRST, ...), which ensures that this system is called before any other.

I did most of this work manually (init_resource, creating the system, and registering it), but bevy comes with default plugins that handle these things for us.

Drawing spheres

We also want to draw the spheres. Bevy comes with a set of components to draw 3D scenes and meshes. Each 3D object has its own Transform matrix, independent of the Position component. I will add a system that goes through every entity with a position and sets its transform component to the right translation matrix:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
fn drawing_system(
  position: &Position,
  mut transform: Mut<Transform>,
) {
  *transform = Transform::from_translation(
    Vec3::new(position.x, position.y, 0.0),
  );
}

fn setup(
  mut commands: Commands,
  mut meshes: ResMut<Assets<Mesh>>,
  mut materials: ResMut<Assets<StandardMaterial>>,
) {
  let material = materials.add(Color::rgb(0.1, 0.4, 0.8).into());
  let mesh = meshes.add(Mesh::from(
    shape::Icosphere { subdivisions: 4, radius: 0.5 },
  ));

  commands.spawn(Camera3dComponents {
      transform: Transform::new(Mat4::face_toward(
        Vec3::new(0.0, 0.0, 40.0),
        Vec3::new(0.0, 0.0, 0.0),
        Vec3::new(0.0, 1.0, 0.0),
      )),
      ..Default::default()
  });

  commands.spawn((
    Position { x: 0.0, y: 0.0 },
    FixedSpeedMovement { dx: 1.0, dy: 2.0 },
  )).with_bundle(
    PbrComponents { mesh, material, ..Default::default() }
  );

  commands.spawn((
    Position { x: 0.0, y: 0.0 },
    BobbingMovement { dy: 0.0, amplitude: 5.0, period: 2.0 },
  )).with_bundle(
    PbrComponents { mesh, material, ..Default::default() }
  );
}

fn main() {
  App::build()
    .add_default_plugins()
    .add_startup_system(setup.system())
    .add_system(fixed_speed_movement_system.system())
    .add_system(bobbing_movement_system.system())
    .add_system(drawing_system.system())
    .run();
}

The setup system was changed to spawn a 3D Camera entity and to add the PBR components to the existing sphere entities.

I didn’t have to change the fixed_speed_movement_system and bobbing_movement_system functions. Drawing is decoupled from the position update code.

I added the drawing system that copies the position values to the transform component. I didn’t have to write the the actual drawing systems, bevy comes with those out of the box. You just need to add the default plugins.

add_default_plugins adds a bunch of default resources, components and systems that handle windows, initialize global resources (like Time), etc. This also adds a loop scheduler, so we no longer need to add the RunMode::Loop scheduler manually.

Running the code with these systems will open a window that looks like this:

Although it doesn’t look like it, these are 3D spheres. Adding a light would make their depth a bit more noticeable:

1
2
3
4
commands.spawn(LightComponents {
  transform: Transform::from_translation(Vec3::new(0.0, 0.0, 4.0)),
  ..Default::default()
});

Two blue spheres in a gray canvas, with noticeable shading

For each systems vs query systems

Up until now, I have been using what bevy calls “for each systems”. These systems are called with a single entity that matches its signature. Bevy provides an alternative, “query systems”, that receive an iterator to all the entities that match the query. This is useful if you want to, for example, do some calculations that aggregate values from several entities. Let’s create a system that calculates a score based on the x coordinate of every Position component:

1
2
3
4
5
6
7
8
9
10
fn scoring_system(
  mut positions: Query<&Position>,
) {
  let mut score = 0.0;
  for position in &mut positions.iter() {
    score += position.x;
  }

  println!("score: {}", score);
}

This will iterate through every Position component. In this example, we can’t mutate the position. If we wanted to do that, the query would be Query<Mut<Position>>.

Query systems can also match multiple components per entity. Let’s rewrite the drawing_system to a query system:

1
2
3
4
5
6
7
8
9
fn drawing_system(
  mut spheres: Query<(&Position, Mut<Transform>)>,
) {
  for (position, mut transform) in &mut spheres.iter() {
    *transform = Transform::from_translation(
      Vec3::new(position.x, position.y, 0.0),
    );
  }
}

One limitation of the existing system traits is that you can’t mix these two in the same system. Say that you would want to store the score in a component, instead of just printing it out. You wouldn’t be able to write something like this:

1
2
3
4
5
6
7
8
9
10
// You wouldn't be able to add this system to a bevy app
fn scoring_system(
  mut score: Mut<Score>,
  mut positions: Query<&Position>,
) {
  score.value = 0.0;
  for position in &mut positions.iter() {
    score.value += position.x;
  }
}

You would have to use two queries instead:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn scoring_system(
  mut scores: Query<Mut<Score>>,
  mut positions: Query<&Position>,
) {
  let points = 0.0;
  for position in &mut positions.iter() {
    points += position.x;
  }

  for score in &mut scores.iter() {
    score.value = points;
  }
}

In this example, since you’ll probably only have one Score at any given time, you’d probably be better served with a shared global resource:

1
2
3
4
5
6
7
8
9
fn scoring_system(
  mut score: ResMut<Score>,
  mut positions: Query<&Position>,
) {
  score.value = 0.0;
  for position in &mut positions.iter() {
    score.value += position.x;
  }
}

2D sprites

I didn’t want to deal with 3D models, or complex physics, so I decided to go with something 2D, Gameboy / Pokémon Red like, where movement is tile based. I decided to go with the Gameboy screen size (160x144) and sprite size (16x16).

Bevy comes with some components to draw Sprites. I started by making a couple of sprites in Pixelorama and draw them in a few hardcoded positions:

Game screen with green background with a border of squares, with a green
humanoid avatar in the lower left corner

And here’s the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
use bevy::prelude::*;

struct Position {
  x: i32,
  y: i32,
}

fn spawn_sprite(
  commands: &mut Commands,
  material: Handle<ColorMaterial>,
  position: Position,
) -> &mut Commands {
  commands
    .spawn(SpriteComponents { material, ..Default::default() })
    .with(position)
}

fn sprite_update(
  position: &Position,
  mut transform: Mut<Transform>,
) {
  *transform = Transform::from_translation(
    Vec3::new(
      (position.x as f32 - 4.5) * 16.0,
      (position.y as f32 - 4.0) * 16.0,
      0.0,
    ),
  );
}

fn setup(
  mut commands: Commands,
  mut materials: ResMut<Assets<ColorMaterial>>,
  asset_server: Res<AssetServer>,
) {
  let block = materials.add(asset_server.load("block.png").unwrap().into());
  let player = materials.add(asset_server.load("player.png").unwrap().into());

  commands.spawn(Camera2dComponents::default());

  for i in 0..10 {
    spawn_sprite(&mut commands, block, Position { x: i, y: 0 });
    spawn_sprite(&mut commands, block, Position { x: i, y: 8 });
  }

  for i in 0..7 {
    spawn_sprite(&mut commands, block, Position { x: 0, y: 1 + i });
    spawn_sprite(&mut commands, block, Position { x: 9, y: 1 + i });
  }

  spawn_sprite(&mut commands, player, Position { x: 1, y: 1 });
}

fn main() {
  App::build()
    .add_resource(WindowDescriptor {
      width: 160 + 2*16,
      height: 144 + 2*16,
      vsync: true,
      resizable: false,
      ..Default::default()
    })
    .add_default_plugins()
    .add_resource(ClearColor(Color::rgb(0.792, 0.863, 0.624)))
    .add_startup_system(setup.system())
    .add_system(sprite_update.system())
    .run();
}

This is very similar to the 3D example, but using sprites instead of meshes, and i32 instead of f32 in Position. I also extracted some code into spawn_sprite to make the example a bit shorter.

User input

The next step was to add some movement:

I got here by tagging the player entity with a new component, and adding a single system:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#![feature(clamp)]

struct PC {}

fn setup() {
  // [..]
  spawn_sprite(&mut commands, player, Position { x: 1, y: 1 })
    .with(PC{});
}

fn pc_movement_system(
  keyboard_input: Res<Input<KeyCode>>,
  _pc: &PC,
  mut position: Mut<Position>,
) {
  if keyboard_input.pressed(KeyCode::W) { position.y += 1; }
  if keyboard_input.pressed(KeyCode::A) { position.x -= 1; }
  if keyboard_input.pressed(KeyCode::S) { position.y -= 1; }
  if keyboard_input.pressed(KeyCode::D) { position.x += 1; }

  position.x = position.x.clamp(1, 10-2);
  position.y = position.y.clamp(1, 9-2);
}

fn main() {
  App::build()
    // ...
    .add_system(pc_movement_system.system())
    .run();
}

For now, the PC component is only used for filtering entities, so it’s not even being used explicitly in pc_movement_system, but it needs to be one of the parameters. If we omitted this, the system would run once for every entity with a Position component. This would cause the the walls to start moving.

This is a very basic system, just to get things started. It has a lot of limitations.

First, there’s no sprite animation: the PC sprite jumps from one square to the other. One way of solving this would be to add a component to the player that keeps track of the animation duration. sprite_update would have to be aware of this component and update the sprite accordingly.

Second, this system runs on every update tick, making movement speed a bit erratic. It’s also hard to control player speed like this. If I had movement animations, I would also have to consider input buffering.

Third, there’s no collision detection. I’m faking it by using clamp to limit player movement. When I add more objects to the world, I will need to have some collision detection. This could be naively implemented by adding a query that iterates through all other Positioned entities and checks if any of them are in the destination square. It wouldn’t be super efficient, though. Ideally we’d have an auxiliar data structure that lets us know in constant time if there’s an entity in a given position.

Bevy drawbacks

There was one thing that made getting started with bevy a bit hard. The error messages that you get when your system signature doesn’t match any signatures that implement Into*System traits is not helpful.

For example, if I try to add the broken scoring_system function I mentioned earlier to my app, I get the following error (formatted for readability):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
error[E0599]: no method named `system` found for fn item
              `for<'r, 's, 't0> fn(
                 bevy::prelude::Mut<'r, Score>,
                 bevy::prelude::Query<'s, &'t0 Position>
               ) {scoring_system}` in the current scope
   --> src/main.rs:108:32
    |
108 |     .add_system(scoring_system.system())
    |                                ^^^^^^ method not found in
    |                                `for<'r, 's, 't0> fn(
    |                                   bevy::prelude::Mut<'r, Score>,
    |                                   bevy::prelude::Query<'s, &'t0 Position>
    |                                 ) {scoring_system}`
    |
    = note: `scoring_system` is a function, perhaps you wish to call it

add_system takes a bevy::prelude::System, so we need to call system() on the fn to convert it. Since this function doesn’t match any of the functions that have the trait implemented by default, we get a method not found error, which is not super useful.

Even if add_system did accept a Into*System trait instead, I’m not sure if the error would be that clear. I think this is a disadvantage of using function signatures instead of something more structured.

I eventually got used to it, and started associating “method not found” with “I have a problem in my system signature”. I don’t know if there’s something that can be done to improve error messages in this scenario without compromising the simplicity of using functions.

Trouble with ECS

Before I start, I want to note that I had no prior experience with ECS, and barely anything that counts as game development experience.

My latest encounter with gamedevvy stuff was Make or Break’s AI competition. This work focuses a lot on deterministic behavior and avoiding first player advantage. The ruby code is on github if you’re curious.

The game I worked on is related to time travel, so I knew I would need to record the world state to be able to move back and forth in time. The way I usually approach this is by representing the game as a next(State, Vec<Action>) -> State function.

Recording the actions would allow me to go back to any point in time by replaying the states from the start of the game. I could also record the states if I wanted to optimize things a bit. Things like UI, input buffering, and animation would be left out of this mechanism.

This next pattern makes testing a bit more obvious, since you just have to pass it a state and assert on the resulting state. On the other hand, building a consistent state might be a bit harder, depending on the game complexity.

Initially I tried to avoid using this pattern and going full ECS, but dealing with recording actions or states from a list of Components, and figuring out how to reset every component, proved too much for a starting game.

I will probably revert to the next pattern and limit the ECS to animations, input handling, and menuing. It will reduce the performance benefits of using ECS, but since this is a board game like structure (2D, discrete movement), I’m not planning on having too many entities on the core gameplay. The visual components will still benefit from ECS.

Conclusion

When writing the part 1 of this series, I wrote some benchmarks in C++ to double check what I was saying. These are available on my tests repository. The repo is kind of a mess, but it was useful to get some idea of the benefits of each step.

I still haven’t got far with the game, since the game jam was only one week, but I enjoyed working with bevy so far. Here’s a video of the latest version I did, with a debug system that prints the keys that are being pressed, spritesheets and movement animation:

There is still a lot of change going on in the project, the APIs haven’t stabilized yet, so every release has the potential to break something. They haven’t invested much in documentation efforts to avoid having to rewrite everything constantly.

If you’re interested in learning more about bevy, there are a bunch of community channels you can join. I’ve asked for help a couple of times in their discord server, and folks have been helpful.