Procedurally Generating a Dungeon
Intro
Today I will to run you through creating procedurally generated layouts of a maps of rooms with up to 4 exits that link to other rooms. That's quite a lot to read everytime, and so throughout this blog post I will refer to it as a "dungeon".
I refer to what we're generating as a dungeon because that's what I had in mind when I was developing this algorithm. It doesn't have to be a dungeon, it could easily be house, a maze or anything else that consists of nodes and up to four links.
When creating this I had in mind something that I could use to procedurally generate the layout of levels in games. This would save me time during the development process, could enhance replayablility and lead me to fine tuning or improving other elements of a game's design.
There could be other uses for this. Though, to be clear, I have written from the perspective of creating a dungeon for players to traverse.
This algorithm generates a 2D-array of nodes or "rooms" that can have up to four exits: up, down, left, right. These rooms then connect to another adjacent room in the direction of the exit.
I will now run you through the process of creating this dungeon, using pseudocode.
Pseudocode
I am choosing to use pseudocode as there are many different programming languages or engines you could use this algorithm in. Having a more generic pseudocode algorithm could help you understand this algorithm at a high level.
The low-level details are not important for you to understand this algorithm and they actually consist of a fair amount of code. The implementation may be quite different depending on where you are implementing this algorithm.
var target = number;
var dungeon = 2D target by target array.
var room_count = 0
var room_to_process_list = [];
var previously_processed_room = [];
var current_coordinates = {x: 0, y: 0};
while room_count < target:
coords_to_process = rooms_to_process_list.pop()
IF coords_to_process IS NOT NULL:
current_coordinates = coords_to_process
var room;
var reprocessing_room = false;
WHILE coords_to_process does not have valid addable exits:
coords_to_process = reprocess previously_processed_rooms.pop();
reprocessing_room = true;
IF coords_to_process has a valid addable exit:
room = reprocess_room(coords_to_process):
randomly generate new valid UP,DOWN,LEFT,RIGHT exits and retain previous exits
IF reprocessing_room == false:
room = generate new_room(coords_to_process):
randomly generate UP,DOWN,LEFT,RIGHT exits
add room to dungeon at the current_coordinates within the dungeon array.
IF current_coordinates not in previously_processed_rooms:
add room coordinates to previously_processed_rooms;
connect room entrances
remove dead end exits
return dungeon
What this does is it builds a target room count by target room count 2D array, and then whilst enough rooms have not been created it loops through generating rooms, which have up, down, left or right exits.
A key thing to understand in this algorithm, is the concept of a "valid" exit. A valid exit is an exit which does not exist and does not yet have a node associated with it. Let's list three examples to explain this concept:
- If I have a room (0,0) with no room to the right and I want to generate an exit to the right then that would be a valid exit.
- If I have room (0,0) next to another room (0,1) and I generated a right exit for (0,0), that would be an invalid exit.
- If I have a room (0,0) with no room to the right and I want to generate an exit to the right - but I already have a right exit on my room - that would be an invalid exit.
On each run of the loop, we generate a room. When a room is generated
a list of non-processed adjacent rooms, i.e nodes that now have links
to the newly created room, are added to the
rooms_to_process_list. The next room we process will be a
room popped off of this list.
In each iteration of the loop we pop an item off of the
rooms_to_process_list. When we pop from the front we do a
breadth-first generation and the dungeon tends to fan out, due to us
processing rooms that are adjacent to earlier generated rooms first.
When we pop from the back we do a depth-first generation, as we
process the rooms adjacent to the most recently processed rooms first,
which leads to more long chains of rooms.
On occasion, we will end up in a situation where we try and generate a room but the coordinates we are trying to generate a room for have no addable valid exits. In this scenario, we back-track to a previously processed room that has a valid addable exit and then reprocess that room. Reprocessing is where we add a new valid exit to the room and keep the previous exits.
We continue this process until we have our specified number of rooms.
Once we have our target number of rooms, we knock through the exits. That is, for each exit in each room we ensure that the adjacent room in the direction of the exit has an exit in the opposing direction. For example, if we have a room with a right exit then the room to the right will have a left exit.
We also remove dead ends. Dead ends are exits in a room that do not lead to another room. These exist because we hit the target number of rooms before processing the room with a dead end.
Closing thoughts
Many procedural dungeon generators create corridors or irregular shaped rooms. This algorithm does not handle the creation of corridors or the shapes of the rooms. This algorithm only deals with the rudimentary layout: the coordinates of a room and the adjacent rooms that a room links to. This algorithm is used for the layout but could be plugged into a pipeline that generates corridors or individual room layouts.
This is early in my adventure into procedural generation. It is my second published algorithm for procedural generation. My first - my maze generation npm package - is similar in that it used a 2D array to generate a layout using an up, down, left, right structure for nodes. I would advise looking into my maze generator for inspiration if you were to implement this dungeon generation algorithm.