Bevy Traditional Roguelike Quick-Start - 6. Let Chaos Reign

The Summoning Circle

The more prolific programmers among readers may have been frothing at the mouth for quite some time now. Why? Well, spawn_cage and spawn_player have been sitting there since chapter 2, violating the "Don't Repeat Yourself" principle. Let us cure them of their wrath.

// map.rs

// DELETE spawn_cage and spawn_player.

// NEW!
fn spawn_cage(mut summon: EventWriter<SummonCreature>) {
    let cage = "#########\
                #H......#\
                #.......#\
                #.......#\
                #.......#\
                #.......#\
                #...@...#\
                #.......#\
                #########";
    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,
            _ => continue,
        };
        summon.send(SummonCreature { species, position });
    }
}
// End NEW.

This new system does a couple of things:

  • Funnel the spawning of the player and the cage in the same SummonCreature event, instead of having two systems doing the same thing for both.
  • Introduce a new concept, Species.

Previously, we had only sprite indices (with the texture_atlas) to differentiate one creature from another. This new marker Component will help us know whether something is a Wall, Player, or Hunter.

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

/// 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,
    }
}

We will add this as a mandatory field to all new Creature instances.

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

Now, for the SummonCreature event proper.

// events.rs
/// 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: Sprite {
                image: asset_server.load("spritesheet.png"),
                custom_size: Some(Vec2::new(64., 64.)),
                texture_atlas: Some(TextureAtlas {
                    layout: atlas_layout.handle.clone(),
                    index: get_species_sprite(&event.species),
                }),
                ..default()
            },
            momentum: OrdDir::Up,
        });
        // Add any species-specific components.
        match &event.species {
            Species::Player => {
                new_creature.insert(Player);
            }
            Species::Hunter => {
                new_creature.insert(Hunt);
            }
            _ => (),
        }
    }
}

Register the system and event.

// sets.rs
app.add_systems(
    Update,
    // CHANGED - added summon_creature
    ((summon_creature, register_creatures, teleport_entity).chain())
        .in_set(ResolutionPhase),
);
// events.rs
impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<SummonCreature>(); // NEW!
        app.add_event::<PlayerStep>();
        app.add_event::<TeleportEntity>();
    }
}

If you cargo run now, you will- wait, what is that? An instant panic on startup?

Here, player_step occasionally runs before summon_creature has made the player exist at all, and its first line tries to fetch a non-existing player.

We'll fix this by bumping this line into the event loop itself, preventing it from fetching the player entity when there is no PlayerStep event yet. This was intentionally written in this way to showcase an important fact: event systems marked with Update run every tick regardless of whether their event has been triggered or not. Only the for loop with events.read() is restricted to run only when an event arrives.

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

cargo run again, and everything works - with seemingly no change to the game itself, but with much more flexible code!

Leveling the Playing Field

Here's another function that you may have found limiting:

// DO NOT ADD THIS! It is already in the code.
// events.rs
pub fn player_step(
    mut events: EventReader<PlayerStep>,
    mut teleporter: EventWriter<TeleportEntity>,
    mut player: Query<(Entity, &Position, &mut OrdDir), With<Player>>,
    hunters: Query<(Entity, &Position), With<Hunt>>,
    map: Res<Map>,
) {
    for event in events.read() {
        let (player_entity, player_pos, mut player_momentum) =
            player.get_single_mut().expect("0 or 2+ players");
        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,
        ));

        // Update the direction towards which this creature is facing.
        *player_momentum = event.direction;

        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,
                });
            }
        }
    }
}

The player gets to update their momentum, while the hunters do not. Talk about unequal treatment! This system deserves to be democratized.

// events.rs

// DELETE PlayerStep and player_step.

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

pub fn creature_step(
    mut events: EventReader<CreatureStep>,
    mut teleporter: EventWriter<TeleportEntity>,
    mut turn_end: EventWriter<EndTurn>,
    mut creature: Query<(&Position, Has<Player>, &mut OrdDir)>,
) {
    for event in events.read() {
        let (creature_pos, is_player, mut creature_momentum) =
            creature.get_mut(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,
        ));
        // Update the direction towards which this creature is facing.
        *creature_momentum = event.direction;
        // If this creature was the player, this will end the turn.
        if is_player {
            turn_end.send(EndTurn);
        }
    }
}

