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.

Before diving into the code for this section, let’s add the color we’ll need this chapter, for when the player descends down a level in the dungeon. Open up color.py and add this line:

...
enemy_atk = (0xFF, 0xC0, 0xC0)
needs_target = (0x3F, 0xFF, 0xFF)
status_effect_applied = (0x3F, 0xFF, 0x3F)
+descend = (0x9F, 0x3F, 0xFF)

player_die = (0xFF, 0x30, 0x30)
enemy_die = (0xFF, 0xA0, 0x30)
...
...
enemy_atk = (0xFF, 0xC0, 0xC0)
needs_target = (0x3F, 0xFF, 0xFF)
status_effect_applied = (0x3F, 0xFF, 0x3F)
descend = (0x9F, 0x3F, 0xFF)

player_die = (0xFF, 0x30, 0x30)
enemy_die = (0xFF, 0xA0, 0x30)
...

We will use this color later on, when adding a message to the message log that the player went down one floor.

We’ll also need a new tile type to represent the downward stairs in the dungeon. Typically, roguelikes represent this with the > character, and we’ll do the same. Add the following to tile_types.py:

...
wall = new_tile(
    walkable=False,
    transparent=False,
    dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
    light=(ord(" "), (255, 255, 255), (130, 110, 50)),
)
+down_stairs = new_tile(
+   walkable=True,
+   transparent=True,
+   dark=(ord(">"), (0, 0, 100), (50, 50, 150)),
+   light=(ord(">"), (255, 255, 255), (200, 180, 50)),
+)
...
wall = new_tile(
    walkable=False,
    transparent=False,
    dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
    light=(ord(" "), (255, 255, 255), (130, 110, 50)),
)
down_stairs = new_tile(
    walkable=True,
    transparent=True,
    dark=(ord(">"), (0, 0, 100), (50, 50, 150)),
    light=(ord(">"), (255, 255, 255), (200, 180, 50)),
)

To keep track of where our downwards stairs are located on the map, we can add a new variable in out __init__ function in the GameMap class. The variable needs some sort of default, so to start, we can set that up to be (0, 0) by default. Add the following line to game_map.py:

class GameMap:
    def __init__(
        self, engine: Engine, width: int, height: int, entities: Iterable[Entity] = ()
    ):
        ...
        self.explored = np.full(
            (width, height), fill_value=False, order="F"
        )  # Tiles the player has seen before

+       self.downstairs_location = (0, 0)

    @property
    def gamemap(self) -> GameMap:
        ...
class GameMap:
    def __init__(
        self, engine: Engine, width: int, height: int, entities: Iterable[Entity] = ()
    ):
        ...
        self.explored = np.full(
            (width, height), fill_value=False, order="F"
        )  # Tiles the player has seen before

        self.downstairs_location = (0, 0)

    @property
    def gamemap(self) -> GameMap:
        ...

Of course, (0, 0) won’t be the actual location of the stairs. In order to actually place the downwards stairs, we’ll need to edit our procedural dungeon generator to place the stairs at the proper place. We’ll keep things simple and just place the stairs in the last room that our algorithm generates, by keeping track of the center coordinates of the last room we created. Modify generate_dungeon function in procgen.py:

    ...
    rooms: List[RectangularRoom] = []

+   center_of_last_room = (0, 0)

    for r in range(max_rooms):
        ...
            ...
            for x, y in tunnel_between(rooms[-1].center, new_room.center):
                dungeon.tiles[x, y] = tile_types.floor

+           center_of_last_room = new_room.center

        place_entities(new_room, dungeon, max_monsters_per_room, max_items_per_room)

+       dungeon.tiles[center_of_last_room] = tile_types.down_stairs
+       dungeon.downstairs_location = center_of_last_room

        # Finally, append the new room to the list.
        rooms.append(new_room)

    return dungeon
    ...
    rooms: List[RectangularRoom] = []

    center_of_last_room = (0, 0)

    for r in range(max_rooms):
        ...
            ...
            for x, y in tunnel_between(rooms[-1].center, new_room.center):
                dungeon.tiles[x, y] = tile_types.floor

            center_of_last_room = new_room.center

        place_entities(new_room, dungeon, max_monsters_per_room, max_items_per_room)

        dungeon.tiles[center_of_last_room] = tile_types.down_stairs
        dungeon.downstairs_location = center_of_last_room

        # Finally, append the new room to the list.
        rooms.append(new_room)

    return dungeon

Whichever room is generated last, we take its center and set the downstairs_location equal to those coordinates. We also replace whatever tile type with the down_stairs, so the player can clearly see the location.

