Lesson 2 · Procedural Generation for Godot

Connecting Rooms with Corridors

Turn a handful of floating rooms into one traversable dungeon.

Why this lesson Lesson 1 gave you rooms, but they're islands — nothing carved between them. A player dropped into that grid can walk around one room and nowhere else. This lesson carves corridors so every room is reachable from every other room, which is the minimum bar for a dungeon to be playable.

Primary source

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.

The idea: chain connection

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.

Carving an L-shaped tunnel

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.

The code

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)
Your tangible win Run this and the ASCII output now shows , 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.

Check your understanding

1. What guarantees that every room in this dungeon is reachable from every other room?

Every room connects to the very next room placed.
Every corridor is carved twice as wide as needed.
Every room is carved before any corridor begins.
Every wall tile is checked before floor placement.

2. Why randomize whether the horizontal or vertical leg of each L-tunnel comes first?

It changes which rooms end up overlapping each other.
It only changes the tunnel's shape, not its connectivity.
It determines whether the room count reaches its target.
It prevents the same RNG value from repeating twice.

Try it yourself (predict, then run)

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?

Yes — any single chain order still links every room.
No — shuffling breaks the chain and strands some rooms.
Only if the shuffled order matches the placement order.
Only if every room happens to be the same size.

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.

Where this falls short Chain connection is the minimum viable dungeon: no loops, so there's exactly one path between any two rooms, and long chains can produce corridors that snake across the whole map. Real dungeons usually want a few loops (alternate routes, so the player isn't forced down one corridor) — that needs treating rooms as a graph and adding extra edges, which is a good next step once this feels solid.