Part 11 - Delving into the Dungeon

Our game isn’t much of a “dungeon crawler” if there’s only one floor to our dungeon. In this chapter, we’ll allow the player to go down a level, and we’ll put a very basic leveling up system in place, to make the dive all the more rewarding.

Let’s start by modifying the GameMap to hold the current dungeon depth. This will help out when we’re writing our stairs. Open game_map and make the following modification:

class GameMap:
-   def __init__(self, width, height):
+   def __init__(self, width, height, dungeon_level=1):
       self.width = width
       self.height = height
       self.tiles = self.initialize_tiles()

+       self.dungeon_level = dungeon_level
class GameMap:
    def __init__(self, width, height, dungeon_level=1):
        self.width = width
        self.height = height
        self.tiles = self.initialize_tiles()

        self.dungeon_level = dungeon_level

The stairs themselves will be another Entity, as you might expect. We’ll create a new component that sets it apart from the rest, called Stairs. Create a file called stairs.py and put the following class in it:

class Stairs:
    def __init__(self, floor):
        self.floor = floor

The floor variable tells us which floor we’ll be landing on if we take the stairs. Our game will only allow for downward movement, but you could use this to represent going up a floor as well.

Like with all our other components, we need to pass it into Entity.

class Entity:
   def __init__(self, x, y, char, color, name, blocks=False, render_order=RenderOrder.CORPSE, fighter=None, ai=None,
-                item=None, inventory=None):
+                item=None, inventory=None, stairs=None):
       self.x = x
       self.y = y
       self.char = char
       self.color = color
       self.name = name
       self.blocks = blocks
       self.render_order = render_order
       self.fighter = fighter
       self.ai = ai
       self.item = item
       self.inventory = inventory
+       self.stairs = stairs

       if self.fighter:
           self.fighter.owner = self

       if self.ai:
           self.ai.owner = self

       if self.item:
           self.item.owner = self

       if self.inventory:
           self.inventory.owner = self

+       if self.stairs:
+           self.stairs.owner = self
class Entity:
    def __init__(self, x, y, char, color, name, blocks=False, render_order=RenderOrder.CORPSE, fighter=None, ai=None,
                 item=None, inventory=None, stairs=None):
        self.x = x
        self.y = y
        self.char = char
        self.color = color
        self.name = name
        self.blocks = blocks
        self.render_order = render_order
        self.fighter = fighter
        self.ai = ai
        self.item = item
        self.inventory = inventory
        self.stairs = stairs

        if self.fighter:
            self.fighter.owner = self

        if self.ai:
            self.ai.owner = self

        if self.item:
            self.item.owner = self

        if self.inventory:
            self.inventory.owner = self

        if self.stairs:
            self.stairs.owner = self

For placing our stairs, we’ll turn to our make_map function. To keep things simple, we’ll always place the stairs in the middle of the last room we create. Modify the function like this:

   def make_map(self, max_rooms, room_min_size, room_max_size, map_width, map_height, player, entities,
                max_monsters_per_room, max_items_per_room):
       rooms = []
       num_rooms = 0

+       center_of_last_room_x = None
+       center_of_last_room_y = None

       for r in range(max_rooms):
           # random width and height
           w = randint(room_min_size, room_max_size)
           h = randint(room_min_size, room_max_size)
           # random position without going out of the boundaries of the map
           x = randint(0, map_width - w - 1)
           y = randint(0, map_height - h - 1)

           # "Rect" class makes rectangles easier to work with
           new_room = Rect(x, y, w, h)

           # run through the other rooms and see if they intersect with this one
           for other_room in rooms:
               if new_room.intersect(other_room):
                   break
           else:
               # this means there are no intersections, so this room is valid

               # "paint" it to the map's tiles
               self.create_room(new_room)

               # center coordinates of new room, will be useful later
               (new_x, new_y) = new_room.center()

+               center_of_last_room_x = new_x
+               center_of_last_room_y = new_y

               if num_rooms == 0:
                   # this is the first room, where the player starts at
                   player.x = new_x
                   player.y = new_y
               else:
                   # all rooms after the first:
                   # connect it to the previous room with a tunnel

                   # center coordinates of previous room
                   (prev_x, prev_y) = rooms[num_rooms - 1].center()

                   # flip a coin (random number that is either 0 or 1)
                   if randint(0, 1) == 1:
                       # first move horizontally, then vertically
                       self.create_h_tunnel(prev_x, new_x, prev_y)
                       self.create_v_tunnel(prev_y, new_y, new_x)
                   else:
                       # first move vertically, then horizontally
                       self.create_v_tunnel(prev_y, new_y, prev_x)
                       self.create_h_tunnel(prev_x, new_x, new_y)

               self.place_entities(new_room, entities, max_monsters_per_room, max_items_per_room)

               # finally, append the new room to the list
               rooms.append(new_room)
               num_rooms += 1

