Advertisement

Create a Procedurally Generated Dungeon Cave System

by
Student iconAre you a student? Get a yearly Tuts+ subscription for $45 →

For many, procedural generation is a magical concept that is just out of reach. Only veteran game developers know how to build a game that can create its own levels... right? It may seem like magic, but PCG (procedural content generation) can be learned by beginner game developers. In this tutorial, I'll show you how to procedurally generate a dungeon cave system.


What We're Going to Cover

Here's a SWF demo that shows the kind of level layouts that this technique can generate:


Click the SWF to generate a new level.

Learning the basics usually means a lot of Google searching and experimentation. The problem is, there are very few simple guides on how to get started. For reference, here are some excellent sources of information on the subject, which I have studied:

Before getting into the details, it's a good idea to consider how we're going to solve the problem. Here are some easy-to-digest chunks that we will use to keep this thing simple:

  1. Randomly place your created content into the game world.
  2. Check that the content is placed in a spot that makes sense.
  3. Check that your content is reachable by the player.
  4. Repeat these steps until your level comes together nicely.

Once we've worked through the following examples, you should have the necessary skills to experiment with PCG in your own games. Exciting, eh?


Where Do We Place Our Game Content?

The first thing we are going to do is randomly place the rooms of a procedurally generated dungeon level.

In order to follow along, it's a good idea to have a basic understanding of how tile maps work. In case you need a quick overview or a refresher, check out this tile map tutorial. (It is geared towards Flash but, even if you aren't familiar with Flash, it's still good for getting the gist of tile maps.)

Creating a Room to Be Placed in Your Dungeon Level

Before we get started we have to fill our tile map with wall tiles. All you need to do is iterate through every spot in your map (a 2D array, ideally) and place the tile.

We also need to convert the pixel coordinates of each rectangle to our grid coordinates. If you want to go from pixels to grid location, divide the pixel coordinate by the tile width. To go from grid to pixels, multiple the grid coordinate by the tile width.

For example, if we want to place our room's top left corner at (5, 8) on our grid and we have a tile width of 8 pixels, we would need to place that corner at (5 * 8, 8 * 8) or (40, 64) in pixel coordinates.

Let's create a Room class; it might look like this in Haxe code:

class Room extends Sprite {
	// these values hold grid coordinates for each corner of the room
	public var x1:Int;
	public var x2:Int;
	public var y1:Int;
	public var y2:Int;

	// width and height of room in terms of grid
	public var w:Int;
	public var h:Int;

	// center point of the room
	public var center:Point;

	// constructor for creating new rooms
	public function new(x:Int, y:Int, w:Int, h:Int) {
		super();

		x1 = x;
		x2 = x + w;
		y1 = y;
		y2 = y + h;
		this.x = x * Main.TILE_WIDTH;
		this.y = y * Main.TILE_HEIGHT;
		this.w = w;
		this.h = h;
		center = new Point(Math.floor((x1 + x2) / 2),
			Math.floor((y1 + y2) / 2));
	}

	// return true if this room intersects provided room
	public function intersects(room:Room):Bool {
		return (x1 <= room.x2 && x2 >= room.x1 &&
			y1 <= room.y2 && room.y2 >= room.y1);
	}
}

We have values for each room's width, height, center point position, and four corners' positions, and a function that tells us whether this room intersects another one. Also note that everything except for the x and y values are in our grid coordinate system. This is because it makes life much easier to use small numbers each time we access the room values.

Okay, we have the framework for a room in place. Now how do we procedurally generate and place a room? Well, thanks to built-in random number generators, this part isn't too difficult.

All we have to do is provide random x and y values for our room within the bounds of the map, and give random width and height values within a predetermined range.

procedural-content-room-placement

Does Our Random Placement Make Sense?

Since we are using random locations and dimensions for our rooms, we are bound to overlap with previously created rooms as we fill our dungeon. Well, we already coded up a simple intersects() method in order to help us take care of the problem.

