Bevy Traditional Roguelike Quick-Start - 4. À la Carte Sorcery

With our only unique skill of note being moving around, it's hard to feel emotionally invested in these poor critters running in circles forever in an unbreakable cage. An inevitable component of fantasy gaming is required: magic.

Now, with the way the system is currently set up, "pressing this button to dash forwards 4 spaces" would be extremely easy. We can do better - a system which would normally be painful to implement, but which takes advantage of Rust's pattern matching and enums, as well as Bevy's system ordering... Enter - Spell Crafting.

Design Capsule
Spells will be composed of a series of Forms and Functions. Forms choose tiles on the screen, and Functions execute an effect on those tiles. In the case of a lasso, for example, the Form is a projectile and the Function is getting constricted.

Create a new file, spells.rs.

// spells.rs
use bevy::prelude::*;

pub struct SpellPlugin;

impl Plugin for SpellPlugin {
    fn build(&self, app: &mut App) {}
}

Don't forget to link it into main.rs.

// main.rs

// SNIP
mod spells;

// SNIP
use spells::SpellPlugin;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .add_plugins((
            SpellPlugin, // NEW!
            EventPlugin,
            GraphicsPlugin,
            MapPlugin,
            InputPlugin,
        ))
        .run();
}

D.I.Y. Wizard

Now, we may start to populate this new plugin with some starting structs and enums. I named the individual components that form a Spell "Axiom" because:

  1. Calling them "Components" would get confusing fast with Bevy components.
  2. They are things that happen, an enforceable truth.
  3. The word "Axiom" is just dripping with flair and style.
// spells.rs
#[derive(Event)]
/// Triggered when a creature (the `caster`) casts a `spell`.
pub struct CastSpell {
    pub caster: Entity,
    pub spell: Spell,
}

#[derive(Component, Clone)]
/// A spell is composed of a list of "Axioms", which will select tiles or execute an effect onto
/// those tiles, in the order they are listed.
pub struct Spell {
    pub axioms: Vec<Axiom>,
}

#[derive(Debug, Clone)]
/// There are Form axioms, which target certain tiles, and Function axioms, which execute an effect
/// onto those tiles.
pub enum Axiom {
    // FORMS
    /// Target the caster's tile.
    Ego,

    // FUNCTIONS
    /// The targeted creatures dash in the direction of the caster's last move.
    Dash { max_distance: i32 },
}

We will begin with the very simple spell "Ego, Dash". When cast, the caster dashes in the direction of their last move. Note that I didn't use "Self" for the self-target, because it's already taken by Rust as a keyword, and "Ego" sounds very cool.

The implementation will rely on a struct with yet another cute name: Synapses. Named after the transmission of signals between neurons, they are like a snowball rolling down a hill and accumulating debris.

When a new SynapseData is created, it is blank except for the fact that it knows its caster. It still has no tiles to target (targets is an empty vector), and is on the first step of its execution (step is 0). As it "rolls" down the list of Axioms, it will accumulate targets - tiles where the spell effect happen.

// spells.rs
/// The tracker of everything which determines how a certain spell will act.
struct SynapseData {
    /// Where a spell will act.
    targets: Vec<Position>,
    /// How a spell will act.
    axioms: Vec<Axiom>,
    /// The nth axiom currently being executed.
    step: usize,
    /// Who cast the spell.
    caster: Entity,
}

impl SynapseData {
    /// Create a blank SynapseData.
    fn new(caster: Entity, axioms: Vec<Axiom>) -> Self {
        SynapseData {
            targets: Vec::new(),
            axioms,
            step: 0,
            caster,
        }
    }

    /// Get the Entity of each creature standing on a tile inside `targets` and its position.
    fn get_all_targeted_entity_pos_pairs(&self, map: &Map) -> Vec<(Entity, Position)> {
        let mut targeted_pairs = Vec::new();
        for target in &self.targets {
            if let Some(creature) = map.get_entity_at(target.x, target.y) {
                targeted_pairs.push((*creature, *target));
            }
        }
        targeted_pairs
    }
}

Each ̀synapse is like a customer at a restaurant - when a spell is cast, it is added to a SpellStack. The most recently added spells are handled first, which is not the hallmark of great customer service. This is because spells will later be capable of having chain reactions...

For example, dashing onto a trap which triggers when it is stepped on should resolve the trap effects before continuing with the dash spell.

// spells.rs
pub fn cast_new_spell(
    mut cast_spells: EventReader<CastSpell>,
    mut spell_stack: ResMut<SpellStack>,
) {
    for cast_spell in cast_spells.read() {
        // First, get the list of Axioms.
        let axioms = cast_spell.spell.axioms.clone();
        // Create a new synapse to start "rolling down the hill" accumulating targets and
        // dispatching events.
        let synapse_data = SynapseData::new(cast_spell.caster, axioms);
        // Send it off for processing - right away, for the spell stack is "last in, first out."
        spell_stack.spells.push(synapse_data);
    }
}

Each tick, we'll get the most recently added spell, and where it currently is in its execution (its step). We'll get the corresponding ̀Axiom - for example, being at step 0 in [Axiom::Ego, Axiom::Dash] will result in running Axiom::Ego. Then, we'll run a matching one-shot system - a Bevy feature I will soon demonstrate.

