Bevy Traditional Roguelike Quick-Start - 5. Sumo Ice Skating

Thrilling to be jumping, sliding and bashing in fancy acrobatics, but quite lacking in eye candy. Creatures merely blink from one point to another, without any style or intrigue. Animation is a complex topic, but making creatures properly "dash" from one point to another is certainly doable with as little as one new resource, and a rework of adjust_transforms.

// graphics.rs
#[derive(Resource)]
pub struct SlideAnimation {
    pub elapsed: Timer,
}
// graphics.rs
impl Plugin for GraphicsPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<SpriteSheetAtlas>();
        // NEW!
        app.insert_resource(SlideAnimation {
            elapsed: Timer::from_seconds(0.4, TimerMode::Once),
        });
        // End NEW.
        app.insert_resource(Msaa::Off);
        app.add_systems(Startup, setup_camera);
        app.add_systems(Update, adjust_transforms);
    }
}

This Resource will be used to add a 0.4 second delay after each creature motion, during which the entities will slide from their origin point to their destination. Each time a TeleportEntity event occurs, this timer will reset, allowing the animation to unfold for each move.

// events.rs
fn teleport_entity(
    mut events: EventReader<TeleportEntity>,
    mut creature: Query<&mut Position>,
    mut map: ResMut<Map>,
    mut animation_timer: ResMut<SlideAnimation>, // NEW!
) {
    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) {
            // ...update the Map to reflect this...
            map.move_creature(*creature_position, event.destination);
            // NEW!
            // ...begin the sliding animation...
            animation_timer.elapsed.reset();
            // End NEW.
            // ...and 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;
        }
    }
}

Now... for the main course.

fn adjust_transforms(
    mut creatures: Query<(&Position, &mut Transform, Has<Player>)>,
    mut camera: Query<&mut Transform, (With<Camera>, Without<Position>)>,
    // NEW!
    mut animation_timer: ResMut<SlideAnimation>,
    time: Res<Time>,
    // End NEW.
) {
    // NEW!
    let fraction_before_tick = animation_timer.elapsed.fraction();
    animation_timer.elapsed.tick(time.delta());
    // Calculate what % of the animation has elapsed during this tick.
    let fraction_ticked = animation_timer.elapsed.fraction() - fraction_before_tick;
    // End NEW.
    for (pos, mut trans, is_player) in creatures.iter_mut() {
        // DELETE
        // // 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.;
        // End DELETE

        // NEW!
        // The distance between where a creature CURRENTLY is,
        // and the destination of a creature's movement.
        // Multiplied by the graphical size of a tile, which is 64x64.
        let (dx, dy) = (
            pos.x as f32 * 64. - trans.translation.x,
            pos.y as f32 * 64. - trans.translation.y,
        );
        // The distance between the original position and the destination position.
        let (ori_dx, ori_dy) = (
            dx / animation_timer.elapsed.fraction_remaining(),
            dy / animation_timer.elapsed.fraction_remaining(),
        );
        // The sprite approaches its destination.
        trans.translation.x = bring_closer_to_target_value(
            trans.translation.x,
            ori_dx * fraction_ticked,
            pos.x as f32 * 64.,
        );
        trans.translation.y = bring_closer_to_target_value(
            trans.translation.y,
            ori_dy * fraction_ticked,
            pos.y as f32 * 64.,
        );
        // End NEW.
        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);
        }
    }
}

Each tick, this system runs... but we cannot know for sure how long a tick is! A computer being turned into a localized micro-Sun due to compiling Bevy in the background while playing our game will see its Frames-Per-Seconds drop, and increase the time elapsed per tick. Therefore, the new first three lines calculate which % of the animation has been processed this tick - stored within fraction_ticked.

Let's say that our hero @ is moving to X.

...
@.X
...

Each tile is 64x64 pixels. Right now, ̀@ is (128, 0) pixels away from its destination, which is the tuple (dx, dy). We need to keep track of this original value! As it approaches its goal, the distance will decrease, but our calculations must be based on the original distance.

Later on, when we reach this point, 0.2 seconds later:

...
.@X
...

