snen.dev blog

Building With Bevy

Part 2 - Implementing Crabber

This series is about writing a Bevy project. If you aren't familiar with this series, start here to read about the background. The last post set up a Cargo workspace, introduced Bevy's ECS fundamentals, and integrated some tools that will be used to facilitate development.

This post will focus on building a rudimentary Frogger clone. The goal is to show game logic involving multiple kinds of game objects and some entity interactions, so that there is an expressive level of complexity but a readable level of simplicity. (Some of the decisions will be made with multiplayer in mind, but I will address that in a future post.) Using the testing architecture from before, our test will spawn a level and a player, and player should be able to play the level until KO or until they reach the goal.

If you'd like to see the end result, be sure to take a look at the associated commit in the workbook repository, which contains all the code contained in this post.

Crabber

First, let's break down the features I'm choosing as requirements for Crabber.

  • The player can move a crab. Once the crab starts moving, it will stay in motion until it "lands" on the next tile.
  • The level features a series of "rows" that fill the screen.
    • The rows are either grass, road, or river.
    • The first row is grass, and the last row is a finish line.
    • Cars spawn on road rows
    • Rafts spawn on river rows
  • Cars and rafts move at a random constant speed in a "loop"
    • If they reach the end of the row, they spawn at the start of the row
    • For each row, all cars or rafts are moving in the same direction, left or right
  • If a crab collides with water without a raft, or collides with a car, the crab is KO'd. (Why would a crab KO in water? How should I know? What do you think I am, some kind of Science Doctor Human?)
  • Whenever the crab reaches a row it has never reached before, its score increases.
  • If a crab is KO'd, it no longer accumulates score.
  • If a live crab lands on a raft, the raft carries the crab with it.
  • If a live crab reaches the end, it wins.

Briefly inspecting this list, it's clear that I've made some design review adjustments to Frogger. Score and gameover work very differently, logs have been substituted for rafts (it was easier to find a sprite), and, finally, there is a rather conspicuous replacement of Frogg with Crabb.

This particular ruleset was chosen because it felt like the appropriate degree of complexity. Some of the choices might not be perfect, because there's a lot to describe and a lot of rabbitholes. For the purposes of this series, what's important is that code is modular and self-explanatory enough that if we wanted to add more gameplay logic or change some behavior, it is clear where to do so.

Moving on, we can finally get started implementing this game with Bevy. I'll be a little loose with the specifics in my example code for readability, but the repository and codesandbox examples will have more accurate code for reference.

Assets

We've added a crab sprite, but we need a few more:

  • A spritesheet for rafts
  • A spritesheet for cars
  • A spritesheet for the possible background tiles, which will be Grass, Road, and River.

Simple development assets are fine for now. I've grabbed and manipulated a few from opengameart for use here.

Components

We can break down the game by feature to mark out the "shape" of our data needs.

  • There is the player, represented as a Crab, which has a position that can be moved by player controls
  • There is the level, which is comprised of a tilemap and any non-player-controlled game objects (namely, the rafts and cars).

Let's define some structs and derive Component:

// components.rs, a new file
// (be sure to add `mod components;` to `lib.rs`)

#[derive(Component)]
pub struct Crab; // any properties?
#[derive(Component)]
pub struct Raft; // any properties?
#[derive(Component)]
pub struct Car; // any properties?

Ignore the crab for now, since player movement will be handled uniquely. How would we track the behavior of a raft or car?

The plan is that both cars and rafts will move at a constant velocity in a specified direction, left or right. When it reaches the edge of the play area, we will move it to the other side so that it moves in "loops".

We might be tempted write behavior like:

// components.rs
#[derive(Component)]
pub struct Crab {
   x: i16,
   y: i16,
}
enum Direction { Up, Right, Down, Left }
#[derive(Component)]
pub struct Raft {
   x: i16,
   y: i16,
   speed: i16,
   direction: Direction,
};
#[derive(Component)]
pub struct Car {
   // ... same as Raft
};

But this would not leverage the ECS very well. While this approach does associate each kind of entity with all the relevant data properties, it fails in the sense that the behavior must be implemented twice. For both Raft and Car, there must be a system that updates the x and y properties based on speed and direction.

Instead, we can write our components to handle more granular levels of data, and write systems to handle those specific layers of abstraction instead.

// components.rs
#[derive(Component)]
pub struct Crab;
#[derive(Component)]
pub struct Raft;
#[derive(Component)]
pub struct Car;

// using bevy::transform::Transform will be more correct,
// so we will do that in the rest of the post.
// this is just to mirror the properties from the above snippet
#[derive(Component)]
pub struct Position { x: f32, y: f32 }

pub enum Direction { Up, Right, Down, Left }
// a motor that instructs our systems to propel the entity with a constant velocity
#[derive(Component)]
pub struct ConstantMotor {
   speed: f32,
   direction: Direction,
}

With the above, we can generalize what would otherwise be two systems into one tick_constant_motors system:

// tick.rs
fn tick_constant_motors(mut motor_query: Query<(&mut Transform, &ConstantMotor)>) {
   for (mut transform, motor) in motor_query.iter_mut() {
        // move at a constant speed in a loop
    }
}