// spells.rs
/// Get the most recently added spell (re-adding it at the end if it's not complete yet).
/// Get the next axiom, and runs its effects.
pub fn process_axiom(
    mut commands: Commands,
    axioms: Res<AxiomLibrary>,
    spell_stack: Res<SpellStack>,
) {
    // Get the most recently added spell, if it exists.
    if let Some(synapse_data) = spell_stack.spells.last() {
        // Get its first axiom.
        let axiom = synapse_data.axioms.get(synapse_data.step).unwrap();
        // Launch the axiom, which will send out some Events (if it's a Function,
        // which affect the game world) or add some target tiles (if it's a Form, which
        // decides where the Functions will take place.)
        commands.run_system(*axioms.library.get(&discriminant(axiom)).unwrap());
        // Clean up afterwards, continuing the spell execution.
        commands.run_system(spell_stack.cleanup_id);
    }
}

But first, how do we match the Axiom with the corresponding one-shot system? It uses mem::discriminant - which should be imported right away, and an AxiomLibrary resource.

// spells.rs
#[derive(Resource)]
/// All available Axioms and their corresponding systems.
pub struct AxiomLibrary {
    pub library: HashMap<Discriminant<Axiom>, SystemId>,
}

impl FromWorld for AxiomLibrary {
    fn from_world(world: &mut World) -> Self {
        let mut axioms = AxiomLibrary {
            library: HashMap::new(),
        };
        axioms.library.insert(
            discriminant(&Axiom::Ego),
            world.register_system(axiom_form_ego),
        );
        axioms.library.insert(
            discriminant(&Axiom::Dash { max_distance: 1 }),
            world.register_system(axiom_function_dash),
        );
        axioms
    }
}

We use discriminants, because each Axiom can possibly have extra fields like max_distance, and we wish to differentiate them by variant regardless of their inner contents. We link each one with its own one-shot system, currently axiom_form_ego and axiom_function_dash. These systems - which are not yet implemented - are registered into the World, Bevy's term for the struct which contains... well, everything. Each time a ̀Query is ran, behind the scenes, it reaches into the World to look up entities similarly to SQL queries!

Now, for the SpellStack:

// spells.rs
#[derive(Resource)]
/// The current spells being executed.
pub struct SpellStack {
    /// The stack of spells, last in, first out.
    spells: Vec<SynapseData>,
    /// A system used to clean up the last spells after each Axiom is processed.
    cleanup_id: SystemId,
}

impl FromWorld for SpellStack {
    fn from_world(world: &mut World) -> Self {
        SpellStack {
            spells: Vec::new(),
            cleanup_id: world.register_system(cleanup_last_axiom),
        }
    }
}

One more one-shot system to be implemented later, cleanup_last_axiom. Let's get started with ̀Ego and Dash's one-shot systems. Called with commands.run_system, these are detached from the scheduled Startup and Update, being ran only when demanded. They will never be ran in parallel with another system, which can, in some cases, be a performance bottleneck - but it's exactly what we need for this use-case.

// spells.rs
/// Target the caster's tile.
fn axiom_form_ego(
    mut spell_stack: ResMut<SpellStack>,
    position: Query<&Position>,
) {
    // Get the currently executed spell.
    let synapse_data = spell_stack.spells.last_mut().unwrap();
    // Get the caster's position.
    let caster_position = *position.get(synapse_data.caster).unwrap();
    // Add that caster's position to the targets.
    synapse_data.targets.push(caster_position);
}

Dashing is a significantly more involved process. For each creature standing on a tile targeted by a Form (in this case, the Player only - Ego is cast by the Player, and selects itself), they are commanded to dash in the direction of the Player's last step. This is done by effectively shooting a "beam" forwards, propagating through empty tiles until it hits an impassable one.

// spells.rs
/// The targeted creatures dash in the direction of the caster's last move.
fn axiom_function_dash(
    mut teleport: EventWriter<TeleportEntity>,
    map: Res<Map>,
    spell_stack: Res<SpellStack>,
    momentum: Query<&OrdDir>,
) {
    let synapse_data = spell_stack.spells.last().unwrap();
    let caster_momentum = momentum.get(synapse_data.caster).unwrap();
    if let Axiom::Dash { max_distance } = synapse_data.axioms[synapse_data.step] {
        // For each (Entity, Position) on a targeted tile with a creature on it...
        for (dasher, dasher_pos) in synapse_data.get_all_targeted_entity_pos_pairs(&map) {
            // The dashing creature starts where it currently is standing.
            let mut final_dash_destination = dasher_pos;
            // It will travel in the direction of the caster's last move.
            let (off_x, off_y) = caster_momentum.as_offset();
            // The dash has a maximum travel distance of `max_distance`.
            let mut distance_travelled = 0;
            while distance_travelled < max_distance {
                distance_travelled += 1;
                // Stop dashing if a solid Creature is hit and the dasher is not intangible.
                if !map.is_passable(
                        final_dash_destination.x + off_x,
                        final_dash_destination.y + off_y,
                    )
                {
                    break;
                }
                // Otherwise, keep offsetting the dashing creature's position.
                final_dash_destination.shift(off_x, off_y);
            }

            // Once finished, release the Teleport event.
            teleport.send(TeleportEntity {
                destination: final_dash_destination,
                entity: dasher,
            });
        }
    } else {
        // This should NEVER trigger. This system was chosen to run because the
        // next axiom in the SpellStack explicitly requested it by being an Axiom::Dash.
        panic!()
    }
}

This is almost perfect, aside from the fact that we have absolutely no idea what the Player's last step direction was... And that's what OrdDir is for!

Weaving Magic From Motion (OrdDir momentum component)

OrdDir already exists, but it is currently nothing but a simple enum. It could be elevated into a much greater Component...