Due to this rename, you'll have to replace all instances of PlayerStep across the code, and also add the new field to input.rs.

// input.rs
if input.just_pressed(KeyCode::KeyW) {
    events.send(CreatureStep { // CHANGED to CreatureStep
        direction: OrdDir::Up,
        entity: player.get_single().unwrap(), // NEW!
    });
}
if input.just_pressed(KeyCode::KeyD) {
    events.send(CreatureStep { // CHANGED to CreatureStep
        direction: OrdDir::Right,
        entity: player.get_single().unwrap(), // NEW!
    });
}
if input.just_pressed(KeyCode::KeyA) {
    events.send(CreatureStep { // CHANGED to CreatureStep
        direction: OrdDir::Left,
        entity: player.get_single().unwrap(), // NEW!
    });
}
if input.just_pressed(KeyCode::KeyS) {
    events.send(CreatureStep { // CHANGED to CreatureStep
        direction: OrdDir::Down,
        entity: player.get_single().unwrap(), // NEW!
    });
}

Note the newly introduced EndTurn, which will ensure that each non-player character gets to perform an action after the player's action. It will also be a new system:

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

pub fn end_turn(
    mut events: EventReader<EndTurn>,
    mut step: EventWriter<CreatureStep>,
    player: Query<&Position, With<Player>>,
    hunters: Query<(Entity, &Position), (With<Hunt>, Without<Player>)>,
    map: Res<Map>,
) {
    for _event in events.read() {
        let player_pos = player.get_single().unwrap();
        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_direction) = map.best_manhattan_move(*hunter_pos, *player_pos) {
                // If it is found, cause a CreatureStep event.
                step.send(CreatureStep {
                    direction: move_direction,
                    entity: hunter_entity,
                });
            }
        }
    }
}

Finally, register everything.

// events.rs
impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<SummonCreature>();
        app.add_event::<CreatureStep>(); // CHANGED
        app.add_event::<EndTurn>(); // NEW!
        app.add_event::<TeleportEntity>();
    }
}
// sets.rs
app.add_systems(
    Update,
    ((
        keyboard_input,
        creature_step, // CHANGED
        cast_new_spell,
        process_axiom,
    )
        .chain())
    .in_set(ActionPhase),
);
app.add_systems(
    Update,
    ((
        summon_creature,
        register_creatures,
        teleport_entity,
        end_turn, // NEW!
    )
        .chain())
    .in_set(ResolutionPhase),
);

If you cargo run now, you'll notice something peculiar - the Hunter is completely paralyzed and does nothing. Why? All the events are in place, this makes no sense...

The key is in Bevy's background event manager. When an event is sent, Bevy will only hold onto it for 2 frames, then delete it if it has not been handled yet. This is to prevent clogging of the event queue by a rogue system adding tons of events that never get read, leading to major performance issues!

However, in our case, here is what happens:

  • The player moves, EndTurn is sent, then CreatureStep for the Hunter.
  • The player's movement animation executes over multiple frames.
  • Bevy drops CreatureStep during the animation, as it has run out of patience.
  • The animation ends.
  • creature_step is triggered, and cries because its precious CreatureStep has been taken away. It does nothing.

Tell Bevy to stop being so mean by disabling its event auto-cleanup:

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

If you cargo run again, everything will work as planned.

Lasers For Everyone

This new end_turn system has opened up a whole new possibility space: spells for non-player characters.

First, we'll track the number of elapsed turns:

// events.rs
#[derive(Resource)]
pub struct TurnCount {
    turns: usize,
}
impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<SummonCreature>();
        app.add_event::<EndTurn>();
        app.add_event::<TeleportEntity>();
        app.init_resource::<Events<CreatureStep>>();
        app.insert_resource(TurnCount { turns: 0 }); // NEW!
    }
}

Next up, we'll make all Hunters fire a knockback laser every 5 turns.