+       stairs_component = Stairs(self.dungeon_level + 1)
+       down_stairs = Entity(center_of_last_room_x, center_of_last_room_y, '>', libtcod.white, 'Stairs',
+                            render_order=RenderOrder.STAIRS, stairs=stairs_component)
+       entities.append(down_stairs)
    def make_map(self, max_rooms, room_min_size, room_max_size, map_width, map_height, player, entities,
                 max_monsters_per_room, max_items_per_room):
        rooms = []
        num_rooms = 0

        center_of_last_room_x = None
        center_of_last_room_y = None

        for r in range(max_rooms):
            # random width and height
            w = randint(room_min_size, room_max_size)
            h = randint(room_min_size, room_max_size)
            # random position without going out of the boundaries of the map
            x = randint(0, map_width - w - 1)
            y = randint(0, map_height - h - 1)

            # "Rect" class makes rectangles easier to work with
            new_room = Rect(x, y, w, h)

            # run through the other rooms and see if they intersect with this one
            for other_room in rooms:
                if new_room.intersect(other_room):
                    break
            else:
                # this means there are no intersections, so this room is valid

                # "paint" it to the map's tiles
                self.create_room(new_room)

                # center coordinates of new room, will be useful later
                (new_x, new_y) = new_room.center()

                center_of_last_room_x = new_x
                center_of_last_room_y = new_y

                if num_rooms == 0:
                    # this is the first room, where the player starts at
                    player.x = new_x
                    player.y = new_y
                else:
                    # all rooms after the first:
                    # connect it to the previous room with a tunnel

                    # center coordinates of previous room
                    (prev_x, prev_y) = rooms[num_rooms - 1].center()

                    # flip a coin (random number that is either 0 or 1)
                    if randint(0, 1) == 1:
                        # first move horizontally, then vertically
                        self.create_h_tunnel(prev_x, new_x, prev_y)
                        self.create_v_tunnel(prev_y, new_y, new_x)
                    else:
                        # first move vertically, then horizontally
                        self.create_v_tunnel(prev_y, new_y, prev_x)
                        self.create_h_tunnel(prev_x, new_x, new_y)

                self.place_entities(new_room, entities, max_monsters_per_room, max_items_per_room)

                # finally, append the new room to the list
                rooms.append(new_room)
                num_rooms += 1

        stairs_component = Stairs(self.dungeon_level + 1)
        down_stairs = Entity(center_of_last_room_x, center_of_last_room_y, '>', libtcod.white, 'Stairs',
                             render_order=RenderOrder.STAIRS, stairs=stairs_component)
        entities.append(down_stairs)

Be sure to import Stairs at the top.

...
from components.ai import BasicMonster
from components.fighter import Fighter
from components.item import Item
+from components.stairs import Stairs
...
...
from components.ai import BasicMonster
from components.fighter import Fighter
from components.item import Item
from components.stairs import Stairs
...

We’re creating two new variables to keep track of the last room’s center x and y, and using them to place our stairs. The stairs themselves are just a tuple that holds the x and y coordinates.

Notice that we used a new value in the RenderOrder enum in the code above. We’ll need to add that to RenderOrder. The stairs should appear below everything else, so it will be the first value in our enum; the others will have to be pushed down.

class RenderOrder(Enum):
+   STAIRS = 1
-   CORPSE = 1
+   CORPSE = 2
-   ITEM = 2
+   ITEM = 3
-   ACTOR = 3
+   ACTOR = 4
class RenderOrder(Enum):
    STAIRS = 1
    CORPSE = 1
    CORPSE = 2
    ITEM = 2
    ITEM = 3
    ACTOR = 3
    ACTOR = 4

Note that if you’re working on Python 3.6, you can make this a lot easier with the auto() function.

class RenderOrder(Enum):
    STAIRS = auto()
    CORPSE = auto()
    ITEM = auto()
    ACTOR = auto()

One problem with our current implementation is that we can only see the stairs when they’re in the player’s field of view. This might sound right at first, but consider if the player has seen the stairs, and then moves away from them. The stairs won’t show on the map! It’d be better if once found, the stairs are always drawn.

To make this happen, we can modify the draw_entity function inside render_functions.

-def draw_entity(con, entity, fov_map):
+def draw_entity(con, entity, fov_map, game_map):
-   if libtcod.map_is_in_fov(fov_map, entity.x, entity.y):
+   if libtcod.map_is_in_fov(fov_map, entity.x, entity.y) or (entity.stairs and game_map.tiles[entity.x][entity.y].explored):
       libtcod.console_set_default_foreground(con, entity.color)
       libtcod.console_put_char(con, entity.x, entity.y, entity.char, libtcod.BKGND_NONE)
def draw_entity(con, entity, fov_map):
def draw_entity(con, entity, fov_map, game_map):
    if libtcod.map_is_in_fov(fov_map, entity.x, entity.y):
    if libtcod.map_is_in_fov(fov_map, entity.x, entity.y) or (entity.stairs and game_map.tiles[entity.x][entity.y].explored):
        libtcod.console_set_default_foreground(con, entity.color)
        libtcod.console_put_char(con, entity.x, entity.y, entity.char, libtcod.BKGND_NONE)

We’re now checking if the entity has the ‘stairs’ component, and if the map has been explored. If so, we draw the entity, regardless if it’s in the field of view or not. This works even if there’s another entity on top of the stairs.

Note that we’re now passing the game_map object to draw_entity. We’ll need to update our call to draw_entity in render_all.

   for entity in entities_in_render_order:
-       draw_entity(con, entity, fov_map)
+       draw_entity(con, entity, fov_map, game_map)
    for entity in entities_in_render_order:
        draw_entity(con, entity, fov_map, game_map)