Each time we attempt to place a new room, we simply call intersects() on each pair of rooms within the entire list. This function returns a Boolean value: true if the rooms are overlapping, and false otherwise. We can use that value to decide what to do with the room we just attempted to place.

procedural-content-intersection-diagram
Check back at the intersects() function. You can see how the x and y values overlap and return true.
	private function placeRooms() {
		// create array for room storage for easy access
		rooms = new Array();

		// randomize values for each room
		for (r in 0...maxRooms) {
			var w = minRoomSize + Std.random(maxRoomSize - minRoomSize + 1);
			var h = minRoomSize + Std.random(maxRoomSize - minRoomSize + 1);
			var x = Std.random(MAP_WIDTH - w - 1) + 1;
			var y = Std.random(MAP_HEIGHT - h - 1) + 1;

			// create room with randomized values
			var newRoom = new Room(x, y, w, h);

			var failed = false;
			for (otherRoom in rooms) {
				if (newRoom.intersects(otherRoom)) {
					failed = true;
					break;
				}
			}
			if (!failed) {
				// local function to carve out new room
				createRoom(newRoom);

				// push new room into rooms array
				rooms.push(newRoom)
			}
		}
	}

The key here is the failed Boolean; it's set to the return value of intersects(), and so is true if (and only if) your rooms are overlapping. Once we break out of the loop, we check this failed variable and, if it's false, we can carve out the new room. Otherwise, we just discard the room and try again until we've hit our maximum number of rooms.


How Should We Handle Unreachable Content?

The vast majority of games that use procedurally generated content strive to make all of that content reachable by the player, but there are a few people out there who believe that this isn't necessarily the best design decision. What if you had some rooms in your dungeon that the player could only rarely get to but could always see? This might add an interesting dynamic to your dungeon.

Of course, no matter which side of the argument you are on, it's probably still a good idea to make sure that the player can always progress through the game. It would be pretty frustrating if you got to a level of the game's dungeon and the exit was completely blocked off.

Considering that most games shoot for 100% reachable content, we'll stick with that.

Let's Handle That Reachability

By now, you should have a tile map up and running and there should be code in place to create a variable number of rooms of varying sizes. Look at that; you already have some clever procedurally generated dungeon rooms!

Now the goal is to connect each room so that we can walk through our dungeon and eventually reach an exit that leads to the next level. We can accomplish this by carving out corridors between the rooms.

We will need to add a point variable to the code to keep track of the center of each room created. Whenever we create and place a room, we determine its center and connect it to the previous room's center.

First, we'll implement the corridors:

private function hCorridor(x1:Int, x2:Int, y) {
		for (x in Std.int(Math.min(x1, x2))...Std.int(Math.max(x1, x2)) + 1) {
			// destory the tiles to "carve" out corridor
			map[x][y].parent.removeChild(map[x][y]);

			// place a new unblocked tile
			map[x][y] = new Tile(Tile.DARK_GROUND, false, false);

			// add tile as a new game object
			addChild(map[x][y]);

			// set the location of the tile appropriately
			map[x][y].setLoc(x, y);
		}
	}

	// create vertical corridor to connect rooms
	private function vCorridor(y1:Int, y2:Int, x) {
		for (y in Std.int(Math.min(y1, y2))...Std.int(Math.max(y1, y2)) + 1) {
			// destroy the tiles to "carve" out corridor
			map[x][y].parent.removeChild(map[x][y]);

			// place a new unblocked tile
			map[x][y] = new Tile(Tile.DARK_GROUND, false, false);

			// add tile as a new game object
			addChild(map[x][y]);

			// set the location of the tile appropriately
			map[x][y].setLoc(x, y);
		}
	}

These functions act in nearly the same way, but one carves out horizontally and the other vertically.

procedural-content-corridor-diagram-1

Connecting the first room to the second room requires a vCorridor and an hCorridor.

