loading minimap...

..

Tile-based dungeon generation

This is a project I made about a year ago. It is programmed in Python with the PIL module to generate the images. Some of the code is pretty bad, but the results look cool so I wanted to document it a bit.

It is kind of similar to the Wave Function Collapse algorithm in the way certain tiles can only be placed next to other certain tiles. I didn’t know about WFC before programming this, so I came up with the algorithm that is used here (while it may probably already exist).

Simplified code and algorithm

spawntile(tile, position) is a recursive function that spawns other tiles. For each connexion of the spawned tile, it tries to check if a tile fits after the connexion. For this, it chooses a random tile from the tiles list and checks if it works, until it finds one. If it doesn’t find any fitting tile after randomTries tries, it gives up. This method of iterating over the spawned tile’s connexions implies that when we spawn a tile with two connexions available, it will create new tiles starting from one of the two connexions first, then proceed with the second connexion.

dungeon = matrix of empty lists

def checkTileFit(tile, basePosition, connexion):
    if (there isnt already a tile where we want to spawn the tile):
        tilesCopy = tiles.copy()
        for _ in range(randomTries): # try to get a matching tile
            candidateTile = random tile from tilesList
            remove candidateTile from tilesList
            if (connexions match between the base tile and the candidate tile):
                spawnTile(candidateTile, getRelativeTilePosition(tile, connexion))

def spawnTile(tile, position):
    dungeon[position.x][position.y] = tile
    drawTileOnImage(position, tile.image)
    for connexion in tile.connexions:
        checkTileFit(tile, position, connexion)

This is a very simplified version of the algorithm but explaining it in the very details wouldn’t be necessary.

Maze of tiles

Heres are the basic tiles I first made: the yellow part is a wall, and the black part is “empty”. Instead of drawing every possible rotation of each tile by hand, I rotated them in the code using PIL.

Each tile is represented as a class having two members: tile.image and tile.connexions, the latter being a list that can contain the strings "left", "right", "up" and "down". For sure this could have been optimized with another structure, like a bitset, but this was only to test things out.

A list of every possible tile (including their rotations, which count as separate tiles) is kept as a tiles list. Some tiles can be added multiple times to tiles to give them more probability to be chosen randomly.

First working iterations gave these images:

added a checkerboard
more tiles

I later added these tiles to the tileset:

lots of variation
larger generation

I finally added the last tiles to the tileset:

The “empty” one has four connexions, and therefore helps to generate sort of large “room” spaces. I also quickly wrote a function to generate multiple images in a row, so from now on a random color tint is applied to the tiles for variation.

little rooms
not a hedge maze

I played a bit with the fact that you can add the same tile multiple times to the tiles list. Adding more “empty” tiles makes it so you can increase the size of the “rooms”. But this is very uncontrollable, and I still didn’t find a way to properly detect certain tiles as being part of a room.

Adding more “dead end” tiles make the mazes end sooner.

a bit larger rooms
everything ends in a dead end

Some of them turned out very small! These were due to random.

very small
it reminds me of a bar room

So I tried adding more “dead end” tiles… Which resulted in somewhat smaller mazes. The size could have been more controllable, for example by checking if a maze is growing out of a bounding box and replacing every tile by a closing tile. This wasn’t implemented yet though.

And even smaller ones:

Algorithm visualization with FFMPEG

I decided to generate videos to show the steps of the algorithm. I changed the Python script to save the image in a separate "steps" folder every time a new tile is drawn. It also accepts arguments to change the grid size, and the ratio of “dead end” and “empty” tiles.

Then, I wrote a short Bash script to generate a video compiling all the images. The examples shown below have been converted to GIFs.

#!/usr/bin/env bash
# gensteps.sh name gridfactor endRatio emptyRatio fps

python3 'dungen steps.py' $2 $3 $4

BACK_PID=$!
wait $BACK_PID      # waits until the most recent process has ended

ffmpeg -framerate $5 -i "steps/%04d.png" outputs/output$1.mp4

BACK_PID=$!
wait $BACK_PID

xdg-open outputs/output$1.mp4

On a larger scale, it’s interesting to see all the directions the path takes before finally coming back to the original tile to start from another connexion.

Implementation in Unity

I later tried to implement this algorithm in the Unity game engine. The tiles are a bit different.

Looking a bit closer, you can see this one generated small rooms, and not the typical maze we had in the Python version. This could have been interesting as a starting point to then “dig” holes in the walls to interconnect them, but I have absolutely no idea of how to go back to this version…

Here’s a generated maze, obtained after fixing the algorithm.

The last thing I wanted to add was perspective. Or kind of.

Looking back I think this way of generating mazes would be more fitting for a metroidvania platforming type of game. Because for a top-down roguelike game, the space between the walls is either too small or too large, depending on the player size.

very small one

Bonus: limiting the tileset

By only using certain tiles, we get weird results… This one was made only using “column” tiles.