// events.rs
pub fn end_turn(
    mut events: EventReader<EndTurn>,
    mut step: EventWriter<CreatureStep>,
    mut spell: EventWriter<CastSpell>, // NEW!
    mut turn_count: ResMut<TurnCount>, // NEW!
    player: Query<&Position, With<Player>>,
    hunters: Query<(Entity, &Position), (With<Hunt>, Without<Player>)>,
    map: Res<Map>,
) {
    for _event in events.read() {
        turn_count.turns += 1; // NEW!
        let player_pos = player.get_single().unwrap();
        for (hunter_entity, hunter_pos) in hunters.iter() {
        // NEW!
            // Occasionally cast a spell.
            if turn_count.turns % 5 == 0 {
                spell.send(CastSpell {
                    caster: hunter_entity,
                    spell: Spell {
                        axioms: vec![Axiom::MomentumBeam, Axiom::Dash { max_distance: 5 }],
                    },
                });
            }
            // Try to find a tile that gets the hunter closer to the player.
        // End NEW.
        // CHANGED: now an else if
            else if let Some(move_direction) = map.best_manhattan_move(*hunter_pos, *player_pos) {
                // If it is found, cause a CreatureStep event.

                step.send(CreatureStep {
                    direction: move_direction,
                    entity: hunter_entity,
                });
            }
        }
    }
}

This will have the exact same problem as CreatureStep - Bevy cleans up unused events after 2 frames. Remove CastSpell from the cleanup routine:

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

If you cargo run now, the Hunter will occasionally shoot lasers at you and the surrounding walls!

The Hunter, now with a knockback laser of its own which shoots at walls, then the player.

Magical Barricades

To conclude this chapter, we'll tie in SummonCreature with spells that call upon this event on demand!

Before anything else, we'll need to know who is summoning what, which can be solved by adding a pretty animation for which we already have all necessary components.

// events.rs

#[derive(Event)]
pub struct SummonCreature {
    pub position: Position,
    pub species: Species,
    pub summon_tile: Position, // NEW!
}

When a creature is summoned, they will now visibly move from their summoner to their assigned tile, giving a feel like they are being "thrown out" by the caster. We'll just need to add Transform and SlideAnimation:

// events.rs
pub fn summon_creature(/* SNIP */) {
    // SNIP
    let mut new_creature = commands.spawn(( // CHANGED - added "("
        Creature {
            position: event.position,
            species: event.species,
            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: get_species_sprite(&event.species),
                }),
                ..default()
            },
            momentum: OrdDir::Up,
        },
        // NEW!
        Transform::from_xyz(
            event.summon_tile.x as f32 * 64.,
            event.summon_tile.y as f32 * 64.,
            0.,
        ),
        SlideAnimation,
        // End NEW.
    )); // CHANGED - added ")"

This is a great example of Bevy's signature ECS modularity - once the building blocks of your game are well established, tacking on a few labels is all you need to radically change the behaviour of some Entities. Creatures will start with their sprite visually placed by Transform, moving towards their real tile position with SlideAnimation.

Fix the fields in summon_cage.

