Procedural Dungeon Generation in Godot 4
By Mika Solaris — Indie dev and procedural generation enthusiast. Shipped two roguelikes and a dungeon-crawler on Steam.
Contents
BSP Tree Fundamentals
Binary Space Partitioning (BSP) is the workhorse algorithm behind dungeon generation in roguelikes like Spelunky, Dead Cells, and countless game jam entries. The idea is deceptively simple: start with a large rectangle representing your dungeon bounds, then recursively split it into smaller rectangles until each one is a suitable room size. Each split creates a binary tree node — left child and right child — and the leaf nodes become your rooms.
Why BSP over other approaches? Random room placement (scatter rooms, check for overlaps) produces unstructured results and often requires expensive collision checks. BSP guarantees non-overlapping rooms by construction — each room lives in its own partition of the space. It also gives you a natural tree structure for connecting rooms: sibling nodes should always be connected by a corridor. This means you get full connectivity for free without running a separate pathfinding pass.
Create a new script called `dungeon_generator.gd` and extend Node2D. We'll represent each BSP node as an inner class with a bounds rectangle, optional children, and a room rectangle that gets carved out of the bounds later. The constants at the top control the dungeon's personality — tweak them freely.
class_name DungeonGenerator
extends Node2D
## Minimum size a BSP leaf can be before we stop splitting
const MIN_LEAF_SIZE := 8
## Maximum room dimension (rooms are carved inside leaves)
const MAX_ROOM_SIZE := 15
## Total dungeon dimensions in tiles
const DUNGEON_WIDTH := 80
const DUNGEON_HEIGHT := 60
class BSPNode:
var bounds: Rect2i
var left: BSPNode = null
var right: BSPNode = null
var room: Rect2i # The actual room carved inside bounds
func _init(b: Rect2i) -> void:
bounds = b
func is_leaf() -> bool:
return left == null and right == null
var root: BSPNode
var rng := RandomNumberGenerator.new()
var rooms: Array[Rect2i] = []
func generate(seed_value: int = -1) -> void:
rooms.clear()
if seed_value >= 0:
rng.seed = seed_value
else:
rng.randomize()
root = BSPNode.new(Rect2i(0, 0, DUNGEON_WIDTH, DUNGEON_HEIGHT))
_split(root)
_create_rooms(root)The `rooms` array collects every room rectangle we carve, which we'll need later for corridor generation and loot placement. The `seed_value` parameter is crucial for roguelikes: if you pass the same seed, you get the exact same dungeon. This is essential for debugging (reproduce a bug on a specific layout), daily challenges (every player gets the same dungeon), and replays.
Now for the recursive split function — this is where the algorithm's character comes from. For each node, we decide whether to split horizontally or vertically based on the aspect ratio. Wide leaves split vertically, tall leaves split horizontally. The split point is randomized between 30% and 70% of the relevant dimension, which prevents uniform-looking rooms while avoiding degenerate slivers.
func _split(node: BSPNode) -> void:
var b := node.bounds
# Stop if too small to split further
if b.size.x < MIN_LEAF_SIZE * 2 and b.size.y < MIN_LEAF_SIZE * 2:
return
# Occasionally stop splitting early for room size variety
if b.size.x < MIN_LEAF_SIZE * 3 and b.size.y < MIN_LEAF_SIZE * 3:
if rng.randf() < 0.25:
return
# Choose split direction: wide rooms split vertically, tall ones horizontally
var split_h: bool
if b.size.x < MIN_LEAF_SIZE * 2:
split_h = true # Can only split horizontally
elif b.size.y < MIN_LEAF_SIZE * 2:
split_h = false # Can only split vertically
else:
split_h = b.size.y >= b.size.x # Split along the longer axis
var dim := b.size.y if split_h else b.size.x
var split_min := max(int(dim * 0.3), MIN_LEAF_SIZE)
var split_max := min(int(dim * 0.7), dim - MIN_LEAF_SIZE)
if split_min >= split_max:
return
var split_pos := rng.randi_range(split_min, split_max)
if split_h:
node.left = BSPNode.new(Rect2i(b.position.x, b.position.y, b.size.x, split_pos))
node.right = BSPNode.new(Rect2i(b.position.x, b.position.y + split_pos, b.size.x, b.size.y - split_pos))
else:
node.left = BSPNode.new(Rect2i(b.position.x, b.position.y, split_pos, b.size.y))
node.right = BSPNode.new(Rect2i(b.position.x + split_pos, b.position.y, b.size.x - split_pos, b.size.y))
_split(node.left)
_split(node.right)Notice the early-stop chance (25%) when a leaf is between 2x and 3x the minimum size. This introduces room size variety — without it, every room ends up roughly the same dimensions because the recursion always continues to the minimum. Tuning this probability is one of the biggest levers for dungeon feel: higher values produce fewer, larger rooms; lower values produce many small rooms. For a classic roguelike, 20-30% works well. For a boss-rush layout, try 60%+ to get wide arenas.
Carving Rooms from BSP Leaves
Once the BSP tree is built, every leaf node has a `bounds` rectangle — but we don't want rooms that fill their entire bounds. That would leave zero space for walls and corridors between adjacent rooms. Instead, we carve a smaller room inside each leaf's bounds with random padding on each side. The gap between the room edge and the bounds edge becomes wall space.
The padding range directly affects dungeon density. Small padding (1-2 tiles) creates tight, claustrophobic dungeons where rooms almost touch. Large padding (3-5 tiles) creates spacious layouts with wide corridors and lots of wall space for decorations. We randomize each side independently to avoid perfectly centered rooms.
func _create_rooms(node: BSPNode) -> void:
if node.is_leaf():
var pad_left := rng.randi_range(1, 3)
var pad_right := rng.randi_range(1, 3)
var pad_top := rng.randi_range(1, 3)
var pad_bottom := rng.randi_range(1, 3)
var room_x := node.bounds.position.x + pad_left
var room_y := node.bounds.position.y + pad_top
var room_w := node.bounds.size.x - pad_left - pad_right
var room_h := node.bounds.size.y - pad_top - pad_bottom
# Enforce minimum room size
room_w = max(room_w, 4)
room_h = max(room_h, 4)
node.room = Rect2i(room_x, room_y, room_w, room_h)
rooms.append(node.room)
return
if node.left:
_create_rooms(node.left)
if node.right:
_create_rooms(node.right)Now we need to paint these rooms onto a TileMapLayer. Godot 4 uses TileMapLayer nodes — the old multi-layer TileMap API was deprecated in Godot 4.3. Set up a TileSet resource with at least two terrain types: floor and wall. The generator writes floor tiles for every cell inside each room, then we'll fill walls around them.
@onready var tilemap: TileMapLayer = $TileMapLayer
# Atlas coordinates in your TileSet — adjust these to match your tileset layout
const FLOOR_ATLAS := Vector2i(0, 0)
const WALL_ATLAS := Vector2i(1, 0)
const SOURCE_ID := 0 # TileSet source index
func _paint_dungeon() -> void:
tilemap.clear()
# First, fill the entire area with walls
for x in range(DUNGEON_WIDTH):
for y in range(DUNGEON_HEIGHT):
tilemap.set_cell(Vector2i(x, y), SOURCE_ID, WALL_ATLAS)
# Then carve out rooms by placing floor tiles
_paint_rooms(root)
func _paint_rooms(node: BSPNode) -> void:
if node.is_leaf() and node.room:
for x in range(node.room.position.x, node.room.end.x):
for y in range(node.room.position.y, node.room.end.y):
tilemap.set_cell(Vector2i(x, y), SOURCE_ID, FLOOR_ATLAS)
return
if node.left:
_paint_rooms(node.left)
if node.right:
_paint_rooms(node.right)A common mistake is only painting floors and leaving everything else empty. You need explicit wall tiles around every floor tile for proper collision and visual boundaries. The approach above handles this by filling walls first, then overwriting with floors. Alternatively, you can skip the full fill and use an autotile pass after painting floors, which we'll cover in Lesson 4.
At this point you can call `generate()` and `_paint_dungeon()` from `_ready()` and you should see isolated rectangular rooms scattered across your TileMap. They won't be connected yet — that's the next lesson. But take a moment to play with `MIN_LEAF_SIZE`, the padding ranges, and the early-stop probability. You'll be surprised how much the dungeon character changes with small tweaks to these three parameters.
Connecting Rooms with Corridors
Rooms without corridors are just a scatter of isolated boxes. The elegant thing about BSP is that the tree structure tells us exactly which rooms to connect: every pair of sibling leaves should have a corridor between them. We walk the tree bottom-up, connecting siblings at each level. This guarantees full connectivity — every room is reachable from every other room.
The corridor algorithm works like this: for each internal (non-leaf) node, find one room in the left subtree and one in the right subtree, then carve a path between their centers. We use an L-shaped corridor — horizontal first, then vertical (or vice versa, chosen randomly). This avoids diagonal paths which would require diagonal tiles and complicate collision.
func _connect_rooms(node: BSPNode) -> void:
if node.is_leaf():
return
if node.left:
_connect_rooms(node.left)
if node.right:
_connect_rooms(node.right)
# Find a room in each subtree to connect
var left_room := _find_room(node.left)
var right_room := _find_room(node.right)
if left_room and right_room:
_carve_corridor(left_room.get_center(), right_room.get_center())
func _find_room(node: BSPNode) -> Rect2i:
"""Walk down to find any leaf room in this subtree."""
if node.is_leaf() and node.room:
return node.room
# Try left subtree first, then right
if node.left:
var r := _find_room(node.left)
if r.size.x > 0:
return r
if node.right:
var r := _find_room(node.right)
if r.size.x > 0:
return r
return Rect2i()The `_find_room` helper walks down a subtree and returns the first room it encounters. In practice, you could pick a random room from the subtree for more organic-looking corridors, but grabbing the first one is simpler and produces good results. Now the corridor carving itself:
const CORRIDOR_WIDTH := 2
func _carve_corridor(from: Vector2i, to: Vector2i) -> void:
# L-shaped corridor: horizontal then vertical (or random order)
var go_h_first := rng.randf() < 0.5
if go_h_first:
_carve_h_tunnel(from.x, to.x, from.y)
_carve_v_tunnel(from.y, to.y, to.x)
else:
_carve_v_tunnel(from.y, to.y, from.x)
_carve_h_tunnel(from.x, to.x, to.y)
func _carve_h_tunnel(x1: int, x2: int, y: int) -> void:
var start_x := min(x1, x2)
var end_x := max(x1, x2)
for x in range(start_x, end_x + 1):
for w in range(CORRIDOR_WIDTH):
tilemap.set_cell(Vector2i(x, y + w), SOURCE_ID, FLOOR_ATLAS)
func _carve_v_tunnel(y1: int, y2: int, x: int) -> void:
var start_y := min(y1, y2)
var end_y := max(y1, y2)
for y in range(start_y, end_y + 1):
for w in range(CORRIDOR_WIDTH):
tilemap.set_cell(Vector2i(x + w, y), SOURCE_ID, FLOOR_ATLAS)The CORRIDOR_WIDTH constant controls how wide your hallways are. A width of 1 feels cramped and can cause pathfinding issues with larger characters. A width of 2 is the sweet spot for most games. Width 3+ creates grand hallways that feel more like a castle than a dungeon. You can even randomize the width per corridor for variety.
One subtle issue: the L-shaped corridors can sometimes overlap with other rooms in unexpected ways, creating doorways in odd places. This is actually a feature, not a bug — it creates organic-feeling connections that make the dungeon feel handcrafted. If you want cleaner connections, you can add a check that only carves through wall tiles and stops when it hits an existing floor tile, but in practice the overlaps look fine.
Update your main generate function to include corridor generation. The order matters — rooms first, then corridors, because corridors need room centers to calculate their paths:
func _ready() -> void:
generate(12345) # Fixed seed for testing
_paint_dungeon()
_connect_rooms(root) # Must come after _paint_dungeon
# Print some stats
print("Generated %d rooms" % rooms.size())
print("Seed: %d" % rng.seed)Run the scene now and you should see a fully connected dungeon. Every room should be reachable. If any room appears isolated, double-check that your `_connect_rooms` recursion visits every internal node — the most common bug is forgetting to recurse into both children before connecting them.
TileMap Integration and Autotiling
So far we've been placing individual floor and wall tiles with hardcoded atlas coordinates. This works, but it produces a flat, monotonous look. Godot 4's TileSet terrain system can automatically select the right wall tile variant based on neighboring tiles — corners, edges, T-junctions, and straight walls all get distinct tiles. This makes your dungeon look polished with zero extra work per room.
To use terrain autotiling, your TileSet needs a terrain set with peering bits defined. The setup in the editor is: open your TileSet resource, go to the Terrains panel, create a terrain set (type: Match Corners and Sides for the most flexibility), add at least two terrains (Floor and Wall), then paint the peering bits for each tile in your atlas. This is a one-time setup cost that pays for itself immediately.
Once your terrains are configured, replace the manual `set_cell` calls with terrain-aware painting. Instead of specifying atlas coordinates, you tell the TileMap which terrain you want and it picks the right tile automatically:
# Terrain set index and terrain indices from your TileSet setup
const TERRAIN_SET := 0
const TERRAIN_FLOOR := 0
const TERRAIN_WALL := 1
func _paint_dungeon_with_terrain() -> void:
tilemap.clear()
# Collect all floor positions
var floor_cells: Array[Vector2i] = []
var wall_cells: Array[Vector2i] = []
_collect_floor_cells(root, floor_cells)
# Corridors were already painted — collect those too
for x in range(DUNGEON_WIDTH):
for y in range(DUNGEON_HEIGHT):
var pos := Vector2i(x, y)
if tilemap.get_cell_source_id(pos) != -1:
floor_cells.append(pos)
# Determine wall cells: any non-floor cell adjacent to a floor cell
for fc in floor_cells:
for dx in range(-1, 2):
for dy in range(-1, 2):
var neighbor := Vector2i(fc.x + dx, fc.y + dy)
if neighbor not in floor_cells:
wall_cells.append(neighbor)
# Apply terrains — Godot resolves the correct tile variants automatically
tilemap.set_cells_terrain_connect(floor_cells, TERRAIN_SET, TERRAIN_FLOOR)
tilemap.set_cells_terrain_connect(wall_cells, TERRAIN_SET, TERRAIN_WALL)The `set_cells_terrain_connect` method is the key API here. You pass it an array of cell positions and the terrain index, and Godot handles the autotiling lookup. It examines each cell's neighbors to pick the correct tile variant from your terrain definitions. This means corners automatically get corner tiles, straight walls get straight tiles, and so on.
For the wall detection, we check all 8 neighbors (including diagonals) of every floor cell. Any position that isn't a floor and is adjacent to a floor becomes a wall. This creates a one-tile-thick border around all rooms and corridors, which is exactly what you want for collision. The diagonal check ensures corners are filled — without it, you'd get gaps at room corners where the player could see through the walls.
Adding decorative tiles is straightforward once you have this system. You can add a third terrain for 'special floor' tiles (cracked stone, moss, etc.) and randomly assign some floor cells to it. Or scatter decoration objects on top of wall tiles using a separate TileMapLayer:
@onready var decor_layer: TileMapLayer = $DecorTileMapLayer
const DECOR_CHANCE := 0.08 # 8% of wall tiles get a decoration
const DECOR_ATLAS_OPTIONS: Array[Vector2i] = [
Vector2i(0, 2), # Torch
Vector2i(1, 2), # Crack
Vector2i(2, 2), # Moss
Vector2i(3, 2), # Cobweb
]
func _place_decorations() -> void:
for x in range(DUNGEON_WIDTH):
for y in range(DUNGEON_HEIGHT):
var pos := Vector2i(x, y)
# Only decorate wall tiles that have a floor neighbor below or to the side
if _is_wall(pos) and _has_floor_neighbor(pos):
if rng.randf() < DECOR_CHANCE:
var atlas := DECOR_ATLAS_OPTIONS[rng.randi() % DECOR_ATLAS_OPTIONS.size()]
decor_layer.set_cell(pos, SOURCE_ID, atlas)
func _is_wall(pos: Vector2i) -> bool:
return tilemap.get_cell_source_id(pos) != -1 # Has a tile
# Alternatively check the terrain: tilemap.get_cell_tile_data(pos).terrain == TERRAIN_WALL
func _has_floor_neighbor(pos: Vector2i) -> bool:
for offset in [Vector2i(0, 1), Vector2i(0, -1), Vector2i(1, 0), Vector2i(-1, 0)]:
var neighbor := pos + offset
if tilemap.get_cell_tile_data(neighbor) and tilemap.get_cell_tile_data(neighbor).terrain == TERRAIN_FLOOR:
return true
return falseThe decoration layer sits on top of the main tilemap as a separate TileMapLayer node. This separation matters because decorations shouldn't affect collision — your walls collide, but the torch sprite on the wall shouldn't create a separate collision shape. Keep gameplay-critical tiles (floor, wall) on the collision layer, and purely visual elements on decoration layers above it.
Seeding, Randomization, and Loot Placement
Reproducible randomness is the secret weapon of great roguelikes. Every call to the RandomNumberGenerator should flow from a single seed so that the same seed always produces the same dungeon — same room layout, same loot positions, same enemy placements. This enables daily challenge modes, replay systems, and most importantly, reproducible bug reports. When a playtester says 'the dungeon broke on seed 7829403,' you can reproduce it instantly.
The key rule is: never use Godot's global `randf()` or `randi()` functions for anything that affects gameplay state. Those use a shared global RNG that gets polluted by engine internals, particle systems, and other random calls. Always use your own RandomNumberGenerator instance with an explicit seed. If you need separate RNG streams for different systems (dungeon layout vs. loot vs. enemies), derive sub-seeds from the master seed:
var master_seed: int = 0
var layout_rng := RandomNumberGenerator.new()
var loot_rng := RandomNumberGenerator.new()
var enemy_rng := RandomNumberGenerator.new()
func setup_rngs(seed_value: int) -> void:
master_seed = seed_value
# Derive sub-seeds using hash mixing to avoid correlation
layout_rng.seed = seed_value
loot_rng.seed = seed_value ^ 0x12345678 # XOR with a constant
enemy_rng.seed = seed_value ^ 0x9ABCDEF0
# Alternative: use the layout RNG to generate seeds for others
# This works but means loot_rng depends on layout_rng's call count
# layout_rng.seed = seed_value
# loot_rng.seed = layout_rng.randi()
# enemy_rng.seed = layout_rng.randi()The XOR approach is preferred because it makes each RNG stream independent. If you later add a new `rng.randf()` call to the layout generation code, it won't shift every subsequent loot placement. With the sequential approach (using layout_rng to generate other seeds), adding one extra random call anywhere in the chain changes everything downstream.
Now let's place loot using the room list we collected during generation. A common pattern is to designate certain rooms as 'treasure rooms' based on their size or position, then scatter items within them using weighted random selection:
enum LootTier { COMMON, UNCOMMON, RARE }
# Weighted loot table: [scene_path, weight, tier]
var loot_table := [
["res://items/health_potion.tscn", 40, LootTier.COMMON],
["res://items/mana_potion.tscn", 30, LootTier.COMMON],
["res://items/iron_sword.tscn", 15, LootTier.UNCOMMON],
["res://items/fire_scroll.tscn", 10, LootTier.UNCOMMON],
["res://items/dragon_amulet.tscn", 5, LootTier.RARE],
]
func _place_loot() -> void:
for room in rooms:
var room_area := room.size.x * room.size.y
# Larger rooms get more loot (1 item per 20 tiles, minimum 1)
var item_count := max(1, room_area / 20)
# Small chance of a treasure room (2x loot)
if loot_rng.randf() < 0.1:
item_count *= 2
for i in range(item_count):
var pos := Vector2(
loot_rng.randi_range(room.position.x + 1, room.end.x - 2),
loot_rng.randi_range(room.position.y + 1, room.end.y - 2)
)
var item_scene := _pick_weighted(loot_table)
var item := load(item_scene).instantiate()
item.position = tilemap.map_to_local(Vector2i(pos))
add_child(item)func _pick_weighted(table: Array) -> String:
"""Select a random entry from a weighted table."""
var total_weight := 0
for entry in table:
total_weight += entry[1]
var roll := loot_rng.randi_range(0, total_weight - 1)
var cumulative := 0
for entry in table:
cumulative += entry[1]
if roll < cumulative:
return entry[0]
return table[0][0] # FallbackThe weighted selection algorithm sums all weights, picks a random number in that range, then walks through the table accumulating weights until it passes the roll. Items with weight 40 are 8x more likely to appear than items with weight 5. You can tune these weights endlessly — many roguelikes make rare items more common on deeper floors by scaling weights based on depth.
For enemy placement, apply the same pattern but with spatial constraints — enemies shouldn't spawn too close to room entrances (frustrating for the player) or inside walls. A safe approach is to only spawn enemies in floor tiles that are at least 2 tiles from any wall:
func _place_enemies() -> void:
for room in rooms:
# Skip the first room (player spawn room)
if room == rooms[0]:
continue
var enemy_count := enemy_rng.randi_range(1, 3)
for i in range(enemy_count):
# Try up to 10 times to find a valid position
for _attempt in range(10):
var pos := Vector2i(
enemy_rng.randi_range(room.position.x + 2, room.end.x - 3),
enemy_rng.randi_range(room.position.y + 2, room.end.y - 3)
)
if _is_valid_spawn(pos):
var enemy := load("res://enemies/base_enemy.tscn").instantiate()
enemy.position = tilemap.map_to_local(pos)
add_child(enemy)
break
func _is_valid_spawn(pos: Vector2i) -> bool:
"""Check that the position is floor and has 2+ tiles of clearance from walls."""
for dx in range(-1, 2):
for dy in range(-1, 2):
var check := tilemap.get_cell_tile_data(Vector2i(pos.x + dx, pos.y + dy))
if not check or check.terrain != TERRAIN_FLOOR:
return false
return trueNotice that we skip `rooms[0]` for enemy placement — that's the player's spawn room, and spawning enemies right on top of the player feels unfair. The retry loop (10 attempts) handles cases where a room is too small to find a valid spawn point; if all 10 attempts fail, we simply skip that enemy rather than placing it in a wall.
To wire everything together, your final `_ready` function should call each generation step in order. The full generation pipeline is: split BSP tree → carve rooms → paint tiles → connect corridors → apply autotiling → place decorations → place loot → place enemies → place player. Each step uses its own RNG stream, so modifying one system never affects another. This is the foundation of a production-quality dungeon generator, and you can extend it with stairs between floors, locked doors, keys, boss rooms, shops, and anything else your game needs.
Course Complete
You've finished all 5 lessons of Procedural Dungeon Generation in Godot 4. Go build something amazing.