(The reader might argue in favor of a Velocity component instead of ConstantMotor -- this is a good idea, generally. But we won't maintain any other "physics" data except for velocity in this specific case, which also happens to be constant by design. Further, we will soon add a player-driven "Motor" component, and the internal symmetry between the names will be useful for readers.)

We can also use components to track things like a player's score:

#[derive(Component)]
struct Score(pub u16);

Then, we can use marker components (like Crab below, discussed here) to differentiate "game-object kinds" when necessary.

For example, we can define a Knockout component and use it to filter queries so that KO'd crabs are not queried for some behavior. The following example shows how this is used to filter the query for the system that updates score:

use bevy::prelude::Without;

#[derive(Component)]
struct Knockout;

fn tick_score(mut player_query: Query<(&mut Score, &Transform), Without<Knockout>>) {
    //                                                          ^^^^^^^^^^^^^^^^^^
    for (mut score, transform) in player_query.iter_mut() {
        // update score based on y position
    }
}

The Without type is a query filter, which can be passed as the second generic parameter to Query. Such query filters must implement the ReadOnlyWorldQuery trait.

These components should be a good set to get us started, but we'll add more as we go.

Bundle

Some components are only validly used in conjunction with other components. With the Bundle trait, we can define common groupings of components (or other bundles) that can be added in bulk.

#[derive(Bundle)]
struct RaftBundle {
   raft: Raft,
   motor: ConstantMotor,
}

impl RaftBundle {
    pub fn new(/* some useful signature for "raft" entities */) -> Self {}
}

// in system code
commands.spawn(RaftBundle::new(/* speed and direction? */))

Bundle makes it easy to spawn entities with these components or add these components to existing entites. For example, tuples of bundles implement Bundle and tuple bundles can be nested, so it's easy to extend behavior as needed.

We can create similar bundles for the Raft and Car components, and we can even extend this implementation with another wrapper Bundle, like some CrabWithOtherStuffBundle that also inserts some OtherBundle and any other desired components:

// not-real-file.rs
#[derive(Bundle)]
struct CrabWithOtherStuffBundle {
   crab: CrabBundle,
   other: OtherBundle,
   ... // whatever components or bundles you want to add
}

Bundle enables all sorts of creative mechanisms for composing behaviors. For example, we can define bundles that wrap existing bundles with specific implementations. As a contrived example, we can imagine wanting to spawn sprite bundles with some common color tint but nothing else:

// graphics.rs
#[derive(Bundle)]
struct TintedSpriteBundle {
   sprite: SpriteBundle,
}

Since SpriteBundle comes from outside our crate, we cannot implement methods on it due to the orphan rule. However, by defining a struct that wraps the SpriteBundle, we can then implement constructors on our wrapper (TintedSpriteBundle) that provide the behavior we want to share. Then, since Bundle is derived on our wrapper struct, we can use TintedSpriteBundle as a proxy for our desired SpriteBundle behavior.

impl TintedSpriteBundle {
   pub fn new(texture: Handle<Image>) -> Self {
      TintedSpriteBundle {
         sprite: SpriteBundle {
            sprite: Sprite {
                // add some semitransparent-red tint
                color: Color::rgba(1., 0., 0., 0.7),
                ..Default::default()
            },
            texture,
            ..Default::default()
         }
      }
   }
}

// Now there is a useful constructor that shares the intended tint logic
fn startup_system(mut commands: Commands) {
    commands.spawn((
        Crab,
        TintedSpriteBundle::new(/* texture */),
        OtherBundle, // whatever else
    ));
}

(We'll use this strategy later on to simplify constructing a specific input handler component.)

Bundles are powerful. As systems increase in complexity and quantity, implementing useful bundles can help ensure that components are spawned with valid "sibling" components and with the intended properties.

Systems

Now that we have an idea of how to create components and bundles, we can define some systems that express how entities behave each tick. After that we implement the player controls.

Tick

Every tick -- meaning every execution of the game loop -- the level will move some objects on screen. Since the motion of rafts and cars is constant, the implementation above was straightforward:

fn tick_constant_motors(
   mut motor_query: Query<(&mut Transform, &ConstantMotor)>,
) {
    for (mut transform, motor) in motor_query.iter_mut() {
        let (x, y) = match motor.direction {
            Left => (-1, 0),
            Up => (0, 1),
            Right => (1, 0),
            Down => (0, -1),
        };
        transform.translation.x += x * motor.speed;
        transform.translation.y += y * motor.speed;
        // TODO: loop around to the other side of the screen if offscreen
    }
}

There's missing logic related to "looping" behavior that we'll have to add later, but the idea is to highlight the way we reference and operate on game state. In this case, the system queries for all entities that have a Transform and a ConstantMotor and then mutates the Transform according to the motor's properties. Most systems we write will do something similar, iterating over some query (or multiple) and operating on the components attached to each entity with all the queried components.

We also discussed tick_score above, which implements a rudimentary scoring mechanism that tracks the entity's max Y value:

fn tick_score(mut score_query: Query<(&mut Score, &Transform), With<Knockout>>) {
    for (mut score, transform) in score_query.iter_mut() {
        let row = position.y / TILE_SIZE;
        if *score.value < row && knockout.is_none() {
            *score.value = row;
        }
    }
}

We can also write systems that execute multiple queries, especially when objects of different "kinds" are interacting:

// check whether the character is colliding with any car
fn tick_road_collisions(
    mut commands: Commands,
    player_query: Query<(Entity, &Transform), With<Crab>>,
    car_query: Query<(&Car, &Transform)>,
) {
    // iterate through players
    for (entity, transform) in player_query.iter() {
        // iterate through cars
        if todo!() /* player is on a road */ &&
            car_query.iter().any(|(car, car_transform)| {
                todo!() /* car_transform and transform are colliding */
            })
        {
            // knockout the player if any car collides with the player!
            commands.entity(entity).insert(Knockout);
        }
    }
}

Again, some logic needs to be filled in, but this is what it might look like. The same approach can be used for rafts and the water:

// check whether the character is in the river, or carried by a raft
fn tick_river_collisions(
   mut commands: Commands,
   mut player_query: Query<(Entity, &mut Transform), With<Crab>>,
   // since we access player_query mutably, we need to ensure that these queries do not intersect
   // hence Without<Crab>
   raft_query: Query<(&Position, &ConstantMotor), (With<Raft>, Without<Crab>)>,
) {
    for (entity, mut transform) in player_query.iter_mut() {
        let mut should_crab_ko = false;

        if todo!() /* if player is on a river */ {
            // if player is on some raft, carry the player with the raft
            if let Some(raft_motor) = raft_query.iter().find_map(|(raft_transform, motor)| {
                if todo!() /* raft_transform, transform are colliding */ {
                    Some(motor)
                } else {
                    None
                }
            }) {
                raft_motor.drive(&mut transform);
                // need to detect if the raft moved the player offscreen here!
                should_crab_ko = todo!();
            } else {
                // if not on a raft, player is KO
                should_crab_ko = true;
            }
        }

        if should_crab_ko {
            commands.entity(entity).insert(Knockout);
        }
    }
}

So far I've left a lot of pieces missing, but hopefully this provides a nice skeleton for the solution.

Let's pause to add a new test scene and verify that ConstantMotor works as expected, and we can return to the missing chunks once we've built the Level functionality.

// e2e/motors.rs
fn spawn_raft(mut commands: Commands, spritesheets: Res<SpriteSheetAssets>) {
    commands.spawn((
        SpriteSheetBundle {
            texture_atlas: spritesheets.car.clone(),
            sprite: TextureAtlasSprite::new(0),
            ..Default::default()
        },
        ConstantMotor {
            speed: 4.,
            direction: Direction::Right,
        },
    ));
}

fn main() {
    Test {
        label: "Test constant motors".to_string(),
        setup: |app| {
            app.add_state::<AppState>()
                .add_plugin(CoreGameLoopPlugin)
                .add_system(spawn_raft.in_schedule(OnEnter(AppState::InGame)));
        },
        setup_graphics: |app| {
            app.add_plugin(CrabGraphicsPlugin);
        },
    }
    .run();
}

Oh, right, we didn't implement the whole "looping" part yet. First we define some values for the "edges" of the level:

// constants.rs
pub const MAX_X_F32: f32 = (LEVEL_WIDTH_F32 / 2 - 1) * TILE_SIZE_F32;
pub const MAX_Y_F32: f32 = (LEVEL_HEIGHT_F32 / 2 - 1) * TILE_SIZE_F32;

Then we can use those values to determine when to move the object to the other side of the screen:

// Check whether we are offscreen (and moving in that direction offscreen).
// This is a little more complicated than intuition because you want things
// to be able to enter from offscreen without being teleported farther away.
// Also probably can be improved.
fn is_offscreen(transform: &Transform, direction: Direction) -> bool {
    let max_abs = match direction {
        Direction::Left | Direction::Right => MAX_X_F32,
        Direction::Up | Direction::Down => MAX_Y_F32,
    };
    let current_value = match direction {
        Direction::Left | Direction::Right => transform.translation.x,
        Direction::Up | Direction::Down => transform.translation.y,
    };
    let motion_vector = match direction {
        Direction::Left | Direction::Right => direction.to_vec().x,
        Direction::Up | Direction::Down => direction.to_vec().y,
    };
    // we are past the max value in the expected dimension
    current_value.abs() > max_abs
        // and we are moving farther "outside" the bounds
        && current_value.is_sign_positive() == motion_vector.is_sign_positive()
}

// determine how far to "jump" the object
fn get_offset_for_loop(direction: Direction) -> Vec3 {
    let amplitude_tiles = match direction {
        Direction::Up | Direction::Down => LEVEL_HEIGHT_F32,
        Direction::Left | Direction::Right => LEVEL_WIDTH_F32,
    };
    -direction.to_vec() * TILE_SIZE_F32 * amplitude_tiles
}

...define the two useful methods for things that move this way:

impl ConstantMotor {
    // execute motion without teleporting the entity
    pub fn drive_offscreen(&self, transform: &mut Transform) -> bool {
        transform.translation += self.direction.to_vec() * self.speed;
        is_offscreen(transform, self.direction)
    }

    // execution motion and also teleport the entity to the other side of the screen
    pub fn drive_and_loop(&self, transform: &mut Transform) {
        if self.drive_offscreen(transform) {
            transform.translation += get_offset_for_loop(self.direction);
        }
    }
}

...and finally, fix tick_constant_motors:

fn tick_constant_motors(
   mut motor_query: Query<(&mut Transform, &ConstantMotor)>,
) {
   for (mut transform, motor) in motor_query.iter_mut() {
      motor.drive_and_loop(&mut transform);
   }
}

Now let's try running the e2e-motors test.


Much better. That handles all the objects moving in the level -- next we can build a character controller and sprite.

Player controls

Now to define how the player will move. Starting with an idle crab, once a movement command is executed, we want the crab to move until it reaches the next tile. While it is in motion, it cannot be interrupted. And collisions should be detected only once the crab is idle.

We can represent the player's motion state with another component:

// components.rs

// Executes motion in "steps"
// Once a step is started, motion continues until the step is complete.
#[derive(Component)]
struct StepMotor {
    // this value is None if the motor is not in motion
    // if this value is Some, the inner value is the current tick count in the lifecycle of a leap
    // we could use this later to track which sprite in an animation to render, for example
    step: Option<usize>,
}

With this definition, when step is None, it is "idle", but when it is Some, the inner value can track how many ticks have executed since motion started. We can also implement some useful helper methods on StepMotor that define common operations:

const STEP_SPEED: f32 = 4.; // 4. pixels per tick
const MOTION_STEPS: usize = 16; // 16 ticks per step * 4 px per tick = 64 px / step, which is 1 tile

impl StepMotor {
    pub fn new() -> Self {
        StepMotor { step: None }
    }

    pub fn is_running(&self) -> bool {
        self.step.is_some()
    }

    // call this to begin motion
    pub fn start(&mut self, transform: &mut Transform, direction: Direction) {
        // point the sprite in the correct direction
        *transform = transform.looking_to(Vec3::Z, direction.to_vec());
        // initialize step
        self.step = Some(0);
    }

    // call this on tick
    pub fn drive(&mut self, transform: &mut Transform) {
        if self.is_running() {
            // move forward
            transform.translation += transform.local_y() * STEP_SPEED;
            // increment step
            self.step = self
                .step
                .map(|step| step + 1)
                .filter(|&step| step < MOTION_STEPS);
        }
    }

    pub fn reset(&mut self) {
        self.step = None;
    }
}

For player controls, we can define an Action enum that represents the possible input actions.

// inputs.rs
#[derive(Clone, Debug)]
pub enum Action {
    Up, // D-Pad
    Down,
    Left,
    Right,
    Menu, // let's plan for some sort of menu button as well
}

Every tick, controller inputs will yield some Option<Action> that represents the player input. The variant of Action initiates the player's motion.

We could build the input management systems with Bevy's native tools, but I have enjoyed using leafwing-input-manager, which helps register input mappings and build action state queries in more composable and configurable ways, so we will use that here as well to implement some WASD controls.

// inputs.rs
use leafwing_input_manager::{Actionlike, InputManagerBundle, InputManagerPlugin};

// new: derive `ActionLike`
#[derive(Actionlike, Clone, Debug)]
pub enum Action { Up, Down, Left, Right, Menu }

pub type PlayerActionState = ActionState<Action>;

// We use the "wrapper bundle" strategy to make it easy to attach wasd controls to an entity
#[derive(Bundle)]
struct WASDControllerBundle {
    input_manager: InputManagerBundle::<Action>
}

impl WASDControllerBundle {
    pub fn new() -> Self {
        WASDControllerBundle {
            input_manager: InputManagerBundle::<Action> {
                action_state: ActionState::default(),
                input_map: InputMap::new([
                    (KeyCode::W, Action::Up),
                    (KeyCode::A, Action::Left),
                    (KeyCode::S, Action::Down),
                    (KeyCode::D, Action::Right),
                    (KeyCode::Escape, Action::Menu),
                ])
                .build(),
            },
        }
    }
}

fn register_inputs(
    mut player_query: Query<(&mut Transform, &mut StepMotor, &PlayerActionState)>,
) {
    for (transform, motor, action) in player_query.iter() {
        let direction = if action.pressed(Action::Up) {
            Some(Direction::Up)
        } else if action.pressed(Action::Down) {
            Some(Direction::Down)
        } else if action.pressed(Action::Left) {
            Some(Direction::Left)
        } else if action.pressed(Action::Right) {
            Some(Direction::Right)
        } else {
            None
        };
        if let Some(direction) = direction && !motor.is_running() {
            motor.start(&mut transform, direction);
        }
    }
}

And we can wrap this functionality in a Plugin to for easy usage. Any entity with a PlayerActionState, Transform, and StepMotor will be able to process inputs once this plugin is attached.

pub struct InputPlugin;

impl Plugin for InputPlugin {
    fn build(&self, app: &mut App) {
        app.add_plugin(InputManagerPlugin::<Action>::default())
            .add_system(
                register_inputs
                    .in_base_set(CoreSet::PreUpdate)
                    .after(InputSystem),
            );
    }
}

Now to bring it all together, we can add a "tick" system that will execute the StepMotor::drive to yield the forward motion for active motors.

// tick.rs
fn tick_step_motors(mut motor_query: Query<(&mut Transform, &mut StepMotor)>) {
    for (mut transform, mut motor) in motor_query.iter_mut() {
        motor.drive(&mut transform);
    }
}

Finally we can write some tests to check if all this works as expected:

// e2e/inputs.rs
use bevy::{
    prelude::{Commands, IntoSystemAppConfig, OnEnter, Query, Res, Transform},
    sprite::{SpriteSheetBundle, TextureAtlasSprite},
};

use common_e2e::Test;

use crabber::{
    components::{Crab, StepMotor, Transform},
    resources::SpriteSheetAssets,
    AppState, GraphicsPlugin as CrabGraphicsPlugin, InputPlugin, WASDControllerBundle,
};

// I will move this to a different plugin later, but you could choose to add it to
// whichever plugin makes the most sense for you
fn tick_step_motors(mut motor_query: Query<(&mut Transform, &mut StepMotor)>) {
    for (mut transform, mut motor) in motor_query.iter_mut() {
        motor.drive(&mut transform);
    }
}

fn spawn_crab(mut commands: Commands, spritesheets: Res<SpriteSheetAssets>) {
    commands.spawn((
        // this provides Transform
        SpriteSheetBundle {
            texture_atlas: spritesheets.crab.clone(),
            sprite: TextureAtlasSprite::new(0),
            ..Default::default()
        },
        StepMotor::new(), // StepMotor
        WASDControllerBundle::new(), // PlayerActionState
        Crab,
    ));
}

fn main() {
    Test {
        label: "Test inputs".to_string(),
        setup: |app| {
            app.add_state::<AppState>()
                .add_plugin(InputPlugin) // this will allow us to register inputs
                .add_system(spawn_crab.in_schedule(OnEnter(AppState::InGame)))
                .add_system(tick_step_motors);
        },
        setup_graphics: |app| {
            app.add_plugin(CrabGraphicsPlugin);
        },
    }
    .run();
}

Ta-da! We can "step" the crab around the screen. It does rotate to face the direction of movement, but the movement isn't animated at all. Since StepMotor has a step counter, we can use it to update the crab's TextureAtlasSprite:

// components.rs
impl StepMotor { 
    pub fn get_sprite_index(&self) -> usize {
        match self.step {
            Some(step) => step % 2,
            None => 0,
        }
    }
}

// graphics.rs
use bevy::prelude::TextureAtlasSprite;
fn animate_sprites(
    mut crab_query: Query<(&StepMotor, &mut TextureAtlasSprite), (Changed<StepMotor>, With<Crab>)>,
) {
    for (motor, mut sprite) in crab_query.iter_mut() {
        sprite.index = motor.get_sprite_index();
    }
}

This video makes it look pretty decent! I have to admit though, it looks hilariously janky in the real test run. Basically, since we aren't limiting the tick rate in any way, it processes the sprite updates fast enough that it just looks blurred between the two sprites. Thankfully, my recording software captured the video at a better framerate which shows the little scuttle motion.

Anyway, hilarity aside, this does get the point across. Next let's build the level and spawn some level entities.

Level

To build the level, we need two main features:

  • Randomly generating what rows to spawn
  • Generating the tilemap for the randomly-generated rows
  • Randomly generating the direction, speed, and number of game entities to spawn on a given row

The first part isn't too hard. We could imagine building out some complex grammar here, but for now, let's just write a small skeleton of one. We can generate each row based on some likelihoods that are dependent on the previous row. (To be honest, this has been a is a weak solution, but I wrote it way long ago and there has been better stuff to do.)

#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LevelRow {
    Grass,
    River,
    Road,
    Finish,
}

impl LevelRow {
    fn get_random_next(&self) -> LevelRow {
        let mut rng = rand::thread_rng();
        let r = rng.gen_range(0..=9);
        match self {
            LevelRow::Grass => match r {
                0..=3 => LevelRow::Grass,
                4..=6 => LevelRow::Road,
                _ => LevelRow::River,
            },
            LevelRow::River => match r {
                0..=4 => LevelRow::River,
                5..=7 => LevelRow::Grass,
                _ => LevelRow::Road,
            },
            LevelRow::Road => match r {
                0..=5 => LevelRow::Road,
                5..=8 => LevelRow::Grass,
                _ => LevelRow::River,
            },
            LevelRow::Finish => LevelRow::Finish,
        }
    }
}

Let's also define some useful constants:

// In code, these are defined as LEVEL_WIDTH_I16 or LEVEL_HEIGHT_F32
// since it is often useful to use these values in a variety of places, which often have different types.
pub const TILE_SIZE: i16 = 64; // the crab sprite is 64x64, so let's use that
pub const LEVEL_HEIGHT: i16 = 10; // a short course seems nice for these purposes
pub const LEVEL_WIDTH: i16 = 20; // idk

// We also need to set "layers" for the sprites, this is one way to name the layers.
pub const BACKGROUND_Z: f32 = 1.;
pub const LEVEL_Z: f32 = 3.;
pub const PLAYER_Z: f32 = 5.;

Each river or road row will spawn a random number of non-colliding objects (rafts or cars). We pick a random direction and spawn at random locations until we reach the end of the row. The snippet below shows some utilities which help with that.

fn select_random_left_or_right() -> Direction {
    let mut rng = rand::thread_rng();
    match rng.gen_range(0..=1) {
        0 => Direction::Left,
        _ => Direction::Right,
    }
}

fn build_random_motors(row_index: i16) -> Vec<(Transform, ConstantMotor)> {
    let mut rng = rand::thread_rng();
    let speed = rng.gen_range(1.0..6.0);
    let direction = select_random_left_or_right();
    let y = (row_index) * TILE_SIZE_I16;

    let mut vec = Vec::new();
    let mut current_x: i16 = 0;
    while current_x < LEVEL_WIDTH_I16 {
        let position = rng.gen_range(current_x..LEVEL_WIDTH_I16);

        // spawn a random-size clump
        let num_rafts = rng.gen_range(1i16..3);

        // space this clump from the next clump by at least 1
        current_x = position + num_rafts + rng.gen_range(1i16..3);

        for index in 0..num_rafts {
            let x = (position + index) * TILE_SIZE_I16;
            vec.push((
                Transform::from_xyz(x as f32, y as f32, LEVEL_Z),
                ConstantMotor { speed, direction },
            ));
        }
    }

    vec
}

Now we can define a system that builds each row and spawns the appropriate objects.

fn spawn_level_entities(
    mut commands: Commands,
    spritesheets: Res<SpriteSheetAssets>,
) {
    // The level should start with grass
    let mut level_row_kind = LevelRow::Grass;
    // Starting at 1, since the first row is already added, we can populate up to (exclusive) N-1
    // (since the last row is the finish line).
    for row_index in 1..(LEVEL_HEIGHT_I16 - 1) {
        level_row_kind = level_row_kind.get_random_next();

        if LevelRow::Road == level_row_kind {
            for (transform, motor) in build_random_motors(row_index).into_iter() {
                commands.spawn((
                    Car,
                    motor,
                    SpriteSheetBundle {
                        texture_atlas: spritesheets.car.clone(),
                        // do a little more here later on to spawn different color cars from a spritesheet, etc...
                        sprite: TextureAtlasSprite::new(0),
                        transform,
                        ..Default::default()
                    },
                ));
            }
        }
        if LevelRow::River == level_row_kind {
            for (transform, motor) in build_random_motors(row_index).into_iter() {
                commands.spawn((
                    Raft,
                    motor,
                    SpriteBundle {
                        texture: sprites.raft.clone(),
                        transform,
                        ..Default::default()
                    },
                ));
            }
        }
    }
}

To render the tilemap, we can use bevy_ecs_tilemap. It has many examples that show how, for example, to fill a tilemap of some size. I don't want to clutter the "game object" code above with the "tilemap" code, so instead we can split the logic in spawn_level_entities into two functions, create_level_and_spawn_entities and spawn_level. Then we can add a new spawn_level_tilemap (there's a lot of content in here I'm not discussing, so check out bevy_ecs_tilemap's examples to learn more):