Run the project now, and you should be able to see the stairs (if you can find them before meeting your end!). Now let’s make them do something.

First, let’s add a handler for going down the stairs in input_handlers.py. Add the following to the handle_player_turn_keys function:

   ...
   elif key_char == 'd':
       return {'drop_inventory': True}

+   elif key.vk == libtcod.KEY_ENTER:
+       return {'take_stairs': True}

   if key.vk == libtcod.KEY_ENTER and key.lalt:
       ...
    ...
    elif key_char == 'd':
        return {'drop_inventory': True}

    elif key.vk == libtcod.KEY_ENTER:
        return {'take_stairs': True}

    if key.vk == libtcod.KEY_ENTER and key.lalt:
        ...

*Note: I’ve used the Enter key here rather than the traditional ‘>’ key. This is because the current Roguebasin tutorial’s code for the ‘>’ key does not work.

With all this in place, we’ll need to implement the code to actually move the player down a floor. To go down a floor, we’ll need to generate a new map, create a new list of entities, and increment the integer that represents the dungeon floor. It’s not nearly as complex as it sounds! Things get a little more difficult if you want to allow the player to move back up the stairs, but in order to keep this tutorial as simple as possible, we’ll say that once you descend down to the next floor, you cannot go back up.

Now let’s write the function that will take us down a floor. Add the following to the bottom of the game_map.py:

    def next_floor(self, player, message_log, constants):
        self.dungeon_level += 1
        entities = [player]

        self.tiles = self.initialize_tiles()
        self.make_map(constants['max_rooms'], constants['room_min_size'], constants['room_max_size'],
                      constants['map_width'], constants['map_height'], player, entities,
                      constants['max_monsters_per_room'], constants['max_items_per_room'])

        player.fighter.heal(player.fighter.max_hp // 2)

        message_log.add_message(Message('You take a moment to rest, and recover your strength.', libtcod.light_violet))

        return entities

The function starts by incrementing the dungeon level by one. The entities list is created from scratch, with only the player in it initially. We then call make_map to generate the new floor, like we did at the game’s start. We’ll also give the player half of the max HP back, as a reward for making it to the new floor, and add a message to this effect. We then return the entities list to be used in engine.py.

At last, let’s modify engine.py to use this new function.

       ...
       inventory_index = action.get('inventory_index')
+       take_stairs = action.get('take_stairs')
       exit = action.get('exit')
       ...
       if inventory_index is not None and previous_game_state != GameStates.PLAYER_DEAD and inventory_index < len(
           ...

+       if take_stairs and game_state == GameStates.PLAYERS_TURN:
+           for entity in entities:
+               if entity.stairs and entity.x == player.x and entity.y == player.y:
+                   entities = game_map.next_floor(player, message_log, constants)
+                   fov_map = initialize_fov(game_map)
+                   fov_recompute = True
+                   libtcod.console_clear(con)
+
+                   break
+           else:
+               message_log.add_message(Message('There are no stairs here.', libtcod.yellow))

       if game_state == GameStates.TARGETING:
           ...
        ...
        inventory_index = action.get('inventory_index')
        take_stairs = action.get('take_stairs')
        exit = action.get('exit')
        ...
        if inventory_index is not None and previous_game_state != GameStates.PLAYER_DEAD and inventory_index < len(
            ...

        if take_stairs and game_state == GameStates.PLAYERS_TURN:
            for entity in entities:
                if entity.stairs and entity.x == player.x and entity.y == player.y:
                    entities = game_map.next_floor(player, message_log, constants)
                    fov_map = initialize_fov(game_map)
                    fov_recompute = True
                    libtcod.console_clear(con)

                    break
            else:
                message_log.add_message(Message('There are no stairs here.', libtcod.yellow))

        if game_state == GameStates.TARGETING:
            ...

If the player is standing on the stairs, we call the next_floor function and set the entities list to the new values. We also clear the screen, so that the map shows as unexplored once again, and set the FOV to recompute. If there aren’t any stairs, we let the player know.

We can easily display the dungeon’s current depth right below the HP bar, by rendering the render_all function like so:

   ...
   render_bar(panel, 1, 1, bar_width, 'HP', player.fighter.hp, player.fighter.max_hp,
              libtcod.light_red, libtcod.darker_red)
+   libtcod.console_print_ex(panel, 1, 3, libtcod.BKGND_NONE, libtcod.LEFT,
+                            'Dungeon level: {0}'.format(game_map.dungeon_level))

   libtcod.console_set_default_foreground(panel, libtcod.light_gray)
   ...
    ...
    render_bar(panel, 1, 1, bar_width, 'HP', player.fighter.hp, player.fighter.max_hp,
               libtcod.light_red, libtcod.darker_red)
    libtcod.console_print_ex(panel, 1, 3, libtcod.BKGND_NONE, libtcod.LEFT,
                             'Dungeon level: {0}'.format(game_map.dungeon_level))

    libtcod.console_set_default_foreground(panel, libtcod.light_gray)
    ...

And that’s it! We are now officially dungeon diving! However, the way our game works right now, going deeper into the dungeon isn’t particularly interesting. In order to make it feel more like a roguelike, we’ll need to do two things: give our character some sort of progression (either through leveling up or better equipment) and make the monsters more threatening at lower levels. We’ll focus on the former for the remainder of this chapter, while the latter will be for the next one.

Most roguelikes (and RPGs in general) reward the player with experience points upon killing an opponent. Once a certain amount of experience has been collected, the player levels up and gets stronger. In order to achieve that, we’ll need to do several things. Let’s start by modifying the Fighter component to hold a new variable: xp. This will represent the experience points the player receives upon killing an enemy (but not the experience points of the Entity itself, more on that later).

class Fighter:
-   def __init__(self, hp, defense, power):
+   def __init__(self, hp, defense, power, xp=0):
       self.max_hp = hp
       self.hp = hp
       self.defense = defense
       self.power = power
+       self.xp = xp
class Fighter:
    def __init__(self, hp, defense, power, xp=0):
        self.max_hp = hp
        self.hp = hp
        self.defense = defense
        self.power = power
        self.xp = xp

We don’t need to modify the player’s fighter component at all, but we’ll need to alter the components for our enemies. Open up game_map.py and modify the place_entities function to include experience points in each fighter component.

               ...
               if randint(0, 100) < 80:
-                   fighter_component = Fighter(hp=10, defense=0, power=3)
+                   fighter_component = Fighter(hp=10, defense=0, power=3, xp=35)
                   ai_component = BasicMonster()

                   monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True,
                                    render_order=RenderOrder.ACTOR, fighter=fighter_component, ai=ai_component)
               else:
-                   fighter_component = Fighter(hp=16, defense=1, power=4)
+                   fighter_component = Fighter(hp=16, defense=1, power=4, xp=100)
                   ai_component = BasicMonster()

                   monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component,
                                    render_order=RenderOrder.ACTOR, ai=ai_component)
               ...
                ...
                if randint(0, 100) < 80:
                    fighter_component = Fighter(hp=10, defense=0, power=3, xp=35)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True,
                                     render_order=RenderOrder.ACTOR, fighter=fighter_component, ai=ai_component)
                else:
                    fighter_component = Fighter(hp=16, defense=1, power=4, xp=100)
                    ai_component = BasicMonster()

                    monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component,
                                     render_order=RenderOrder.ACTOR, ai=ai_component)
                ...