(dx, dy) is now (64, 0)̀. The fraction elapsed of the timer is 50%. 64 / 0.5 = 128, meaning the original distance is restored - stored in (ori_dx, ori_dy).

Finally, the Transform component is adjusted. If the original distance was 128 and the fraction elapsed this tick is 3%, then the creature will move 3.84 pixels to the right this tick!

In order to avoid little visual "bumps" (in the cases where a creature is, say, at 127.84 pixels, and moves 5 pixels to the right, overshooting its objective), I also added the bring_closer_to_target_value function, preventing any increases past the limit no matter if that limit is negative or positive.

// graphics.rs
fn bring_closer_to_target_value(value: f32, adjustment: f32, target_value: f32) -> f32 {
    let adjustment = adjustment.abs();
    if value > target_value {
        (value - adjustment).max(target_value)
    } else if value < target_value {
        (value + adjustment).min(target_value)
    } else {
        target_value // Value is already at target.
    }
}

Finally, cargo run, and behold these smooth and graceful motions!

The player dashing left and right in continuous, sliding motions.

The Summoning Circle

A single test subject is insufficient - in research, experiments must be reproducible. We will need industrial quantities of these pesky Hunters.

Enter: the Spawner. As this is a fourth, different creature type, it is about time we gave them some distinction beyond merely different sprites.

// creature.rs
#[derive(Debug, Component, Clone, Copy)]
pub enum Species {
    Player,
    Wall,
    Hunter,
    Spawner,
}

/// Get the appropriate texture from the spritesheet depending on the species type.
pub fn get_species_sprite(species: &Species) -> usize {
    match species {
        Species::Player => 0,
        Species::Wall => 3,
        Species::Hunter => 4,
        Species::Spawner => 75,
    }
}

This will be a new component for all Creaturès.

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

Immediately, map.rs will start screaming at you that its Creatures need the new component. Leave their prayers unanswered. This part of the code is long overdue for a refactor, as spawn_player and spawn_cage are repeating each other for little reason. This can be funnelled down a new event, SummonCreature, which will handle both initial map generation and new arrivals during the game, possibly from summoning spells or such.

// events.rs
#[derive(Event)]
pub struct SummonCreature {
    pub species: Species,
    pub position: Position,
}

/// Place a new Creature on the map of Species and at Position.
pub fn summon_creature(
    mut commands: Commands,
    mut events: EventReader<SummonCreature>,
    asset_server: Res<AssetServer>,
    atlas_layout: Res<SpriteSheetAtlas>,
    map: Res<Map>,
) {
    for event in events.read() {
        // Avoid summoning if the tile is already occupied.
        if !map.is_passable(event.position.x, event.position.y) {
            continue;
        }
        let mut new_creature = commands.spawn(Creature {
            position: event.position,
            species: event.species,
            sprite: SpriteBundle {
                texture: asset_server.load("spritesheet.png"),
                transform: Transform::from_scale(Vec3::new(4., 4., 0.)),
                ..default()
            },
            atlas: TextureAtlas {
                layout: atlas_layout.handle.clone(),
                index: get_species_sprite(&event.species),
            },
            momentum: OrdDir::Up,
        });
        // Add any species-specific components.
        match &event.species {
            Species::Player => {
                new_creature.insert(Player);
            }
            Species::Hunter => {
                new_creature.insert(Hunt);
            }
            _ => (),
        }
    }
}

You may notice that this implementation is extremely similar to spawn_player and spawn_cage. Speaking of those, let us unify them under this new event. There will now be a single spawn_cage function, looking like this:

// map.rs

// DELETE spawn_player

fn spawn_cage(mut summon: EventWriter<SummonCreature>) {
    let cage = "#########\
                #H......#\
                #.......#\
                #.......#\
                #...S...#\
                #.......#\
                #...@...#\
                #.......#\
                #########";
    for (idx, tile_char) in cage.char_indices() {
        let position = Position::new(idx as i32 % 9, idx as i32 / 9);
        let species = match tile_char {
            '#' => Species::Wall,
            'H' => Species::Hunter,
            '@' => Species::Player,
            'S' => Species::Spawner,
            _ => continue,
        };
        summon.send(SummonCreature { species, position });
    }
}