// map.rs
fn summon_cage(/* SNIP */) {
    // SNIP
    summon.send(SummonCreature {
        species,
        position,
        summon_tile: Position::new(4, 4), // NEW!
    });

We may now add the spell itself.

// 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 {

    // SNIP

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

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

// spells.rs
impl FromWorld for AxiomLibrary {
    fn from_world(world: &mut World) -> Self {
        let mut axioms = AxiomLibrary {
            library: HashMap::new(),
        };
        // SNIP
        // NEW!
        axioms.library.insert(
            discriminant(&Axiom::SummonCreature {
                species: Species::Player,
            }),
            world.register_system(axiom_function_summon_creature),
        );
        // End NEW.
        axioms
    }
}
// spells.rs
/// The targeted passable tiles summon a new instance of species.
fn axiom_function_summon_creature(
    mut summon: EventWriter<SummonCreature>,
    spell_stack: Res<SpellStack>,
    position: Query<&Position>,
) {
    let synapse_data = spell_stack.spells.last().unwrap();
    let caster_position = position.get(synapse_data.caster).unwrap();
    if let Axiom::SummonCreature { species } = synapse_data.axioms[synapse_data.step] {
        for position in &synapse_data.targets {
            summon.send(SummonCreature {
                species,
                position: *position,
                summon_tile: *caster_position,
            });
        }
    } else {
        panic!()
    }
}

If you now modify the Hunter's spellcasting like so:

// events.rs
pub fn end_turn(/* SNIP */) {

    // SNIP

    spell.send(CastSpell {
        caster: hunter_entity,
        spell: Spell {
            axioms: vec![
            // CHANGED
                Axiom::MomentumBeam,
                Axiom::SummonCreature {
                    species: Species::Wall,
                },
            // End CHANGED.
            ],
        },
    });

You'll find (after cargo run) a green friend who seems a little too enthusiastic about modern architecture.

The Hunter, using its laser to fill the cage with additional walls

To up the stakes, we'll now add a new Form Axiom and a new Species who will use it.

// spells.rs
pub enum Axiom {
    // FORMS
    
    // SNIP

    // NEW!
    /// Target a ring of `radius` around the caster.
    Halo { radius: i32 },
    // End NEW.
// spells.rs
impl FromWorld for AxiomLibrary {
    fn from_world(world: &mut World) -> Self {
        let mut axioms = AxiomLibrary {
            library: HashMap::new(),
        };
        // SNIP
        // NEW!
        axioms.library.insert(
            discriminant(&Axiom::Halo { radius: 1 }),
            world.register_system(axiom_form_halo),
        );
        // End NEW.
// spells.rs
/// Target a ring of `radius` around the caster.
fn axiom_form_halo(
    mut magic_vfx: EventWriter<PlaceMagicVfx>,
    mut spell_stack: ResMut<SpellStack>,
    position: Query<&Position>,
) {
    let synapse_data = spell_stack.spells.last_mut().unwrap();
    let caster_position = position.get(synapse_data.caster).unwrap();
    if let Axiom::Halo { radius } = synapse_data.axioms[synapse_data.step] {
        let mut circle = circle_around(caster_position, radius);
        // Sort by clockwise rotation.
        circle.sort_by(|a, b| {
            let angle_a = angle_from_center(caster_position, a);
            let angle_b = angle_from_center(caster_position, b);
            angle_a.partial_cmp(&angle_b).unwrap()
        });
        // Add some visual halo effects.
        magic_vfx.send(PlaceMagicVfx {
            targets: circle.clone(),
            sequence: EffectSequence::Sequential { duration: 0.04 },
            effect: EffectType::GreenBlast,
            decay: 0.5,
            appear: 0.,
        });
        // Add these tiles to `targets`.
        synapse_data.targets.append(&mut circle);
    } else {
        panic!()
    }
}

/// Generate the points across the outline of a circle.
fn circle_around(center: &Position, radius: i32) -> Vec<Position> {
    let mut circle = Vec::new();
    for r in 0..=(radius as f32 * (0.5f32).sqrt()).floor() as i32 {
        let d = (((radius * radius - r * r) as f32).sqrt()).floor() as i32;
        let adds = [
            Position::new(center.x - d, center.y + r),
            Position::new(center.x + d, center.y + r),
            Position::new(center.x - d, center.y - r),
            Position::new(center.x + d, center.y - r),
            Position::new(center.x + r, center.y - d),
            Position::new(center.x + r, center.y + d),
            Position::new(center.x - r, center.y - d),
            Position::new(center.x - r, center.y + d),
        ];
        for new_add in adds {
            if !circle.contains(&new_add) {
                circle.push(new_add);
            }
        }
    }
    circle
}

/// Find the angle of a point on a circle relative to its center.
fn angle_from_center(center: &Position, point: &Position) -> f64 {
    let delta_x = point.x - center.x;
    let delta_y = point.y - center.y;
    (delta_y as f64).atan2(delta_x as f64)
}

Create a circle, then rotate around it in a clockwise maneer so the animation looks pretty. If you are curious about my circle-making function, I highly recommend Red Blob Game's entry on the topic.

Now, for the new species:

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

/// 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 => 5, // NEW!
    }
}
// events.rs
/// Place a new Creature on the map of Species and at Position.
pub fn summon_creature(/* SNIP */) {

        // SNIP

        // Add any species-specific components.
        match &event.species {
            Species::Player => {
                new_creature.insert(Player);
            }
            Species::Hunter | Species::Spawner => { // CHANGED: Added Spawner.
                new_creature.insert(Hunt);
            }
            _ => (),
        }

And for its spellcasting:

// events.rs
pub fn end_turn(
    // SNIP
    hunters: Query<(Entity, &Position, &Species), (With<Hunt>, Without<Player>)>, // CHANGED: Added Species.
    map: Res<Map>,
) {
    for _event in events.read() {
        turn_count.turns += 1;
        let player_pos = player.get_single().unwrap();
        for (hunter_entity, hunter_pos, hunter_species) in hunters.iter() { // CHANGED: Added hunter_species.
            // Occasionally cast a spell.
            if turn_count.turns % 5 == 0 {
                // NEW!
                match hunter_species {
                    Species::Hunter => {
                        spell.send(CastSpell {
                            caster: hunter_entity,
                            spell: Spell {
                                axioms: vec![Axiom::MomentumBeam, Axiom::Dash { max_distance: 5 }],
                            },
                        });
                    }
                    Species::Spawner => {
                        spell.send(CastSpell {
                            caster: hunter_entity,
                            spell: Spell {
                                axioms: vec![
                                    Axiom::Halo { radius: 3 },
                                    Axiom::SummonCreature {
                                        species: Species::Hunter,
                                    },
                                ],
                            },
                        });
                    }
                    _ => (),
                }
                // End NEW.
            }
            // Try to find a tile that gets the hunter closer to the player.
            else if let Some(move_direction) = map.best_manhattan_move(*hunter_pos, *player_pos) {
                // If it is found, cause a CreatureStep event.

                step.send(CreatureStep {
                    direction: move_direction,
                    entity: hunter_entity,
                });
            }
        }
    }
}

That's right - halo summoning of Hunters every 5 turns, who all have knockback beams. Whatever it is you are imagining right now, it is nowhere as glorious as the pandemonium about to be unleashed.

// map.rs
fn spawn_cage(mut summon: EventWriter<SummonCreature>) {
    // CHANGED
    let cage = ".........\
                .........\
                ....S....\
                .........\
                .........\
                .........\
                ....@....\
                .........\
                .........";
    // End CHANGED.
    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,
            'S' => Species::Spawner, // NEW!
            '@' => Species::Player,
            _ => continue,
        };

cargo run, and LET CHAOS REIGN.

The Spawner creating an armada of Hunters, which then proceed to laser everything and cause chaotic knockback fun!

Bevy Traditional Roguelike Quick-Start - 5. Laser Sumo Rave

Magic is nothing without the sparkles! The artifice! We need laser beams and confetti.

Visual Effects

Let us start with the preliminary Bundle, Event and other supporting structs and enums. Whenever this event will be triggered, effects of a certain colour and sprite will be placed on all selected tiles, and will gradually decay with time.

// graphics.rs
#[derive(Bundle)]
pub struct MagicEffect {
    /// The tile position of this visual effect.
    pub position: Position,
    /// The sprite representing this visual effect.
    pub sprite: Sprite,
    pub visibility: Visibility,
    /// The timers tracking when the effect appears, and how
    /// long it takes to decay.
    pub vfx: MagicVfx,
}

#[derive(Event)]
/// An event to place visual effects on the game board.
pub struct PlaceMagicVfx {
    /// All tile positions on which a visual effect will appear.
    pub targets: Vec<Position>,
    /// Whether the effect appear one by one, or all at the same time.
    pub sequence: EffectSequence,
    /// The effect sprite.
    pub effect: EffectType,
    /// How long these effects take to decay.
    pub decay: f32,
    /// How long these effects take to appear.
    pub appear: f32,
}

#[derive(Clone, Copy)]
pub enum EffectSequence {
    /// All effects appear at the same time.
    Simultaneous,
    /// Effects appear one at a time, in a queue.
    /// `duration` is how long it takes to move from one effect to the next.
    Sequential { duration: f32 },
}

#[derive(Clone, Copy)]
pub enum EffectType {
    HorizontalBeam,
    VerticalBeam,
    RedBlast,
    GreenBlast,
    XCross,
}

#[derive(Component)]
pub struct MagicVfx {
    /// How long this effect takes to decay.
    appear: Timer,
    /// How long this effect takes to appear.
    decay: Timer,
}

/// Get the appropriate texture from the spritesheet depending on the effect type.
pub fn get_effect_sprite(effect: &EffectType) -> usize {
    match effect {
        EffectType::HorizontalBeam => 15,
        EffectType::VerticalBeam => 16,
        EffectType::RedBlast => 14,
        EffectType::GreenBlast => 13,
        EffectType::XCross => 1,
    }
}

There are only two systems to make, now - one to place the effects, and one to ensure they decay appropriately. Each effect initially has its Visibility set to Hidden - if the effect is the tip of a laser beam, we want it to be displayed after the start of the laser beam, as to create a "trailing" effect.

// graphics.rs
pub fn place_magic_effects(
    mut events: EventReader<PlaceMagicVfx>,
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    atlas_layout: Res<SpriteSheetAtlas>,
) {
    for event in events.read() {
        for (i, target) in event.targets.iter().enumerate() {
            // Place effects on all positions from the event.
            commands.spawn(MagicEffect {
                position: *target,
                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: get_effect_sprite(&event.effect),
                    }),
                    ..default()
                },
                visibility: Visibility::Hidden,
                vfx: MagicVfx {
                    appear: match event.sequence {
                        // If simultaneous, everything appears at the same time.
                        EffectSequence::Simultaneous => {
                            Timer::from_seconds(event.appear, TimerMode::Once)
                        }
                        // Otherwise, effects gradually get increased appear timers depending on
                        // how far back they are in their queue.
                        EffectSequence::Sequential { duration } => Timer::from_seconds(
                            i as f32 * duration + event.appear,
                            TimerMode::Once,
                        ),
                    },
                    decay: Timer::from_seconds(event.decay, TimerMode::Once),
                },
            });
        }
    }
}

Then, effects appear one by one (if sequential) or all at once (if simultaneous), and decay into 100% transparency after a delay preset by their decay timers.

// graphics.rs
pub fn decay_magic_effects(
    mut commands: Commands,
    mut magic_vfx: Query<(Entity, &mut Visibility, &mut MagicVfx, &mut Sprite)>,
    time: Res<Time>,
) {
    for (vfx_entity, mut vfx_vis, mut vfx_timers, mut vfx_sprite) in magic_vfx.iter_mut() {
        // Effects that have completed their appear timer and are now visible, decay.
        if matches!(*vfx_vis, Visibility::Inherited) {
            vfx_timers.decay.tick(time.delta());
            // Their alpha (transparency) slowly loses opacity as they decay.
            vfx_sprite
                .color
                .set_alpha(vfx_timers.decay.fraction_remaining());
            if vfx_timers.decay.finished() {
                commands.entity(vfx_entity).despawn();
            }
        // Effects that have not appeared yet progress towards appearing for the first time.
        } else {
            vfx_timers.appear.tick(time.delta());
            if vfx_timers.appear.finished() {
                *vfx_vis = Visibility::Inherited;
            }
        }
    }
}

All we need now is to properly place these effects as a result of casting spells.

// spells.rs
/// Target the caster's tile.
fn axiom_form_ego(
    mut magic_vfx: EventWriter<PlaceMagicVfx>, // NEW!
    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();

    // NEW!
    // Place the visual effect.
    magic_vfx.send(PlaceMagicVfx {
        targets: vec![caster_position],
        sequence: EffectSequence::Sequential { duration: 0.04 },
        effect: EffectType::RedBlast,
        decay: 0.5,
        appear: 0.,
    });
    // End NEW.

    // Add that caster's position to the targets.
    synapse_data.targets.push(caster_position);
}
// spells.rs
/// 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(
    mut magic_vfx: EventWriter<PlaceMagicVfx>, // NEW!
    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);

    // NEW!
    // Add some visual beam effects.
    magic_vfx.send(PlaceMagicVfx {
        targets: output.clone(),
        sequence: EffectSequence::Sequential { duration: 0.04 },
        effect: match caster_momentum {
            OrdDir::Up | OrdDir::Down => EffectType::VerticalBeam,
            OrdDir::Right | OrdDir::Left => EffectType::HorizontalBeam,
        },
        decay: 0.5,
        appear: 0.,
    });
    // End NEW.
    
    // Add these tiles to `targets`.
    synapse_data.targets.append(&mut output);
}