The player’s xp will be a bit different, because we’ll be keeping track of a running total. We’ll also need to know how much more experience the player needs until the next level up. Let’s create a new component to keep track of all this, which we’ll call Level. Create a new file in components called level.py and put the following code in it.

class Level:
    def __init__(self, current_level=1, current_xp=0, level_up_base=200, level_up_factor=150):
        self.current_level = current_level
        self.current_xp = current_xp
        self.level_up_base = level_up_base
        self.level_up_factor = level_up_factor

    @property
    def experience_to_next_level(self):
        return self.level_up_base + self.current_level * self.level_up_factor

    def add_xp(self, xp):
        self.current_xp += xp

        if self.current_xp > self.experience_to_next_level:
            self.current_xp -= self.experience_to_next_level
            self.current_level += 1

            return True
        else:
            return False

I’ve set all the variables in this class to have the defaults I want, which you should feel free to change. Also, it probably makes more sense to put these defaults in our constants dictionary, but in the interest of moving things along faster, I’ve put them here.

The current_level is our player’s level, which should start at 1, unless we’re loading a saved game. current_xp is a running total of the player’s experience points, which resets when the player levels up. level_up_base and level_up_factor are using in our level up formula.

When the player gains experience points, we check if the current xp is greater than the level up base, plus the current level times the level up factor. This makes it such that leveling up takes a longer time at higher levels. If it is, then we reset the current_xp, and return True (which our engine will know means that the player leveled up).

The actual level up threshold is handled by the experience_to_next_level property. What’s a property? It’s basically a read only variable that we can easily access inside the class and on the objects we create. experience_to_next_level will always have the latest value when we access it, so we can just say player.level.experience_to_next_level and get the correct value.

Let’s add this new component to the Entity:

class Entity:
   def __init__(self, x, y, char, color, name, blocks=False, render_order=RenderOrder.CORPSE, fighter=None, ai=None,
-                item=None, inventory=None, stairs=None):
+                item=None, inventory=None, stairs=None, level=None):
       self.x = x
       self.y = y
       self.char = char
       self.color = color
       self.name = name
       self.blocks = blocks
       self.render_order = render_order
       self.fighter = fighter
       self.ai = ai
       self.item = item
       self.inventory = inventory
       self.stairs = stairs
+       self.level = level

       if self.fighter:
           self.fighter.owner = self

       if self.ai:
           self.ai.owner = self

       if self.item:
           self.item.owner = self

       if self.inventory:
           self.inventory.owner = self

       if self.stairs:
           self.stairs.owner = self

+       if self.level:
+           self.level.owner = self
class Entity:
    def __init__(self, x, y, char, color, name, blocks=False, render_order=RenderOrder.CORPSE, fighter=None, ai=None,
                 item=None, inventory=None, stairs=None, level=None):
        self.x = x
        self.y = y
        self.char = char
        self.color = color
        self.name = name
        self.blocks = blocks
        self.render_order = render_order
        self.fighter = fighter
        self.ai = ai
        self.item = item
        self.inventory = inventory
        self.stairs = stairs
        self.level = level

        if self.fighter:
            self.fighter.owner = self

        if self.ai:
            self.ai.owner = self

        if self.item:
            self.item.owner = self

        if self.inventory:
            self.inventory.owner = self

        if self.stairs:
            self.stairs.owner = self

        if self.level:
            self.level.owner = self

