Home Posts Post Search Tag Search

Hands on Rust - 14 - Deeper Dungeons
Published on: 2026-07-23 Tags: Systems, Player, Game Logic, Rust, Hands on Rust, Rusty Roguelike, ECS, HUD, Amulet of Yala, Turn State, HashSet

14. Deeper Dungeons

Okay let’s start to add in some more depth to the dungeons in the form of more levels. You don’t just finish a single level and then call it a day you want to find more and more tough enemies and then reach the end after taking care of a lot more enemies.


We will also want to make sure that the Amulet will only spawn on the final level of the game. Also we will want to update the HUD so that the player will have some feedback about how far they have come.




Adding Stairs to the Map

This is the most used version of progressing from one level to the next a staircase that will be used to get to the next level. We will need to add in an other tile type and then make sure that the dungeon types can add in that new image.


Making and Rendering Staircases

Let’s open the map.rs and add in the new enumeration to the list.

#[derive(Copy, Clone, PartialEq)]
pub enum TileType {
    Wall,
    Floor,
    Exit,
}

Okay now we need to open up every instance that we are trying to deal with any clauses for this enumeration. Ill put the clippy errors here to see all the places that we use this.

error[E0004]: non-exhaustive patterns: `map::TileType::Exit` not covered
  --> src/map_builder/themes.rs:13:15
   |