To hold the information about the maps, including the size, the room variables (size and maximum number), along with the floor that the player is currently on, we can add a class to hold these variables, as well as generate new maps when the time comes. Open up game_map.py and add the following class:

class GameMap:
    ...


+class GameWorld:
+   """
+   Holds the settings for the GameMap, and generates new maps when moving down the stairs.
+   """

+   def __init__(
+       self,
+       *,
+       engine: Engine,
+       map_width: int,
+       map_height: int,
+       max_rooms: int,
+       room_min_size: int,
+       room_max_size: int,
+       max_monsters_per_room: int,
+       max_items_per_room: int,
+       current_floor: int = 0
+   ):
+       self.engine = engine

+       self.map_width = map_width
+       self.map_height = map_height

+       self.max_rooms = max_rooms

+       self.room_min_size = room_min_size
+       self.room_max_size = room_max_size

+       self.max_monsters_per_room = max_monsters_per_room
+       self.max_items_per_room = max_items_per_room

+       self.current_floor = current_floor

+   def generate_floor(self) -> None:
+       from procgen import generate_dungeon

+       self.current_floor += 1

+       self.engine.game_map = generate_dungeon(
+           max_rooms=self.max_rooms,
+           room_min_size=self.room_min_size,
+           room_max_size=self.room_max_size,
+           map_width=self.map_width,
+           map_height=self.map_height,
+           max_monsters_per_room=self.max_monsters_per_room,
+           max_items_per_room=self.max_items_per_room,
+           engine=self.engine,
+       )
class GameMap:
    ...


class GameWorld:
    """
    Holds the settings for the GameMap, and generates new maps when moving down the stairs.
    """

    def __init__(
        self,
        *,
        engine: Engine,
        map_width: int,
        map_height: int,
        max_rooms: int,
        room_min_size: int,
        room_max_size: int,
        max_monsters_per_room: int,
        max_items_per_room: int,
        current_floor: int = 0
    ):
        self.engine = engine

        self.map_width = map_width
        self.map_height = map_height

        self.max_rooms = max_rooms

        self.room_min_size = room_min_size
        self.room_max_size = room_max_size

        self.max_monsters_per_room = max_monsters_per_room
        self.max_items_per_room = max_items_per_room

        self.current_floor = current_floor

    def generate_floor(self) -> None:
        from procgen import generate_dungeon

        self.current_floor += 1

        self.engine.game_map = generate_dungeon(
            max_rooms=self.max_rooms,
            room_min_size=self.room_min_size,
            room_max_size=self.room_max_size,
            map_width=self.map_width,
            map_height=self.map_height,
            max_monsters_per_room=self.max_monsters_per_room,
            max_items_per_room=self.max_items_per_room,
            engine=self.engine,
        )

The generate_floor method will create the new maps each time we go down a floor, using the variables that GameWorld stores. In this tutorial, we won’t program in the ability to go back up a floor after going down one, but you could perhaps modify GameWorld to hold the previous maps.

In order to utilize the new GameWorld class, we’ll need to add it to the Engine, like this:

...
if TYPE_CHECKING:
    from entity import Actor
-   from game_map import GameMap
+   from game_map import GameMap, GameWorld


class Engine:
    game_map: GameMap
+   game_world: GameWorld

    def __init__(self, player: Actor):
        ...
...
if TYPE_CHECKING:
    from entity import Actor
    from game_map import GameMap
    from game_map import GameMap, GameWorld


class Engine:
    game_map: GameMap
    game_world: GameWorld

    def __init__(self, player: Actor):
        ...

Pretty simple. To utilize the new game_world class attribute, edit setup_game.py like this:

import tcod
import color
from engine import Engine
import entity_factories
+from game_map import GameWorld
import input_handlers
-from procgen import generate_dungeon
...

    ...
    engine = Engine(player=player)

