Bevy Traditional Roguelike Quick-Start - 7. Peace Was Never An Option

It is finally possible to engineer something which somewhat resembles a challenge - or a "game", as the optimists would put it. This lonely little cage has been dreary long enough.

// map.rs
fn spawn_cage(mut summon: EventWriter<SummonCreature>) {
// NEW!
    let cage = "\
##################\
#H.H..H.##...HH..#\
#.#####.##..###..#\
#...#...##.......#\
#..H#......#####.#\
#...#...##...H...#\
#.#####.##..###..#\
#..H...H##.......#\
####.########.####\
####.########.####\
#.......##H......#\
#.#####.##.......#\
#.#H....##..#.#..#\
#.#.##.......@...#\
#.#H....##..#.#..#\
#.#####.##.......#\
#.......##......H#\
##################\
    ";
// End NEW.
    for (idx, tile_char) in cage.char_indices() {
        let position = Position::new(idx as i32 % 18, idx as i32 / 18); // CHANGED - 9 -> 18

A little maze, full of dastardly foes. There is immediately a problem: spells allow both the player and the Hunters to deconstruct our beautiful architecture.

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

#[derive(Component)]
pub struct Attackproof;

Note the physical/magical duality! Right now, only Spellproof will be implemented, but Attackproof will be next very soon in this chapter.

// events.rs
// Add any species-specific components.
pub fn summon_creature(/* SNIP */) { // SNIP
match &event.species {
    Species::Player => {
        new_creature.insert(Player);
    }
    // NEW!
    Species::Wall => {
        new_creature.insert((Attackproof, Spellproof));
    }
    // End NEW.

Now, to ensure that Axiom::Dash has no effect on Spellproof creatures:

// 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>,
    is_spellproof: Query<Has<Spellproof>>, // NEW!
) {
    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) {
        // NEW!
            // Spellproof entities cannot be affected.
            if is_spellproof.get(dasher).unwrap() {
                continue;
            }
        // End NEW.

If you cargo run now, the cage will now be fully inescapable.

A 18x18 cage with diverse wall patterns, which cannot be altered in any way despite the numerous beams firing inside.

And, in such a doomed prison, the only thing left to do is fight for entertainment.

// creature.rs
#[derive(Component)]
pub struct Health {
    pub hp: usize,
    pub max_hp: usize,
}

// The graphical representation of Health: a health bar.
#[derive(Bundle)]
pub struct HealthIndicator {
    pub sprite: Sprite,
    pub visibility: Visibility,
    pub transform: Transform,
}

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

Let's add Health to all creatures, with individual robustness values for each different Species...

// events.rs

/// Place a new Creature on the map of Species and at Position.
pub fn summon_creature(
    // SNIP
) {
let mut new_creature = commands.spawn((
Creature {
    // SNIP
    momentum: OrdDir::Up,
    // NEW!
    health: {
        let max_hp = match &event.species {
            Species::Player => 7,
            Species::Wall => 10,
            Species::Hunter => 2,
            Species::Spawner => 3,
            _ => 2,
        };
        // Start at full health.
        let hp = max_hp;
        Health { max_hp, hp }
    },
    // End NEW.
},

...and give them all a HealthIndicator to visually track this.

// events.rs
/// Place a new Creature on the map of Species and at Position.
pub fn summon_creature(
    // SNIP
) {
    // Add any species-specific components.
    match &event.species {
        // SNIP
    }

    // NEW!
    // Free the borrow on Commands.
    let new_creature_entity = new_creature.id();
    let hp_bar = commands
        .spawn(HealthIndicator {
            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: 178,
                }),
                ..default()
            },
            visibility: Visibility::Hidden,
            transform: Transform::from_xyz(0., 0., 1.),
        })
        .id();
    commands.entity(new_creature_entity).add_child(hp_bar);
    // End NEW.
}

The HealthIndicator will be added as a child entity of the creature. While it may seem like blasphemy to have hierarchies like this in an ECS game development environment, they do exist in a limited form. In Bevy, this is useful to have a sprite follow another as if it were "glued" to it, since the children inherit a Transform component from their parent. This is exactly what we want for a creature-specific HP bar! Children can be attached with commands.entity(parent).add_child(child);

Now, for damage to actually exist, it will of course be passed as an ̀Event. Note the &Children in the Query, which allows for easy access of the damaged creature's HealthIndicator!

// events.rs
#[derive(Event)]
pub struct HarmCreature {
    entity: Entity,
    culprit: Entity,
    damage: usize,
}

pub fn harm_creature(
    mut events: EventReader<HarmCreature>,
    mut remove: EventWriter<RemoveCreature>,
    mut creature: Query<(&mut Health, &Children)>,
    mut hp_bar: Query<(&mut Visibility, &mut Sprite)>,
) {
    for event in events.read() {
        let (mut health, children) = creature.get_mut(event.entity).unwrap();
        // Deduct damage from hp.
        health.hp = health.hp.saturating_sub(event.damage);
        // Update the healthbar.
        for child in children.iter() {
            let (mut hp_vis, mut hp_bar) = hp_bar.get_mut(*child).unwrap();
            // Don't show the healthbar at full hp.
            if health.max_hp == health.hp {
                *hp_vis = Visibility::Hidden;
            } else {
                *hp_vis = Visibility::Inherited;
                let hp_percent = health.hp as f32 / health.max_hp as f32;
                hp_bar.texture_atlas.as_mut().unwrap().index = match hp_percent {
                    0.86..1.00 => 178,
                    0.72..0.86 => 179,
                    0.58..0.72 => 180,
                    0.44..0.58 => 181,
                    0.30..0.44 => 182,
                    0.16..0.30 => 183,
                    0.00..0.16 => 184,
                    _ => panic!("That is not a possible HP %!"),
                }
            }
        }
        // 0 hp creatures are removed.
        if health.hp == 0 {
            remove.send(RemoveCreature {
                entity: event.entity,
            });
        }
    }
}

saturating_sub prevents integer overflow by stopping subtraction at 0. The healthbar is hidden when it is full, and then gradually deteriorates by shifting its sprite into increasingly dire variations as the HP percentage lowers. Visibility::Inherited means the health bar will also be hidden should the parent (the creature itself) be hidden.

Note the yet unimplemented event at the end for creatures to remove from the game board - which we will attend to immediately.

#[derive(Event)]
pub struct RemoveCreature {
    entity: Entity,
}

pub fn remove_creature(
    mut events: EventReader<RemoveCreature>,
    mut commands: Commands,
    mut map: ResMut<Map>,
    creature: Query<(&Position, Has<Player>)>,
    mut spell_stack: ResMut<SpellStack>,
    mut magic_vfx: EventWriter<PlaceMagicVfx>,
) {
    for event in events.read() {
        let (position, is_player) = creature.get(event.entity).unwrap();
        // Visually flash an X where the creature was removed.
        magic_vfx.send(PlaceMagicVfx {
            targets: vec![*position],
            sequence: EffectSequence::Simultaneous,
            effect: EffectType::XCross,
            decay: 0.5,
            appear: 0.,
        });
        // For now, avoid removing the player - the game panics without a player.
        if !is_player {
            // Remove the creature from Map
            map.creatures.remove(position);
            // Remove the creature AND its children (health bar)
            commands.entity(event.entity).despawn_recursive();
            // Remove all spells cast by this creature
            // (this entity doesn't exist anymore, casting its spells would crash the game)
            spell_stack
                .spells
                .retain(|spell| spell.caster != event.entity);
        }
    }
}

We do have a little bit of upkeep to make sure the (very involved!) task of removing an Entity goes according to plan. Merely calling despawn instead of despawn_recursive would keep floating health bars that belong to no one! Not to mention the instant panic that would result from a spell still in the SpellStack with the removed creature as a caster, trying to target itself, something which does not exist.

We still have no way to inflict harm from within the game. But, perhaps you remember this little placeholder?

// Nothing here just yet, but this is where collisions between creatures will be handled.̀ (events.rs, teleport_entity)

It is time to put it to use.

// events.rs
pub fn teleport_entity(
    mut events: EventReader<TeleportEntity>,
    mut creature: Query<&mut Position>,
    mut map: ResMut<Map>,
    mut commands: Commands,
    mut collision: EventWriter<CreatureCollision>, // NEW!
) {
    // SNIP
    if map.is_passable(event.destination.x, event.destination.y) {
        // SNIP
    } else {
        // NEW!
        // A creature collides with another entity.
        let collided_with = map
            .get_entity_at(event.destination.x, event.destination.y)
            .unwrap();
        collision.send(CreatureCollision {
            culprit: event.entity,
            collided_with: *collided_with,
        });
        // End NEW.
    }

A new, unimplemented event... yes, because not all collisions will necessarily be harmful. Some could be interacting with a mechanism, talking to an NPC, or... opening a door.

#[derive(Event)]
pub struct CreatureCollision {
    culprit: Entity,
    collided_with: Entity,
}

pub fn creature_collision(
    mut events: EventReader<CreatureCollision>,
    mut harm: EventWriter<HarmCreature>,
    mut open: EventWriter<OpenDoor>,
    flags: Query<(Has<Door>, Has<Attackproof>)>,
    mut turn_manager: ResMut<TurnManager>,
    mut creature: Query<(&OrdDir, &mut Transform)>,
    mut commands: Commands,
) {
    for event in events.read() {
        if event.culprit == event.collided_with {
            // No colliding with yourself.
            continue;
        }
        let (is_door, cannot_be_attacked) = flags.get(event.collided_with).unwrap();
        if is_door {
            // Open doors.
            open.send(OpenDoor {
                entity: event.collided_with,
            });
        } else if !cannot_be_attacked {
            // Melee attack.
            harm.send(HarmCreature {
                entity: event.collided_with,
                culprit: event.culprit,
                damage: 1,
            });
            // Melee attack animation.
            let (attacker_orientation, mut attacker_transform) =
                creature.get_mut(event.culprit).unwrap();
            attacker_transform.translation.x +=
                attacker_orientation.as_offset().0 as f32 * 64. / 4.;
            attacker_transform.translation.y +=
                attacker_orientation.as_offset().1 as f32 * 64. / 4.;
            commands.entity(event.culprit).insert(SlideAnimation);
        } else if matches!(turn_manager.action_this_turn, PlayerAction::Step) {
            // The player spent their turn walking into a wall, disallow the turn from ending.
            turn_manager.action_this_turn = PlayerAction::Invalid;
        }
    }
}

There are quite a few things of interest in this new system:

  • Attackproof is finally checked. This prevents the player from melee-attacking walls to break them, and escape the cage.
  • There is a melee attack animation. It shifts the attacking entity 1/4th of a tile closer to their attack direction, and the added SlideAnimation returns them to their original placement, making it look like a "jab" onto the attacked creature.
  • There is a yet unimplemented resource, TurnManager, which will be addressed next.
  • There is a yet unimplemented event, OpenDoor, which will be showcased later down the chapter, accompanied by a Door component.

Wallhack Anticheat

Currently, it is possible to wait for enemies to get into melee range by scratching at the walls over and over again. Even though they are indestructible (because of Attackproof), this still skips turns even though nothing is actually happening!

This is because end_turn triggers no matter what, even if the player performed an invalid action. This must be checked.

TurnCount does a little more than just counting, now, so it has been renamed to TurnManager.

// events.rs
#[derive(Resource)]
pub struct TurnManager { // CHANGED - rename all instances of the TurnCount symbol to TurnManager.
    pub turn_count: usize, // CHANGED to turn_count
    // NEW!
    /// Whether the player took a step, cast a spell, or did something useless (like step into a wall) this turn.
    pub action_this_turn: PlayerAction,
    // End NEW.
}

// NEW!
pub enum PlayerAction {
    Step,
    Spell,
    Invalid,
}
// End NEW.
// events.rs
pub fn end_turn(
    // SNIP
    mut turn_manager: ResMut<TurnManager>, // CHANGED - renamed to turn_manager
) {
    for _event in events.read() {
        // NEW!
        // The player shouldn't be allowed to "wait" turns by stepping into walls.
        if matches!(turn_manager.action_this_turn, PlayerAction::Invalid) {
            return;
        }
        // End NEW.
        turn_manager.turn_count += 1; // CHANGED to turn_manager.turn_count
        let player_pos = player.get_single().unwrap();
        for (hunter_entity, hunter_pos, hunter_species) in hunters.iter() {
            // Occasionally cast a spell.
            if turn_manager.turn_count % 5 == 0 { // CHANGED to turn_manager.turn_count

For this to do anything, we'll ensure each possible action is registered the moment the player presses a key:

// input.rs

/// Each frame, if a button is pressed, move the player 1 tile.
pub fn keyboard_input(
    // SNIP
    mut turn_manager: ResMut<TurnManager>, // NEW!
    mut turn_end: EventWriter<EndTurn>, // NEW!
) {
    if input.just_pressed(KeyCode::Space) {
        // SNIP
        turn_manager.action_this_turn = PlayerAction::Spell; // NEW!
        turn_end.send(EndTurn); // NEW!
    }
    if input.just_pressed(KeyCode::KeyW) {
        // SNIP
        turn_manager.action_this_turn = PlayerAction::Step; // NEW!
        turn_end.send(EndTurn); // NEW!
    }
    if input.just_pressed(KeyCode::KeyD) {
        // SNIP
        turn_manager.action_this_turn = PlayerAction::Step; // NEW!
        turn_end.send(EndTurn); // NEW!
    }
    if input.just_pressed(KeyCode::KeyA) {
        // SNIP
        turn_manager.action_this_turn = PlayerAction::Step; // NEW!
        turn_end.send(EndTurn); // NEW!
    }
    if input.just_pressed(KeyCode::KeyS) {
        // SNIP
        turn_manager.action_this_turn = PlayerAction::Step; // NEW!
        turn_end.send(EndTurn); // NEW!
    }
}

Note how this is offshoring EndTurn to keyboard_input - this is because we want spells to cost a turn as well. We'll remove the original EndTurn send in creature_step... and we'll offshore the momentum shift to a whole new event, AlterMomentum.

The reason for this is simple - now that Invalid moves are a thing, we don't want the player to be able to change the momentum of their laser beams by uselessly pushing against walls. One valid step or melee attack = one momentum shift!

// events.rs

pub fn creature_step(
    mut events: EventReader<CreatureStep>,
    mut teleporter: EventWriter<TeleportEntity>,
    mut momentum: EventWriter<AlterMomentum>, // CHANGED EndTurn for AlterMomentum.
    mut creature: Query<&Position>, // CHANGED removed all components except Position.
) {
    for event in events.read() {
        // CHANGED only the Position is accessed.
        let creature_pos = 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,
        ));
        // CHANGED momentum update is now an event, and there is no more EndTurn.
        // Update the direction towards which this creature is facing.
        momentum.send(AlterMomentum {
            entity: event.entity,
            direction: event.direction,
        });
        // End CHANGED
    }
}

AlterMomentum ensures your move is not invalid to properly change the creature's momentum. To signify this graphically, it will also rotate the sprite around to indicate in which direction it is currently "facing", as well as ensuring the health bar always stays on the bottom of the sprite despite this rotation.

// events.rs

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

pub fn alter_momentum(
    mut events: EventReader<AlterMomentum>,
    mut creature: Query<(&mut OrdDir, &mut Transform, &Children)>,
    mut hp_bar: Query<&mut Transform, Without<OrdDir>>,
    turn_manager: Res<TurnManager>,
) {
    for event in events.read() {
        // Don't allow changing your momentum by stepping into walls.
        if matches!(turn_manager.action_this_turn, PlayerAction::Invalid) {
            return;
        }
        let (mut creature_momentum, mut creature_transform, children) =
            creature.get_mut(event.entity).unwrap();
        *creature_momentum = event.direction;
        match event.direction {
            OrdDir::Down => creature_transform.rotation = Quat::from_rotation_z(0.),
            OrdDir::Right => creature_transform.rotation = Quat::from_rotation_z(PI / 2.),
            OrdDir::Up => creature_transform.rotation = Quat::from_rotation_z(PI),
            OrdDir::Left => creature_transform.rotation = Quat::from_rotation_z(3. * PI / 2.),
        }
        // Keep the HP bar on the bottom.
        for child in children.iter() {
            let mut hp_transform = hp_bar.get_mut(*child).unwrap();
            match event.direction {
                OrdDir::Down => hp_transform.rotation = Quat::from_rotation_z(0.),
                OrdDir::Right => hp_transform.rotation = Quat::from_rotation_z(3. * PI / 2.),
                OrdDir::Up => hp_transform.rotation = Quat::from_rotation_z(PI),
                OrdDir::Left => hp_transform.rotation = Quat::from_rotation_z(PI / 2.),
            }
        }
    }
}

Sliding Dystopian Airlocks

Now, for OpenDoor and Door.

// creature.rs

#[derive(Component)]
pub struct Door;
// events.rs

#[derive(Event)]
pub struct OpenDoor {
    entity: Entity,
}
// creature.rs

#[derive(Debug, Component, Clone, Copy)]
pub enum Species {
    Player,
    Wall,
    Hunter,
    Spawner,
    Airlock, // 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,
        Species::Airlock => 17, // NEW!
    }
}
// map.rs
fn spawn_cage(mut summon: EventWriter<SummonCreature>) {
// CHANGED - added <>V^
    let cage = "\
##################\
#H.H..H.##...HH..#\
#.#####.##..###..#\
#...#...##.......#\
#..H#...><.#####.#\
#...#...##...H...#\
#.#####.##..###..#\
#..H...H##.......#\
####^########^####\
####V########V####\
#.......##H......#\
#.#####.##.......#\
#.#H....##..#.#..#\
#.#.##..><...@...#\
#.#H....##..#.#..#\
#.#####.##.......#\
#.......##......H#\
##################\
    ";
// End CHANGED
    for (idx, tile_char) in cage.char_indices() {
        let position = Position::new(idx as i32 % 18, idx as i32 / 18);
        let species = match tile_char {
            '#' => Species::Wall,
            'H' => Species::Hunter,
            'S' => Species::Spawner,
            '@' => Species::Player,
            '^' | '>' | '<' | 'V' => Species::Airlock, // NEW!
            _ => continue,
        };
        // NEW!
        let momentum = match tile_char {
            '^' => OrdDir::Up,
            '>' => OrdDir::Right,
            '<' => OrdDir::Left,
            'V' | _ => OrdDir::Down,
        };
        // End NEW.
        summon.send(SummonCreature {
            species,
            position,
            momentum, // NEW!
            summon_tile: Position::new(0, 0),
        });
    }
}
// events.rs

pub fn summon_creature(/* SNIP */) {
        // SNIP
        // NEW!
            Species::Airlock => {
                new_creature.insert((Attackproof, Spellproof, Door));
            }
        // End NEW.

Airlocks face a direction, represented by a graphical arrow on their tile - this will allow us to know in which direction to slide their panes, so it looks like they are retreating inside the walls. To this end, we must add an additional field to SummonCreature.

// events.rs

#[derive(Event)]
pub struct SummonCreature {
    pub position: Position,
    pub species: Species,
    pub momentum: OrdDir, // NEW!
    pub summon_tile: Position,
}
// 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>,
) {
    // SNIP
            summon.send(SummonCreature {
                species,
                position: *position,
                momentum: OrdDir::Down, // NEW!
                summon_tile: *caster_position,
            });
    // SNIP
}

We'll need to ensure all newly spawned creatures start with their proper momentum, both graphically and in game logic.

// 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() {
            // SNIP
            Creature {
                // SNIP
                momentum: event.momentum, // CHANGED - no longer defaults to Down
                health: // SNIP
            },
            // CHANGED - defines fields instead of from_xyz
            Transform {
                translation: Vec3 {
                    x: event.summon_tile.x as f32 * 64.,
                    y: event.summon_tile.y as f32 * 64.,
                    z: 0.,
                },
                rotation: Quat::from_rotation_z(match event.momentum {
                    OrdDir::Down => 0.,
                    OrdDir::Right => PI / 2.,
                    OrdDir::Up => PI,
                    OrdDir::Left => 3. * PI / 2.,
                }),
                scale: Vec3::new(1., 1., 1.),
            },
            // End NEW.
            SlideAnimation,
        ));
    // SNIP
    let hp_bar = commands
        .spawn(HealthIndicator {
            // SNIP
            // CHANGED - defines fields instead of from_xyz
            transform: Transform {
                translation: Vec3 {
                    x: event.summon_tile.x as f32 * 64.,
                    y: event.summon_tile.y as f32 * 64.,
                    z: 1.,
                },
                rotation: Quat::from_rotation_z(match event.momentum {
                    OrdDir::Down => 0.,
                    OrdDir::Right => 3. * PI / 2.,
                    OrdDir::Up => PI,
                    OrdDir::Left => PI / 2.,
                }),
                scale: Vec3::new(1., 1., 1.),
            },
            // End CHANGED
        })
        .id();

Before proceeding, we'll register absolutely everything we've added before we forget!

I also elected to add .run_if(spell_stack_is_empty) to end_turn. If a spell has a very large amount of Axioms and takes a while to work its magic, this will prevent other creatures from taking their turns before it completes.

// sets.rs
        app.add_systems(
            Update,
            ((
                summon_creature,
                register_creatures,
                teleport_entity,
                creature_collision, // NEW!
                alter_momentum, // NEW!
                harm_creature, // NEW!
                remove_creature, // NEW!
                end_turn.run_if(spell_stack_is_empty), // CHANGED - This will prevent problems
            )
                .chain())
            .in_set(ResolutionPhase),
        );
// events.rs

impl Plugin for EventPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<SummonCreature>();
        app.init_resource::<Events<EndTurn>>();
        app.add_event::<TeleportEntity>();
        app.add_event::<CreatureCollision>(); // NEW!
        app.add_event::<AlterMomentum>(); // NEW!
        app.add_event::<HarmCreature>(); // NEW!
        app.add_event::<OpenDoor>(); // NEW!
        app.add_event::<RemoveCreature>(); // NEW!
        app.init_resource::<Events<CreatureStep>>();
        // NEW!
        app.insert_resource(TurnManager {
            turn_count: 0,
            action_this_turn: PlayerAction::Invalid,
        });
        // End NEW.
    }
}

Try cargo run.

You'll find everything in working order: the sprite rotations when walking around, the inescapable cage, and melee attacking the pesky denizens intruding on your personal space... but you'll have to bash your way through the doors to move from quadrant to quadrant!

The player melee attacks doors to force them open, and gets slapped from all sides by the various Hunters around.

We'll fix that. When a door is opened, it will become Intangible, which means it will be removed from the Map. It will still exist, but will no longer be included in any collisions or spell targeting.

// creature.rs

#[derive(Component)]
pub struct Intangible;

To enforce this, we'll add a new Added filter to register_creatures to handle any newly Intangible creature by removing it from the Map.

However, we'll also need to track creatures which are no longer tangible for their re-insertion into the Map. This will be done with RemovedComponents, an unique parameter that is basically the opposite of Added.

// map.rs

/// Newly spawned creatures earn their place in the HashMap.
pub fn register_creatures(
    mut map: ResMut<Map>,
    // Any entity that has a Position that just got added to it -
    // currently only possible as a result of having just been spawned in.
    displaced_creatures: Query<(&Position, Entity), (Added<Position>, With<Species>)>,
    intangible_creatures: Query<&Position, (Added<Intangible>, With<Species>)>, // NEW!
    tangible_creatures: Query<&Position, With<Species>>, // NEW!
    mut tangible_entities: RemovedComponents<Intangible>, // NEW!
) {
    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);
    }

    // NEW!
    // Newly intangible creatures are removed from the map.
    for intangible_position in intangible_creatures.iter() {
        map.creatures.remove(intangible_position);
    }

    // A creature recovering its tangibility is added to the map.
    for entity in tangible_entities.read() {
        let tangible_position = tangible_creatures.get(entity).unwrap();
        if map.creatures.get(tangible_position).is_some() {
            panic!("A creature recovered its tangibility while on top of another creature!");
        }
        map.creatures.insert(*tangible_position, entity);
    }
    // End NEW.
}