// main.rs
#[derive(Component, PartialEq, Eq, Copy, Clone, Debug)] // CHANGED: Added Component.
pub enum OrdDir {
    Up,
    Right,
    Down,
    Left,
}

It will also need to be a crucial part of each Creature.

// creature.rs
#[derive(Bundle)]
pub struct Creature {
    pub position: Position,
    pub momentum: OrdDir, // NEW!
    pub sprite: Sprite,
}

This will instantly rain down errors into the crate - all Creatures must now receive this new Component.

// map.rs
fn spawn_player(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    atlas_layout: Res<SpriteSheetAtlas>,
) {
            // SNIP
                ..default()
            },
            momentum: OrdDir::Up, // NEW!
        },
        Player,
    ));
}
fn spawn_cage(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    atlas_layout: Res<SpriteSheetAtlas>,
) {
            // SNIP
                ..default()
            },
            momentum: OrdDir::Up, // NEW!
        });
        if tile_char == 'H' {
            creature.insert(Hunt);
        }
    }
}

All good, but all Creatures are now eternally "facing" upwards regardless of their actions. Let us adjust this, at least for only the Player... for now.

// events.rs
fn player_step(
    mut events: EventReader<PlayerStep>,
    mut teleporter: EventWriter<TeleportEntity>,
    mut player: Query<(Entity, &Position, &mut OrdDir), With<Player>>, // CHANGED - mutable, and with &mut OrdDir
    hunters: Query<(Entity, &Position), With<Hunt>>,
    map: Res<Map>,
) {
        let (player_entity, player_pos, mut player_momentum) // CHANGED - New mutable player_momentum
            = player.get_single_mut().expect("0 or 2+ players"); // CHANGED - get_single_mut
        // SNIP
        teleporter.send(TeleportEntity::new(
            player_entity,
            player_pos.x + off_x,
            player_pos.y + off_y,
        ));
        // NEW!
        // Update the direction towards which this creature is facing.
        *player_momentum = event.direction;
        // End NEW.

        for (hunter_entity, hunter_pos) in hunters.iter() {
        // SNIP
        }
    }
}

The Cleanup

Remember this? commands.run_system(spell_stack.cleanup_id); Running Axioms is fine and all, but we'll also want to progress through the list so we aren't stuck selecting the player's tile for eternity.

fn cleanup_last_axiom(mut spell_stack: ResMut<SpellStack>) {
    // Get the currently executed spell, removing it temporarily.
    let mut synapse_data = spell_stack.spells.pop().unwrap();
    // Step forwards in the axiom queue.
    synapse_data.step += 1;
    // If the spell is finished, do not push it back.
    if synapse_data.axioms.get(synapse_data.step).is_some() {
        spell_stack.spells.push(synapse_data);
    }
}

The step advances, and the spell is removed if it is finished. Therefore, a typical spell would run like this:

  • Step 0, run Ego. Select the Player.
  • Cleanup. Move to step 1, the spell isn't finished yet.
  • Step 1, run Dash. The Player teleports.
  • Cleanup, Move to step 2. There is no Axiom at index 2, and the spell is deleted.

The Test Run

After all this, the first spell Ego, Dash is ready to enter our grimoire - and while that was a lot, future spell effects will be a lot easier to implement from now on. Simply add more entries in the AxiomLibrary with one-shot systems to match!

One last thing: actually casting it.

// input.rs
/// Each frame, if a button is pressed, move the player 1 tile.
fn keyboard_input(
    player: Query<Entity, With<Player>>, // NEW!
    mut spell: EventWriter<CastSpell>, // NEW!
    mut events: EventWriter<PlayerStep>,
    input: Res<ButtonInput<KeyCode>>,
) {
    // NEW!
    if input.just_pressed(KeyCode::Space) {
        spell.send(CastSpell {
            caster: player.get_single().unwrap(),
            spell: Spell {
                axioms: vec![Axiom::Ego, Axiom::Dash { max_distance: 5 }],
            },
        });
    }
    // End NEW.

    // SNIP
}

Finally, cargo ruǹ will allow us to escape the sticky grasp of the Hunter by pressing the spacebar! What's that? It crashed? Of course, we now need to register absolutely everything that was just added into Bevy's ECS.

This is extremely easy to forget and is mostly indicated by "struct is never constructed"-type warnings. If you are ever testing your changes and things seem to be going wrong, check first that you registered your systems, events and resources!

With that said:

// spells.rs
impl Plugin for SpellPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<CastSpell>();
        app.init_resource::<SpellStack>();
        app.init_resource::<AxiomLibrary>();
        app.add_systems(Update, cast_new_spell);
        app.add_systems(Update, process_axiom);
    }
}

And there's just one last thing I'd like to change for now: knocking down the light-speed movement down a notch.

// input.rs
    if input.just_pressed(KeyCode::KeyW) { // CHANGED to just_pressed
        events.send(PlayerStep {
            direction: OrdDir::Up,
        });
    }
    if input.just_pressed(KeyCode::KeyD) { // CHANGED to just_pressed
        events.send(PlayerStep {
            direction: OrdDir::Right,
        });
    }
    if input.just_pressed(KeyCode::KeyA) { // CHANGED to just_pressed
        events.send(PlayerStep {
            direction: OrdDir::Left,
        });
    }
    if input.just_pressed(KeyCode::KeyS) { // CHANGED to just_pressed
        events.send(PlayerStep {
            direction: OrdDir::Down,
        });
    }

Try again. cargo run. Pressing the space bar will now allow you to escape your sticky little friend!

The player getting chased by the Hunter, until the player dashes out of the way and strikes the wall.

Intermediate Wizardry 201

