Lesson 2 · Procedural Generation for Godot
Turn a handful of floating rooms into one traversable dungeon.
The approach here — connect each room to the previous one with an L-shaped tunnel — is the same one used in Roguelike Tutorials, Part 3: "Generating a dungeon" (Python/libtcod, but the algorithm reads directly across to GDScript). Worth a skim for a second explanation in a different language.
You already have rooms, an ordered Array[Rect2i] from Lesson 1 — ordered by the sequence they were placed in, not by position. The simplest way to guarantee every room is reachable: connect room 1 to room 2, room 2 to room 3, and so on down the list.
for i in 1..<rooms.size():
connect(rooms[i - 1], rooms[i])
This is a chain connection: n rooms need only n-1 corridors to be fully connected — any two rooms have a path between them by walking along the chain. It's the smallest possible connected structure (a spanning tree), just shaped like a straight line rather than a branching tree. That's a deliberate simplification for this lesson — real dungeons usually want loops too, which is a later lesson.
To connect two room centers that don't share a row or column, carve one horizontal segment and one vertical segment, joined at a corner:
center A ---------+
|
|
+--------- center B
Randomizing whether the horizontal or vertical leg comes first just varies the corner's position — it changes the shape of the corridor, not whether A and B end up connected.
This extends Lesson 1's script directly. New pieces: a three-way Tile enum (so corridors print differently from rooms — nice visual proof they're doing something), and _connect_rooms.
extends Node
const GRID_W := 40
const GRID_H := 20
const ROOM_COUNT := 8
const MIN_SIZE := 3
const MAX_SIZE := 7
enum Tile { WALL, ROOM, CORRIDOR }
func _ready() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 12345
var grid := _make_grid(GRID_W, GRID_H)
var rooms := _place_rooms(grid, rng)
_connect_rooms(grid, rooms, rng)
_print_grid(grid)
print("Placed %d of %d requested rooms, connected in sequence." % [rooms.size(), ROOM_COUNT])
func _make_grid(w: int, h: int) -> Array:
var grid := []
for y in h:
var row := []
row.resize(w)
row.fill(Tile.WALL)
grid.append(row)
return grid
func _place_rooms(grid: Array, rng: RandomNumberGenerator) -> Array[Rect2i]:
var rooms: Array[Rect2i] = []
var attempts := 0
while rooms.size() < ROOM_COUNT and attempts < 200:
attempts += 1
var w := rng.randi_range(MIN_SIZE, MAX_SIZE)
var h := rng.randi_range(MIN_SIZE, MAX_SIZE)
var x := rng.randi_range(1, GRID_W - w - 1)
var y := rng.randi_range(1, GRID_H - h - 1)
var candidate := Rect2i(x, y, w, h)
if _overlaps_any(candidate, rooms):
continue
rooms.append(candidate)
_carve_room(grid, candidate)
return rooms
func _overlaps_any(candidate: Rect2i, rooms: Array[Rect2i]) -> bool:
var padded := candidate.grow(1)
for r in rooms:
if padded.intersects(r):
return true
return false
func _carve_room(grid: Array, room: Rect2i) -> void:
for y in range(room.position.y, room.end.y):
for x in range(room.position.x, room.end.x):
grid[y][x] = Tile.ROOM
func _connect_rooms(grid: Array, rooms: Array[Rect2i], rng: RandomNumberGenerator) -> void:
for i in range(1, rooms.size()):
var a := rooms[i - 1].position + rooms[i - 1].size / 2
var b := rooms[i].position + rooms[i].size / 2
if rng.randi_range(0, 1) == 0:
_carve_h_tunnel(grid, a.x, b.x, a.y)
_carve_v_tunnel(grid, a.y, b.y, b.x)
else:
_carve_v_tunnel(grid, a.y, b.y, a.x)
_carve_h_tunnel(grid, a.x, b.x, b.y)
func _carve_h_tunnel(grid: Array, x1: int, x2: int, y: int) -> void:
for x in range(min(x1, x2), max(x1, x2) + 1):
if grid[y][x] == Tile.WALL:
grid[y][x] = Tile.CORRIDOR
func _carve_v_tunnel(grid: Array, y1: int, y2: int, x: int) -> void:
for y in range(min(y1, y2), max(y1, y2) + 1):
if grid[y][x] == Tile.WALL:
grid[y][x] = Tile.CORRIDOR
func _print_grid(grid: Array) -> void:
var symbols := {Tile.WALL: "#", Tile.ROOM: ".", Tile.CORRIDOR: ","}
for row in grid:
var line := ""
for cell in row:
line += symbols[cell]
print(line)
, corridors threading through the # walls, linking every . room to the next. Trace any two rooms by eye — there's a path of ./, tiles between them. That's a traversable dungeon.
1. What guarantees that every room in this dungeon is reachable from every other room?
2. Why randomize whether the horizontal or vertical leg of each L-tunnel comes first?
Suppose that before calling _connect_rooms, you shuffled the rooms array into a random order, then connected consecutive rooms in that order instead. Would every room still be reachable from every other room?
Connectivity only depends on visiting every room exactly once in some chain — which order doesn't matter. What does change with order: corridor length and how much they crisscross the map. That's a design knob, not a correctness one.