13 |         match tile_type {
   |               ^^^^^^^^^ pattern `map::TileType::Exit` not covered
   |
note: `map::TileType` defined here
  --> src/map.rs:8:5
   |
5  | pub enum TileType {
   |          --------
...
8  |     Exit,
   |     ^^^^ not covered
   = note: the matched value is of type `map::TileType`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
   |
15 ~             TileType::Wall => to_cp437('#'),
16 ~             map::TileType::Exit => todo!(),
   |

error[E0004]: non-exhaustive patterns: `map::TileType::Exit` not covered
  --> src/map_builder/themes.rs:30:15
   |
30 |         match tile_type {
   |               ^^^^^^^^^ pattern `map::TileType::Exit` not covered
   |
note: `map::TileType` defined here
  --> src/map.rs:8:5
   |
5  | pub enum TileType {
   |          --------
...
8  |     Exit,
   |     ^^^^ not covered
   = note: the matched value is of type `map::TileType`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
   |
32 ~             TileType::Wall => to_cp437('"'),
33 ~             map::TileType::Exit => todo!(),
   |

Let’s fix those now.

pub struct DungeonTheme {}

impl DungeonTheme {
    pub fn new() -> Box<dyn MapTheme> {
        Box::new(Self {})
    }
}

impl MapTheme for DungeonTheme {
    fn tile_to_render(&self, tile_type: TileType) -> FontCharType {
        match tile_type {
            TileType::Floor => to_cp437('.'),
            TileType::Wall => to_cp437('#'),
            TileType::Exit => to_cp437('>'),
        }
    }
}

pub struct ForestTheme {}

impl ForestTheme {
    pub fn new() -> Box<dyn MapTheme> {
        Box::new(Self {})
    }
}

impl MapTheme for ForestTheme {
    fn tile_to_render(&self, tile_type: TileType) -> FontCharType {
        match tile_type {
            TileType::Floor => to_cp437(';'),
            TileType::Wall => to_cp437('"'),
            TileType::Exit => to_cp437('>'),
        }
    }
}

Okay now we can at least render the image.


Update Dungeon Navigation

Now we should update the can_enter to allow for us to go on an exit tile

    pub fn can_enter_tile(&self, point: Point) -> bool {
        self.in_bounds(point) && {
            self.tiles[map_idx(point.x, point.y)] == TileType::Floor
                || self.tiles[map_idx(point.x, point.y)] == TileType::Exit
        }
    }

We will need to now work on spawning the stairs.


Spawning Stairs Instead of the Amulet

Okay lets head to the main.rs to work on the lines for the beginning and then the reset. Right now we are spawning the Amulet in a line we need to update that now.

        let mut map_builder = MapBuilder::new(&mut rng);
        spawn_player(&mut ecs, map_builder.player_start);
        // spawn_amulet_of_yala(&mut ecs, map_builder.amulet_start);
        let exit_idx = map_builder.map.point2d_to_index(map_builder.amulet_start);
        map_builder.map.tiles[exit_idx] = TileType::Exit;
        ...

We will not be able to do this in a single line even though it looks like it should be able to. Remember that we needed to make the map_builder be mutable and that we borrowed the map_builder in line one and need to release it before we change anything about it.


Now let’s deal with a problem that we are always trying to find the amulet in every level and now there will not always be one to render.

    let mut amulet = <&Point>::query().filter(component::<AmuletOfYala>());
    let amulet_default = Point::new(-1, -1);
    let amulet_pos = amulet.iter(ecs).nth(0).unwrap_or(&amulet_default);

This introduced a new concept unwrap_or which will try and unwrap a piece of data and if it returns a Nothing it will use the given data.






Tracking Game Level

Okay so now we want to add in the way of showing the current level and then transitioning to the next level. Let’s first add in a new field to the Player struct.

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Player {
    pub map_level: u32,
}

We will now need to make sure that we deal with the Player not having a field set on the initialization of that Player. Head to spawners.rs

pub fn spawn_player(ecs: &mut World, pos: Point) {
    ecs.push((
        Player { map_level: 0 },
        pos,
        Render {
            color: ColorPair::new(WHITE, BLACK),
            glyph: to_cp437('@'),
        },
        Health {
            current: 10,
            max: 10,
        },
        FieldOfView::new(8),
    ));
}

Level Transition State

We want to now work on a new state for when the player gets to the stairs and is ready to proceed to the next level of the dungeon. This will be an other turn_state so let’s head there.

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum TurnState {
    AwaitingInput,
    PlayerTurn,
    MonsterTurn,
    GameOver,
    Victory,
    NextLevel,
}

Now guess what… We have a new Enum and we don’t have all the conditions met… Go to main.rs

TurnState::NextLevel => {
    self.advance_level();
}

Now we need to define this new function for the State. This will just be a place holder for now.

impl GameState {
...
    fn advance_level(&mut self) {}
}

Right now we have a check for when the player gets to the Amulet on the end_turn system. We need to add in a way to check for when the player gets to the stairs now.


pub fn end_turn(ecs: &SubWorld, #[resource] turn_state: &mut TurnState, #[resource] map: &Map) {
    ...
    player_hp.iter(ecs).for_each(|(hp, pos)| {
        if hp.current < 1 {
            new_state = TurnState::GameOver;
        }
        if pos == amulet_pos {
            new_state = TurnState::Victory;
        }
        let idx = map.point2d_to_index(*pos);
        if map.tiles[idx] == TileType::Exit {
            new_state = TurnState::NextLevel;
        }
    });
}

Okay so we now have a way of saying that we are in a new turn_state let’s do something about it.


Changing Level

This is what we will need to do here:

1. Remove all entities from the ECS World that aren’t either the player or
items carried by the player.
2. Set the is_dirty flag on the player’s FieldOfView to ensure that the map renders
correctly on the next turn.
3. Generate a new level as you did before.
4. Check the current level number: if it’s 0 or 1, spawn an exit staircase; if
it’s 2, spawn the Amulet of Yala.
5. Finish setting up spawned monsters and resources as you did before.

Find the Player

We will now be adding in the advance_level for the turn state.

fn advance_level(&mut self) {
        let player_entity = *<Entity>::query()
            .filter(component::<Player>())
            .iter(&mut self.ecs)
            .nth(0)
            .unwrap();
    }

Mark Entities to Keep

use std::collections::HashSet;
        let mut entities_to_keep = HashSet::new();
        entities_to_keep.insert(player_entity);

        <(Entity, &Carried)>::query()
            .iter(&self.ecs)
            .filter(|(_e, carry)| carry.0 == player_entity)
            .map(|(e, _carry)| *e)
            .for_each(|e| {
                entities_to_keep.insert(e);
            });

Find the player and the entities that the player is carrying.


Remove the Other Entities

let mut cb = CommandBuffer::new(&mut self.ecs);
for e in Entity::query().iter(&self.ecs) {
    if !entities_to_keep.contains(e) {
        cb.remove(*e);
    }
}
cb.flush(&mut self.ecs);

We created a command buffer, we query for all Entities, check to see if they are contained within the to_keep, remove them, then flush the command buffer.


Set the Field of View to Dirty

<&mut FieldOfView>::query()
    .iter_mut(&mut self.ecs)
    .for_each(|fov| fov.is_dirty = true);

Create a New Map

let mut rng = RandomNumberGenerator::new();
let mut map_builder = MapBuilder::new(&mut rng); 

Place the Player in the New Map

let mut map_level = 0;
<(&mut Player, &mut Point)>::query()
    .iter_mut(&mut self.ecs)
    .for_each(|(player, pos)| {
        player.map_level += 1;
        map_level = player.map_level;
        pos.x = map_builder.player_start.x;
        pos.y = map_builder.player_start.y;
    });

Spawn the Amulet of Yala or a Staircase

spawn_magic_mapper(&mut self.ecs, map_builder.map_start);

if map_level == 2 {
    spawn_amulet_of_yala(&mut self.ecs, map_builder.amulet_start);
} else {
    let exit_idx = map_builder.map.point2d_to_index(map_builder.amulet_start);
    map_builder.map.tiles[exit_idx] = TileType::Exit;
}

// Now we can spawn in the rest of the resources
map_builder
    .monster_spawns
    .iter()
    .for_each(|pos| spawn_entity(&mut self.ecs, &mut rng, *pos));
self.resources.insert(map_builder.map);
self.resources.insert(Camera::new(map_builder.player_start));
self.resources.insert(TurnState::AwaitingInput);
self.resources.insert(map_builder.theme);

We did it we now have all the needed elements to make a new level and even spawn the Amulet if we are on the right level.




Displaying the Current Level on the HUD

Okay last but not least let’s add in the level of the dungeon that the player is currently on.

let (player, map_level) = <(Entity, &Player)>::query()
    .iter(ecs)
    .find_map(|(entity, player)| Some((*entity, player.map_level)))
    .unwrap();
draw_batch.print_color_right(
    Point::new(CONSOLE_WIDTH, 1),
    format!("Dungeon Level: {}", map_level + 1),
    ColorPair::new(YELLOW, BLACK),
);



Wrap-Up

Okay we are all set on adding more dungeon levels. We have all that we need in order to add more tile types to the game as well. Think about what you could add to this game?