// the "game object" code
fn create_level_and_spawn_entities(
    commands: &mut Commands,
    spritesheets: &SpriteSheetAssets,
) -> Vec<LevelRow> {
    let mut level = Vec::new();
    let mut level_row_kind = LevelRow::Grass;
    level.push(level_row_kind);
    for row_index in 0..LEVEL_HEIGHT_I16 {
        level_row_kind = level_row_kind.get_random_next();
        level.push(level_row_kind);

        // ... same as before
    }
    // Finally, add a finish line
    level.push(LevelRow::Finish);
    level
}

#[derive(Component)]
pub struct Level(pub Vec<LevelRow>);

// the tilemap code
// see bevy_ecs_tilemap examples for more
pub fn spawn_level_tilemap(
    commands: &mut Commands,
    level: Vec<LevelRow>,
    texture_atlas: &TextureAtlas,
) {
    let map_entity = commands.spawn_empty().id();

    let tilemap_size = TilemapSize {
        x: LEVEL_WIDTH_U32,
        y: LEVEL_HEIGHT_U32,
    };
    let row_size = TilemapSize {
        x: LEVEL_WIDTH_U32,
        y: 1,
    };
    let tile_size = TilemapTileSize {
        x: TILE_SIZE_F32,
        y: TILE_SIZE_F32,
    };

    let mut tile_storage = TileStorage::empty(tilemap_size);
    let tilemap_id = TilemapId(map_entity);

    for (y, level_row) in level.iter().enumerate() {
        let texture_index = TileTextureIndex(match level_row {
            LevelRow::Grass => 1,
            LevelRow::River => 0,
            LevelRow::Road => 2,
            LevelRow::Finish => 3,
        });

        fill_tilemap_rect(
            texture_index,
            TilePos { x: 0, y: y as u32 },
            row_size,
            tilemap_id,
            commands,
            &mut tile_storage,
        );
    }

    let grid_size: TilemapGridSize = tile_size.into();
    let map_type = TilemapType::default();

    commands.entity(map_entity).insert((
        TilemapBundle {
            map_type,
            tile_size,
            grid_size,
            size: tilemap_size,
            storage: tile_storage,
            texture: TilemapTexture::Single(texture_atlas.texture.clone()),
            transform: get_tilemap_center_transform(&tilemap_size, &grid_size, &map_type, BACKGROUND_Z),
            ..Default::default()
        },
        Level(level),
    ));
}

