Procedurally Generating Dungeon Geomorph Tiles in Godot cover image

Procedurally Generating Dungeon Geomorph Tiles in Godot


0
Like

There are many ways to create procedural dungeon layouts. Binary Space Partitioning (BSP), Cellular automata, Random Room placement to name a few.

I have been experimenting with the idea of procedurally generating dungeon geomorphs which can then be placed together like puzzle pieces to form a dungeon. This idea is often used in physical table top dungeon games or RPGs.

dungeon crawl

A full set of corners, intersections and dead ends need to be created to form a full geomorph tile set. A total of 16 tiles. While these can be created as 2 dimensional arrays it quickly becomes tedious when creating very large tiles.

My strategy is to first procedurally create tile border templates. These represent areas where a wall must be placed. I also create a path template, this one represents where a wall must not be placed.

These two templates ensure that there will be no passages leading into oblivion, or walls that block the path through the dungeon.

Once these are created, the remaining blocks can be procedurally filled without causing unusable tiles.

Creating the wall template

I am using Godot and GdScript but the idea can be used in any language or game engine.

A custom Godot resource is used to represent each tile. The properties width and height are exported to the editor.

@export var width := 6
@export var height := 6
@export var bitmask := 15

A property called bitmask is also exported. I wont go into detail regarding bitmasks in this post (maybe later), but at a high level each number from 0 through to 15 represents one of the tiles in the geomorph set. If the bitmask is set to 1 the resulting binary is 0001 that means there is no wall at the top. If bitmask is set to 2 with a binary of 0010 that means there is no wall to the left. Bitmask 3 which in binary is 0011, means that there is no wall to the top or to the left.

bitmask 1
0001
bitmask 2
0010
bitmask 3
0011
bitmask 4
0100
bitmask 5
0101
bitmask 6
0110
bitmask 7
0111
bitmask 8
1000
bitmask 9
1001
bitmask 10
1010
bitmask 11
1011
bitmask 12
1100
bitmask 13
1101
bitmask 14
1110
bitmask 15
1111

If you are interested in reading more about bitmasks and tiles I recommend this post

Now that the size of the tile and its bitmask are set, we can start to generate the border. The tile and its border are represented as a 2 dimensional array of 1 and 0. 1 means wall 0 means no wall

To start create a function that generates a border on all sides:

func create_mask_0_array() -> Array[Array]:
    var matrix : Array[Array] = []
    for y in range(height):
        var row := []
        for x in range(width):
            var value = 0
            if y == 0 or y == height-1:
                value = 1 
            if x == 0 or x == width-1:
                value = 1
            row.append(value)
        matrix.append(row)
    return matrix

The result should be as follows:

[1, 1, 1, 1, 1, 1]
[1, 0, 0, 0, 0, 1]
[1, 0, 0, 0, 0, 1]
[1, 0, 0, 0, 0, 1]
[1, 0, 0, 0, 0, 1]
[1, 1, 1, 1, 1, 1]
All walls
All walls

Now loop through this two dimensional array, and depending on what bitmask is set, clear the appropriate walls:

func create_section_border() -> Array[Array]:
    var matrix: Array[Array] = create_mask_0_array()

    for y in range(height):
        for x in range(width):
            var value := 1
            # -Z boundary (NORTH) the 1 bit is set (0001)
            if y == 0 and x > 0 and x < width-1 and bitmask & 1:
                matrix[y][x] = 0
            # +Z boundary (SOUTH) the 8 bit is set (1000)
            if y == height-1 and x > 0 and x < width-1 and bitmask & 8:
                matrix[y][x] = 0
            # -X boundary (WEST) the 2 bit is set (0010)
            if x == 0 and y > 0 and y < height-1 and bitmask & 2:
                matrix[y][x] = 0
            # +X boundary (EAST) the 4 bit is set (0100)
            if x == width-1 and y > 0 and y < height-1 and bitmask & 4:
                matrix[y][x] = 0

    return matrix

The result is as follows when the bitmask is set to 5