Don't forget to de-register spawn_player. As if the compiler wasn't already complaining about it...

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

Oh, what else did we forget? That's right, registering SummonCreature.

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

If you cargo run now, nothing will appear to have changed, aside from the ominous new spawner in the centre... wait, what is that? An instant panic on startup?

spawn_cage runs on Startup, sends out some SummonCreature events, and then, summon_creature, an Update system, should handle the rest... right? Wrong!

Bevy's systems are non-deterministic. Anything marked as Update can run at any time. Here, player_step occasionally runs before summon_creature has made the player exist at all, and the first line tries to fetch a non-existing player.

We'll fix this for now by bumping this line into the event loop itself, preventing it from fetching the player entity when there is no PlayerStep event yet. This is a rough fix (if you were sending inputs every millisecond as the game was booting up, you could still manage to try to make a non-existent player step 1 tile). However, it will do for now, until this tutorial touches on explicit system ordering.

// events.rs
fn player_step(
    mut events: EventReader<PlayerStep>,
    mut teleporter: EventWriter<TeleportEntity>,
    mut momentum: EventWriter<AlterMomentum>,
    player: Query<(Entity, &Position), With<Player>>,
    hunters: Query<(Entity, &Position), With<Hunt>>,
    map: Res<Map>,
) {
    // let (player_entity, player_pos) = player.get_single().expect("0 or 2+ players"); // DELETE!
    for event in events.read() {
        let (player_entity, player_pos) = player.get_single().expect("0 or 2+ players"); // NEW!

cargo run, one more time. There we go.

The cage, with a red spawner object in the centre.

Industrial Production

Now. To make this red thing actually accomplish its purpose, we will need some new spell logic.

// spells.rs
#[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,

    // NEW!
    // Target all orthogonally adjacent tiles to the caster.
    Plus,
    // End 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,

The Form goes first, alongside its implementation:

// spells.rs
impl Axiom {
    fn target(&self, synapse_data: &mut SynapseData, map: &Map) {
        match self {
            // Target the caster's tile.
            Self::Ego => {
                synapse_data.targets.push(synapse_data.caster_position);
            }
            
            // NEW!
            // Target all orthogonally adjacent tiles to the caster.
            Self::Plus => {
                let adjacent = [OrdDir::Up, OrdDir::Right, OrdDir::Down, OrdDir::Left];
                for direction in adjacent {
                    let mut new_pos = synapse_data.caster_position;
                    let offset = direction.as_offset();
                    new_pos.shift(offset.0, offset.1);
                    synapse_data.targets.push(new_pos);
                }
            }
            // End NEW.

Then, the Function:

    // FUNCTIONS

    // The targeted creatures dash in the direction of the caster's last move.
    Dash,

    // NEW!
    // The targeted passable tiles summon a new instance of species.
    SummonCreature { species: Species },
    // End NEW.

It generates a new EventDispatch...

// spells.rs
    fn execute(&self, synapse_data: &mut SynapseData, map: &Map) -> bool {
        match self {

            // SNIP
        
            // The targeted passable tiles summon a new instance of species.
            Self::SummonCreature { species } => {
                for position in &synapse_data.targets {
                    synapse_data.effects.push(EventDispatch::SummonCreature {
                        species: *species,
                        position: *position,
                    });
                }
                true
            }

...which is implemented with a little bit of boilerplate.

// spells.rs
/// An enum with replicas of common game Events, to be translated into the real Events
/// and dispatched to the main game loop.
#[derive(Clone, Copy)]
pub enum EventDispatch {
    TeleportEntity {
        destination: Position,
        entity: Entity,
    },
    // NEW!
    SummonCreature {
        species: Species,
        position: Position,
    },
    // End NEW.
}
// spells.rs
/// Translate a list of EventDispatch into their "real" Event counterparts and send them off
/// into the main game loop to modify the game's creatures.
pub fn dispatch_events(
    mut receiver: EventReader<SpellEffect>,
    mut teleport: EventWriter<TeleportEntity>,
    mut summon: EventWriter<SummonCreature>, // NEW!
) {
    for effect_list in receiver.read() {
        for effect in &effect_list.events {
            // Each EventDispatch enum is translated into its Event counterpart.
            match effect {
                // SNIP
                // NEW!
                EventDispatch::SummonCreature { species, position } => {
                    summon.send(SummonCreature {
                        species: *species,
                        position: *position,
                    });
                }
                // End NEW.

Finally, we'll actually give this spell to the ominous Spawner. Right now, the code that lets other entities act after the player is quite unwieldy:

// Do not add this code, it is already here.
// events.rs
fn player_step( //SNIP
) {
        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,
                });
            }
        }

There are numerous problems:

  1. This only seeks creatures with the Hunt component.
  2. This happens only when the player steps, not when they take a different action, such as spellcasting.
  3. This does not modify the "momentum" of the hunters in case we ever want to give momentum-reliant spells to NPCs (we will).

This can be solved with a much more robust EndTurn event.

// events.rs
#[derive(Event)]
pub struct EndTurn;

fn end_turn(
    mut events: EventReader<EndTurn>,
    mut step: EventWriter<CreatureStep>,
    mut spell: EventWriter<CastSpell>,
    npcs: Query<(Entity, &Position, &Species), Without<Player>>,
    player: Query<&Position, With<Player>>,
    map: Res<Map>,
    animation_timer: Res<SlideAnimation>,
    mut momentum: EventWriter<AlterMomentum>,
) {
    // Wait for the player's action to complete before starting NPC turns.
    if !animation_timer.elapsed.finished() {
        return;
    }
    for _event in events.read() {
        let player_pos = player.get_single().unwrap();
        for (creature_entity, creature_position, creature_species) in npcs.iter() {
            match creature_species {
                Species::Hunter => {
                    // Try to find a tile that gets the hunter closer to the player.
                    if let Some(move_target) =
                        map.best_manhattan_move(*creature_position, *player_pos)
                    {
                        // If it is found, the hunter approaches the player by stepping.
                        step.send(CreatureStep {
                            direction: OrdDir::as_variant(
                                move_target.x - creature_position.x,
                                move_target.y - creature_position.y,
                            ),
                            entity: creature_entity,
                        });
                    }
                }
                Species::Spawner => {
                    // Cast a spell which tries to summon Hunters on all orthogonally
                    // adjacent tiles.
                    spell.send(CastSpell {
                        caster: creature_entity,
                        spell: Spell {
                            axioms: vec![
                                Axiom::Plus,
                                Axiom::SummonCreature {
                                    species: Species::Hunter,
                                },
                            ],
                        },
                    });
                }
                _ => (),
            }
        }
    }
}

Everything in this block should already have an implementation, with the exception of two things. First, a simple helper to transition from (i32, i32) to OrdDir...

// main.rs
impl OrdDir {

    // SNIP

    // NEW!
    pub fn as_variant(dx: i32, dy: i32) -> Self {
        match (dx, dy) {
            (0, 1) => OrdDir::Up,
            (0, -1) => OrdDir::Down,
            (1, 0) => OrdDir::Right,
            (-1, 0) => OrdDir::Left,
            _ => panic!("Invalid offset provided."),
        }
    }
    // End NEW.
}

And, also, CreatureStep. That is right - we will also unify together player and NPC stepping logic, so they become restricted by the same rules.

This will replace the old player_step...

// events.rs

// DELETE player_step and PlayerStep

#[derive(Event)]
pub struct CreatureStep {
    pub entity: Entity,
    pub direction: OrdDir,
}

fn creature_step(
    mut events: EventReader<CreatureStep>,
    mut teleporter: EventWriter<TeleportEntity>,
    mut momentum: EventWriter<AlterMomentum>,
    mut turn_end: EventWriter<EndTurn>,
    creature: Query<(&Position, Has<Player>)>,
) {
    for event in events.read() {
        let (creature_pos, is_player) = creature.get(event.entity).unwrap();
        let (off_x, off_y) = event.direction.as_offset();
        teleporter.send(TeleportEntity::new(
            event.entity,
            creature_pos.x + off_x,
            creature_pos.y + off_y,
        ));

        momentum.send(AlterMomentum {
            entity: event.entity,
            direction: event.direction,
        });
        // If this creature was the player, this will end the turn.
        if is_player {
            turn_end.send(EndTurn);
        }
    }
}

Now, time to register everything.

impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<CreatureStep>(); // CHANGED to CreatureStep
        app.add_event::<TeleportEntity>();
        app.add_event::<AlterMomentum>();
        app.add_event::<SummonCreature>();
        app.add_event::<EndTurn>(); // NEW!
        app.add_systems(Update, creature_step); // CHANGED to creature_step
        app.add_systems(Update, teleport_entity);
        app.add_systems(Update, alter_momentum);
        app.add_systems(Update, summon_creature);
        app.add_systems(Update, end_turn); // NEW!
    }
}

Finally, our input.rs file needs to be adapted to these new changes.


/// Each frame, if a button is pressed, move the player 1 tile.
fn keyboard_input(
    player: Query<Entity, With<Player>>,
    mut events: EventWriter<CreatureStep>, // CHANGED to CreatureStep
    input: Res<ButtonInput<KeyCode>>,
    mut spell: EventWriter<CastSpell>,
    mut turn_end: EventWriter<EndTurn>, // NEW!
    animation_timer: Res<SlideAnimation>, // NEW!
) {
    // NEW!
    // Do not accept input until the animations have finished.
    if !animation_timer.elapsed.finished() {
        return;
    }
    // Wrap everything in an if statement to find the player entity.
    if let Ok(player) = player.get_single() {
    // End NEW.
        if input.just_pressed(KeyCode::Space) {
            spell.send(CastSpell {
                caster: player, // CHANGED to use the player entity.
                spell: Spell {
                    axioms: vec![Axiom::Ego, Axiom::Dash],
                },
            });
            turn_end.send(EndTurn); // NEW!
        }
        if input.just_pressed(KeyCode::KeyW) {
            events.send(CreatureStep { // CHANGED to CreatureStep.
                entity: player, // NEW!
                direction: OrdDir::Up,
            });
        }
        if input.just_pressed(KeyCode::KeyD) {
            events.send(CreatureStep { // CHANGED to CreatureStep.
                entity: player, // NEW!
                direction: OrdDir::Right,
            });
        }
        if input.just_pressed(KeyCode::KeyA) {
            events.send(CreatureStep { // CHANGED to CreatureStep.
                entity: player, // NEW!
                direction: OrdDir::Left,
            });
        }
        if input.just_pressed(KeyCode::KeyS) {
            events.send(CreatureStep { // CHANGED to CreatureStep.
                entity: player, // NEW!
                direction: OrdDir::Down,
            });
        }
    }
}

cargo run... And very weird things are happening. Everyone seems frozen, until they are not, in a completely unpredictable fashion. What?

The player steps. An EndTurn event is sent out. Then, end_turn gets locked for a while waiting for the animation to complete. During that time, the EndTurn event has been "garbage collected" by Bevy, as it's not a fan of letting unconsumed events lying around the place. If they accumulate, they can take up big chunks of memory! In fact, Bevy is very impatient, letting unattended Events exist for only two frames.

However, we know what we're doing, as we have a system to clean up those EndTurns as soon as the animations complete. We can disable this autocollection like this:

// events.rs
impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        // SNIP
        app.init_resource::<Events<EndTurn>>(); // CHANGED
        // SNIP
    }
}

With this alternative, EndTurn will now patiently wait to be consumed instead of mysteriously disappearing.

cargo run, and you should now be getting swarmed very, very fast.

Hunters keep pouring from the centre and swarm the cage, pushing the player in a corner.

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: SpriteBundle,
    pub atlas: TextureAtlas,
}

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
            atlas: TextureAtlas {
                layout: atlas_layout.handle.clone(),
                index: 0,
            },
            momentum: OrdDir::Up, // NEW!
        },
        Player,
    ));
}
fn spawn_cage(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    atlas_layout: Res<SpriteSheetAtlas>,
) {
            // SNIP
            atlas: TextureAtlas {
                layout: atlas_layout.handle.clone(),
                index,
            },
            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.