// the system which calls helpers
pub fn setup_level(
    mut commands: Commands,
    spritesheets: Res<SpriteSheetAssets>,
    atlas_assets: Res<Assets<TextureAtlas>>,
) {
    // get the level and spawn relevant cars and rafts
    let level = create_level_and_spawn_entities(&mut commands, &sprites, &spritesheets);
    let texture_atlas = atlas_assets.get(&spritesheets.level).unwrap();
    // spawn the tilemap for the level
    spawn_level_tilemap(&mut commands, level, &texture_atlas);
}

With that, the level is all set. We can add a plugin to capture the expected behavior like elsewhere:

pub struct LevelPlugin;

impl Plugin for LevelPlugin {
    fn build(&self, app: &mut App) {
        app.add_plugin(TilemapPlugin)
            .add_system(setup_level.in_schedule(OnEnter(AppState::InGame)));
    }
}

Now we can test it out:

// e2e/spawn-level.rs
fn main() {
    Test {
        label: "Test spawning level entities".to_string(),
        setup: |app| {
            app.add_state::<AppState>().add_plugin(LevelPlugin);
        },
        setup_graphics: |app| {
            app.add_plugin(CrabGraphicsPlugin);
        },
    }
    .run();
}

Screenshot from test showing poorly-aligned game-object tiles