Now we’ll need to add it to the player object. Open initialize_new_game.py and make the following modifications:

   fighter_component = Fighter(hp=30, defense=2, power=5)
   inventory_component = Inventory(26)
+   level_component = Level()
   player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR,
-                   fighter=fighter_component, inventory=inventory_component)
+                   fighter=fighter_component, inventory=inventory_component, level=level_component)
    fighter_component = Fighter(hp=30, defense=2, power=5)
    inventory_component = Inventory(26)
    level_component = Level()
    player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR,
                    fighter=fighter_component, inventory=inventory_component, level=level_component)

Remember to import Level at the top:

from components.fighter import Fighter
from components.inventory import Inventory
+from components.level import Level
from components.fighter import Fighter
from components.inventory import Inventory
from components.level import Level

As is tradition in RPGs, we’ll gain this experience when we defeat the monsters. We can return the xp amount along with our death result, in the Fighter component’s take_damage function.

   def take_damage(self, amount):
       results = []

       self.hp -= amount

       if self.hp <= 0:
-           results.append({'dead': self.owner})
+           results.append({'dead': self.owner, 'xp': self.xp})

       return results
    def take_damage(self, amount):
        results = []

        self.hp -= amount

        if self.hp <= 0:
            results.append({'dead': self.owner, 'xp': self.xp})

        return results

And now let’s process the result in engine.py:

           ...
           targeting_cancelled = player_turn_result.get('targeting_cancelled')
+           xp = player_turn_result.get('xp')

           if message:
               ...
            ...
            targeting_cancelled = player_turn_result.get('targeting_cancelled')
            xp = player_turn_result.get('xp')

            if message:
                ...
           ...
           if targeting_cancelled:
               ...

+           if xp:
+               leveled_up = player.level.add_xp(xp)
+               message_log.add_message(Message('You gain {0} experience points.'.format(xp)))
+
+               if leveled_up:
+                   message_log.add_message(Message(
+                       'Your battle skills grow stronger! You reached level {0}'.format(
+                           player.level.current_level) + '!', libtcod.yellow))
+                   previous_game_state = game_state
+                   game_state = GameStates.LEVEL_UP

       if game_state == GameStates.ENEMY_TURN:
           ...
            ...
            if targeting_cancelled:
                ...

            if xp:
                leveled_up = player.level.add_xp(xp)
                message_log.add_message(Message('You gain {0} experience points.'.format(xp)))

                if leveled_up:
                    message_log.add_message(Message(
                        'Your battle skills grow stronger! You reached level {0}'.format(
                            player.level.current_level) + '!', libtcod.yellow))
                    previous_game_state = game_state
                    game_state = GameStates.LEVEL_UP

        if game_state == GameStates.ENEMY_TURN:
            ...

Obviously, we’ll need to add the LEVEL_UP game state to our GameStates enum.

class GameStates(Enum):
   PLAYERS_TURN = 1
   ENEMY_TURN = 2
   PLAYER_DEAD = 3
   SHOW_INVENTORY = 4
   DROP_INVENTORY = 5
   TARGETING = 6
+   LEVEL_UP = 7
class GameStates(Enum):
    PLAYERS_TURN = 1
    ENEMY_TURN = 2
    PLAYER_DEAD = 3
    SHOW_INVENTORY = 4
    DROP_INVENTORY = 5
    TARGETING = 6
    LEVEL_UP = 7

So what happens when the player levels up? Our system will be pretty simple: the player will have a choice between increasing HP, attack, or defense. A menu will pop up, prompting the user to select one of these power ups, and won’t close until a selection is made.

Let’s create a new menu function, called level_up_menu, which will display our options:

def main_menu(con, background_image, screen_width, screen_height):
   ...

+def level_up_menu(con, header, player, menu_width, screen_width, screen_height):
+   options = ['Constitution (+20 HP, from {0})'.format(player.fighter.max_hp),
+              'Strength (+1 attack, from {0})'.format(player.fighter.power),
+              'Agility (+1 defense, from {0})'.format(player.fighter.defense)]
+
+   menu(con, header, options, menu_width, screen_width, screen_height)


def message_box(con, header, width, screen_width, screen_height):
   ...
def main_menu(con, background_image, screen_width, screen_height):
    ...

def level_up_menu(con, header, player, menu_width, screen_width, screen_height):
    options = ['Constitution (+20 HP, from {0})'.format(player.fighter.max_hp),
               'Strength (+1 attack, from {0})'.format(player.fighter.power),
               'Agility (+1 defense, from {0})'.format(player.fighter.defense)]

    menu(con, header, options, menu_width, screen_width, screen_height)


def message_box(con, header, width, screen_width, screen_height):
    ...

Modify the render_all function to display this menu, after importing the level_up_menu function.

import tcod as libtcod

from enum import Enum

from game_states import GameStates

-from menus import inventory_menu
+from menus import inventory_menu, level_up_menu
...
import tcod as libtcod

from enum import Enum

from game_states import GameStates

from menus import inventory_menu, level_up_menu
...
   if game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY):
       ...

