Roguelike Tutorial: n Steps Forward, n-1 Steps Back

Last time I thought the Wraith was a complicated feature. This time I found myself implementing an even more difficult one: stairs.

If you’re familiar with the finished game, you know it already has stairs. But really it’s more like a “blow up the entire world, build a new one, then say it was from descending a staircase.” I decided I wanted the ability to move back and forth between floors. That sounds simple enough, but it’s not even close.

Hairball Removal

This section is going to be very dry. I really feel the need to talk about this, but I fear it may not be interesting to read. It’s basically about how and why I refactored 200 or so lines of code to do the same thing it did before but in a slightly more organized way.

In previous posts I’ve set up systems for easy creation of spinning off new content. monsters.py for new enemies. items.py for new treasures. This week some things dawned on me:

  • I’d really like a way to generate different levels instead of using the same algorithm every time
  • I’m having a surprisingly, frustratingly difficult time figuring out how to do that
  • I sure am tweaking this GameMap class every time I do anything with this project

Looking at the tutorial’s history, it’s easy to see how it ended up this way. The original version on the roguebasin wiki was clearly meant as a “here’s the fastest way from Point A to Point B” demonstration. The Python 3 update I’m working from clearly tried to clean things up, and often succeeded, but some parts of it are still giant tangled hairballs and the GameMap class is one of the hairiest. On GitHub you can find the final version of the GameMap class.

So, what is a GameMap object? To start, it’s almost the entire game state, including every single bit of the level data hardcoded. Which means it might make more sense to ask what is the GameMap object, which already hints at the problem. This class includes:

  • Logic for generating the first dungeon floor
  • Logic for generating all subsequent dungeon floors and the only exit to them
  • Logic for generating all rooms including blocking tiles
  • All item/monster drop tables
  • All item/monster definitions

The big problem, and something that can be seen in most commits to my version that isn’t a bugfix, is that so much of this stuff is hardcoded with no reasonable way to change or add to it apart from rewriting the class. The place_entities method alone includes monster definitions, item definitions, drop rates, and drop generation methods so if you want to change any part of that you have to change all of it or start refactoring.

I’ve been slowly chipping away at this class over the course of previous updates, but thinking about floors and creating new ones required me to pay more attention to the game’s entities and that’s where the real pain started. Some other things I noticed when I took a step back and really started examining this class:

  • There is an entities list created at the beginning of the game. Nearly everything that touches it both (A) Takes game_map as a parameter, and (B) mutates entities. In theory this is supposed to prevent using entities as a global variable, but in practice this version is more like an obfuscated global variable.
  • Many of the values it uses are constants, set in a dictionary literally called constants. Yet instead of asking for and holding onto those values in the __init__ method, GameMap repeatedly asks for them as individual parameters in method calls.
  • make_map has to be manually called in the game’s initialization even though it’s automatically called on subsequent rooms and there’s no reason that couldn’t be done in __init__ as written.
  • initialize_tiles returns a list of tiles. But it’s only ever called in the form self.tiles = initialize_tiles(), which means there’s no reason not to just make the initialize_tiles method set self.tiles directly.
  • Where’s the logic for transitioning from one floor to another? The engine.py game loop handles deciding whether the player successfully used an exit or not, the Stairs component contains only an integer (representing how many floors deep the new floor will be), and the actual new floor is handled by next_floor in the GameMap class.
  • The constants in the initialize_game.py file include values for things like max_monsters_per_room and max_room_width. Setting aside the wisdom of hard-coding those values for the entire game in the first place, it turns out that these values aren’t even used because they’re also hardcoded in GameMap. And the versions in GameMap differ from the constants version.

The good news is that my previous work has really been helping to streamline this class. I’m happy with how items.py and monsters.py turned out, and factoring out the drop tables will be trivial once I decide if, how, and why I would want to do that.

I ultimately decided I would need to overhaul GameMap. The first step was to add an instance variable for entities and force the rest of the game to only interact with it through this class. I know, I just criticized a class for doing too much and then added stuff to it, but the idea to make it clearer what’s actually happening whether I approve of it or not. entities is an instance variable no matter how hard it’s pretending not to be. On a similar note, I also made __init__ set all the constants that are currently hardcoded. This at least made it obvious what values were being set in which places.

On that note, the second step was…deleting the GameMap class. Not literally, of course, but I did remove the class name completely just to make sure I didn’t accidentally keep any code calling it. The GameMap class is trying to be both the state of the entire game world as well as the current floor and if I’m going to have a concept of different floors then I wanted to separate those things. After a lot of flailing around, I ended up with two classes: World and DungeonFloor.

World is meant to be the primary interface between the game world and the player. DungeonFloor and the other game objects should mostly interact each other, while the engine and renderer should be polling World about the state of the world.

DungeonFloor currently does most of what GameMap did. The key differences, other than that entities instance variable, is that it now calls methods like make_map on its own as needed. Now that I’m confident that DungeonFloor can do its job with no involvement from other code, it will be easier to pull more stuff out as needed.

It also no longer changes floors by deleting all its own data and creating new rooms. A new floor means a new DungeonFloor, while World keeps track of what floor we are on. This makes it easy to keep track of the game state; by making our save system save the World, it also automatically saves:

  • The current floor
  • The current floor’s entities, including the staircases
  • The staircases themselves, and by extension their destination floors
  • All of the above for the destination floors themselves