If you cargo run now, you'll run into an amusing bug - the laser beams create invisible walls. This is because register_creatures cannot make the difference between a creature and a magic effect - they both have the Position component! It thinks lasers are creatures, and adds them to the Map. Let us filter them out.

// 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.

    // CHANGED - Added Without<MagicVfx>
    displaced_creatures: Query<(&Position, Entity), (Added<Position>, Without<MagicVfx>)>,
) {
    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);
    }
}

Don't forget to register.

// graphics.rs
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);
        // NEW!
        app.add_systems(Update, place_magic_effects);
        app.add_systems(Update, decay_magic_effects);
        app.add_event::<PlaceMagicVfx>();
        // End NEW.
    }
}

All done! cargo run, and enjoy the fireworks.

The player lasering the wall, and cyan blue beam effects leaving a trail as they do so.

Motion Animations

All fun and good, but the instant-teleports don't fit in with all this aesthetic superiority we have just implemented. Let us fix that.

Whenever a creature teleports, it will instead interpolate its Transform translation from its origin to its destination. To do so, we'll need to rewrite adjust_transforms.

// graphics.rs
#[derive(Component)]
pub struct SlideAnimation;

/// Each frame, adjust every entity's display location to match
/// their position on the grid, and make the camera follow the player.
pub fn adjust_transforms(
    mut creatures: Query<(
        Entity, // NEW!
        &Position,
        &mut Transform,
        Has<SlideAnimation>, // NEW!
        Has<Player>,
    )>,
    mut camera: Query<&mut Transform, (With<Camera>, Without<Position>)>,
    time: Res<Time>, // NEW!
    mut commands: Commands, // NEW!
) {
    // NEW!
    for (entity, pos, mut trans, is_animated, is_player) in creatures.iter_mut() {
        // If this creature is affected by an animation...
        if is_animated {
            // The sprite approaches its destination.
            let current_translation = trans.translation;
            let target_translation = Vec3::new(pos.x as f32 * 64., pos.y as f32 * 64., 0.);
            // The creature is more than 0.5 pixels away from its destination - smooth animation.
            if ((target_translation.x - current_translation.x).abs()
                + (target_translation.y - current_translation.y).abs())
                > 0.5
            {
                trans.translation = trans
                    .translation
                    .lerp(target_translation, 10. * time.delta_secs());
            // Otherwise, the animation is over - clip the creature onto the grid.
            } else {
                commands.entity(entity).remove::<SlideAnimation>();
            }
        } else {
    // End NEW.

            // For creatures with no animation.
            // 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);
        }
    }
}