+   elif game_state == GameStates.LEVEL_UP:
+       level_up_menu(con, 'Level up! Choose a stat to raise:', player, 40, screen_width, screen_height)
    if game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY):
        ...

    elif game_state == GameStates.LEVEL_UP:
        level_up_menu(con, 'Level up! Choose a stat to raise:', player, 40, screen_width, screen_height)

Of course, we’ll need to handle the input for this menu. Open up input_handlers.py and add the following function:

def handle_main_menu(key):
   ...

+def handle_level_up_menu(key):
+   if key:
+       key_char = chr(key.c)
+
+       if key_char == 'a':
+           return {'level_up': 'hp'}
+       elif key_char == 'b':
+           return {'level_up': 'str'}
+       elif key_char == 'c':
+           return {'level_up': 'def'}
+
+   return {}


def handle_mouse(mouse):
   ...
def handle_main_menu(key):
    ...

def handle_level_up_menu(key):
    if key:
        key_char = chr(key.c)

        if key_char == 'a':
            return {'level_up': 'hp'}
        elif key_char == 'b':
            return {'level_up': 'str'}
        elif key_char == 'c':
            return {'level_up': 'def'}

    return {}


def handle_mouse(mouse):
    ...

Modify the handle_keys function to use this new handler:

def handle_keys(key, game_state):
   if game_state == GameStates.PLAYERS_TURN:
       return handle_player_turn_keys(key)
   elif game_state == GameStates.PLAYER_DEAD:
       return handle_player_dead_keys(key)
   elif game_state == GameStates.TARGETING:
       return handle_targeting_keys(key)
   elif game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY):
       return handle_inventory_keys(key)
+   elif game_state == GameStates.LEVEL_UP:
+       return handle_level_up_menu(key)

   return {}
def handle_keys(key, game_state):
    if game_state == GameStates.PLAYERS_TURN:
        return handle_player_turn_keys(key)
    elif game_state == GameStates.PLAYER_DEAD:
        return handle_player_dead_keys(key)
    elif game_state == GameStates.TARGETING:
        return handle_targeting_keys(key)
    elif game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY):
        return handle_inventory_keys(key)
    elif game_state == GameStates.LEVEL_UP:
        return handle_level_up_menu(key)

    return {}

With our key handler in place, let’s handle the results in engine.py:

       ...
       take_stairs = action.get('take_stairs')
+       level_up = action.get('level_up')
       exit = action.get('exit')
       ...
        ...
        take_stairs = action.get('take_stairs')
        level_up = action.get('level_up')
        exit = action.get('exit')
        ...
       if take_stairs and game_state == GameStates.PLAYERS_TURN:
           ...

+       if level_up:
+           if level_up == 'hp':
+               player.fighter.max_hp += 20
+               player.fighter.hp += 20
+           elif level_up == 'str':
+               player.fighter.power += 1
+           elif level_up == 'def':
+               player.fighter.defense += 1
+
+           game_state = previous_game_state

       if game_state == GameStates.TARGETING:
           ...
        if take_stairs and game_state == GameStates.PLAYERS_TURN:
            ...

        if level_up:
            if level_up == 'hp':
                player.fighter.max_hp += 20
                player.fighter.hp += 20
            elif level_up == 'str':
                player.fighter.power += 1
            elif level_up == 'def':
                player.fighter.defense += 1

            game_state = previous_game_state

        if game_state == GameStates.TARGETING:
            ...

In order to help the players keep track of their progress, let’s create a “character” screen, which displays the player’s current stats. This will require another game state, so let’s add that now.

class GameStates(Enum):
   PLAYERS_TURN = 1
   ENEMY_TURN = 2
   PLAYER_DEAD = 3
   SHOW_INVENTORY = 4
   DROP_INVENTORY = 5
   TARGETING = 6
   LEVEL_UP = 7
+   CHARACTER_SCREEN = 8
class GameStates(Enum):
    PLAYERS_TURN = 1
    ENEMY_TURN = 2
    PLAYER_DEAD = 3
    SHOW_INVENTORY = 4
    DROP_INVENTORY = 5
    TARGETING = 6
    LEVEL_UP = 7
    CHARACTER_SCREEN = 8

We should display this screen when the ‘c’ key is pressed. Let’s add the key to handle_player_turn_keys:

   ...
   elif key.vk == libtcod.KEY_ENTER:
       return {'take_stairs': True}

+   elif key_char == 'c':
+       return {'show_character_screen': True}

   if key.vk == libtcod.KEY_ENTER and key.lalt:
       ...
    ...
    elif key.vk == libtcod.KEY_ENTER:
        return {'take_stairs': True}

    elif key_char == 'c':
        return {'show_character_screen': True}

    if key.vk == libtcod.KEY_ENTER and key.lalt:
        ...

Now let’s handle that in engine.py:

       ...
       level_up = action.get('level_up')
+       show_character_screen = action.get('show_character_screen')
       exit = action.get('exit')
       ...
        ...
        level_up = action.get('level_up')
        show_character_screen = action.get('show_character_screen')
        exit = action.get('exit')
        ...
       ...
       if level_up:
           ...

+       if show_character_screen:
+           previous_game_state = game_state
+           game_state = GameStates.CHARACTER_SCREEN

       if game_state == GameStates.TARGETING:
           ...
        ...
        if level_up:
            ...

        if show_character_screen:
            previous_game_state = game_state
            game_state = GameStates.CHARACTER_SCREEN

        if game_state == GameStates.TARGETING:
            ...