[1, 0, 0, 0, 0, 1]
[1, 0, 0, 0, 0, 0]
[1, 0, 0, 0, 0, 0]
[1, 0, 0, 0, 0, 0]
[1, 0, 0, 0, 0, 0]
[1, 1, 1, 1, 1, 1]

And we have a border template.

walls
wall template

Creating the path template

Similar to how the border template is created, a path template can be created. This time the value is set to 1 if is in the centre based on the bitmask. This one is a bit more complicated as the width and height of the tile needs to be checked. If it is an odd number only a single width path is cleared. But if the width or height is even a double width path needs to be created in order to keep it centre aligned.

func create_path_mask() -> Array[Array]:
    #generate a matrix with all values set to 0
    var matrix : Array[Array] = []
    for y in range(height):
        var row := []
        for x in range(width):
            row.append(val)
        matrix.append(row)

    for y in range(height):
        for x in range(width):
            if width % 2 == 0: # evens add double center
                if bitmask & 1 and x == width / 2 and y <= height / 2:
                    matrix[y][x-1] = 1
                    matrix[y][x] = 1
                if bitmask & 2 and y == height / 2 and x <= width / 2:
                    matrix[y-1][x] = 1
                    matrix[y][x] = 1
                if bitmask & 4 and y == height / 2 and x >= width / 2 and x < width:
                    matrix[y-1][x] = 1
                    matrix[y][x] = 1
                if bitmask & 8 and x == width / 2 and y >= height / 2 and y < height:
                    matrix[y][x-1] = 1
                    matrix[y][x] = 1
            else: # odd add single center
                if bitmask & 1 and x == ceil(width / 2) and y <= ceil(height / 2):
                    matrix[y][x] = 1
                if bitmask & 2 and y == ceil(height / 2) and x <= ceil(width / 2):
                    matrix[y][x] = 1
                if bitmask & 4 and y == ceil(height / 2) and x >= ceil(width / 2) and x < width:
                    matrix[y][x] = 1
                if bitmask & 8 and x == ceil(width / 2) and y >= ceil(height / 2) and y < height:
                    matrix[y][x] = 1
    return  matrix

If the bitmask is 5, the result will be the following, note how because there is a width and height of 6. The path has a width of 2:

[0, 0, 1, 1, 0, 0]
[0, 0, 1, 1, 0, 0]
[0, 0, 1, 1, 1, 1]
[0, 0, 1, 1, 1, 1]
[0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 0, 0]
Path mask
Note: for illustration purposes this image is the reverse

Randomly filling the geomorph tile

Now that the walls and non blocking areas are defined, the fun bit begins! Procedurally filling in the rest of the geomorph tile with an algorithm (or multiple) of choice. Below is a simple example using random placement.

func random_build() -> Array[Array]:
      # create the walls and mask of non block-able areas
    var build_mask = create_path_mask()
    var base = create_section_border()

    var placed = 0
    var rng = RandomNumberGenerator.new()

    # only fill in a maximum quarter of the geomorph
    var max_placements = width * height / 4

    # Just in case we create an infinite loop!
    var emergency_stop = max_placements * 5
    var current_iter = 0
    while placed < max_placements:
        if current_iter >= emergency_stop:
            break
        current_iter += 1

        # find a random place to put a wall
        var y = rng.randi_range(0, base.size()-1)
        var x = rng.randi_range(0, base[0].size()-1)

        # this space must be a wall!
        if base[y][x] == 1: continue

        # this space must be a path
        if build_mask[y][x] == 1: 
            base[y][x] = 0
            continue

        base[y][x] = 1
        placed += 1
    return  base

And a procedural geomorph tile is born!

[1, 1, 0, 0, 1, 1]
[1, 1, 0, 0, 1, 0]
[1, 1, 0, 0, 0, 0]
[1, 0, 0, 0, 0, 0]
[1, 1, 1, 0, 1, 0]
[1, 1, 1, 1, 1, 1]
finished geomorph
finished geomorph

Here is an example putting multiple geomorphs together to create a world map

map





Enjoying my website? You can help keep the adventure going!