Creatures have their translation interpolate (lerp) towards their target translation, until the animation completes and the component responsible for orchestrating this (SlideAnimation) is removed.

Great, now, any creature with the new SlideAnimation component will gracefully make its way to its destination. Let us add that component each time a creature teleports.

// events.rs
fn teleport_entity(
    mut events: EventReader<TeleportEntity>,
    mut creature: Query<&mut Position>,
    mut map: ResMut<Map>,
    mut commands: Commands, // NEW!
) {
    for event in events.read() {
        // SNIP
        // If motion is possible...
        if map.is_passable(event.destination.x, event.destination.y) {
            // SNIP
            creature_position.update(event.destination.x, event.destination.y);
            // Also, animate this creature, making its teleport action visible on the screen.
            commands.entity(event.entity).insert(SlideAnimation); // NEW!
        } else {
            // Nothing here just yet, but this is where collisions between creatures
            // will be handled.
            continue;
        }
    }
}

cargo run, and your two caged creatures may have developed a little bit of sliding grace, as shown below.

The player lasering the wall, and the wall sliding smoothly instead of sharply teleporting.

Wait... why "may"? Indeed, should you restart the game multiple times, you might notice that it sometimes works, and sometimes doesn't.

Oh heavens, the worse type of bug - the non-deterministic error! How will we ever solve this? Is it time to give up?