Now let’s write the input handler for the character screen. All it does is handles the ‘Escape’ key, since the character screen isn’t interactive in any way.

def handle_level_up_menu(key):
   ...

+def handle_character_screen(key):
+   if key.vk == libtcod.KEY_ESCAPE:
+       return {'exit': True}
+
+   return {}


def handle_mouse(mouse):
   ...
def handle_level_up_menu(key):
    ...

def handle_character_screen(key):
    if key.vk == libtcod.KEY_ESCAPE:
        return {'exit': True}

    return {}


def handle_mouse(mouse):
    ...

Modify handle_keys to call this function when showing the character screen:

def handle_keys(key, game_state):
   if game_state == GameStates.PLAYERS_TURN:
       return handle_player_turn_keys(key)
   elif game_state == GameStates.PLAYER_DEAD:
       return handle_player_dead_keys(key)
   elif game_state == GameStates.TARGETING:
       return handle_targeting_keys(key)
   elif game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY):
       return handle_inventory_keys(key)
   elif game_state == GameStates.LEVEL_UP:
       return handle_level_up_menu(key)
+   elif game_state == GameStates.CHARACTER_SCREEN:
+       return handle_character_screen(key)

   return {}
def handle_keys(key, game_state):
    if game_state == GameStates.PLAYERS_TURN:
        return handle_player_turn_keys(key)
    elif game_state == GameStates.PLAYER_DEAD:
        return handle_player_dead_keys(key)
    elif game_state == GameStates.TARGETING:
        return handle_targeting_keys(key)
    elif game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY):
        return handle_inventory_keys(key)
    elif game_state == GameStates.LEVEL_UP:
        return handle_level_up_menu(key)
    elif game_state == GameStates.CHARACTER_SCREEN:
        return handle_character_screen(key)

    return {}

If the player does press the escape key, we’ll just want to revert the game state. For this, we can extend our current code for ’exit’.

       if exit:
-           if game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY):
+           if game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY, GameStates.CHARACTER_SCREEN):
               game_state = previous_game_state
           elif game_state == GameStates.TARGETING:
               player_turn_results.append({'targeting_cancelled': True})
           else:
               save_game(player, entities, game_map, message_log, game_state)

               return True
        if exit:
            if game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY, GameStates.CHARACTER_SCREEN):
                game_state = previous_game_state
            elif game_state == GameStates.TARGETING:
                player_turn_results.append({'targeting_cancelled': True})
            else:
                save_game(player, entities, game_map, message_log, game_state)

                return True

That takes care of the input handling. Now, to actually display the screen, we’ll need a new menu function. Unlike the other menu functions, we’re not displaying a list of options. Instead, we know up front what we want to display. Therefore, we can directly print the information to the screen in a more straightforward fashion. Open menus.py and add the following function.

def level_up_menu(con, header, player, menu_width, screen_width, screen_height):
   ...

+def character_screen(player, character_screen_width, character_screen_height, screen_width, screen_height):
+   window = libtcod.console_new(character_screen_width, character_screen_height)
+
+   libtcod.console_set_default_foreground(window, libtcod.white)
+
+   libtcod.console_print_rect_ex(window, 0, 1, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
+                                 libtcod.LEFT, 'Character Information')
+   libtcod.console_print_rect_ex(window, 0, 2, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
+                                 libtcod.LEFT, 'Level: {0}'.format(player.level.current_level))
+   libtcod.console_print_rect_ex(window, 0, 3, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
+                                 libtcod.LEFT, 'Experience: {0}'.format(player.level.current_xp))
+   libtcod.console_print_rect_ex(window, 0, 4, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
+                                 libtcod.LEFT, 'Experience to Level: {0}'.format(player.level.experience_to_next_level))
+   libtcod.console_print_rect_ex(window, 0, 6, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
+                                 libtcod.LEFT, 'Maximum HP: {0}'.format(player.fighter.max_hp))
+   libtcod.console_print_rect_ex(window, 0, 7, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
+                                 libtcod.LEFT, 'Attack: {0}'.format(player.fighter.power))
+   libtcod.console_print_rect_ex(window, 0, 8, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
+                                 libtcod.LEFT, 'Defense: {0}'.format(player.fighter.defense))
+
+   x = screen_width // 2 - character_screen_width // 2
+   y = screen_height // 2 - character_screen_height // 2
+   libtcod.console_blit(window, 0, 0, character_screen_width, character_screen_height, 0, x, y, 1.0, 0.7)


def message_box(con, header, width, screen_width, screen_height):
   ...
def level_up_menu(con, header, player, menu_width, screen_width, screen_height):
    ...

