Every roguelike needs a dungeon generator. Hand-designed levels get stale. Procedural generation gives you a different dungeon every run from a few dozen lines of GDScript.
The core loop is the same everywhere: start with a grid of walls, carve floor tiles using an algorithm, verify the result is connected, then place the player and entities on valid floors. What changes is the carving algorithm, and each one produces a different feel.
Quick answers:
- Best algorithm for classic roguelike rooms?
- How does drunkard's walk work?
- When do I need flood fill cleanup?
Practice the four main approaches and learn when to pick each one. Seeded generation is the difference between "that map was neat" and "I can reproduce that exact bug on seed 481516."
Every tile-based generator follows the same pattern:
- Fill the grid with walls. TileMapLayer starts solid
- Carve floors using an algorithm (walk, automata, partition, or noise)
- Verify connectivity with flood fill so the player can reach everything
- Place entities by spawning the player, enemies, and items on valid floor tiles
The difference between algorithms is step 2. Everything else is shared infrastructure you write once.
Use RandomNumberGenerator for map generation instead of scattering randf() and randi() across your generator. Explicit seeds make bugs reproducible and let you offer daily seeds or shareable runs.
# Shared pattern: fill grid with walls, then carve
func fill_with_walls() -> void:
for x in range(width):
for y in range(height):
ground_layer.set_cell(Vector2i(x, y), 0, WALL_ATLAS)
A random walker starts at the center and stumbles around, carving floor tiles wherever it steps. The result is an organic cave with winding passages.
const DIRECTIONS: Array[Vector2i] = [
Vector2i.UP, Vector2i.DOWN, Vector2i.LEFT, Vector2i.RIGHT
]
func drunkard_walk(rng: RandomNumberGenerator, target_coverage: float) -> void:
var pos := Vector2i(width / 2, height / 2)
var total_cells := width * height
var carved := 0
while float(carved) / total_cells < target_coverage:
if ground_layer.get_cell_source_id(pos) != FLOOR_SOURCE:
ground_layer.set_cell(pos, 0, FLOOR_ATLAS)
carved += 1
var dir := DIRECTIONS[rng.randi() % DIRECTIONS.size()]
pos = (pos + dir).clamp(Vector2i.ONE, Vector2i(width - 2, height - 2))
Key insight: Connectivity is free because the walker never lifts off the grid. Every carved tile is reachable from every other carved tile. No flood fill needed.
Build this yourself with the Drunkard's Walk tutorial →
Start with random noise (~45% floor, ~55% walls), then repeatedly smooth it with the 4-5 rule: a cell becomes a wall if 5 or more of its 8 neighbors are walls.
func cellular_automata(rng: RandomNumberGenerator, iterations: int) -> void:
# Step 1: Random fill (~45% become floor, rest stay as walls)
for x in range(1, width - 1):
for y in range(1, height - 1):
if rng.randf() < 0.45:
ground_layer.set_cell(Vector2i(x, y), 0, FLOOR_ATLAS)
# Step 2: Smooth with 4-5 rule
for i in range(iterations):
var snapshot := {} # Read from snapshot, write to live grid
for x in range(width):
for y in range(height):
var pos := Vector2i(x, y)
snapshot[pos] = ground_layer.get_cell_source_id(pos)
for x in range(1, width - 1):
for y in range(1, height - 1):
var pos := Vector2i(x, y)
var walls := count_wall_neighbors(snapshot, pos)
if walls >= 5:
ground_layer.set_cell(pos, 0, WALL_ATLAS)
else:
ground_layer.set_cell(pos, 0, FLOOR_ATLAS)
Critical: You must read from a snapshot and write to the live grid (double-buffering). Reading and writing the same grid in-place corrupts the neighbor counts.
Warning: Cellular automata can produce disconnected chambers. Run flood fill afterward to keep only the largest connected region.
Build this yourself with the Cellular Automata tutorial →
Binary Space Partitioning recursively splits a rectangle into halves, places rooms inside the smallest partitions (leaves), and connects siblings with corridors.
func bsp_split(rect: Rect2i, depth: int, rng: RandomNumberGenerator) -> void:
if rect.size.x < MIN_PARTITION * 2 and rect.size.y < MIN_PARTITION * 2:
place_room(rect, rng)
return
var split_h := rect.size.x < rect.size.y # Split the longer axis
if split_h:
var split_y := rng.randi_range(rect.position.y + MIN_PARTITION,
rect.end.y - MIN_PARTITION)
var top := Rect2i(rect.position, Vector2i(rect.size.x, split_y - rect.position.y))
var bottom := Rect2i(Vector2i(rect.position.x, split_y),
Vector2i(rect.size.x, rect.end.y - split_y))
bsp_split(top, depth + 1, rng)
bsp_split(bottom, depth + 1, rng)
connect_rooms(top, bottom)
else:
# Vertical split (similar logic)
pass
Connectivity guarantee: The binary tree structure means every room connects to its sibling. No flood fill needed. Corridors follow the tree.
Build this yourself with the BSP Dungeons tutorial →
Godot's built-in FastNoiseLite generates smooth terrain. Threshold the noise into land and water, apply distance falloff to shape an island, then flood fill to remove disconnected landmasses.
func noise_terrain(rng: RandomNumberGenerator) -> void:
var noise := FastNoiseLite.new()
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
noise.frequency = 0.05
noise.fractal_octaves = 4
noise.seed = rng.randi()
var center := Vector2(width / 2.0, height / 2.0)
var max_dist := center.length()
for x in range(width):
for y in range(height):
var value := noise.get_noise_2d(float(x), float(y))
# Distance falloff: edges become water
var dist := Vector2(x, y).distance_to(center) / max_dist
value -= dist * 1.2
if value > -0.1:
ground_layer.set_cell(Vector2i(x, y), 0, FLOOR_ATLAS)
Tip: Lower frequency = larger landmasses. Higher fractal_octaves = more fine detail. Apply distance falloff before thresholding so edges always become water.
FastNoiseLite caveat: most noise types stay in the [-1, 1] range, but some cellular noise variants can exceed 1.0. If you swap noise types, retune your threshold instead of assuming the same cutoff still works.
Warning: Noise terrain often produces disconnected islands. Always run flood fill to keep only the largest connected region and remove floating chunks.
Build this yourself with the Noise Terrain tutorial →
Cellular automata and noise terrain often create disconnected pockets. Flood fill solves that by starting from a valid floor tile and marking every reachable neighbor.
func flood_fill(start: Vector2i) -> Dictionary:
var reachable := {}
var queue: Array[Vector2i] = [start]
while queue.size() > 0:
var pos := queue.pop_front()
if reachable.has(pos):
continue
reachable[pos] = true
for dir in DIRECTIONS:
var next := pos + dir
if is_floor(next) and not reachable.has(next):
queue.append(next)
return reachable
After the pass, loop over your floor cells and turn any non-reachable tile back into a wall. This is the cleanup step that makes "looks good" become "actually playable."
| Algorithm | Feel | Connectivity | Control | Best For |
|---|---|---|---|---|
| Drunkard's Walk | Organic caves | Guaranteed | Low | Quick caves, early prototypes |
| Cellular Automata | Smooth chambers | Needs flood fill | Medium | Natural caverns, open areas |
| BSP | Structured rooms | Guaranteed | High | Traditional roguelike dungeons |
| Noise Terrain | Natural islands | Needs flood fill | Medium | Overworld, biome maps |
Use an enum and match statement to switch algorithms at runtime:
enum MapType { DRUNKARD, CELLULAR, BSP, NOISE }
func generate(map_type: MapType, rng: RandomNumberGenerator) -> void:
fill_with_walls()
match map_type:
MapType.DRUNKARD:
drunkard_walk(rng, 0.45)
MapType.CELLULAR:
cellular_automata(rng, 4)
MapType.BSP:
bsp_split(Rect2i(1, 1, width - 2, height - 2), 0, rng)
MapType.NOISE:
noise_terrain(rng)
This lets you swap algorithms with a single enum change. Useful for level variety, or for letting players choose a map style.
When to Use GDScript Procedural Generation
- Generating dungeon layouts, cave systems, or overworld terrain that differs every playthrough
- Prototyping level design quickly before committing to hand-crafted layouts
- Building roguelike games where replayability depends on procedural variety
Check Your Understanding: GDScript Procedural Generation
Check Your Understanding: Why do some procedural generation algorithms need a flood fill pass while others don't?
Drunkard's walk and BSP guarantee connectivity by construction. The walker never lifts off the grid, and BSP corridors follow the binary tree. Cellular automata and noise terrain can produce disconnected regions because they operate on each cell independently. Flood fill from the player's starting position finds all reachable tiles; anything unreachable gets filled back with walls.
What You'll Practice: GDScript Procedural Generation
Common GDScript Procedural Generation Pitfalls
- Reading and writing the same grid during cellular automata. Double-buffer by taking a snapshot before each smoothing pass, or neighbor counts will be corrupted
- Skipping flood fill after cellular automata or noise terrain. Disconnected regions trap the player; always verify connectivity from the spawn point
- Using unseeded RNG (randf/randi) for map generation. Use RandomNumberGenerator with an explicit seed so maps are reproducible for debugging and sharing
- Feeding sequential or low-entropy external seeds directly into rng.seed. Godot documents that the RNG has no avalanche effect, so hash external seed inputs if you want better spread
- Splitting BSP partitions below the minimum room size. Enforce a MIN_PARTITION constraint or rooms become single-tile slivers that look broken
- Mixing cosmetic RNG with gameplay RNG. Use separate RandomNumberGenerator instances so particle effects don't change the dungeon layout in replays
GDScript Procedural Generation FAQ
What is procedural generation in Godot?
Procedural generation creates game content algorithmically instead of by hand. In Godot, this typically means generating tile-based maps with TileMapLayer: fill a grid with walls, then carve floor tiles using an algorithm like drunkard's walk, cellular automata, BSP, or noise.
How do I do procedural dungeon generation in Godot 4?
The usual Godot 4 pattern is: fill a TileMapLayer with walls, carve floor tiles with an algorithm, run flood fill if the algorithm can create disconnected regions, then spawn the player and entities on valid floor cells. BSP is the classic choice for room-and-corridor dungeons; drunkard's walk and cellular automata are more cave-like.
How do I make procedural maps reproducible?
Use RandomNumberGenerator with an explicit seed: rng.seed = 12345. The same seed reproduces the same sequence within the same project/runtime assumptions, which is ideal for debugging, daily seeds, and shared maps. One caveat from the Godot docs: the RNG algorithm is considered an engine detail, so do not promise identical output across future Godot versions forever.
What is the 4-5 rule in cellular automata?
A cell becomes a wall if 5 or more of its 8 neighbors (Moore neighborhood) are walls; otherwise it becomes floor. This smooths random noise into cave chambers over 4-5 iterations.
What is drunkard's walk in Godot?
Drunkard's walk is a random-walker map generator. Start from one tile, repeatedly pick a random cardinal direction, move there, and carve that cell into a floor. Because the walker always moves from one carved tile to the next, the resulting cave is connected by construction.
Why do I need double-buffering for cellular automata?
If you read and write the same grid during a smoothing pass, early changes affect later neighbor counts in the same iteration. Take a snapshot of the entire grid before each pass, read neighbor values from the snapshot, and write results to the live grid.
What is BSP dungeon generation?
Binary Space Partitioning recursively splits a rectangular area into two halves (alternating horizontal and vertical), places rooms inside the smallest partitions (leaves), and connects sibling rooms with corridors. The tree structure guarantees every room is reachable.
How does flood fill work for map cleanup?
Start from a known floor tile (the player spawn). Use a queue or stack to visit all connected floor tiles, marking them as "reachable." After the flood fill completes, any floor tile NOT marked is a disconnected island. Fill it back with walls.
What FastNoiseLite settings should I use for terrain?
TYPE_SIMPLEX_SMOOTH with frequency around 0.05 and fractal_octaves at 4 is a good starting point. Lower frequency creates larger landmasses; more octaves add fine detail. Apply distance falloff from the center to shape an island.
Why do similar seeds sometimes produce similar-looking maps?
Godot's RandomNumberGenerator docs note that the RNG does not have an avalanche effect, which means similar seeds can lead to similar streams. If your seeds come from external values like day numbers or usernames, hash them first before assigning them to rng.seed.
How do I choose between procedural generation algorithms?
Drunkard's walk for quick organic caves. Cellular automata for smooth chambers. BSP for structured rooms with corridors (classic roguelike). Noise terrain for overworld or biome maps. You can combine them behind an enum to switch at runtime.
GDScript Procedural Generation Syntax Quick Reference
func fill_with_walls() -> void:
for x in range(width):
for y in range(height):
ground_layer.set_cell(Vector2i(x, y), 0, WALL_ATLAS)const DIRS: Array[Vector2i] = [
Vector2i.UP, Vector2i.DOWN, Vector2i.LEFT, Vector2i.RIGHT
]
var dir := DIRS[rng.randi() % DIRS.size()]
pos = (pos + dir).clamp(Vector2i.ONE, Vector2i(width - 2, height - 2))func count_wall_neighbors(snapshot: Dictionary, pos: Vector2i) -> int:
var count := 0
for dx in range(-1, 2):
for dy in range(-1, 2):
if dx == 0 and dy == 0:
continue
if snapshot.get(pos + Vector2i(dx, dy), -1) != FLOOR_SOURCE:
count += 1
return countvar noise := FastNoiseLite.new()
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
noise.frequency = 0.05
noise.fractal_octaves = 4
noise.seed = rng.randi()func flood_fill(start: Vector2i) -> Array[Vector2i]:
var visited: Array[Vector2i] = []
var queue: Array[Vector2i] = [start]
while queue.size() > 0:
var pos := queue.pop_front()
if pos in visited:
continue
visited.append(pos)
for dir in DIRECTIONS:
var next := pos + dir
if is_floor(next) and next not in visited:
queue.append(next)
return visitedvar hashed_seed := hash(seed_text)
rng.seed = hashed_seedenum MapType { DRUNKARD, CELLULAR, BSP, NOISE }
func generate(map_type: MapType, rng: RandomNumberGenerator) -> void:
fill_with_walls()
match map_type:
MapType.DRUNKARD: drunkard_walk(rng, 0.45)
MapType.CELLULAR: cellular_automata(rng, 4)
MapType.BSP: bsp_split(bounds, 0, rng)
MapType.NOISE: noise_terrain(rng)GDScript Procedural Generation Sample Exercises
Declare a typed array variable called rooms that holds Rect2i values, initialized empty.
var rooms: Array[Rect2i] = []Create a Rect2i called room at position (x, y) with size (width, height).
var room = Rect2i(x, y, width, height)Write an if condition that checks whether room intersects with existing using the intersects method.
if room.intersects(existing):+ 6 more exercises