Lesson 1 · Procedural Generation for Godot

Seeded Randomness & Random Room Placement

Your first working dungeon generator: a reproducible grid of non-overlapping rooms, in about 50 lines of GDScript.

Why this lesson Every dungeon-generation algorithm you'll learn — BSP trees, rooms-and-mazes, cellular automata — is built on two ideas: a seeded source of randomness, and a grid to write into. Get these solid first and every later algorithm is just a smarter way to fill the grid.

Primary source

Read Godot's RandomNumberGenerator docs before or after this lesson — it's short. For syntax while you work, keep the RNG cheat sheet open.

The core idea: seeding

A PRNG (pseudorandom number generator) produces numbers that look random but are fully determined by a starting value — the seed. Same seed, same sequence, every time. That's not a limitation to work around; it's the property that makes procgen usable in a shipped game:

In Godot, you get this with an instance of RandomNumberGenerator — not the global randi()/randf() functions, which share one engine-wide stream you don't control. Details in the cheat sheet.

The algorithm: rejection sampling

The simplest room-placement strategy is rejection sampling: propose a random rectangle, check whether it overlaps any room already placed, keep it if not, throw it away and try again if it does.

loop until enough rooms placed (or too many attempts):
    propose a random rectangle (random size, random position)
    if it overlaps an existing room:
        discard it, try again
    else:
        keep it, carve it into the grid

It's not the most efficient algorithm — dense grids waste a lot of attempts — but it's the right first algorithm because the whole idea fits in working memory at once, and it already produces a real, playable-looking room layout.

The code

This is a complete, runnable script. Attach it to any Node in an empty scene and run the scene — the dungeon prints to the Output panel as ASCII.

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, FLOOR }

func _ready() -> void:
    var rng := RandomNumberGenerator.new()
    rng.seed = 12345  # change this and re-run — same code, new dungeon

    var grid := _make_grid(GRID_W, GRID_H)
    var rooms := _place_rooms(grid, rng)
    _print_grid(grid)
    print("Placed %d of %d requested rooms." % [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  # rejection sampling: discard and retry

        rooms.append(candidate)
        _carve(grid, candidate)

    return rooms

func _overlaps_any(candidate: Rect2i, rooms: Array[Rect2i]) -> bool:
    var padded := candidate.grow(1)  # keep a 1-tile gap between rooms
    for r in rooms:
        if padded.intersects(r):
            return true
    return false

func _carve(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.FLOOR

func _print_grid(grid: Array) -> void:
    for row in grid:
        var line := ""
        for cell in row:
            line += "#" if cell == Tile.WALL else "."
        print(line)
Your tangible win Run this and you'll see an ASCII dungeon in the Output panel — a grid of # walls with . rooms carved out, never overlapping, and identical every time you run it with rng.seed = 12345. Change the seed, get a different layout. That's the reproducibility property doing its job.

Check your understanding

1. Why does a dungeon generator need a seed?

So players can share and reproduce one layout.
So Godot's editor loads scene files much faster.
So collision shapes automatically avoid all overlap.
So imported textures compress into smaller files.

2. In rejection sampling, what happens when a candidate room overlaps an existing one?

It shrinks the room until the overlap disappears.
It discards the candidate and generates a new one.
It merges the two overlapping rooms into one room.
It moves the existing room to a random new position.

Try it yourself (predict, then run)

Before touching the editor: if you change const ROOM_COUNT := 8 to const ROOM_COUNT := 30 and re-run with the same seed, what do you expect to happen, given the attempts < 200 cap in _place_rooms?

All thirty rooms fit, just packed more tightly.
Fewer rooms get placed once attempts run out.
The script crashes from running out of memory.
Room sizes shrink automatically to fit them all.

Then actually run it and check the printed "Placed X of 30 requested rooms" line against your prediction — a 40×20 grid can't fit 30 rooms of size 3–7 with a 1-tile gap each, so most late attempts overlap and get rejected until the 200-attempt cap ends the loop early.