Whoa, everything's way out of alignment! What gives?

Well, we set the Transform of the tilemap with get_tilemap_center_transform, but we didn't move all the spawned objects to match.

How should we solve this? We could call get_tilemap_center_transform before spawning the game objects, and use that as an "origin" for calculating other transforms. Another good approach would be to keep the tilemap at Transform::ZERO, and move the camera based on the tilemap positioning -- or, better, based on some camera logic that follows the player on the level's "track". Both of these would provide logical consistency for the set of transform calculations being performed.

Later on, if I build the gameplay out more, I'd probably want to go with the second option. For now, to avoid dwelling on correctness, I'm just going to slap on some offsets that will fix the alignment:

fn build_random_motors(row_index: i16) -> Vec<(Transform, ConstantMotor)> {
    // ...
    let y = (row_index - LEVEL_HEIGHT_I16 / 2) * TILE_SIZE_I16 + TILE_SIZE_I16 / 2;
    // ...
    let x = (position + index - LEVEL_WIDTH_I16 / 2) * TILE_SIZE_I16 + TILE_SIZE_I16 / 2;
    // ...
}

Screenshot from test showing correctly aligned game-object tiles

Looks like that fixes it for now.

Since we've already run into some alignment issues, it might be useful to set up some development graphics to help with visual debugging later on. This will be especially useful when we code the object collisions.