We need three values in order to do this. For horizontal corridors we need the starting x value, the ending x value, and the current y value. For vertical corridors we need the starting and ending y values along with the current x value.

Since we are moving from left to right we need the two corresponding x values, but only one y value since we won't be moving up or down. When we move vertically we will need the y values. In the for loop at the beginning of each function, we iterate from the starting value (x or y) to the ending value until we have carved out the entire corridor.

Now that we have the corridor code in place, we can change our placeRooms() function and call our new corridor functions:

	private function placeRooms() {
	// store rooms in an array for easy access
	rooms = new Array();

	// variable for tracking center of each room
	var newCenter = null;

	// randomize values for each room
	for (r in 0...maxRooms) {
		var w = minRoomSize + Std.random(maxRoomSize - minRoomSize + 1);
		var h = minRoomSize + Std.random(maxRoomSize - minRoomSize + 1);
		var x = Std.random(MAP_WIDTH - w - 1) + 1;
		var y = Std.random(MAP_HEIGHT - h - 1) + 1;

		// create room with randomized values
		var newRoom = new Room(x, y, w, h);

		var failed = false;
		for (otherRoom in rooms) {
			if (newRoom.intersects(otherRoom)) {
				failed = true;
				break;
			}
		}
		if (!failed) {
			// local function to carve out new room
			createRoom(newRoom);

			// store center for new room
			newCenter = newRoom.center;

			if(rooms.length != 0){
				// store center of previous room
				var prevCenter = rooms[rooms.length - 1].center;

				// carve out corridors between rooms based on centers
				// randomly start with horizontal or vertical corridors
				if (Std.random(2) == 1) {
					hCorridor(Std.int(prevCenter.x), Std.int(newCenter.x),
						Std.int(prevCenter.y));
					vCorridor(Std.int(prevCenter.y), Std.int(newCenter.y),
						Std.int(newCenter.x));
				} else {
					vCorridor(Std.int(prevCenter.y), Std.int(newCenter.y),
						Std.int(prevCenter.x));
					hCorridor(Std.int(prevCenter.x), Std.int(newCenter.x),
						Std.int(newCenter.y));
					}
				}
			}
		if(!failed) rooms.push(newRoom);
		}
	}
procedural-content-corridor-diagram-2

In the above image, you can follow the corridor creation from the first to the fourth room: red, green, then blue. You can get some interesting results depending on the placement of the rooms - for instance, two corridors next to each other make a double-wide corridor.

We added some variables for tracking the center of each room and we attached the rooms with corridors between their centers. Now there are multiple non-overlapping rooms and corridors that keep the entire dungeon level connected. Not bad.

procedural-content-room-connection

We're Done With Our Dungeon, Right?

You've come a long way building your first procedurally generated dungeon level, and I'm hoping you've realized that PCG isn't some magical beast that you will never have a chance to slay.

We went over how to randomly place content around your dungeon level with simple random number generators, and a few predetermined ranges for keeping your content the right size and roughly in the right place. Next, we discovered a very simple way to determine if your random placement made sense by checking for overlapping rooms. Lastly, we talked a little bit about the merits of keeping your content reachable and we found a way to ensure that your player can reach every room in your dungeon.

The first three steps of our four step process are finished, which means that you have the building blocks of a great dungeon for your next game. The final step is down to you: you must iterate over what you learned to create more procedurally generated content for endless replayability.

There Is Always More to Learn

The method for carving out simple dungeon levels in this tutorial only scratches the surface of PCG and there are some other simple algorithms that you can easily pick up.

My challenge for you is to start experimenting with the beginnings of your game that you created here and to do some research into more methods to change up your dungeons.

One great method for creating cave levels is using cellular automata, which has infinite possibilities for customizing dungeon levels. Another great method to learn is Binary Space Partitioning (BSP), which creates some wicked looking gridlike dungeon levels.

I hope this gave you a good jump start into procedural content generation. Make sure to comment below with any questions you have, and I'd love to see some examples of what you are creating with PCG.

Advertisement