No. We simply have not scheduled our systems.

System Scheduling

Marking systems as Update only means they trigger every tick. In which order they trigger, however, that's fully up to chance! This causes a dizzying amount of non-deterministic bugs.

To prevent this, we must tell Bevy in which order all our events and animations are handled. As a starting point, let us diagnose just how bad it has gotten.

// main.rs
fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        // SNIP
        // NEW!
        .edit_schedule(Update, |schedule| {
            schedule.set_build_settings(ScheduleBuildSettings {
                ambiguity_detection: LogLevel::Warn,
                ..default()
            });
        })
        // End NEW.
        .run();
}

You should obtain this ominous message:

7 pairs of systems with conflicting data access have indeterminate execution order. Consider adding `before`, `after`, or `ambiguous_with` relationships between these:
 -- adjust_transforms and teleport_entity
    conflict on: ["redesign_tgfp::map::Position"]
 -- teleport_entity and player_step
    conflict on: ["bevy_ecs::event::Events<redesign_tgfp::events::TeleportEntity>", "redesign_tgfp::map::Map", "redesign_tgfp::map::Position"]
 -- teleport_entity and register_creatures
    conflict on: ["redesign_tgfp::map::Map", "redesign_tgfp::map::Position"]
 -- keyboard_input and player_step
    conflict on: ["bevy_ecs::event::Events<redesign_tgfp::events::PlayerStep>"]
 -- keyboard_input and cast_new_spell
    conflict on: ["bevy_ecs::event::Events<redesign_tgfp::spells::CastSpell>"]
 -- player_step and register_creatures
    conflict on: ["redesign_tgfp::map::Map"]
 -- process_axiom and cast_new_spell
    conflict on: ["redesign_tgfp::spells::SpellStack"]