To do that, let's import bevy_prototype_lyon, which I mentioned briefly at the end of the last post, as a development-only dependency:

[dev-dependencies]
common_e2e = { path = "../lib/common-e2e" }
bevy_prototype_lyon = "*"

This allows us to draw some simple shapes in the 2D camera view. We can use it to wrap our sprites with little rectangles and also draw an "anchor" to help clarify the centerpoint and edges of the sprite's "tile" (the tile-sized space around its transform center).

In e2e/spawn-level.rs, we can define a system that draws a box around all our crab, car, and raft sprites:

// a utility that attaches debug graphics to objects to show where their transforms
// are centered as well as the bounds of the "tile" around it 
pub fn handle_debug_graphic(
    mut commands: Commands,
    new_game_object_query: Query<
        (Entity, Option<&Crab>, Option<&Car>, Option<&Raft>),
        // `Or` filters queries for a union of the filters in the tuple
        // `Added` filters queries so that only entities where the component has changed
        //   _since the last system tick_.
        Or<(Added<Crab>, Added<Car>, Added<Raft>)>,
    >,
) {
    for (entity, crab, car, raft) in new_game_object_query.iter() {
        // pick a color based on which one is selected
        // because we are enforcing the `Or`, we know that one of these should be `Some`.
        let color = if crab.is_some() {
            Some(Color::BLUE)
        } else if car.is_some() {
            Some(Color::RED)
        } else if raft.is_some() {
            Some(Color::GREEN)
        } else {
            None
        };

        if let Some(color) = color {
            // attach the shapes as children so that shapes are centered on the transform
            // and also we can do whatever we need, e.g. increase Z offset to ensure the
            // shape doesn't get occluded by the sprite.
            commands.entity(entity).with_children(|parent| {
                let highlight = Rectangle {
                    origin: RectangleOrigin::Center,
                    extents: Vec2::new(TILE_SIZE_F32, TILE_SIZE_F32),
                };
                let anchor = Circle {
                    radius: 6.,
                    center: Vec2::ZERO,
                };
                let stroke_color = Color::rgba(color.r(), color.g(), color.b(), 0.7);
                // a tile-sized box to define the boundaries of the tile
                parent.spawn((
                    ShapeBundle {
                        path: GeometryBuilder::build_as(&highlight),
                        transform: Transform::from_xyz(0., 0., 1.),
                        ..Default::default()
                    },
                    Stroke::color(stroke_color),
                ));
                // a small filled dot to highlight the centerpoint
                parent.spawn((
                    ShapeBundle {
                        path: GeometryBuilder::build_as(&anchor),
                        transform: Transform::from_xyz(0., 0., 1.),
                        ..Default::default()
                    },
                    Fill::color(color),
                ));
            });
        }
    }
}