Saving all generated level data is as simple as saving the World instance.

Can We Make Stairs Yet?

At some point I decided enough was enough and turned my eye to actually making the staircase. I decided that staircases need to know where they lead, which means having them point to their destination floor. This causes problems in our theoretically-infinite dungeon. We start by creating our first floor, which has a staircase to the second floor, which has a staircase to the third floor, and so on until we’ve created an infinite loop before the game even starts.

I ended up changing the Stairs component to the Exit component. Then, following my map_objects of monsters.py and items.py, I added exits.py, whose name I already hate but I haven’t come up with an alternative yet. The actual staircases are the easy part (I decided that if > is downstairs then obviously < is upstairs):

import tcod as libtcod

import components.exit # doesn't alias to exit because of builtin exit function
from entity import Entity
from render_functions import RenderOrder

class DownStairs(Entity):
    def __init__(self, x, y, destination):
        super().__init__(x, y, '>', libtcod.white, 'Stairs (Down)', render_order=RenderOrder.STAIRS)
        components.exit.DownStairsExit(destination).add_to_entity(self)

class UpStairs(Entity):
    def __init__(self, x, y, old_floor):
        super().__init__(x, y, '<', libtcod.white, 'Stairs (Up)', render_order=RenderOrder.STAIRS)
        components.exit.Exit(destination=(), new_floor=old_floor).add_to_entity(self)

That’s the easy part (if you’re wondering what a destination is, that’s what I’m about to show you). The harder part:

 class Exit(Component):
    def __init__(self, destination=tuple(), new_floor=None): 
        super().__init__('exit')
        # destination defaults to the empty tuple because we want a falsy, immutable, ordered container
        # if a destination isn't given here, the new floor will have to be defined some other way
        self.destination = destination
        # Sets the destination now if a new floor was provided
        self.new_floor = new_floor

    def take_exit(self, player, message_log):
        # This is here for subclasses to override if they want to add logic before moving to the next room
        # e.g. the default game's healing before progressing to a new floor
        return self.next_floor

    @property
    def next_floor(self):
        if not self.new_floor:
            # construct the new floor if it doesn't exist yet
            # currently just letting this throw an exception if no destination was ever defined
            floor_constructor, args, kwargs = self.destination
            self.new_floor = floor_constructor(*args, **kwargs)

        return self.new_floor

class DownStairsExit(Exit):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.already_taken = False

    def take_exit(self, player, message_log):
        if not self.already_taken:
            self.already_taken = True
            player.fighter.heal(player.fighter.max_hp // 2)
            message_log.add_message(Message('You take a moment to rest, and recover your strength.'))

        return super().take_exit(player, message_log)

The destination and next_floor parameters are essentially mutually exclusive, but currently I don’t see a way around accommodating both. The problem:

  • You can’t assume the next floor exists when the stairs are first created, or you end up with the infinite loop discussed earlier.
  • You can’t create an exit that returns to a previous room unless you handle the case where the previous room already exists.

I actually tried creating a function that would turn an existing room into a destination tuple, but that confused shelve and created the same crashing bug I encountered when implementing my status effects. So instead the component accepts a DungeonFloor, or a constructor and parameters to create one. If it gets the floor it just uses that, whereas if it gets the constructor it builds the floor the first time the player tries to actually enter it. My current code for calling it looks like this:

# This is deep in the DungeonFloor class

if num_rooms == 0:
    # this is the first room, where the player starts
    player.x = new_x
    player.y = new_y
    if self.previous_floor:
        self.entities.append(exits.UpStairs(new_x, new_y, self.previous_floor)) 

# some intervening code intermitted
destination=(
    DungeonFloor,
    (player, self.width, self.height, self.dungeon_level + 1),
    {'room_max_size': self.room_max_size, 'room_min_size': self.room_min_size, 'max_rooms': self.max_rooms, 'previous_floor': self})
    self.entities.append(exits.DownStairs(center_of_last_room_x, center_of_last_room_y, destination))

Finally, there’s the small matter of placing the player when they reenter a room. As written, this created an amusing bug where the player doesn’t change coordinates after entering a staircase if it’s not a new floor. I forgot to get a screenshot of the bug sticking me in a wall. Anyway, the new World class made this an easy fix:

def change_room(self, player, entity, message_log):
    # remember the player's current position in case they come back to this room
    self.current_floor.last_player_position = (player.x, player.y)
    self.current_floor = entity.exit.take_exit(player, message_log)
    # reset the player's position if they've been here before
    # note: currently this does not work if a player has more than one way to reenter a room where they've already been
    if self.current_floor.last_player_position:
        player.x, player.y = self.current_floor.last_player_position 

After all that…we have stairs that go up.

Finally, We Have Stairs

The funny thing is that a lot of the things I didn’t like about the code still aren’t fixed. Each floor is still hardcoded to go to the next random floor, and it’s not even possible to have more than one staircase/exit leading into the same room or my player placement code above breaks. If it seemed like I was trashing the previous tutorial authors above, I really wasn’t; I recognize and appreciate the work it took to build as much as they did. And on the bright side, we now have stairs that go up and won’t trap you in walls.

This session did give me a lot of ideas, both for code and for game mechanics. Once I gave the player the ability to retrace their steps to previous floors, it made me wonder why that might be a fun thing to do. I already have some ideas. Hopefully I won’t have to rewrite half the core code this time.

As always, I’ve posted the full code on GitHub.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s