Finally, the door-interacting system can be written:

  • It hides the door creature's sprite, and spawns two MagicEffects on top with the exact same appearance as the door.
  • These two effects receive SlideAnimation and slide away in a direction dictated by the door's original orientation.
// events.rs

pub fn open_door(
    mut events: EventReader<OpenDoor>,
    mut commands: Commands,
    mut door: Query<(&mut Visibility, &Position, &OrdDir)>,
    asset_server: Res<AssetServer>,
    atlas_layout: Res<SpriteSheetAtlas>,
) {
    for event in events.read() {
        // Gather component values of the door.
        let (mut visibility, position, orientation) = door.get_mut(event.entity).unwrap();
        // The door becomes intangible, and can be walked through.
        commands.entity(event.entity).insert(Intangible);
        // The door is no longer visible, as it is open.
        *visibility = Visibility::Hidden;
        // Find the direction in which the door was facing to play its animation correctly.
        let (offset_1, offset_2) = match orientation {
            OrdDir::Up | OrdDir::Down => (OrdDir::Left.as_offset(), OrdDir::Right.as_offset()),
            OrdDir::Right | OrdDir::Left => (OrdDir::Down.as_offset(), OrdDir::Up.as_offset()),
        };
        // Loop twice: for each pane of the door.
        for offset in [offset_1, offset_2] {
            commands.spawn((
                // The sliding panes are represented as a MagicEffect with a very slow decay.
                MagicEffect {
                    // The panes slide into the adjacent walls to the door, hence the offset.
                    position: Position::new(position.x + offset.0, position.y + offset.1),
                    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(&EffectType::Airlock),
                        }),
                        ..default()
                    },
                    visibility: Visibility::Inherited,
                    vfx: MagicVfx {
                        appear: Timer::from_seconds(0., TimerMode::Once),
                        // Very slow decay - the alpha shouldn't be reduced too much
                        // while the panes are still visible.
                        decay: Timer::from_seconds(3., TimerMode::Once),
                    },
                },
                // Ensure the panes are sliding.
                SlideAnimation,
                Transform {
                    translation: Vec3 {
                        x: position.x as f32 * 64.,
                        y: position.y as f32 * 64.,
                        // The pane needs to hide under actual tiles, such as walls.
                        z: -1.,
                    },
                    // Adjust the pane's rotation with its door.
                    rotation: Quat::from_rotation_z(match orientation {
                        OrdDir::Down => 0.,
                        OrdDir::Right => PI / 2.,
                        OrdDir::Up => PI,
                        OrdDir::Left => 3. * PI / 2.,
                    }),
                    scale: Vec3::new(1., 1., 1.),
                },
            ));
        }
    }
}
// graphics.rs


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

// SNIP

/// 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,
        EffectType::Airlock => 17, // NEW!
    }
}

With a final registration, we'll be able to open the doors like proper, civilized members of the elite.

// sets.rs

        app.add_systems(
            Update,
            ((
                summon_creature,
                register_creatures,
                teleport_entity,
                creature_collision,
                alter_momentum,
                harm_creature,
                open_door, // NEW!
                remove_creature,
                end_turn.run_if(spell_stack_is_empty),
            )
                .chain())
            .in_set(ResolutionPhase),
        );
This time, the doors slide cleanly when the player bumps into them, allowing transition between each quadrant of the 18x18 play area.

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!