def character_screen(player, character_screen_width, character_screen_height, screen_width, screen_height):
    window = libtcod.console_new(character_screen_width, character_screen_height)

    libtcod.console_set_default_foreground(window, libtcod.white)

    libtcod.console_print_rect_ex(window, 0, 1, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
                                  libtcod.LEFT, 'Character Information')
    libtcod.console_print_rect_ex(window, 0, 2, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
                                  libtcod.LEFT, 'Level: {0}'.format(player.level.current_level))
    libtcod.console_print_rect_ex(window, 0, 3, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
                                  libtcod.LEFT, 'Experience: {0}'.format(player.level.current_xp))
    libtcod.console_print_rect_ex(window, 0, 4, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
                                  libtcod.LEFT, 'Experience to Level: {0}'.format(player.level.experience_to_next_level))
    libtcod.console_print_rect_ex(window, 0, 6, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
                                  libtcod.LEFT, 'Maximum HP: {0}'.format(player.fighter.max_hp))
    libtcod.console_print_rect_ex(window, 0, 7, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
                                  libtcod.LEFT, 'Attack: {0}'.format(player.fighter.power))
    libtcod.console_print_rect_ex(window, 0, 8, character_screen_width, character_screen_height, libtcod.BKGND_NONE,
                                  libtcod.LEFT, 'Defense: {0}'.format(player.fighter.defense))

    x = screen_width // 2 - character_screen_width // 2
    y = screen_height // 2 - character_screen_height // 2
    libtcod.console_blit(window, 0, 0, character_screen_width, character_screen_height, 0, x, y, 1.0, 0.7)


def message_box(con, header, width, screen_width, screen_height):
    ...

In order to display this new menu, we’ll modify render_all once again. Start by importing the menu.

import tcod as libtcod

from enum import Enum

from game_states import GameStates

-from menus import inventory_menu, level_up_menu
+from menus import character_screen, inventory_menu, level_up_menu
...
import tcod as libtcod

from enum import Enum

from game_states import GameStates

from menus import character_screen, inventory_menu, level_up_menu
...

Now, add the menu to the bottom of render_all.

   elif game_state == GameStates.LEVEL_UP:
       level_up_menu(con, 'Level up! Choose a stat to raise:', player, 40, screen_width, screen_height)

+   elif game_state == GameStates.CHARACTER_SCREEN:
+       character_screen(player, 30, 10, screen_width, screen_height)
    elif game_state == GameStates.LEVEL_UP:
        level_up_menu(con, 'Level up! Choose a stat to raise:', player, 40, screen_width, screen_height)

    elif game_state == GameStates.CHARACTER_SCREEN:
        character_screen(player, 30, 10, screen_width, screen_height)

Final thing before we wrap up this chapter: Awhile ago, we included diagonal movement for the player character, but we forgot (okay, I forgot) to include a wait command. It’s simple to add, but it’s something we’ll definitely want before the next chapter, where the game will start getting more difficult. Open up input_handlers.py and add the following to handle_player_turn_keys:

def handle_player_turn_keys(key):
   key_char = chr(key.c)

   if key.vk == libtcod.KEY_UP or key_char == 'k':
       return {'move': (0, -1)}
   elif key.vk == libtcod.KEY_DOWN or key_char == 'j':
       return {'move': (0, 1)}
   elif key.vk == libtcod.KEY_LEFT or key_char == 'h':
       return {'move': (-1, 0)}
   elif key.vk == libtcod.KEY_RIGHT or key_char == 'l':
       return {'move': (1, 0)}
   elif key_char == 'y':
       return {'move': (-1, -1)}
   elif key_char == 'u':
       return {'move': (1, -1)}
   elif key_char == 'b':
       return {'move': (-1, 1)}
   elif key_char == 'n':
       return {'move': (1, 1)}
+   elif key_char == 'z':
+       return {'wait': True}
def handle_player_turn_keys(key):
    key_char = chr(key.c)

    if key.vk == libtcod.KEY_UP or key_char == 'k':
        return {'move': (0, -1)}
    elif key.vk == libtcod.KEY_DOWN or key_char == 'j':
        return {'move': (0, 1)}
    elif key.vk == libtcod.KEY_LEFT or key_char == 'h':
        return {'move': (-1, 0)}
    elif key.vk == libtcod.KEY_RIGHT or key_char == 'l':
        return {'move': (1, 0)}
    elif key_char == 'y':
        return {'move': (-1, -1)}
    elif key_char == 'u':
        return {'move': (1, -1)}
    elif key_char == 'b':
        return {'move': (-1, 1)}
    elif key_char == 'n':
        return {'move': (1, 1)}
    elif key_char == 'z':
        return {'wait': True}

Then, in engine.py:

       move = action.get('move')
+       wait = action.get('wait')
       pickup = action.get('pickup')
       ...

       if move and game_state == GameStates.PLAYERS_TURN:
           ...

+       elif wait:
+           game_state = GameStates.ENEMY_TURN

       elif pickup and game_state == GameStates.PLAYERS_TURN:
           ...
        move = action.get('move')
        wait = action.get('wait')
        pickup = action.get('pickup')
        ...

        if move and game_state == GameStates.PLAYERS_TURN:
            ...

        elif wait:
            game_state = GameStates.ENEMY_TURN

        elif pickup and game_state == GameStates.PLAYERS_TURN:
            ...

So all we’re doing is “skipping” the player’s turn. Easy! You could do a number of things here, like giving the player back 1 HP for waiting, but I won’t do that because I’m cruel and unforgiving.

That’s all for this chapter. We’ve given the player a lot of advantages (in fact, just one more point in defense makes Orcs a non-threat), but that’s all about to change. Next chapter, we’re going to buff up the monsters, while making the player weaker. This is a roguelike after all, it’s not supposed to be easy!

If you want to see the code so far in its entirety, click here.

Click here to move on to the next part of this tutorial.