The player dashing around is fun and good... but what about a projectile that knocks back whatever critter it hits? This sounds slightly far-fetched, but it actually takes almost no code that we have not already seen. Enter... MomentumBeam, Dash.

// spells.rs
pub enum Axiom {
    // FORMS
    /// Target the caster's tile.
    Ego,

    // NEW!
    /// Fire a beam from the caster, towards the caster's last move. Target all travelled tiles,
    /// including the first solid tile encountered, which stops the beam.
    MomentumBeam,
    // End NEW.

    // SNIP
}

It, of course, receives its own implementation.

/// Fire a beam from the caster, towards the caster's last move. Target all travelled tiles,
/// including the first solid tile encountered, which stops the beam.
fn axiom_form_momentum_beam(
    map: Res<Map>,
    mut spell_stack: ResMut<SpellStack>,
    position_and_momentum: Query<(&Position, &OrdDir)>,
) {
    let synapse_data = spell_stack.spells.last_mut().unwrap();
    let (caster_position, caster_momentum) =
        position_and_momentum.get(synapse_data.caster).unwrap();
    // Start the beam where the caster is standing.
    // The beam travels in the direction of the caster's last move.
    let (off_x, off_y) = caster_momentum.as_offset();
    let mut output = linear_beam(*caster_position, 10, off_x, off_y, &map);
    // Add these tiles to `targets`.
    synapse_data.targets.append(&mut output);
}


fn linear_beam(
    mut start: Position,
    max_distance: usize,
    off_x: i32,
    off_y: i32,
    map: &Map,
) -> Vec<Position> {
    let mut distance_travelled = 0;
    let mut output = Vec::new();
    // The beam has a maximum distance of max_distance.
    while distance_travelled < max_distance {
        distance_travelled += 1;
        start.shift(off_x, off_y);
        // The new tile is always added, even if it is impassable...
        output.push(start);
        // But if it is impassable, the beam stops.
        if !map.is_passable(start.x, start.y) {
            break;
        }
    }
    output
}

You may notice that this is extremely similar to the Dash logic... Its differences are the inclusion of the final impact tile (which is solid), and how it collects all travelled tiles in an output vector, added to targets.

// Do not add this block, it is already included.
let mut distance_travelled = 0;
while distance_travelled < max_distance {
    distance_travelled += 1;
    // Stop dashing if a solid Creature is hit and the dasher is not intangible.
    if !map.is_passable(
            final_dash_destination.x + off_x,
            final_dash_destination.y + off_y,
        )
    {
        break;
    }
    // Otherwise, keep offsetting the dashing creature's position.
    final_dash_destination.shift(off_x, off_y);
}

In software development, "don't repeat yourself" is a common wisdom, but in games development, sometimes, it must be done within reason. There might be intangible creatures later capable of moving through solid blocks (a purely theoretical concern which will totally not be the subject of a future chapter). In this case, their dashes must move through walls, and their beams must not.

Back to the implemention. Add this new Axiom to the AxiomLibrary.

impl FromWorld for AxiomLibrary {
    fn from_world(world: &mut World) -> Self {
        // SNIP
        // NEW!
        axioms.library.insert(
            discriminant(&Axiom::MomentumBeam),
            world.register_system(axiom_form_momentum_beam),
        );
        // End NEW.
        // SNIP
    }
}

And just like that, with only 1 new one-shot-system (which was very similar to our Dash implementation), the projectile is ready:

// input.rs
    if input.just_pressed(KeyCode::Space) {
        spell.send(CastSpell {
            caster: player.get_single().unwrap(),
            spell: Spell {
                axioms: vec![Axiom::MomentumBeam, Axiom::Dash { max_distance: 5 }],
            },
        });
    }

cargo run. Not only can you teach your sticky companion some manners, you can even break the walls of the cage, and escape into the abyss beyond.

The player getting chased by the Hunter, who gets repelled by a burst of knockback. Then, the player knocks a wall back and escapes the cage.

Bevy Traditional Roguelike Quick-Start - 3. Establishing the Hunting Grounds

Cleaning Our Room

Before continuing, it must be noted that the main.rs file is slowly reaching critical mass with its 161 lines of code. Before it swallows the Sun, it would be wise to divide it into multiple files, using Plugins.

As an example, let's bundle up everything that has something to do with displaying things on screen into a single GraphicsPlugin.

Create a new file in src/graphics.rs. Write within:

// graphics.rs

use bevy::prelude::*;
// Note the imports from main.rs
use crate::{Player, OrdDir, Position};

pub struct GraphicsPlugin;

impl Plugin for GraphicsPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<SpriteSheetAtlas>();
        app.add_systems(Startup, setup_camera);
        app.add_systems(Update, adjust_transforms);
    }
}

Then, add the resource and the two systems, as they appeared in Part 2 of the tutorial:

// graphics.rs
#[derive(Resource)]
pub struct SpriteSheetAtlas { // Note the pub!
    handle: Handle<TextureAtlasLayout>,
}

impl FromWorld for SpriteSheetAtlas {
    fn from_world(world: &mut World) -> Self {
        let layout = TextureAtlasLayout::from_grid(UVec2::splat(16), 8, 1, None, None);
        let mut texture_atlases = world
            .get_resource_mut::<Assets<TextureAtlasLayout>>()
            .unwrap();
        Self {
            handle: texture_atlases.add(layout),
        }
    }
}

fn setup_camera(mut commands: Commands) {
    commands.spawn((
        Camera2d::default(),
        Transform::from_xyz(0., 0., 0.),
    ));
}

