Hand-designed levels get stale after a few runs. Procedural generation gives you a different dungeon every time from a few dozen lines of GDScript. Four algorithms cover most of what roguelike developers need: drunkard's walk for organic caves, cellular automata for smooth chambers, BSP for structured rooms with corridors, and noise terrain for natural islands.
The core loop is the same for all four: start with a TileMapLayer grid full of walls, carve floor tiles using an algorithm, verify the result is connected (flood fill), then place the player and entities on valid floors. The algorithms differ only in how they carve. Once you understand that shared pattern, learning each algorithm is just learning a different carving strategy.
Quick answers:
- Best algorithm for classic roguelike rooms?
- How does drunkard's walk work?
- When do I need flood fill cleanup?
- How do I make maps reproducible? (seeded RandomNumberGenerator)
Each algorithm below includes working GDScript code and a link to an interactive tutorial where you type it from memory. Seeded generation with RandomNumberGenerator is covered throughout -- it's 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)
The drunkard's walk (also called a random walk) is the simplest procedural generation algorithm that produces interesting results. A single walker starts at the center of the grid and picks a random cardinal direction each step -- up, down, left, or right. Wherever it steps, it carves a floor tile. Because the walker frequently backtracks over its own path, you get branching passages, irregular chambers, and dead ends rather than a single long corridor. The winding, unpredictable path is what makes the caves feel organic.
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.
Tuning the drunkard's walk: The target_coverage parameter controls cave density. 0.35-0.40 produces tight, winding caves with lots of walls. 0.50+ opens the map up into wider chambers. The walker revisits already-carved tiles often at higher coverage targets -- that's expected and is what creates the branching pattern. Boundary clamping with Vector2i.clamp() keeps the walker one tile away from map edges, leaving a solid border.
Build this yourself with the Drunkard's Walk tutorial →
Cellular automata map generation works in two phases. First, scatter random noise across the grid -- each cell has roughly a 45% chance of being a wall. Then repeatedly apply the 4-5 rule: count each cell's 8 neighbors (the Moore neighborhood), and if 5 or more are walls, make it a wall too. Walls next to walls survive; isolated walls get eaten. After 4-5 passes, random static sharpens into smooth, wide-open cave chambers connected by natural-looking passages. The result is very different from a drunkard's walk -- cellular automata caves have rounder, more open rooms instead of winding corridors.
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: double-buffering. You must read from a snapshot and write to the live grid. If you read and write the same grid in-place, changing one cell alters the neighbor count of the next cell in the same pass. The snapshot (created with duplicate(true) for a deep copy) freezes the state so all cells read from the same generation. This is the most common bug in cellular automata implementations.
Tuning: The initial wall probability (0.45) controls the final cave shape. Lower values (0.35-0.40) produce more open space with thin walls. Higher values (0.50-0.55) produce narrow tunnels through thick rock. Four iterations is a good default; more over-smooths the map into a single blob.
Warning: Cellular automata can produce disconnected chambers because each cell is processed independently. Always run flood fill afterward to keep only the largest connected region.
Build this yourself with the Cellular Automata tutorial →
Binary Space Partitioning (BSP) produces the classic roguelike layout: rectangular rooms connected by corridors, with no overlap and guaranteed connectivity. The algorithm works by recursively splitting the map rectangle into two halves -- sometimes horizontally, sometimes vertically -- until every partition is too small to split further. These terminal partitions are the "leaves" of a binary tree. Each leaf gets a room placed inside it with random size and position. Then you walk back up the tree and connect each pair of sibling rooms with an L-shaped corridor. The tree structure means every room connects to its sibling, which connects to its sibling, all the way back to the root.
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.
Tuning BSP dungeons: MIN_PARTITION controls the density of rooms. Smaller values (6-8) create more, smaller rooms packed tightly together. Larger values (12-15) create fewer, bigger rooms with longer corridors between them. The split axis is chosen based on which dimension is longer, keeping partitions roughly square. Random split positions within the valid range (MIN_PARTITION to size - MIN_PARTITION) prevent degenerate slivers.
Build this yourself with the BSP Dungeons tutorial →
Noise-based terrain generation uses Godot's built-in FastNoiseLite to produce smooth, continuous value fields across the grid -- imagine a topographic map where neighboring cells have similar heights. You sample the noise at each grid position, getting a value between -1.0 and 1.0. Values above a threshold become land (floor tiles), values below become water (wall tiles). The raw output looks like random continents, so you shape it by applying distance falloff from the center -- cells near the edges get their values pushed down, turning the map into an island. Fractal octaves layer multiple noise samples at different scales to add coastline detail: octave 1 draws the broad landmass shape, octaves 2-4 add smaller bumps and inlets.
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 can leave disconnected pockets of floor tiles that the player can never reach. This happens because both algorithms process each cell independently -- they don't track connectivity while generating. Flood fill solves the problem after generation. Starting from the player's spawn position, it visits every reachable floor tile using a queue-based breadth-first search.
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 flood fill completes, loop over all floor cells in the grid. Any floor tile NOT in the reachable dictionary is a disconnected island -- fill it back with walls. This cleanup step is what turns "looks good" into "actually playable." Drunkard's walk and BSP don't need this step because their generation methods guarantee connectivity by construction.
When to run flood fill: Always after cellular automata or noise terrain. Never needed after drunkard's walk (the walker is always connected) or BSP (the tree structure connects everything). If you combine algorithms -- for example, cellular automata for caves with BSP corridors connecting them -- run flood fill on the final result to be safe.
Each algorithm produces a different type of map. The right choice depends on what kind of game space you need.
| Algorithm | Feel | Connectivity | Control | Best For |
|---|---|---|---|---|
| Drunkard's Walk | Winding caves, dead ends | Guaranteed (no flood fill) | Low -- hard to predict shape | Quick prototypes, natural cave systems |
| Cellular Automata | Wide open chambers | Needs flood fill | Medium -- wall probability + iterations | Boss rooms, large combat arenas, natural caverns |
| BSP | Rectangular rooms + corridors | Guaranteed (tree structure) | High -- partition size, room padding | Traditional roguelike dungeons, structured levels |
| Noise Terrain | Natural islands, coastlines | Needs flood fill | Medium -- frequency, octaves, falloff | Overworld maps, biome boundaries, island survival |
Drunkard's walk is the fastest to implement (under 15 lines) and produces cave systems that work for early prototypes. The tradeoff is low control -- you can't predict the shape of any individual map, only the density.
Cellular automata produces the most visually distinct caves: round, smooth chambers connected by natural passages. The 4-5 rule is easy to tweak (try 4-4 or 5-5 for different shapes), but you always need flood fill afterward.
BSP gives you the most control. Adjusting MIN_PARTITION, room padding, and corridor style lets you create everything from cramped catacombs to spacious hall-and-corridor layouts. This is what most people think of as a "roguelike dungeon."
Noise terrain is the outlier -- it produces geographic landscapes rather than architectural dungeons. Combine it with distance falloff for island maps, or use different threshold bands for multiple biome types (deep water, shallow water, sand, grass, mountain).
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 is the strategy pattern -- all four generators share the same interface (fill the grid, then carve), so swapping them is a one-line enum change. Useful for varying dungeon types across floors, or for letting players choose a map style.
Build the strategy pattern yourself with the Synthesis tutorial →
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 and large combat arenas. BSP for structured rooms with corridors (the classic roguelike layout). Noise terrain for overworld maps, islands, and biome boundaries. You can combine them behind an enum and match statement to switch at runtime.
Can I combine multiple procedural generation algorithms?
Yes. A common approach is to use BSP for the room layout, then run cellular automata inside individual rooms to give them organic shapes instead of rectangles. Or use noise terrain for the overworld and BSP for dungeon interiors. The Synthesis tutorial shows how to put all four behind an enum so you can swap or layer them.
What is the difference between Perlin noise and simplex noise in Godot?
Godot's FastNoiseLite supports both. Simplex noise (TYPE_SIMPLEX_SMOOTH) is generally preferred for terrain generation because it produces smoother gradients with fewer directional artifacts than classic Perlin noise. Both output values in the -1.0 to 1.0 range and work the same way with frequency, octaves, and seeding.
How do I make a roguelike dungeon in Godot 4?
Fill a TileMapLayer grid with wall tiles, then carve floor tiles using a generation algorithm. BSP is the most popular choice for classic room-and-corridor dungeons. Use RandomNumberGenerator with an explicit seed for reproducible layouts, run flood fill if your algorithm can create disconnected regions, then spawn the player and enemies on valid floor tiles.
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