Every roguelike needs random dungeons, and Binary Space Partitioning (BSP) is the algorithm that most of them use under the hood. It guarantees connected rooms, produces natural-looking layouts, and runs fast enough to generate a new floor in a single frame. The catch is that most tutorials covering BSP in Godot are written for Godot 3 and use the deprecated TileMap node.

This tutorial walks through building a complete BSP dungeon generator in Godot 4 using GDScript and the current TileMapLayer API. By the end you will have a working system that splits space into rooms, connects them with corridors, and renders everything to a tilemap. If you have worked through our Wave Function Collapse tutorial, this is the natural next step for your procedural generation toolkit.

Key Takeaways

  • BSP splits space recursively: a binary tree divides your map into smaller partitions, then places one room per leaf node
  • Corridors connect sibling rooms: walking up the tree from leaves to root guarantees every room is reachable
  • Godot 4 uses TileMapLayer: the old TileMap node is deprecated, this tutorial uses the current API
  • Five parameters control everything: map size, recursion depth, split range, minimum room size, and room padding
  • The full script is under 150 lines: BSP is one of the simplest procedural generation algorithms to implement

How BSP Dungeon Generation Works

BSP dungeon generation is a three-phase process. First, you recursively split a rectangular space into smaller partitions using a binary tree. Second, you place a room inside each leaf node (the partitions that were not split further). Third, you connect sibling rooms with corridors by walking back up the tree.

The binary tree structure is what makes BSP reliable. Because every split produces exactly two children, and corridors connect siblings at every level of the tree, the resulting dungeon is always fully connected. No orphaned rooms, no unreachable areas. That guarantee is why BSP is the go-to algorithm for roguelikes like Binding of Isaac, Enter the Gungeon, and countless indie projects.

BSP dungeon generation diagram showing recursive space partitioning into rooms and corridors
BSP recursively splits space, places rooms in leaves, then connects them with corridors.

Setting Up the Godot 4 Project

Create a new Godot 4 project. Your scene needs two nodes:

  • A Node2D as the root (this holds the generator script)
  • A TileMapLayer as a child (this renders the dungeon)

Set up a TileSet on the TileMapLayer with at least two tile types: a floor tile and a wall tile. A 16×16 pixel tile size works well for prototyping. The atlas coordinates for your floor tile will go into the generator script. For this tutorial, we use Vector2i(0, 0) for walls and Vector2i(1, 0) for floors. Adjust these to match your own tileset.

Attach a new script to the root Node2D. This is where all the generation logic lives.

Building the BSP Tree

The core data structure is a Branch class that represents one partition of space. Each branch stores its position, size, and references to two child branches (or null if it is a leaf).

Create a new script file called branch.gd:

class_name Branch

var position: Vector2i
var size: Vector2i
var left: Branch
var right: Branch
var room: Rect2i

func _init(pos: Vector2i, s: Vector2i) -> void:
    position = pos
    size = s

func is_leaf() -> bool:
    return left == null and right == null

func get_center() -> Vector2i:
    if room != Rect2i():
        return Vector2i(
            room.position.x + room.size.x / 2,
            room.position.y + room.size.y / 2
        )
    return Vector2i(position.x + size.x / 2, position.y + size.y / 2)

func get_leaves() -> Array[Branch]:
    if is_leaf():
        return [self]
    var result: Array[Branch] = []
    if left:
        result.append_array(left.get_leaves())
    if right:
        result.append_array(right.get_leaves())
    return result

The room property stores the actual room rectangle placed inside this partition. It starts empty and gets assigned during room generation. The get_center() method returns the room center if a room exists, otherwise the partition center. This is used later for corridor routing.

Recursive Splitting

The split function takes a branch and divides it into two children. The split direction depends on the aspect ratio: tall partitions split horizontally, wide partitions split vertically. The split position is randomized between 30% and 70% of the available space to avoid uniform grids.

Add this function to your main generator script:

const MIN_PARTITION_SIZE := 10