/// Each frame, adjust every entity's display location to match
/// their position on the grid, and make the camera follow the player.
fn adjust_transforms(
    mut creatures: Query<(&Position, &mut Transform, Has<Player>)>,
    mut camera: Query<&mut Transform, (With<Camera>, Without<Position>)>,
) {
    for (pos, mut trans, is_player) in creatures.iter_mut() {
        // Multiplied by the graphical size of a tile, which is 64x64.
        trans.translation.x = pos.x as f32 * 64.;
        trans.translation.y = pos.y as f32 * 64.;
        if is_player {
            // The camera follows the player.
            let mut camera_trans = camera.get_single_mut().unwrap();
            (camera_trans.translation.x, camera_trans.translation.y) =
                (trans.translation.x, trans.translation.y);
        }
    }
}

This can finally be connected to main.rs:

// main.rs
mod graphics; // NEW!

use graphics::GraphicsPlugin; // NEW!

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .add_plugins(GraphicsPlugin) // NEW!
        // Note that the following have been removed:
        // - SpriteSheetAtlas
        // - setup_camera
        // - adjust_transforms
        .add_systems(Startup, spawn_player)
        .add_systems(Startup, spawn_cage)
        .add_systems(Update, keyboard_input)
        .run();
}

Note that this reorganization comes with the necessity of many import (use) statements. In the future of this tutorial, inter-file imports will no longer be represented in the code snippets. rust-analyzer offers auto-importing of unimported items as a code action, and compiler errors for this particular issue are clear and offer precise suggestions. Also remember to clean as you go, and remove unused imports marked by warnings.

I have organized the rest of the Part 2 components, bundles, systems and resources in the following way:

creature.rs (No plugin! Only struct definitions.)

  • Player
  • Creature

input.rs

  • keyboard_input

map.rs

  • Position
  • spawn_player
  • spawn_cage

And, as it was only just done:

graphics.rs

  • SpriteSheetAtlas
  • setup_camera
  • adjust_transforms

We will also add pub markers to the structs and enums moved over (but not the systems). As Components and Resourcès tend to travel around quite a bit, they will often need to be imported across other Plugins. Not to worry, missing a pub will simply have the compiler complain a bit and provide a helpful error message to correct the issue, mentioning that "this struct is inaccessible".

This leads to this main() function:

// main.rs
fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .add_plugins((GraphicsPlugin, MapPlugin, InputPlugin))
        .run();
}

Note the tuple in the second add_plugins̀. Just as it was shown in Part 2 for commands.spawn(), many Bevy functions can take either a single item or a tuple of items as an argument!

Compile everything with cargo run to make sure all is neat and proper, and to fix potential still-private or unimported structs/struct fields.

If it works, you may notice strange black lines on the periphery of the walls:

The player in the centre of the cage, with odd black line artefacts on the textures.

This can happen when working with a 2D spritesheet in Bevy. To fix it, disable Multi Sample Anti-aliasing:

// graphics.rs
fn setup_camera(mut commands: Commands) {
    commands.spawn((
        Camera2d::default(),
        Transform::from_xyz(0., 0., 0.),
        Msaa::Off, // NEW!
    ));
}
The player in the centre of the cage, with the artefacts fixed.

Much better. If you'd like to see how the fully reorganized code looks like, check in tutorial/source_code/3-getting-chased-around/3.1-reorganized.

Detecting the Happening of Things - Events

You may remember keyboard_input and how it adjusts the Player's position:

// input.rs
// SNIP
if input.pressed(KeyCode::KeyW) {
    player.y += 1;
}
// SNIP

This is very weak programming! As the game expands, we might need to detect when the player steps on slippery goo or when it collides with another entity. We'll need to implement these checks on each possible direction to step in, have error-prone repeated code blocks, and end up with a towering heap of function arguments that looks like this:

fn dispense_functions(
    mut creatures: ParamSet<(
        Query<(&Transform, &mut Species, &mut SoulBreath, &mut AxiomEffects, 
        	&mut Animator<Transform>, &mut Position, Has<RealityAnchor>)>,
        Query<&Position>,
        Query<&Species>,
        Query<&SoulBreath>,
        Query<(&Position, &Transform), With<RealityAnchor>>,
    )>,
    mut plant: Query<&mut Plant>,
    faction: Query<&Faction>,
    check_wound: Query<Entity, With<Wounded>>,
    mut next_state: ResMut<NextState<TurnState>>,
    mut world_map: ResMut<WorldMap>,
    mut souls: Query<(&mut Animator<Transform>, &Transform, 
    	&mut TextureAtlasSprite, &mut Soul), Without<Position>>,
    ui_center: Res<CenterOfWheel>,
    time: Res<SoulRotationTimer>,
    mut events: EventWriter<LogMessage>,
    mut zoom: ResMut<ZoomInEffect>,
    mut commands: Commands,
    mut current_crea_display: ResMut<CurrentEntityInUI>,
    texture_atlas_handle: Res<SpriteSheetHandle>,
){ /* endless misery */ }

Yes, this is a real function, from one of my old (and bad) Bevy projects. We wish to avoid this. Enter: Events!

This revolution will be neatly contained in a new plugin, EventPlugin, inside a new file, events.rs. It will serve as a repository of the "actions" being taken within our game. The player taking a step is one such action of interest.

// events.rs
pub struct EventPlugin;

impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<PlayerStep>();
    }
}

#[derive(Event)]
pub struct PlayerStep {
    pub direction: OrdDir,
}

Don't forget to link all this to main.rs.

