Fun with Roguelike Generators

Posted by Jack on 2013-01-23 at 17:02
Tagged: programming

I may or may not be tooling around with a roguelike. Not because I think the genre is dead (it most certainly isn't) but because some programming tasks are made fun just by their subject matter. I haven't quite gotten to the point where I'm using "mana" and "damage" and etc. as variable names, but that's not the first fun part. The first fun part is generating a rogue dungeon level.

Now, anybody that's ever even thought about developing a roguelike should know about ASCII Dreams written by the developer of Unangband, Andrew Doull. I especially found his series on Unangband Dungeon Generation to provide a lot of insight into generating dungeons that are interesting a long with a lot of interesting history and philosophizing.

In the end, though, I wanted to try my own, naive, hand at the dungeon generation problem. I did take a few major things away from Andrew's discussion however. Mostly that there are a lot of nice rooms that can be generating procedurally with simple tricks and, failing that, having a system for "vaults" (which are various, hand-designed rooms with interesting features) can be interspersed for extra flavor. Also, various features added to rooms, like water, lava, ice, minerals, rubble, etc. compel players to explore.

Most of this I'll get to later, if ever, with my toy generator. The first problem is generating the topology of the dungeon itself.

My Approach

Now, one thing that I've found annoying about classic generation algorithms is that they tend to have a lot of really long tunnels. This is because rooms are generated at random across the static map and then, if they haven't merged together, are connected by tunnels. This has never felt right to me. I understand that - in universe - a dungeon might not have the most sensible design, but having long winding tunnels are boring. Doing connectivity checks on the rooms is boring as well. While we're at it, I don't want to have a predefined playing field (array) to work with. I'll put a limit on the area of the dungeon level, but if it's a whole bunch of tiny rooms in a very long line, so be it. Unfortunately, I also want the level to be consistent (i.e. no physics violating overlapping inconsistent geometry) so it seems inevitable that the level will eventually be represented on a global grid, but at least that grid will be bounded and reasonably shaped to the level. If necessary, after the level is fully generated the excess grid could be eliminated just by noting where one room enters into another.

So, what I wanted to do was generate a dungeon level that is both tunnel free (for the most part), consistent and connected a priori. Interesting stuff like themed-features, rivers, lava flows, etc. could then be painted over the level geometry in broad strokes.

Problems vs. Classical

There are some troubles with this approach. The first of which is that it makes multiple level consistency really hard with multiple staircases. For a classical generator, you can randomly place down staircases on one level, and then replicate that pattern with up staircases on the next level and ensure that you have rooms to encompass them. This works when you're going to manually connect all the rooms in the end, but it doesn't work so well when you're building a pre-connected level. As such, either there has to be only a single up and down staircase per level (not a bad idea, really) or you have to throw that level of consistency out the window and just match arbitrary up and down staircases. This means that you could have two down staircases right next to each other that would teleport you to different ends of the next level, but in a gametype that traditionally promotes save scumming (i.e. if you go down the same staircase twice, the level is different each time you descend) I think that's acceptable.

Another problem is that, without tunnels, the dungeons are more likely to be dense. That's a good thing in the fact that it gives a lot more interesting rooms close by and the player spends a lot of time in an environment. It's also a dangerous thing because it means there's a lot fewer twisty places to get out of sight of pursuing monsters. I think that's acceptable as well, although it'll be something to account for if I ever get around to generating monsters.

Implementation

I decided to bang out a proof of concept in an evening. Breaking down the logic, the easiest way to generate in this fashion is to generate one room, which will be the root. Then, generate another room. Connect these two rooms with a doorway, then that whole complex becomes the root "room". Rinse and repeat until the dungeon is of a certain size.

In order to encompass this, I came up with a class for Space. A Space is any arbitrary portion of the dungeon level. It includes a 2-dimensional array geometry that describes what's in that space. One Space's geometry can be added to another Space's geometry with a set overlapping point. A Room is just a space with a name and whose geometry is likely a single room, but is arbitrary. Then, special types of rooms, like one mentioned in Unangband as the core type, two overlapping rectangles (which results in single rooms, crosses, T-shapes, L-shapes, etc.) are just Rooms with special geometry generation.

[expand title="Expand Code"]

#!/usr/bin/python

import random
import sys

def chance(c):
    if random.randint(1, 100) <= c:
        return True
    return False

def rand_element(l):
    return l[random.randint(0, len(l) - 1)]

def rand_block(max_x, max_y):
    return (random.randint(0, max_x), random.randint(0, max_y))

class Space(object):
    def __init__(self, geom = []):
        self.global_x = 0
        self.global_y = 0
        self.geometry = geom

    def point(self, x, y):
        return (self.global_x + x, self.global_y + y)

    def adjust_x(self, x):
        self.global_x = x

    def adjust_y(self, y):
        self.global_y = y

    def area(self):
        area = 0
        for row in self.geometry:
            for x in row:
                if x:
                    area += 1
        return area

    # Create an initialized array for a space with dimensions.  we can't just do
    # [[0] * x] * y because then the sublists are all just instances of the same
    # list.

    def space(self, x, y, val = 0):
        geom = []
        for y in range(0, y):
            geom.append([val] * x)
        return Space(geom)

    # Add space takes two arbitrary spaces and merges together, using points
    # s1p and s2p as the same points in the resulting form.

    # If kwargs["chicken"] is True, it will return False (i.e. fail), if any of s1's
    # points are overwritten by s2 except for the intersection point.

    def add_space(self, sq, s1p, s2p, **kwargs):

        chicken = False
        if "chicken" in kwargs and kwargs["chicken"]:
            chicken = True

        s1 = self.geometry
        s2 = sq.geometry

        s1_dim_y = len(s1)
        s1_dim_x = len(s1[0])

        s1_o_x, s1_o_y = s1p

        s2_dim_y = len(s2)
        s2_dim_x = len(s2[0])

        s2_o_x, s2_o_y = s2p

        g_max_x = s1_dim_x

        # Account for s2's width going farther to left
        if (s1_o_x < s2_o_x):
            s1_start_x = (s2_o_x - s1_o_x)
            s2_start_x = 0
            g_max_x += s1_start_x
        else:
            s1_start_x = 0
            s2_start_x = (s1_o_x - s2_o_x)

        # Account for s2's width going farther to right
        if ((s1_dim_x - s1_o_x) < (s2_dim_x - s2_o_x)):
            g_max_x += (s2_dim_x - s2_o_x) - (s1_dim_x - s1_o_x)

        g_max_y = s1_dim_y

        # Account for s2's height going higher
        if (s1_o_y < s2_o_y):
            s1_start_y = (s2_o_y - s1_o_y)
            g_max_y += s1_start_y
            s2_start_y = 0
        else:
            s1_start_y = 0
            s2_start_y = (s1_o_y - s2_o_y)

        # Account for s2's depth going lower
        if ((s1_dim_y - s1_o_y) < (s2_dim_y - s2_o_y)):
            g_max_y += (s2_dim_y - s2_o_y) - (s1_dim_y - s1_o_y)

        geom = self.space(g_max_x, g_max_y)

        # Draw both into geometry:

        for y in range(0, s1_dim_y):
            for x in range(0, s1_dim_x):
                geom.geometry[y + s1_start_y][x + s1_start_x] = s1[y][x]

        for y in range(0, s2_dim_y):
            for x in range(0, s2_dim_x):
                if s2[y][x]:
                    if chicken and (x, y) != s2p and\
                            geom.geometry[y + s2_start_y][x + s2_start_x]:
                        return False
                    geom.geometry[y + s2_start_y][x + s2_start_x] = s2[y][x]

        self.adjust_x(s1_start_x)
        sq.adjust_x(s2_start_x)

        self.adjust_y(s1_start_y)
        sq.adjust_y(s2_start_y)

        self.geometry = geom.geometry
        return True

    # Sort out surrounding used and unused coordinates.

    def _cardinal_sort(self, x, y):
        unused = []
        used = []
        pairs = [(-1, 0, 0),(0, -1, 3),(0, 1, 1),(1, 0, 2)]

        for off_y, off_x, direction in pairs:
            b = (x + off_x, y + off_y, direction)
            # Count out of bounds as unused.
            if (y + off_y) < 0 or\
               (y + off_y) >= len(self.geometry) or\
               (x + off_x) < 0 or\
               (x + off_x) >= len(self.geometry[y + off_y]) or\
               self.geometry[y + off_y][x + off_x] == 0:
                unused.append(b)
            else:
                used.append(b)

        return (used, unused)

    # Return a list of coordinates of used squares that have at least one
    # unused square (four-way) adjacent that, if used, would have no other used
    # neighbors but the first one. So, in summary

    # 1|
    # 2| <- all perimeter blocks
    # 3|
    #
    # 1|_  <-- neither are perimeter blocks
    #   2

    # The intent here is to find blocks where it's appropriate to attach a
    # doorway. For now, this only list blocks that are pressing against the
    # outside boundary of the geometry. There are some neat cases that could be
    # generated with finer granularity, but I'm not sure if that's better done
    # with specific generators.

    def connectable_coords(self):
        r = []
        for i, y in enumerate(self.geometry):
            for j, x in enumerate(y):

                # Skip unused blocks
                if not x:
                    continue

                used, unused = self._cardinal_sort(j, i)
                for s_x, s_y, s_d in unused:
                    sub_used, sub_unused = self._cardinal_sort(s_x, s_y)
                    if len(sub_used) == 1:
                        r.append((j, i, s_d))
        return r

    def print_geom(self):
        for row in self.geometry:
            p = ""
            for x in row:
                if x:
                    p += ("%s" % (chr(x),))
                else:
                    p += " "
            print(p)

    def gen(self):
        pass


class Room(Space):
    def __init__(self):
        Space.__init__(self)
        self.name = ""

        self.name_prefixes = [ "Beautiful", "Evil", "Corrupted", "Pristine" ]
        self.name_suffixes = [ "of Doom", "of Dancing", "of Flowers", "of Blood" ]
        self.name_bases = [ "Room" ]

        self.name_prefix_chance = 25
        self.name_suffix_chance = 25
        
    def give_name(self):
        n = ""
        if chance(self.name_prefix_chance):
            n += rand_element(self.name_prefixes) + " "
        n += rand_element(self.name_bases)
        if chance(self.name_suffix_chance):
            n += " " + rand_element(self.name_suffixes)
        self.name = n

    def gen(self):
        self.give_name()

class SimpleRoom(Room):
    def gen(self, x, y, val):
        Room.gen(self)
        self.geometry = self.space(x, y, val).geometry

class SimpleAdditiveRoom(Room):
    def gen(self, min_x, max_x, min_y, max_y, val):
        Room.gen(self)

        # Get section 1 dimensions
        r1_dim_x = random.randint(min_x, max_x)
        r1_dim_y = random.randint(min_y, max_y)

        # Section 2 dimensions
        r2_dim_x = random.randint(min_x, max_x)
        r2_dim_y = random.randint(min_y, max_y)

        # Random overlapping points
        r1p = rand_block(r1_dim_x, r1_dim_y)
        r2p = rand_block(r2_dim_x, r2_dim_y)

        r1 = self.space(r1_dim_x, r1_dim_y, val)
        r2 = self.space(r2_dim_x, r2_dim_y, val)

        self.geometry = r1.geometry
        self.add_space(r2, r1p, r2p)

class Hallway(Space):
    def gen(self, x, y, val):
        Space.gen(self)
        self.geometry = self.space(x, y, val).geometry

if __name__ == "__main__":
    root = SimpleAdditiveRoom()
    root.gen(3, 10, 3, 10, ord('A'))

    val = ord('B')

    while root.area() < 2000:
        sr2 = SimpleAdditiveRoom()
        sr2.gen(3, 10, 3, 10, val)

        c1 = rand_element(root.connectable_coords())

        m = [ 2, 3, 0, 1 ]  
        c2 = rand_element([ x for x in sr2.connectable_coords() if x[2] == m[c1[2]]])

        hall = Hallway()

        if c1[2] == 0:
            hall.gen(1, 3, ord('+'))
            hall.add_space(root, hall.point(0,2), c1[:2])
            r = hall.add_space(sr2, hall.point(0,0), c2[:2], chicken = True)
        elif c1[2] == 2:
            hall.gen(1, 3, ord('+'))
            hall.add_space(root, hall.point(0,0), c1[:2])
            r = hall.add_space(sr2, hall.point(0,2), c2[:2], chicken = True)
        elif c1[2] == 1:
            hall.gen(3, 1, ord('+'))
            hall.add_space(root, hall.point(0,0), c1[:2])
            r = hall.add_space(sr2, hall.point(2,0), c2[:2], chicken = True)
        elif c1[2] == 3:
            hall.gen(3, 1, ord('+'))
            hall.add_space(root, hall.point(2,0), c1[:2])
            r = hall.add_space(sr2, hall.point(0,0), c2[:2], chicken = True)

        if r:
            root = hall
            val += 1

    root.print_geom()

[/expand]

I got a little lazy with the execution of __main__. There's a cleaner way to deal with matching up the direction end-points (hell, just take a random one and rotate the entire room to match, really) but for an evening's playing around I think the results are actually pretty nice.

[expand title="Expand Example"]



        ]]]]]]                                                           
        ]]]]]]                                                           
        ]]]]]]]]]]]                                                      
        ]]]]]]]]]]]                                                      
        ]]]]]]]]]]]                                                      
        ]]]]]]]]]]]                                                      
        ]]]]]]]]]]]                                                      
        ]]]]]]]]]]]          UUUUUUUU                                    
        ]]]]]]               UUUUUUUU                                    
          +                UUUUUUUUUU                                    
         ZZZZ              UUUUUUUUUU                                    
         ZZZZ              UUUUUUUUUU                                    
         ZZZZ              UUUUUUUUUU                                    
         ZZZZ                UUUUUUUU MMMMMMMMM                          
         ZZZZZZZZ            UUUUUUUU+MMMMMMMMM                          
         ZZZZZZZZ            UUUUUUUU MMMMMMMMM                          
         ZZZZZZZZ            UUUUUUUU MMMMMMMMM                          
         ZZZZZZZZ                        MMMMMM                          
         ZZZZZZZZ                        MMMMMM                          
         ZZZZZZZZ                JJJJJJJ MMMMMM                          
         ZZZZZZZZ                JJJJJJJ MMMMMM                          
            +                    JJJJJJJ  +                              
          WWWWWWW                JJJJJJJJJJJJJJ                          
          WWWWWWWWWW NNNNNN      JJJJJJJJJJJJJJ                          
          WWWWWWWWWW NNNNNN      JJJJJJJJJJJJJJ                          
          WWWWWWWWWW+NNNNNN      JJJJJJJJJJJJJJ                          
          WWWWWWWWWW NNNNNN       +                                      
          WWWWWWWWWW NNNNNNBBBBBBBB                                      
          WWWWWWWWWW NNNNNNBBBBBBBBBB                                    
          WWWWWWWWWW NNNNNNBBBBBBBBBB                                    
             WWWWWWW  NNNN BBBBBBBBBB                                    
             WWWWWWW  NNNN BBBBBBBBBB                                    
            OOOOOOOOOONNNN       BBBB                                    
            OOOOOOOOOO   +       BBBB                                    
            OOOOOOOOOO FFFFFFF   BBBB                                    
            OOOOOOOOOO FFFFFFF   BBBB                                    
            OOOOOOOOOO FFFFFFF    +                                      
            OOOOOOOOOO FFFFFFF AAAAAAAAAAA                               
            OOOOOOOOOO FFFFFFF+AAAAAAAAAAA                               
            OOOOOOOOOO FFFFFFF AAAAAAAAAAA                               
            OOOOOOOOOO FFFFFFF AAAAA                                     
            OOOOOOOOOO+FFFFFFF AAAAA          PPPPPPPPP                  
             OOOOO     FFFFFFF AAAAA          PPPPPPPPP                  
             OOOOO LLL FFFFFFF AAAAA          PPPPPPPPP TTTT             
                   LLL +         +            PPPPPPPPP TTTT             
                  LLLLLLL        CCCCC        PPPPPPPPP+TTTT             
                  LLLLLLL        CCCCC        PPPP   +  TTTTT            
                  LLLLLLL        CCCCC      HHHHHHHHHH  TTTTT            
     [[[[[[[[[                   CCCCCCCCCC HHHHHHHHHH  TTTTT  XXXXXXXXXX
     [[[[[[[[[                   CCCCCCCCCC HHHHHHHHHHHHTTTTT XXXXXXXXXXX
     [[[[[[[[[ GGGGGGGGGG        CCCCCCCCCC HHHHHHHHHHHHTTTTT+XXXXXXXXXXX
     [[[[[[[[[ GGGGGGGGGG  EEEEE+CCCCCCCCCC HHHHHHHHHHHH      XXXXXXXXXX 
     [[[[[[[[[ GGGGGGGGGG  EEEEE CCCCCCCCCC+HHHHHHHHHH        XXXXXXXXXX 
     [[[[[[[[[ GGGGGGGGGG EEEEEE CCCCCCCCCC                   XXXXXXXXXX 
     [[[[[[[[[+GGGGGGGGGG EEEEEE CCCCCCCCCC                   XXXXXXXXXX 
   [[[[[[[[[[[ GGGGGGGGGG EEEEEE     +                        XXXXXXXXXX 
   [[[[[[[[[[[ GGGGGGGGGG EEE       DDD                                  
   [[[[[[[[[[[ GGGGGGGGGG+EEE       DDD                                  
   [[[[[[[[    GGGGGGGGGG EEE   III DDD                                  
   [[[[[[[[    GGGGGGGGGG EEE  IIII DDD                                  
   [[[[[[[[         +     EEE  IIII DDD                                  
   [[[[[[[[         KKK   EEE  IIII DDD                                  
                    KKK        IIII DDD                                  
                    KKK        IIII+DDDQQQQ                              
                    KKK        IIII QQQQQQQQQQ                           
                 KKKKKKK       IIII+QQQQQQQQQQ                           
                 KKKKKKK       IIII QQQQQQQQQQ                           
                 KKKKKKK       IIII QQQQQQQQQQ                           
                 KKKKKKK                    +                            
                 KKKKKKK                    SSS                          
\\\\\\\\\        KKKKKKK                SSSSSSSS                         
\\\\\\\\\        KKKKKKK                SSSSSSSS                         
\\\\\\\\\        KKKKKKK                SSSSSSSS                         
\\\\\\\\\        KKKKKKK                SSSSSSSS                         
\\\\\\\\\\\\\\   KKKKKKK                SSSSSSSS                         
\\\\\\\\\\\\\\     +                    SSSSSSSS                         
\\\\\\\\\\\\\\RRRRRRRRR                 SSSSSSSS                         
      \\\\\\\\RRRRRRRRR                     SSS                          
      \\\\\\\\RRRRRRRRR                                                  
      \\\\\\\\RRRRRRRRR                                                  
         +    RRRRRRRRR                                                  
        VVVVV RRRRRRRRR                                                  
      VVVVVVV RRRRRRRRR                                                  
      VVVVVVV+RRRRRRRRR                                                  
      VVVVVVV RRRRRRRRR                                                  
            + RRRRRRRRR                                                  
          YYYYY                                                          
          YYYYY                                                          
          YYYYY                                                          
          YYYYY                                                          
     YYYYYYYYYY                                                          
     YYYYYYYYYY                                                          
     YYYYYYYYYY                                                          
     YYYYYYYYYY                                               

[/expand]

Not many long tunnels, and a large number of rooms (which are indicated by different letters. Doorways are +s. There are still, of course, some places the geometry doesn't make sense. Usually when two joined rooms have a doorway and also have adjacent open blocks (like P and H in the above). However, all in all, not bad for an evening's screwing around.