All of these are systems where at least one of the two modifies a certain component, leading the other system to behave completely differently depending on whether it runs before or after.

Let's bring on board a grand overseer to resolve all of these inconsistencies. Create the file sets.rs, in which a new plugin will take shape.

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

pub struct SetsPlugin;

impl Plugin for SetsPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(
            Update,
            ((
                keyboard_input.run_if(spell_stack_is_empty),
                creature_step,
                cast_new_spell,
                process_axiom,
            )
                .in_set(ActionPhase),
        );
        app.add_systems(
            Update,
            ((register_creatures, teleport_entity).chain()).in_set(ResolutionPhase),
        );
        app.add_systems(
            Update,
            ((place_magic_effects, adjust_transforms, decay_magic_effects).chain())
                .in_set(AnimationPhase),
        );
        app.configure_sets(
            Update,
            (ActionPhase, AnimationPhase, ResolutionPhase).chain(),
        );
    }
}

#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
struct ActionPhase;

#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
struct ResolutionPhase;

#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
struct AnimationPhase;
// spells.rs
pub fn spell_stack_is_empty(spell_stack: Res<SpellStack>) -> bool {
    spell_stack.spells.is_empty()
}

chain effectively means the systems run one after the other. First, I place everything related to making choices and casting spells into ActionPhase, then everything related to gameplay actions in ResolutionPhase, and finally put all the visuals and animations in AnimationPhase. The ordering was not chosen at random:

  • decay_magic_effects goes after adjust_transforms, so the magical effects can snap to their tile position before starting to decay.
  • register_creatures goes before teleport_entity, so that moving into the tile of a newly-summoned creature won't succeed due to the creature not having been registered into the Map yet.
  • keyboard_input is disallowed until all spells have finished executing, so the player cannot twitch-reflex their way out of an incoming beam.

Add this new Plugin to main.rs.

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

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

Also, remove all instance of the Update systems in other files to avoid duplicate systems.

// graphics.rs
impl Plugin for GraphicsPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<SpriteSheetAtlas>();
        app.add_event::<PlaceMagicVfx>();
        app.add_systems(Startup, setup_camera);
        // REMOVED Update systems.
    }
}
// map.rs
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);
        // REMOVED Update systems.
    }
}
// events.rs
impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<PlayerStep>();
        app.add_event::<TeleportEntity>();
        // REMOVED Update systems.
    }
}
// spells.rs
impl Plugin for SpellPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<CastSpell>();
        app.init_resource::<SpellStack>();
        app.init_resource::<AxiomLibrary>();
        // REMOVED Update systems.
    }
}
// input.rs
// REMOVE all of the following.
pub struct InputPlugin;

impl Plugin for InputPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Update, keyboard_input);
    }
}
// main.rs
use events::EventPlugin;
use graphics::GraphicsPlugin;
// REMOVED InputPlugin.
use map::MapPlugin;
use sets::SetsPlugin;
use spells::SpellPlugin;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .add_plugins((
            SetsPlugin,
            SpellPlugin,
            EventPlugin,
            GraphicsPlugin,
            MapPlugin,
            // REMOVED InputPlugin.
        ))

With all that done, cargo run! Note that this time around, the beam hits the wall, and then the wall is knocked back, all thanks to our system ordering.

The player lasering the wall, and the wall only being knocked back after it has been hit by the beam.