// main.rs
mod creature;
mod events; // NEW!
mod graphics;
mod input;
mod map;

use bevy::prelude::*;
use events::EventPlugin; // NEW!
use graphics::GraphicsPlugin;
use input::InputPlugin;
use map::MapPlugin;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .add_plugins((EventPlugin, GraphicsPlugin, MapPlugin, InputPlugin)) // CHANGED
        .run();
}

Note the new struct: OrdDir, short for "Ordinal Direction". This will be a very common enum throughout the game's code - so common, in fact, that I have opted to place it within ̀main.rs̀̀̀. This is personal preference and it could have very well been integrated into one of the plugins.

// main.rs
#[derive(PartialEq, Eq, Copy, Clone, Debug)]
pub enum OrdDir {
    Up,
    Right,
    Down,
    Left,
}

impl OrdDir {
    pub fn as_offset(self) -> (i32, i32) {
        let (x, y) = match self {
            OrdDir::Up => (0, 1),
            OrdDir::Right => (1, 0),
            OrdDir::Down => (0, -1),
            OrdDir::Left => (-1, 0),
        };
        (x, y)
    }
}

And, at last, the very first ̀Event-based system can be implemented:

// events.rs
fn player_step(
    // Incoming events must be read with an EventReader.
    mut events: EventReader<PlayerStep>,
    // Fetch the Position of the Player.
    mut player: Query<&mut Position, With<Player>>,
) {
    // There should only be one player.
    let mut player_pos = player.get_single_mut().expect("0 or 2+ players");
    // Unpack the event queue - not that it will be very long in this case!
    for event in events.read() {
        // Calculate how to modify the player's Position from the OrdDir.
        let (off_x, off_y) = event.direction.as_offset();
        // Change the player's position.
        player_pos.shift(off_x, off_y);
    }
}

Register it.

// events.rs
impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<PlayerStep>();
        app.add_systems(Update, player_step); // NEW!
    }
}

First, note the EventReader argument, which is a requirement to unpack the contents of received Event̀s, which are getting produced by... nothing at the moment. An EventReader, of course, needs a companion EventWriter. This is how the previously unwieldy keyboard_input system can be reworked!

// input.rs
fn keyboard_input(
    mut events: EventWriter<PlayerStep>,
    input: Res<ButtonInput<KeyCode>>,
) {
    if input.pressed(KeyCode::KeyW) {
        events.send(PlayerStep {
            direction: OrdDir::Up,
        });
    }
    if input.pressed(KeyCode::KeyD) {
        events.send(PlayerStep {
            direction: OrdDir::Right,
        });
    }
    if input.pressed(KeyCode::KeyA) {
        events.send(PlayerStep {
            direction: OrdDir::Left,
        });
    }
    if input.pressed(KeyCode::KeyS) {
        events.send(PlayerStep {
            direction: OrdDir::Down,
        });
    }
}

Instead of this system handling the player's motion - and being responsible for the implementation of all the subtleties that may imply, the heavy work is now all offshored to an Event specialized in handling this task!

cargo ruǹ's results should be fairly disappointing - as, from a non-developer perspective, nothing about the game has fundamentally changed - at least not our ability to phase through walls at lightspeed. However, our codebase will be much more extensible for the near future - not to mention that this Event is only the first of many.

Enforcing Basic Physics - Collisions & The Map

A wall should wall things. It's in the name.

There are multiple ways to implement this - the simplest would be to query every single creature with a Position on the player's move, check if any of them occupies the destination tile, and abort the move if that's the case. Computers today are decently fast, but that is still a very naive implementation.

The alternative is to keep a tidy phone book of everyone's location! Enter - the Map Resource.

// map.rs
/// The position of every creature, updated automatically.
#[derive(Resource)]
pub struct Map {
    pub creatures: HashMap<Position, Entity>,
}

impl Map {
    /// Which creature stands on a certain tile?
    pub fn get_entity_at(&self, x: i32, y: i32) -> Option<&Entity> {
        self.creatures.get(&Position::new(x, y))
    }

    /// Is this tile passable?
    pub fn is_passable(&self, x: i32, y: i32) -> bool {
        self.get_entity_at(x, y).is_none()
    }
}

It's a HashMap which contains only entries where a creature exists, which gives it the ability to fetch whoever is standing on, say, (27, 4) in record time with no ̀Query or iterating over entities required!

When importing the HashMap, I suggest using the use bevy::utils::HashMap instead of Rust's std implementation. The Bevy version bases itself off of hashbrown, which is weaker to flooding hacks but more performant - an interesting characteristic for game development, unless one is making the next CIA agent training simulator.

Don't forget to register this new Resourcè.

// map.rs
pub struct MapPlugin;

impl Plugin for MapPlugin {
    fn build(&self, app: &mut App) {
        // NEW!
        app.insert_resource(Map {
            creatures: HashMap::new(),
        });
        // End NEW.
        app.add_systems(Startup, spawn_player);
        app.add_systems(Startup, spawn_cage);
    }
}

It's now possible to test the waters before venturing into a new tile, thus avoiding any further phasing incidents.

// events.rs
fn player_step(
    mut events: EventReader<PlayerStep>,
    mut player: Query<&mut Position, With<Player>>,
    map: Res<Map>,
) {
    let mut player_pos = player.get_single_mut().expect("0 or 2+ players");
    for event in events.read() {
        let (off_x, off_y) = event.direction.as_offset();
        // REPLACES player_pos.shift(off_x, off_y)
        // Get the destination tile.
        let destination = Position::new(player_pos.x + off_x, player_pos.y + off_y);
        // Check if the destination tile is empty.
        if map.is_passable(destination.x, destination.y) {
            // If yes, authorize the move.
            player_pos.shift(off_x, off_y);
        }
        // End REPLACES.
    }
}