...and attach it to our plugin alongside the ShapePlugin:

fn main() {
    Test {
        label: "Test spawning level entities".to_string(),
        setup: |app| {
            app.add_state::<AppState>().add_plugin(LevelPlugin);
        },
        setup_graphics: |app| {
            app.add_plugin(CrabGraphicsPlugin)
                .add_plugin(ShapePlugin)
                .add_system(handle_debug_graphic);
        },
    }
    .run();
}

Screenshot showing the level decorated with little squares and a target dot, which show the tile around and the center of the entity's position.

Now we see our debug graphics! This test will come in handy if we ever get confused about sprite alignment later on.

Tick: Reloaded

We still need to finish the car, river, and raft collision detection for our tick systems.

First, we need to add a way to compare transforms for collision. We have the concept of a "tile", and we wrote some code to convert Transforms to "tile x/y" values. We can define types to help with these conversions:

// will uses LEVEL_WIDTH and compare column values
pub struct TileColumn(pub i16);
// will use LEVEL_HEIGHT and compare row values
pub struct TileRow(pub i16);

impl From<f32> for TileColumn {
    fn from(x: f32) -> TileColumn {
        TileColumn(((x - TILE_SIZE_F32 / 2.) / TILE_SIZE_F32 + LEVEL_WIDTH_F32 / 2.) as i16)
    }
}

impl From<TileColumn> for f32 {
    fn from(TileColumn(column): TileColumn) -> f32 {
        ((column - LEVEL_WIDTH_I16 / 2) * TILE_SIZE_I16 + TILE_SIZE_I16 / 2) as f32
    }
}

impl From<f32> for TileRow {
    fn from(y: f32) -> TileRow {
        TileRow(((y - TILE_SIZE_F32 / 2.) / TILE_SIZE_F32 + LEVEL_HEIGHT_F32 / 2.) as i16)
    }
}