-   engine.game_map = generate_dungeon(
+   engine.game_world = GameWorld(
+       engine=engine,
        max_rooms=max_rooms,
        room_min_size=room_min_size,
        room_max_size=room_max_size,
        map_width=map_width,
        map_height=map_height,
        max_monsters_per_room=max_monsters_per_room,
        max_items_per_room=max_items_per_room,
-       engine=engine,
    )

+   engine.game_world.generate_floor()
    engine.update_fov()
    ...
import tcod
import color
from engine import Engine
import entity_factories
from game_map import GameWorld
import input_handlers
from procgen import generate_dungeon
...

    ...
    engine = Engine(player=player)

    engine.game_map = generate_dungeon(
    engine.game_world = GameWorld(
        engine=engine,
        max_rooms=max_rooms,
        room_min_size=room_min_size,
        room_max_size=room_max_size,
        map_width=map_width,
        map_height=map_height,
        max_monsters_per_room=max_monsters_per_room,
        max_items_per_room=max_items_per_room,
        engine=engine,
    )

    engine.game_world.generate_floor()
    engine.update_fov()
    ...

Now, instead of calling generate_dungeon directly, we create a new GameWorld and allow it to call its generate_floor method. While this doesn’t change anything for the first floor that’s created, it will allow us to more easily create new floors on the fly.

In order to actually take the stairs, we’ll need to add an action and a way for the player to trigger it. Adding the action is pretty simple. Add the following to actions.py:

class WaitAction(Action):
    pass


+class TakeStairsAction(Action):
+   def perform(self) -> None:
+       """
+       Take the stairs, if any exist at the entity's location.
+       """
+       if (self.entity.x, self.entity.y) == self.engine.game_map.downstairs_location:
+           self.engine.game_world.generate_floor()
+           self.engine.message_log.add_message(
+               "You descend the staircase.", color.descend
+           )
+       else:
+           raise exceptions.Impossible("There are no stairs here.")


class ActionWithDirection(Action):
    def __init__(self, entity: Actor, dx: int, dy: int):
        super().__init__(entity)
class WaitAction(Action):
    pass


class TakeStairsAction(Action):
    def perform(self) -> None:
        """
        Take the stairs, if any exist at the entity's location.
        """
        if (self.entity.x, self.entity.y) == self.engine.game_map.downstairs_location:
            self.engine.game_world.generate_floor()
            self.engine.message_log.add_message(
                "You descend the staircase.", color.descend
            )
        else:
            raise exceptions.Impossible("There are no stairs here.")


class ActionWithDirection(Action):
    def __init__(self, entity: Actor, dx: int, dy: int):
        super().__init__(entity)

To call this action, the player should be able to press the > key. This can be accomplished by adding this to input_handlers.py:

class MainGameEventHandler(EventHandler):
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        action: Optional[Action] = None

        key = event.sym
+       modifier = event.mod

        player = self.engine.player

+       if key == tcod.event.K_PERIOD and modifier & (
+           tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT
+       ):
+           return actions.TakeStairsAction(player)

        if key in MOVE_KEYS:
            dx, dy = MOVE_KEYS[key]
class MainGameEventHandler(EventHandler):
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        action: Optional[Action] = None

        key = event.sym
        modifier = event.mod

        player = self.engine.player

        if key == tcod.event.K_PERIOD and modifier & (
            tcod.event.KMOD_LSHIFT | tcod.event.KMOD_RSHIFT
        ):
            return actions.TakeStairsAction(player)

        if key in MOVE_KEYS:
            dx, dy = MOVE_KEYS[key]

modifier tells us if the player is holding a key like control, alt, or shift. In this case, we’re checking if the user is holding shift while pressing the period key, which gives us the “>” key.

With that, the player can now descend the staircase to the next floor of the dungeon!

Part 11 - Stairs Part 11 - Stairs Taken

One little touch we can add before moving on to the next section is adding a way to see which floor the player is on. It’s simple enough: We’ll use the current_floor in GameWorld to know which floor we’re on, and we’ll modify our render_functions.py file to add a method to print this information out to the UI.

Add this function to render_functions.py:

from __future__ import annotations

-from typing import TYPE_CHECKING
+from typing import Tuple, TYPE_CHECKING

import color
...

...
def render_bar(
    console: Console, current_value: int, maximum_value: int, total_width: int
) -> None:
    ...


+def render_dungeon_level(
+   console: Console, dungeon_level: int, location: Tuple[int, int]
+) -> None:
+   """
+   Render the level the player is currently on, at the given location.
+   """
+   x, y = location

+   console.print(x=x, y=y, string=f"Dungeon level: {dungeon_level}")


def render_names_at_mouse_location(
    console: Console, x: int, y: int, engine: Engine
) -> None:
    ...
from __future__ import annotations

from typing import TYPE_CHECKING
from typing import Tuple, TYPE_CHECKING

import color
...

...
def render_bar(
    console: Console, current_value: int, maximum_value: int, total_width: int
) -> None:
    ...


def render_dungeon_level(
    console: Console, dungeon_level: int, location: Tuple[int, int]
) -> None:
    """
    Render the level the player is currently on, at the given location.
    """
    x, y = location

    console.print(x=x, y=y, string=f"Dungeon level: {dungeon_level}")


def render_names_at_mouse_location(
    console: Console, x: int, y: int, engine: Engine
) -> None:
    ...

The render_dungeon_level function is fairly straightforward: Given a set of (x, y) coordinates as a Tuple, it prints to the console which dungeon level was passed to the function.

To call this function, we can edit the Engine’s render function, like so:

...
import exceptions
from message_log import MessageLog
-from render_functions import (
-   render_bar,
-   render_names_at_mouse_location,
-)
+import render_functions

if TYPE_CHECKING:
    ...


class Engine:
    ...

    def render(self, console: Console) -> None:
        self.game_map.render(console)

        self.message_log.render(console=console, x=21, y=45, width=40, height=5)

-       render_bar(
+       render_functions.render_bar(
            console=console,
            current_value=self.player.fighter.hp,
            maximum_value=self.player.fighter.max_hp,
            total_width=20,
        )

-       render_names_at_mouse_location(console=console, x=21, y=44, engine=self)
+       render_functions.render_dungeon_level(
+           console=console,
+           dungeon_level=self.game_world.current_floor,
+           location=(0, 47),
+       )

+       render_functions.render_names_at_mouse_location(
+           console=console, x=21, y=44, engine=self
+       )

    def save_as(self, filename: str) -> None:
        ...
...
import exceptions
from message_log import MessageLog
from render_functions import (
    render_bar,
    render_names_at_mouse_location,
)
import render_functions

if TYPE_CHECKING:
    ...


class Engine:
    ...

    def render(self, console: Console) -> None:
        self.game_map.render(console)

        self.message_log.render(console=console, x=21, y=45, width=40, height=5)

        render_bar(
        render_functions.render_bar(
            console=console,
            current_value=self.player.fighter.hp,
            maximum_value=self.player.fighter.max_hp,
            total_width=20,
        )

        render_names_at_mouse_location(console=console, x=21, y=44, engine=self)
        render_functions.render_dungeon_level(
            console=console,
            dungeon_level=self.game_world.current_floor,
            location=(0, 47),
        )

        render_functions.render_names_at_mouse_location(
            console=console, x=21, y=44, engine=self
        )

    def save_as(self, filename: str) -> None:
        ...

Note that we’re now importing render_functions instead of importing the functions it contains. After awhile, it makes sense to just import the entire module rather than a few functions here and there. Otherwise, the file can get a bit difficult to read.

The call to render_dungeon_level shouldn’t be anything too surprising. We use self.game_world.current_floor as our dungeon_level, and the location of the printed string is below the health bar (feel free to move this somewhere else, if you like).

Try going down a few levels and make sure everything works as expected. If so, congratulations! Your dungeon now has multiple levels!

Part 11 - Dungeon Level

Speaking of “levels”, many roguelikes (not all!) feature some sort of level-up system, where your character gains experience and gets stronger by fighting monsters. The rest of this chapter will be spent implementing one such system.

In order to allow the rogue to level up, we need to modify the actors in two ways:

  1. The player needs to gain experience points, keeping track of the XP gained thus far, and know when it’s time to level up.
  2. The enemies need to give experience points when they are defeated.

There are several calculations we could use to compute how much XP a player needs to level up (or, theoretically, you could just hard code the values). Ours will be fairly simple: We’ll start with a base number, and add the product of our player’s current level and some other number, which will make it so each level up requires more XP than the last. For this tutorial, the “base” will be 200, and the “factor” will be 150 (so going to level 2 will take 350 XP, level 3 will take 500, and so on).

We can accomplish both of these goals by adding one component: Level. The Level component will hold all of the information that we need to accomplish these goals. Create a file called level.py in the components directory, and put the following contents in it:

from __future__ import annotations

from typing import TYPE_CHECKING

from components.base_component import BaseComponent

if TYPE_CHECKING:
    from entity import Actor


class Level(BaseComponent):
    parent: Actor

    def __init__(
        self,
        current_level: int = 1,
        current_xp: int = 0,
        level_up_base: int = 0,
        level_up_factor: int = 150,
        xp_given: int = 0,
    ):
        self.current_level = current_level
        self.current_xp = current_xp
        self.level_up_base = level_up_base
        self.level_up_factor = level_up_factor
        self.xp_given = xp_given

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

    @property
    def requires_level_up(self) -> bool:
        return self.current_xp > self.experience_to_next_level

    def add_xp(self, xp: int) -> None:
        if xp == 0 or self.level_up_base == 0:
            return

        self.current_xp += xp

        self.engine.message_log.add_message(f"You gain {xp} experience points.")

        if self.requires_level_up:
            self.engine.message_log.add_message(
                f"You advance to level {self.current_level + 1}!"
            )

    def increase_level(self) -> None:
        self.current_xp -= self.experience_to_next_level

        self.current_level += 1

    def increase_max_hp(self, amount: int = 20) -> None:
        self.parent.fighter.max_hp += amount
        self.parent.fighter.hp += amount

        self.engine.message_log.add_message("Your health improves!")

        self.increase_level()

    def increase_power(self, amount: int = 1) -> None:
        self.parent.fighter.power += amount

        self.engine.message_log.add_message("You feel stronger!")

        self.increase_level()

    def increase_defense(self, amount: int = 1) -> None:
        self.parent.fighter.defense += amount

        self.engine.message_log.add_message("Your movements are getting swifter!")

        self.increase_level()

Let’s go over what was just added.

class Level(BaseComponent):
    parent: Actor

    def __init__(
        self,
        current_level: int = 1,
        current_xp: int = 0,
        level_up_base: int = 0,
        level_up_factor: int = 150,
        xp_given: int = 0,
    ):
        self.current_level = current_level
        self.current_xp = current_xp
        self.level_up_base = level_up_base
        self.level_up_factor = level_up_factor
        self.xp_given = xp_given

The values in our __init__ function break down like this:

  • current_level: The current level of the Entity, defaults to 1.
  • current_xp: The Entity’s current experience points.
  • level_up_base: The base number we decide for leveling up. We’ll set this to 200 when creating the Player.
  • level_up_factor: The number to multiply against the Entity’s current level.
  • xp_given: When the Entity dies, this is how much XP the Player will gain.
    @property
    def experience_to_next_level(self) -> int:
        return self.level_up_base + self.current_level * self.level_up_factor

This represents how much experience the player needs until hitting the next level. The formula is explained above. Again, feel free to tweak this formula in any way you see fit.

    @property
    def requires_level_up(self) -> bool:
        return self.current_xp > self.experience_to_next_level

We’ll use this property to determine if the player needs to level up or not. If the current_xp is higher than the experience_to_next_level property, then the player levels up. If not, nothing happens.

    def add_xp(self, xp: int) -> None:
        if xp == 0 or self.level_up_base == 0:
            return

        self.current_xp += xp

        self.engine.message_log.add_message(f"You gain {xp} experience points.")

        if self.requires_level_up:
            self.engine.message_log.add_message(
                f"You advance to level {self.current_level + 1}!"
            )

This method adds experience points to the Entity’s XP pool, as the name implies. If the value is 0, we just return, as there’s nothing to do. Notice that we also return if the level_up_base is set to 0. Why? In this tutorial, the enemies don’t gain XP, so we’ll set their level_up_base to 0 so that there’s no way they could ever gain experience. Perhaps in your game, monsters will gain XP, and you’ll want to adjust this, but that’s left up to you.

The rest of the method adds the xp, adds a message to the message log, and, if the Entity levels up, posts another message.

    def increase_level(self) -> None:
        self.current_xp -= self.experience_to_next_level

        self.current_level += 1

This method adds +1 to the current_level, while decreasing the current_xp by the experience_to_next_level. We do this because if we didn’t it would always just take the level_up_factor amount to level up, which isn’t what we want. If you wanted to keep track of the player’s cumulative XP throughout the playthrough, you could skip decrementing the current_xp and instead adjust the experience_to_next_level formula accordingly.

Lastly, the functions increase_max_hp, increase_power, and increase_defense all do basically the same thing: they raise one of the Entity’s attributes, add a message to the message log, then call increase_level.

To use this component, we need to add it to our Actor class. Make the following changes to the file entity.py:

if TYPE_CHECKING:
    from components.ai import BaseAI
    from components.consumable import Consumable
    from components.fighter import Fighter
    from components.inventory import Inventory
+   from components.level import Level
    from game_map import GameMap

T = TypeVar("T", bound="Entity")
...

class Actor(Entity):
    def __init__(
        self,
        *,
        x: int = 0,
        y: int = 0,
        char: str = "?",
        color: Tuple[int, int, int] = (255, 255, 255),
        name: str = "<Unnamed>",
        ai_cls: Type[BaseAI],
        fighter: Fighter,
        inventory: Inventory,
+       level: Level,
    ):
        super().__init__(
            x=x,
            y=y,
            char=char,
            color=color,
            name=name,
            blocks_movement=True,
            render_order=RenderOrder.ACTOR,
        )

        self.ai: Optional[BaseAI] = ai_cls(self)

        self.fighter = fighter
        self.fighter.parent = self

        self.inventory = inventory
        self.inventory.parent = self

+       self.level = level
+       self.level.parent = self

    @property
    def is_alive(self) -> bool:
        ...
if TYPE_CHECKING:
    from components.ai import BaseAI
    from components.consumable import Consumable
    from components.fighter import Fighter
    from components.inventory import Inventory
    from components.level import Level
    from game_map import GameMap

T = TypeVar("T", bound="Entity")
...

class Actor(Entity):
    def __init__(
        self,
        *,
        x: int = 0,
        y: int = 0,
        char: str = "?",
        color: Tuple[int, int, int] = (255, 255, 255),
        name: str = "<Unnamed>",
        ai_cls: Type[BaseAI],
        fighter: Fighter,
        inventory: Inventory,
        level: Level,
    ):
        super().__init__(
            x=x,
            y=y,
            char=char,
            color=color,
            name=name,
            blocks_movement=True,
            render_order=RenderOrder.ACTOR,
        )

        self.ai: Optional[BaseAI] = ai_cls(self)

        self.fighter = fighter
        self.fighter.parent = self

        self.inventory = inventory
        self.inventory.parent = self

        self.level = level
        self.level.parent = self

    @property
    def is_alive(self) -> bool:
        ...

Let’s also modify our entities in entity_factories.py now:

from components.ai import HostileEnemy
from components import consumable
from components.fighter import Fighter
from components.inventory import Inventory
+from components.level import Level
from entity import Actor, Item


player = Actor(
    char="@",
    color=(255, 255, 255),
    name="Player",
    ai_cls=HostileEnemy,
    fighter=Fighter(hp=30, defense=2, power=5),
    inventory=Inventory(capacity=26),
+   level=Level(level_up_base=200),
)

orc = Actor(
    char="o",
    color=(63, 127, 63),
    name="Orc",
    ai_cls=HostileEnemy,
    fighter=Fighter(hp=10, defense=0, power=3),
    inventory=Inventory(capacity=0),
+   level=Level(xp_given=35),
)
troll = Actor(
    char="T",
    color=(0, 127, 0),
    name="Troll",
    ai_cls=HostileEnemy,
    fighter=Fighter(hp=16, defense=1, power=4),
    inventory=Inventory(capacity=0),
+   level=Level(xp_given=100),
)
...
from components.ai import HostileEnemy
from components import consumable
from components.fighter import Fighter
from components.inventory import Inventory
from components.level import Level
from entity import Actor, Item


player = Actor(
    char="@",
    color=(255, 255, 255),
    name="Player",
    ai_cls=HostileEnemy,
    fighter=Fighter(hp=30, defense=2, power=5),
    inventory=Inventory(capacity=26),
    level=Level(level_up_base=200),
)

orc = Actor(
    char="o",
    color=(63, 127, 63),
    name="Orc",
    ai_cls=HostileEnemy,
    fighter=Fighter(hp=10, defense=0, power=3),
    inventory=Inventory(capacity=0),
    level=Level(xp_given=35),
)
troll = Actor(
    char="T",
    color=(0, 127, 0),
    name="Troll",
    ai_cls=HostileEnemy,
    fighter=Fighter(hp=16, defense=1, power=4),
    inventory=Inventory(capacity=0),
    level=Level(xp_given=100),
)
...

As mentioned, the level_up_base for the player is set to 200. Orcs give 35 XP, and Trolls give 100, since they’re stronger. These values are completely arbitrary, so feel free to adjust them in any way you see fit.

When an enemy dies, we need to give the player XP. This is as simple as adding one line to the Fighter component, so open up fighter.py and add this:

class Fighter(BaseComponent):
    def die(self) -> None:
        ...

        self.engine.message_log.add_message(death_message, death_message_color)

+       self.engine.player.level.add_xp(self.parent.level.xp_given)

    def heal(self, amount: int) -> int:
        ...
class Fighter(BaseComponent):
    def die(self) -> None:
        ...

        self.engine.message_log.add_message(death_message, death_message_color)

        self.engine.player.level.add_xp(self.parent.level.xp_given)

    def heal(self, amount: int) -> int:
        ...

Now the player will gain XP for defeating enemies!

While the player does gain XP now, notice that we haven’t actually called the functions that increase the player’s stats and levels the player up. We’ll need a new interface to do this. The way it will work is that as soon as the player gets enough experience to level up, we’ll display a message to the player, giving the player three choices on what stat to increase. When chosen, the appropriate function will be called, and the message will close.

Let’s create a new event handler, called LevelUpEventHandler, that will do just that. Create the following class in input_handlers.py:

class AskUserEventHandler(EventHandler):
    ...

+class LevelUpEventHandler(AskUserEventHandler):
+   TITLE = "Level Up"

+   def on_render(self, console: tcod.Console) -> None:
+       super().on_render(console)

+       if self.engine.player.x <= 30:
+           x = 40
+       else:
+           x = 0

+       console.draw_frame(
+           x=x,
+           y=0,
+           width=35,
+           height=8,
+           title=self.TITLE,
+           clear=True,
+           fg=(255, 255, 255),
+           bg=(0, 0, 0),
+       )

+       console.print(x=x + 1, y=1, string="Congratulations! You level up!")
+       console.print(x=x + 1, y=2, string="Select an attribute to increase.")

+       console.print(
+           x=x + 1,
+           y=4,
+           string=f"a) Constitution (+20 HP, from {self.engine.player.fighter.max_hp})",
+       )
+       console.print(
+           x=x + 1,
+           y=5,
+           string=f"b) Strength (+1 attack, from {self.engine.player.fighter.power})",
+       )
+       console.print(
+           x=x + 1,
+           y=6,
+           string=f"c) Agility (+1 defense, from {self.engine.player.fighter.defense})",
+       )

+   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
+       player = self.engine.player
+       key = event.sym
+       index = key - tcod.event.K_a

+       if 0 <= index <= 2:
+           if index == 0:
+               player.level.increase_max_hp()
+           elif index == 1:
+               player.level.increase_power()
+           else:
+               player.level.increase_defense()
+       else:
+           self.engine.message_log.add_message("Invalid entry.", color.invalid)

+           return None

+       return super().ev_keydown(event)

+   def ev_mousebuttondown(
+       self, event: tcod.event.MouseButtonDown
+   ) -> Optional[ActionOrHandler]:
+       """
+       Don't allow the player to click to exit the menu, like normal.
+       """
+       return None


class InventoryEventHandler(AskUserEventHandler):
    ...
class AskUserEventHandler(EventHandler):
    ...

class LevelUpEventHandler(AskUserEventHandler):
    TITLE = "Level Up"

    def on_render(self, console: tcod.Console) -> None:
        super().on_render(console)

        if self.engine.player.x <= 30:
            x = 40
        else:
            x = 0

        console.draw_frame(
            x=x,
            y=0,
            width=35,
            height=8,
            title=self.TITLE,
            clear=True,
            fg=(255, 255, 255),
            bg=(0, 0, 0),
        )

        console.print(x=x + 1, y=1, string="Congratulations! You level up!")
        console.print(x=x + 1, y=2, string="Select an attribute to increase.")

        console.print(
            x=x + 1,
            y=4,
            string=f"a) Constitution (+20 HP, from {self.engine.player.fighter.max_hp})",
        )
        console.print(
            x=x + 1,
            y=5,
            string=f"b) Strength (+1 attack, from {self.engine.player.fighter.power})",
        )
        console.print(
            x=x + 1,
            y=6,
            string=f"c) Agility (+1 defense, from {self.engine.player.fighter.defense})",
        )

    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        player = self.engine.player
        key = event.sym
        index = key - tcod.event.K_a

        if 0 <= index <= 2:
            if index == 0:
                player.level.increase_max_hp()
            elif index == 1:
                player.level.increase_power()
            else:
                player.level.increase_defense()
        else:
            self.engine.message_log.add_message("Invalid entry.", color.invalid)

            return None

        return super().ev_keydown(event)

    def ev_mousebuttondown(
        self, event: tcod.event.MouseButtonDown
    ) -> Optional[ActionOrHandler]:
        """
        Don't allow the player to click to exit the menu, like normal.
        """
        return None


class InventoryEventHandler(AskUserEventHandler):
    ...

The idea here is very similar to InventoryEventHandler (it inherits from the same AskUserEventHandler class), but instead of having a variable number of options, it’s set to three, one for each of the primary attributes. Furthermore, there’s no way to exit this menu without selecting something. The user must level up before continuing. (Notice, we had to override ev_mousebutton to prevent clicks from closing the menu.)

Using LevelUpEventHandler is actually quite simple: We can check when the player requires a level up at the same time when we check if the player is still alive. Edit the handle_events method of EventHandler like this:

            if not self.engine.player.is_alive:
                # The player was killed sometime during or after the action.
                return GameOverEventHandler(self.engine)
+           elif self.engine.player.level.requires_level_up:
+               return LevelUpEventHandler(self.engine)
            return MainGameEventHandler(self.engine)  # Return to the main handler.
            if not self.engine.player.is_alive:
                # The player was killed sometime during or after the action.
                return GameOverEventHandler(self.engine)
            elif self.engine.player.level.requires_level_up:
                return LevelUpEventHandler(self.engine)
            return MainGameEventHandler(self.engine)  # Return to the main handler.

Now, when the player gains the necessary number of experience points, the player will have the chance to level up!

Part 11 - Level Up

Before finishing this chapter, there’s one last quick thing we can do to improve the user experience: Add a “character information” screen, which displays the player’s stats and current experience. It’s actually quite simple. Add the following class to input_handlers.py:

class AskUserEventHandler(EventHandler):
    ...

+class CharacterScreenEventHandler(AskUserEventHandler):
+   TITLE = "Character Information"

+   def on_render(self, console: tcod.Console) -> None:
+       super().on_render(console)

+       if self.engine.player.x <= 30:
+           x = 40
+       else:
+           x = 0

+       y = 0

+       width = len(self.TITLE) + 4

+       console.draw_frame(
+           x=x,
+           y=y,
+           width=width,
+           height=7,
+           title=self.TITLE,
+           clear=True,
+           fg=(255, 255, 255),
+           bg=(0, 0, 0),
+       )

+       console.print(
+           x=x + 1, y=y + 1, string=f"Level: {self.engine.player.level.current_level}"
+       )
+       console.print(
+           x=x + 1, y=y + 2, string=f"XP: {self.engine.player.level.current_xp}"
+       )
+       console.print(
+           x=x + 1,
+           y=y + 3,
+           string=f"XP for next Level: {self.engine.player.level.experience_to_next_level}",
+       )

+       console.print(
+           x=x + 1, y=y + 4, string=f"Attack: {self.engine.player.fighter.power}"
+       )
+       console.print(
+           x=x + 1, y=y + 5, string=f"Defense: {self.engine.player.fighter.defense}"
+       )

class LevelUpEventHandler(AskUserEventHandler):
    ...
class AskUserEventHandler(EventHandler):
    ...

class CharacterScreenEventHandler(AskUserEventHandler):
    TITLE = "Character Information"

    def on_render(self, console: tcod.Console) -> None:
        super().on_render(console)

        if self.engine.player.x <= 30:
            x = 40
        else:
            x = 0

        y = 0

        width = len(self.TITLE) + 4

        console.draw_frame(
            x=x,
            y=y,
            width=width,
            height=7,
            title=self.TITLE,
            clear=True,
            fg=(255, 255, 255),
            bg=(0, 0, 0),
        )

        console.print(
            x=x + 1, y=y + 1, string=f"Level: {self.engine.player.level.current_level}"
        )
        console.print(
            x=x + 1, y=y + 2, string=f"XP: {self.engine.player.level.current_xp}"
        )
        console.print(
            x=x + 1,
            y=y + 3,
            string=f"XP for next Level: {self.engine.player.level.experience_to_next_level}",
        )

        console.print(
            x=x + 1, y=y + 4, string=f"Attack: {self.engine.player.fighter.power}"
        )
        console.print(
            x=x + 1, y=y + 5, string=f"Defense: {self.engine.player.fighter.defense}"
        )

class LevelUpEventHandler(AskUserEventHandler):
    ...

Similar to LevelUpEventHandler, CharacterScreenEventHandler shows information in a window, but there’s no real “choices” to be made here. Any input will simply close the screen.

To open the screen, we’ll have the player press the c key. Add the following to MainGameEventHandler:

        elif key == tcod.event.K_d:
            return InventoryDropHandler(self.engine)
+       elif key == tcod.event.K_c:
+           return CharacterScreenEventHandler(self.engine)
        elif key == tcod.event.K_SLASH:
            return LookHandler(self.engine)
        elif key == tcod.event.K_d:
            return InventoryDropHandler(self.engine)
        elif key == tcod.event.K_c:
            return CharacterScreenEventHandler(self.engine)
        elif key == tcod.event.K_SLASH:
            return LookHandler(self.engine)

Part 11 - Character Screen

That’s it for this chapter. We’ve added the ability to go down floors, and to level up. While the player can now “progress”, the environment itself doesn’t. The items that spawn on each floor are always the same, and the enemies don’t get tougher as we go down floors. The next part will address that.

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.