Don't cargo run just yet! Our ̀Map is completely empty and unaware of the existence of walls. This can be fixed with a single new system.

// map.rs
/// Newly spawned creatures earn their place in the HashMap.
fn register_creatures(
    mut map: ResMut<Map>,
    // Any entity that has a Position that just got added to it -
    // currently only possible as a result of having just been spawned in.
    displaced_creatures: Query<(&Position, Entity), Added<Position>>,
) {
    for (position, entity) in displaced_creatures.iter() {
        // Insert the new creature in the Map. Position implements Copy,
        // so it can be dereferenced (*), but `.clone()` would have been
        // fine too.
        map.creatures.insert(*position, entity);
    }
}

The most unique part about this new system is the ̀Added filter, which fetches only entities who have newly received the Position component and not been handled by this system yet. Right now, it means all newly created creatures will be processed by this system once, and then ignored afterwards.

Note! This is not the case here, but running commands.entity(entity).insert(Position::new(5, 5)) on a creature that already has a Position component will overwrite the current Position with the new value, and this will not count as an addition for the purpose of Added.

Register it.

// map.rs
pub struct MapPlugin;

impl Plugin for MapPlugin {
    fn build(&self, app: &mut App) {
        app.insert_resource(Map {
            creatures: HashMap::new(),
        });
        app.add_systems(Startup, spawn_player);
        app.add_systems(Startup, spawn_cage);
        app.add_systems(Update, register_creatures); // NEW!
    }
}

Activate cargo run... and the walls finally have tangibility!

The player bashing itself on the walls of the cage, unable to escape.

A Very Sticky Critter - The First NPC

It's about time the Player got some company. Not a particularly affable one, I must admit, but we all start from somewhere.

// map.rs, spawn_cage
    let cage = "#########\
                #H......#\
                #.......#\
                #.......#\
                #.......#\
                #.......#\
                #.......#\
                #.......#\
                #########";

Edit the wall placement string to include a (H)unter. Yes, this is messy - a proper map generator will be the topic of a future chapter.

This Hunter also earns itself a separate sprite:

// map.rs, spawn_cage
let position = Position::new(idx as i32 % 9, idx as i32 / 9);
let index = match tile_char {
    '#' => 3,
    'H' => 4, // NEW!
    _ => continue,
};

And the ability to be differentiated from walls, with a new Hunt component...

// creature.rs
#[derive(Component)]
pub struct Hunt;

...added to any 'H' character in the initial spawn function.

// map.rs, spawn_cage
let mut creature = commands.spawn(Creature { // CHANGED - note the variable assignment
    position,
    sprite: Sprite {
        image: asset_server.load("spritesheet.png"),
        custom_size: Some(Vec2::new(64., 64.)),
        texture_atlas: Some(TextureAtlas {
            layout: atlas_layout.handle.clone(),
            index,
        }),
        ..default()
    },
});
if tile_char == 'H' {
    creature.insert(Hunt);
}

cargo run, and our new companion is here. Excellent. Now, to give it motion of its own...

The cage, with a green Hunter standing motionless in a corner.

The first problem is that motion, in our game, is currently only supported by player_step, which solely refers to the player character and nothing else. There should be a more generic Event, capable of controlling absolutely any creature to move around...

// events.rs
#[derive(Event)]
pub struct TeleportEntity {
    pub destination: Position,
    pub entity: Entity,
}

impl TeleportEntity {
    pub fn new(entity: Entity, x: i32, y: i32) -> Self {
        Self {
            destination: Position::new(x, y),
            entity,
        }
    }
}

Its matching system has a lot of similarity to player_step.

// events.rs
fn teleport_entity(
    mut events: EventReader<TeleportEntity>,
    mut creature: Query<&mut Position>,
    map: Res<Map>,
) {
    for event in events.read() {
        let mut creature_position = creature
            // Get the Position of the Entity targeted by TeleportEntity.
            .get_mut(event.entity)
            .expect("A TeleportEntity was given an invalid entity");
        // If motion is possible...
        if map.is_passable(event.destination.x, event.destination.y) {
            // ...move that Entity to TeleportEntity's destination tile.
            creature_position.update(event.destination.x, event.destination.y);
        } else {
            // Nothing here just yet, but this is where collisions between creatures
            // will be handled.
            continue;
        }
    }
}

Don't forget to register all of this...

// events.rs
impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<PlayerStep>();
        app.add_event::<TeleportEntity>(); // NEW!
        app.add_systems(Update, player_step);
        app.add_systems(Update, teleport_entity); // NEW!
    }
}

...and, of course, to actually use it in player_step so all entity motion of any kind is handled by this specialized system.

// events.rs
fn player_step(
    mut events: EventReader<PlayerStep>,
    mut teleporter: EventWriter<TeleportEntity>, // NEW!
    // CHANGED, no longer needs mutable access, and also fetches the Entity component.
    player: Query<(Entity, &Position), With<Player>>,
) {
    // CHANGED, no longer needs mutable access, and also fetches the Entity component.
    let (player_entity, player_pos) = player.get_single().expect("0 or 2+ players");
    for event in events.read() {
        let (off_x, off_y) = event.direction.as_offset();
        // CHANGED, Send the event to TeleportEntity instead of handling the motion directly.
        teleporter.send(TeleportEntity::new(
            player_entity,
            player_pos.x + off_x,
            player_pos.y + off_y,
        ));
    }
}