func split_branch(branch: Branch, depth: int) -> void:
    if depth <= 0 or branch.size.x < MIN_PARTITION_SIZE * 2 \
            and branch.size.y < MIN_PARTITION_SIZE * 2:
        return

    var split_h := branch.size.y >= branch.size.x
    var split_pct := randf_range(0.3, 0.7)

    if split_h:
        var split_y := int(branch.size.y * split_pct)
        branch.left = Branch.new(
            branch.position,
            Vector2i(branch.size.x, split_y)
        )
        branch.right = Branch.new(
            Vector2i(branch.position.x, branch.position.y + split_y),
            Vector2i(branch.size.x, branch.size.y - split_y)
        )
    else:
        var split_x := int(branch.size.x * split_pct)
        branch.left = Branch.new(
            branch.position,
            Vector2i(split_x, branch.size.y)
        )
        branch.right = Branch.new(
            Vector2i(branch.position.x + split_x, branch.position.y),
            Vector2i(branch.size.x - split_x, branch.size.y)
        )

    split_branch(branch.left, depth - 1)
    split_branch(branch.right, depth - 1)

A depth of 4 produces up to 16 rooms (2^4). Depth 5 gives 32. Start with 4 for testing and adjust based on your map size. The MIN_PARTITION_SIZE constant prevents partitions from becoming too small to hold a room.

Generating Rooms Inside Leaves

After the tree is built, iterate over all leaf nodes and place a room inside each one. The room is smaller than the partition, with random padding on each side to prevent rooms from touching the partition edges.

const MIN_ROOM_SIZE := 4
const ROOM_PADDING := 2

func generate_rooms(branch: Branch) -> void:
    for leaf in branch.get_leaves():
        var max_w := leaf.size.x - ROOM_PADDING * 2
        var max_h := leaf.size.y - ROOM_PADDING * 2

        if max_w < MIN_ROOM_SIZE or max_h < MIN_ROOM_SIZE:
            continue

        var w := randi_range(MIN_ROOM_SIZE, max_w)
        var h := randi_range(MIN_ROOM_SIZE, max_h)
        var x := leaf.position.x + randi_range(ROOM_PADDING, leaf.size.x - w - ROOM_PADDING)
        var y := leaf.position.y + randi_range(ROOM_PADDING, leaf.size.y - h - ROOM_PADDING)

        leaf.room = Rect2i(x, y, w, h)

The padding creates natural gaps between rooms that become wall space. If a partition is too small to fit the minimum room size with padding, it gets skipped. This occasionally produces empty partitions, which is fine. Real dungeons have dead space.

Connecting Rooms with Corridors

This is where BSP pays off. Because the tree guarantees that every non-leaf node has exactly two children, you connect rooms by walking the tree and drawing a corridor between each pair of siblings.

var corridors: Array[Array] = []

func connect_rooms(branch: Branch) -> void:
    if branch.is_leaf():
        return
    if branch.left and branch.right:
        connect_rooms(branch.left)
        connect_rooms(branch.right)

        var left_center := branch.left.get_center()
        var right_center := branch.right.get_center()
        carve_corridor(left_center, right_center)

func carve_corridor(from: Vector2i, to: Vector2i) -> void:
    var path: Array[Vector2i] = []
    var current := from

    # Move horizontally first
    while current.x != to.x:
        current.x += sign(to.x - current.x)
        path.append(current)

    # Then move vertically
    while current.y != to.y:
        current.y += sign(to.y - current.y)
        path.append(current)

    corridors.append(path)

The corridor carving uses an L-shaped path: move horizontally first, then vertically. This creates clean right-angle corridors. For more organic-looking paths, you could randomize whether horizontal or vertical movement comes first, or add random jogs along the way.

Rendering to the TileMapLayer

With rooms and corridors defined, the final step is painting them onto the TileMapLayer. Start by filling the entire map with wall tiles, then carve out floor tiles for rooms and corridors.

@export var map_size := Vector2i(60, 40)
@export var split_depth := 4
@onready var tile_layer: TileMapLayer = $TileMapLayer

const TILE_SOURCE := 0
const WALL_ATLAS := Vector2i(0, 0)
const FLOOR_ATLAS := Vector2i(1, 0)

func _ready() -> void:
    generate_dungeon()

func generate_dungeon() -> void:
    corridors.clear()

    # Fill with walls
    for x in range(map_size.x):
        for y in range(map_size.y):
            tile_layer.set_cell(Vector2i(x, y), TILE_SOURCE, WALL_ATLAS)

    # Build BSP tree
    var root := Branch.new(Vector2i.ZERO, map_size)
    split_branch(root, split_depth)

    # Place rooms
    generate_rooms(root)

    # Connect rooms
    connect_rooms(root)

    # Carve rooms into tilemap
    for leaf in root.get_leaves():
        if leaf.room != Rect2i():
            for x in range(leaf.room.position.x, leaf.room.position.x + leaf.room.size.x):
                for y in range(leaf.room.position.y, leaf.room.position.y + leaf.room.size.y):
                    tile_layer.set_cell(Vector2i(x, y), TILE_SOURCE, FLOOR_ATLAS)

    # Carve corridors into tilemap
    for path in corridors:
        for cell in path:
            tile_layer.set_cell(cell, TILE_SOURCE, FLOOR_ATLAS)

