Procedural Generation in Godot - Part 6: Dungeons

Tags: godot gamedev tutorial procgen

In this series, we’ll explore the applications of procedural generation to game development. While we’ll be using Godot 3.0 as our platform, much of the concepts and algorithms related to this subject are universal, and you can apply them to whatever platform you may be working on.

In this part, we’ll look at a technique for generating random dungeon maps.

You can watch a video version of this lesson here:

Introduction

For this demo, we want to randomly generate a dungeon - a series of rooms connected by corridors - that the player can explore.

There are many different ways to approach this. Some popular methods use maze generation techniques like we have discussed previously. However, for this demo, I wanted to try a different approach. In this algorithm, we take advantage of Godot’s built-in physics engine to create the map.

Broadly speaking, we’ll break this up into three steps:

  1. Generate the rooms
  2. Find a “path” connecting the rooms
  3. Create a TileMap to use in game

You can download the starting project which contains the minimal art assets we’ll use for this demo.

Generating Rooms

Because we’re going to use the physics engine to generate the basic layout of the dungeon, we’ll need to do a little bit of setup first. Let’s start by defining what we mean by a “room”.

Room object

A “room” in this context is a rectangular space, typically large enough for the player to walk around in and/or contain objects of interest like treasures or mobs. We’ll use a RigidBody2D to represent a room (remember, we’re going to be using physics here). Create a new scene with a RigidBody2D named “Room” and a CollisionShape2D child (but don’t add a shape to it yet). Attach a script to the
Room.

extends RigidBody2D

var size

func make_room(_pos, _size):
    position = _pos
    size = _size
    var s = RectangleShape2D.new()
    s.extents = size
    $CollisionShape2D.shape = s

In this script, we have a size property, which we will be able to randomize. It’s then used in make_room() to generate a RectangleShape2D for collision.

Make sure to set Default Gravity to 0 in the “Physics/2d” section of Project Settings. Also, in the Inspector, set the Mode of the Room to “Character”. This will ensure that the rooms can’t rotate.

Main scene

Now, let’s make a “Main” scene using a Node2D. Give it a Node child called “Rooms”, which will act as a container. Attach a script:

extends Node2D

var Room = preload("res://Room.tscn")

var tile_size = 32  # size of a tile in the TileMap
var num_rooms = 50  # number of rooms to generate
var min_size = 4  # minimum room size (in tiles)
var max_size = 10  # maximum room size (in tiles)

Here are our main input properties for the dungeon.

In _ready() we need to initialize the random number generator and then call our function to create the rooms:

func _ready():
    randomize()
    make_rooms()

The make_rooms() function is going to use our parameters to create the randomly sized rooms. For now, we’ll put them all at (0, 0):

func make_rooms():
    for i in range(num_rooms):
        var pos = Vector2(0, 0)
        var r = Room.instance()
        var w = min_size + randi() % (max_size - min_size)
        var h = min_size + randi() % (max_size - min_size)
        r.make_room(pos, Vector2(w, h) * tile_size)
        $Rooms.add_child(r)

Visualization

If you run the scene, you won’t be able to see anything on the screen. Try turning on “Visible Collision Shapes” from the “Debug” menu.

alt

We can see the shapes, but they’re much larger than the screen. Add a Camera2D to the scene. Change its Zoom to (10, 10) and Current to “On”. Now you should be able to see most of the rooms as they sort themselves out.

It would be more convenient to draw the outlines rather than using the debug option (so turn that back off). Add this code to draw our room outlines:

func _draw():
    for room in $Rooms.get_children():
        draw_rect(Rect2(room.position - room.size, room.size*2),
                Color(32, 228, 0), false)

func _process(delta):
    update()

Let’s also add a way to redraw without starting the program all over again:

func _input(event):
    if event.is_action_pressed('ui_select'):
        for n in $Rooms.get_children():
            n.queue_free()
        make_rooms()

Now you can press the spacebar to generate a new set of rooms:

alt

Room Adjustments

Now we can make a couple of adjustments to how the rooms are being generated. First, since they’re all starting from the same place you may end up with a “tall” or “wide” dungeon. That may be fine with you, but in some games, it may make more sense if the player travels horizontally more than vertically. Here’s how we can influence that:

Add this variable to Main:

var hspread = 400  # horizontal spread

Then, in make_rooms() change the position code to this:

var pos = Vector2(rand_range(-hspread, hspread), 0)

The larger you make this value, the more horizontally spread out the rooms will be.

Secondly, you may notice that it takes some time for the rooms to stop moving as they settle into their final locations. We can influence that by adding the following line to the make_room() function in Room.gd:

s.custom_solver_bias = 0.75

Culling rooms

Finally, we want to be able to remove some rooms to make the dungeon more or less “sparse”. For this, we’ll add another input parameter:

var cull = 0.4 # chance to cull room

And update make_rooms() like so:

func make_rooms():
    for i in range(num_rooms):
        var pos = Vector2(rand_range(-hspread, hspread), 0)
        var r = Room.instance()
        var w = min_size + randi() % (max_size - min_size)
        var h = min_size + randi() % (max_size - min_size)
        r.make_room(pos, Vector2(w, h) * tile_size)
        $Rooms.add_child(r)
    # wait for movement to stop
    yield(get_tree().create_timer(1.1), 'timeout')
    # cull rooms
    for room in $Rooms.get_children():
        if randf() < cull:
            room.queue_free()
        else:
            room.mode = RigidBody2D.MODE_STATIC

Note the use of yield() here. We need to wait until the physics engine has finished separating the rooms before we start removing them. The ones that are left can now be set to MODE_STATIC so they will no longer move at all.

alt

Conclusion

We’ll stop here, as this is getting a bit long-winded - remember we said there were 3 main parts? In the next installment, we’ll look at how to connect the rooms together.

Please comment below with your questions and suggestions.

Download the code for this lesson

Comments