Lesson 1 · Procedural Generation for Godot
Your first working dungeon generator: a reproducible grid of non-overlapping rooms, in about 50 lines of GDScript.
Read Godot's RandomNumberGenerator docs before or after this lesson — it's short. For syntax while you work, keep the RNG cheat sheet open.
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 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.
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)
# 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.
1. Why does a dungeon generator need a seed?
2. In rejection sampling, what happens when a candidate room overlaps an existing one?
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?
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.