🔦

DUNGEON DISPATCH

// devlog for CryptsOfEtherwyn — a 2D pixel RPG

🔦

Procedural Dungeon Gen: When Your Algorithm Builds a Wall Maze

Three weeks ago I sat down with a coffee and a Wikipedia article about Binary Space Partitioning trees, fully convinced I'd have a working dungeon generator by end of day. What I actually built was a dungeon that was, functionally, one very large very depressing corridor with no rooms.

This is the story of how I fixed it, what I learned, and why you should always draw the expected output on paper before writing a single line of code.

What's BSP and Why Did I Choose It?

BSP dungeon generation works by recursively splitting a rectangle into two sub-rectangles, then splitting those, and so on until your partitions are small enough to place a room in. Connect the rooms via corridors through the partition boundaries and you get a dungeon that always has 100% connectivity — no isolated dead zones.

The appeal is obvious: guaranteed connectivity, variable room sizes, easy to tune via a handful of parameters. Compare this to cellular automata (great for caves, terrible for structured dungeons) or random room placement (fast but requires a lot of cleanup for connectivity). For a dungeon crawler, BSP felt right.

# Simplified BSP split in GDScript
class_name BSPNode

var rect: Rect2i
var left: BSPNode
var right: BSPNode
var room: Rect2i  # actual room within this partition

func split(min_size: int) -> bool:
    if rect.size.x <= min_size * 2 and rect.size.y <= min_size * 2:
        return false  # too small to split

    var split_horizontal = randf() > 0.5
    # prefer to split along the longer axis
    if rect.size.x > rect.size.y * 1.25:
        split_horizontal = false
    elif rect.size.y > rect.size.x * 1.25:
        split_horizontal = true

    var max_split = (rect.size.y if split_horizontal else rect.size.x) - min_size
    if max_split <= min_size:
        return false

    var split_pos = randi_range(min_size, max_split)
    # ... create left and right children
    return true

The Bug: 100% Corridors, 0% Rooms

Here's where I went wrong. After building the BSP tree, you're supposed to carve rooms within each leaf node's partition. I was doing that. The rooms were being placed. But the corridor-carving step was... also carving through the rooms. Completely overwriting them.

My tilemap was using value 0 for "empty floor" and I was also using 0 as my default/uninitialized tile. When the corridor painter walked over a room tile, it was writing 0 (floor), but then the room painter ran AFTER and overwrote everything to... also 0. The rooms existed, I just couldn't see them because they were already floor.

It took me two full days to realize the actual rooms were fine. I was just painting corridors on top of them with the wrong painter ordering. The fix was three lines: run room carving after corridor carving, not before.

# WRONG order (what I had):
_carve_rooms(root_node)
_carve_corridors(root_node)

# CORRECT order:
_carve_corridors(root_node)
_carve_rooms(root_node)  # rooms overwrite corridors that ran through them

Tuning the Dungeon Feel

Once the basic generation worked, I spent another week tweaking parameters to make dungeons that actually feel good to play in. Here's what I learned:

Room size vs partition size ratio

If your rooms fill too much of their partition, dungeons feel cramped and every corridor is a one-tile pinch point. I settled on rooms occupying 55-75% of their partition's area (randomly). Below 50% creates weird isolated rooms surrounded by wasted corridor space.

Minimum partition size

This controls how many rooms you get. Too small and your dungeon is a maze of tiny closets. Too large and you get five massive rooms that feel more like arenas than a dungeon. I use 8 tiles minimum with a 32×32 starting grid — gives me 8-14 rooms per floor, which feels right for a ~5-10 minute run.

Add a "special room" pass after BSP generation. Pick 1-2 leaf nodes and mark them as special — larger rooms, different tile appearance, guaranteed treasure/boss. This creates landmarks that make the dungeon feel authored rather than algorithmically soulless.

Corridor width

I started with 1-tile corridors. They work, but they feel punishing in a game where positioning matters. 2-tile corridors made combat in hallways actually feel like a decision rather than a coin flip. It's a subtle change that made a big difference in playtests.

What's Next

The generator now produces solid, playable dungeons consistently. Next I want to add:

Themed rooms — library rooms, crypt chambers, flooded rooms. These are just different tile sets applied to specific partitions based on depth/random chance.

Secret passages — thin breakable walls between non-adjacent rooms. This is just a post-processing pass that identifies walls that separate two rooms within one tile and randomly "weakens" some of them.

Vertical floors — stairs that carry state across floor transitions. The generator is already there; I just need to wire up the scene loading.

Next devlog I'll be deep in the inventory system, which is currently just a Dictionary on the player node and some prayers. See you in the depths.

← DEVLOG #11: Turn-Based Combat ALL ENTRIES →