And there we go! player_step is now only an intermediate point leading to a central teleport_entity system, which can handle any and all creature motion. This means every creature will be on the same footing, with no repeated code!

Just like when player_step was first added, cargo run on this will not change gameplay whatsoever. However, all this has finally allowed us to gift motion to our new Hunter.

First, define a very naive "algorithm" to move towards a point on the map. Start with this helper function to calculate a distance between two points:

// map.rs
fn manhattan_distance(a: Position, b: Position) -> i32 {
    (a.x - b.x).abs() + (a.y - b.y).abs()
}

And then, a way to find the best move among all four orthogonal options:

// map.rs
impl Map {

    // SNIP - all other impl Map functions
    
    /// Find all adjacent accessible tiles to start, and pick the one closest to end.
    pub fn best_manhattan_move(&self, start: Position, end: Position) -> Option<Position> {
        let mut options = [
            Position::new(start.x, start.y + 1),
            Position::new(start.x, start.y - 1),
            Position::new(start.x + 1, start.y),
            Position::new(start.x - 1, start.y),
        ];

        // Sort all candidate tiles by their distance to the `end` destination.
        options.sort_by(|&a, &b| manhattan_distance(a, end).cmp(&manhattan_distance(b, end)));

        options
            .iter()
            // Only keep either the destination or unblocked tiles.
            .filter(|&p| *p == end || self.is_passable(p.x, p.y))
            // Remove the borrow.
            .copied()
            // Get the tile that manages to close the most distance to the destination.
            // If it exists, that is. Otherwise, this is just a None.
            .next()
    }
}

Finally, implement that Hunt implies chasing the player around.

// events.rs
fn player_step(
    mut events: EventReader<PlayerStep>,
    mut teleporter: EventWriter<TeleportEntity>,
    player: Query<(Entity, &Position), With<Player>>,
    hunters: Query<(Entity, &Position), With<Hunt>>, // NEW!
    map: Res<Map>, // NEW! Bringing back the map, so "pathfinding" can be done.
) {
    let (player_entity, player_pos) = player.get_single().expect("0 or 2+ players");
    for event in events.read() {
        let (off_x, off_y) = event.direction.as_offset();
        teleporter.send(TeleportEntity::new(
            player_entity,
            player_pos.x + off_x,
            player_pos.y + off_y,
        ));

        // NEW!
        for (hunter_entity, hunter_pos) in hunters.iter() {
            // Try to find a tile that gets the hunter closer to the player.
            if let Some(move_target) = map.best_manhattan_move(*hunter_pos, *player_pos) {
                // If it is found, cause another TeleportEntity event.
                teleporter.send(TeleportEntity {
                    destination: move_target,
                    entity: hunter_entity,
                });
            }
        }
        // End NEW.
    }
}

cargo run, and let the hunt begin!

The player getting chased by the Hunter, with their sprites occasionally superposing.

There is only the slight issue that our Hunter is rather on the incorporeal side of things. Indeed, as it moves, the Map fails to update and the Hunter is still considered to have phantasmatically remained in its spawn location. Not to mention that the centre of the cage, where we spawned, is also mysteriously blocked by an invisible wall.

There exists another filter like Added, named Changed, which triggers whenever a specified component is not only added for the first time, but also when an already existing instance is modified - such as in the case of moving a creature around. However, using it would be unwise. Here is why - the following happen in order:

  • The user presses a button on their keyboard to move.
  • PlayerStep is triggered. Two TeleportEntity are sent out.
  • The Player's TeleportEntity happens first, moving the Player onto coordinates (2, 3). The Map is NOT updated yet, because it is located in a different system (register_creatures), and ̀teleport_entity isn't done yet, as it has another event to get through.
  • The Hunter's TeleportEntity happens, moving the Hunter onto coordinates (2, 3) too! This appears to be a legal move to the game, because the Map hadn't been updated yet.
  • teleport_entity is done, and register_creatures triggers, editing Map to "knock out" the Player and leave only the Hunter, while the Player is now off the Map and completely untargetable.

To fix this, we need to modify the Map immediately after a creature moves. Leave register_creatures set to Added, and instead, modify teleport_entity:

// events.rs
fn teleport_entity(
    mut events: EventReader<TeleportEntity>,
    mut creature: Query<&mut Position>,
    mut map: ResMut<Map>, // CHANGED, this needs mutability now.
) {
    for event in events.read() {
        let mut creature_position = creature
            .get_mut(event.entity)
            .expect("A TeleportEntity was given an invalid entity");
        if map.is_passable(event.destination.x, event.destination.y) {
            map.move_creature(*creature_position, event.destination); // NEW!
            creature_position.update(event.destination.x, event.destination.y);
        } else {
            continue;
        }
    }
}

map.move_creature is a new impl Map function.

// map.rs
impl Map {
    /// Move a pre-existing entity around the Map.
    pub fn move_creature(&mut self, old_pos: Position, new_pos: Position) {
        // As the entity already existed in the Map's records, remove it.
        let entity = self.creatures.remove(&old_pos).expect(&format!(
            "The map cannot move a nonexistent Entity from {:?} to {:?}.",
            old_pos, new_pos
        ));
        self.creatures.insert(new_pos, entity);
    }
}

And with that, everything is going according to plan.

The player getting chased by the Hunter, who is extremely sticky and always following behind the player, as if it were the player's 'tail'.

The next chapter of this tutorial will introduce basic animation, as well as a cleaner way to generate the starting map, free of mega one-line "#####H....#####"̀-style strings and match statements!