impl From<TileRow> for f32 {
    fn from(TileRow(row): TileRow) -> f32 {
        ((row - LEVEL_HEIGHT_I16 / 2) * TILE_SIZE_I16 + TILE_SIZE_I16 / 2) as f32
    }
}

Now that we have these, we are able to determine what kind of row the crab is on. We can query for the entity with the Level component to check a given row's kind:

// level.rs
impl Level {
    pub fn is_row_of_kind(&self, row: i16, target: LevelRow) -> bool {
        self.0
            .get(row as usize)
            .and_then(|row_kind| if *row_kind == target { Some(()) } else { None })
            .is_some()
    }
}

// tick.rs
let TileRow(row) = TileRow::from(transform.translation.y);
let is_on_river = !player_motor.is_running() && level.is_row_of_kind(row, LevelRow::River);

Collision detection can perform a square-intersection check with r=TILE_SIZE.

fn do_tiles_collide(transform_a: &Transform, transform_b: &Transform) -> bool {
    let dx = transform_a.translation.x - transform_b.translation.x;
    let dy = transform_a.translation.y - transform_b.translation.y;
    dx.abs() < TILE_SIZE_F32 && dy.abs() < TILE_SIZE_F32
}

And now we can join these to complete the missing pieces of our collision systems:

fn tick_road_collisions(
    mut commands: Commands,
    level_query: Query<&Level>,
    player_query: Query<(Entity, &Transform, &StepMotor), With<Crab>>,
    car_query: Query<(&Car, &Transform)>,
) {
    if let Ok(level) = level_query.get_single() {
        for (entity, transform, motor) in player_query.iter() {
            let TileRow(row) = TileRow::from(transform.translation.y);
            if !motor.is_running()
                && level.is_row_of_kind(row, LevelRow::Road)
                && car_query
                    .iter()
                    .any(|(_, car_transform)| do_tiles_collide(transform, car_transform))
            {
                commands.entity(entity).insert(Knockout);
            }
        }
    }
}

fn tick_river_collisions(
    mut commands: Commands,
    level_query: Query<&Level>,
    mut player_query: Query<(Entity, &mut Transform, &StepMotor), With<Crab>>,
    raft_query: Query<(&Position, &ConstantMotor), (With<Raft>, Without<Crab>)>,
) {
    if let Ok(level) = level_query.get_single() {
        for (entity, mut transform, motor) in player_query.iter_mut() {
            let TileRow(row) = TileRow::from(transform.translation.y);
            let mut should_crab_ko = false;

            // if player is on a river...
            if !motor.is_running() && level.is_row_of_kind(row, LevelRow::River) {
                if let Some(raft_motor) = raft_query.iter().find_map(|(raft_transform, motor)| {
                    if do_tiles_collide(transform, raft_transform) {
                        Some(motor)
                    } else {
                        None
                    }
                }) {
                    // ...and also colliding on a raft, player will KO if they are driven offscreen
                    should_crab_ko = raft_motor.drive_offscreen(&mut transform);
                } else {
                    // ...and not on a raft, player is KO
                    should_crab_ko = true;
                }
            }

            if should_crab_ko {
                commands.entity(entity).insert(Knockout);
            }
        }
    }
}

Great! Now, when a crab is KO'd, we should change its visuals somehow. Let's add a system that "reacts" to new Knockout components and mutates the corresponding crab's sprite. We can add a tint and flip the y-axis to make it look "dead":

fn handle_knockout(mut ko_query: Query<&mut TextureAtlasSprite, Added<Knockout>>) {
    for mut sprite in ko_query.iter_mut() {
        sprite.color = Color::rgba(1., 1., 1., 0.5);
        sprite.flip_y = true;
    }
}

With that, we've defined all the systems that we need to run our game logic!

Wrapping up

To conclude, let's build one last test that combines everything.

// e2e/full-game.rs

fn spawn_sprite(mut commands: Commands, spritesheets: Res<SpriteSheetAssets>) {
    commands.spawn((
        Crab,
        SpriteSheetBundle {
            texture_atlas: spritesheets.crab.clone(),
            sprite: TextureAtlasSprite::new(0),
            transform: Transform::from_xyz(0., 0., PLAYER_Z),
            ..Default::default()
        },
        WASDControllerBundle::new(),
        StepMotor::new(),
    ));
}

fn main() {
    Test {
        label: "Test full game".to_string(),
        setup: |app| {
            app.add_state::<AppState>()
                .add_plugin(InputPlugin)
                .add_plugin(CoreGameLoopPlugin)
                .add_plugin(LevelPlugin)
                .add_system(spawn_sprite.in_schedule(OnEnter(AppState::InGame)));
        },
        setup_graphics: |app| {
            app.add_plugin(CrabGraphicsPlugin);
        },
    }
    .run();
}

There are a few missing pieces in the code as I've written it so far, mostly easy-to-fix small stuff. I've gone ahead and fixed these up in the repository, but if you're following along, running this test should expose some of them pretty quickly.

For example: we haven't filtered for Knockout as often as we should!


As the video shows, we also aren't quite "looping" at the right times, and cars are in reverse.

I'm not going to worry about fixing most of these bugs, since this post is long enough and I want to get on to more juicy structural stuff (for which the details of gameplay don't really matter very much). If you're following along and haven't tried this approach before, this could be a nice sandbox to learn in.

In the next post, we're going to add online multiplayer functionality to this game using naia. See you there!

Continue reading