Hit Play and you should see a random dungeon every time the scene loads. The @export variables let you tweak map size and split depth from the inspector without editing code.

Tuning and Extending

The basic generator works, but real games need more refinement. Here are the parameters worth experimenting with and some extensions to consider.

Parameters That Matter

ParameterEffectStarting Value
map_sizeTotal dungeon dimensions in tiles60 x 40
split_depthHow many times to subdivide (2^n max rooms)4
MIN_PARTITION_SIZESmallest allowed partition before stopping splits10
MIN_ROOM_SIZESmallest room dimension4
ROOM_PADDINGGap between room edges and partition edges2
Split range (0.3 - 0.7)How far off-center splits can be. Wider = more variety0.3 - 0.7

Dead-End Removal

Some corridors create short dead-end stubs. A simple cleanup pass checks each floor tile for three or more wall neighbors in cardinal directions and converts those tiles back to walls. Run this in a loop until no more changes occur:

func remove_dead_ends() -> void:
    var changed := true
    while changed:
        changed = false
        for x in range(1, map_size.x - 1):
            for y in range(1, map_size.y - 1):
                var pos := Vector2i(x, y)
                if tile_layer.get_cell_atlas_coords(pos) != FLOOR_ATLAS:
                    continue
                var walls := 0
                for dir in [Vector2i.UP, Vector2i.DOWN, Vector2i.LEFT, Vector2i.RIGHT]:
                    if tile_layer.get_cell_atlas_coords(pos + dir) == WALL_ATLAS:
                        walls += 1
                if walls >= 3:
                    tile_layer.set_cell(pos, TILE_SOURCE, WALL_ATLAS)
                    changed = true

Where to Go from Here

Once the basic structure works, you can layer on more systems: spawn points for enemies and items based on room size, locked doors between specific corridors, room type classification (treasure rooms, boss rooms, shops) based on distance from the start room, or decorative tile autotiling using Godot 4's terrain system. If you want to add AI enemies that move through these dungeons, our steering behaviors for game AI tutorial covers pathfinding and movement in Godot 4.

Long coding sessions building procedural systems need a solid setup. A good mechanical keyboard, focused audio, and a controller for playtesting make the iteration loop faster. If you are still deciding on a game engine, our Godot vs Unity comparison breaks down the tradeoffs.

Frequently Asked Questions

Why use BSP instead of random room placement?

BSP guarantees that all rooms are connected through the tree structure. Random room placement requires a separate pathfinding step to ensure connectivity, and can produce clusters of overlapping rooms or isolated areas that need extra code to fix.

How many rooms can BSP generate?

The maximum number of rooms equals 2 raised to the power of your split depth. Depth 4 gives up to 16 rooms, depth 5 gives 32, and depth 6 gives 64. Some leaves may be too small for rooms, so the actual count is usually slightly less than the maximum.

Does this work with Godot 4's terrain autotiling?

Yes. After generating the floor and wall layout, you can apply terrain connections as a post-processing step. The generator places tiles by atlas coordinates, and Godot's terrain system can then handle wall-to-floor transitions automatically.

Can I use this for 3D dungeons?

The BSP algorithm works in any number of dimensions. For 3D, replace Vector2i with Vector3i, add a z-axis split, and use GridMap or CSG nodes instead of TileMapLayer. The tree logic stays the same.

How do I add a player spawn point?

Pick a random leaf room after generation and place the player at that room's center coordinates. For the exit, pick the leaf room farthest from the spawn using Manhattan distance between room centers.

Summary

BSP dungeon generation is one of the most reliable procedural algorithms for roguelikes, and the full implementation in Godot 4 fits under 150 lines of GDScript. The tree structure guarantees connectivity, the parameters give you fine-grained control over layout variety, and the TileMapLayer integration keeps rendering simple. Start with the basic generator, tweak the five core parameters until the layouts feel right, then layer on room types, enemy spawns, and decoration passes to make it your own. If you're building a roguelike deckbuilder, pair this dungeon generator with a card game system in Godot 4 for a complete Slay the Spire-style foundation. Godot 4 TileMapLayer is where the generator paints its output, using set_cell to place every room and corridor.