You played twenty generated levels and every one was fine. Then a player hits the twenty-first, and the exit is behind a crate. A generator can make more levels in a minute than you will ever play, so playing them is not how you find the broken ones.
The answer is to make the generator check its own work. This is the last part of a series that builds one dungeon generator, pass by pass, and it turns everything the earlier parts learned into checks that run on every level.
Playable means something you can check
"Playable" sounds like a feeling. For a generator it has to be a list. Here is the one this series uses:
- Enough rooms. At least 6. A crowded map can leave the room pass short.
- A clear spawn. The cell the player starts on is floor, with nothing on it.
- Every room reachable. A flood fill from the spawn reaches every room, with props treated as walls.
- A real journey to the boss. The boss room is at least 12 steps from the spawn on foot.
Your game will need its own items. Keys before locked doors, a path wide enough for a large enemy, spawn points far from the player, a shop on every floor. The method stays the same. Each item becomes a function that looks at the level and either passes or returns a reason.
Check the level the player gets
The single most useful rule: validate after decoration, not before.
A layout can pass every check, then a later pass can quietly break it. The demo's careless decoration drops up to two crates in every room. A crate is one cell, and most corridors are one cell wide. When a crate lands in the doorway where a corridor meets a room, everything behind that doorway is cut off.
Pick Props anywhere and look through the report. Every failure here is a layout that passed until the crates went in.
A seed test report
Interactive, engine-accurate5 of 60 seeds fail. 14 × "room # unreachable", 1 × "boss only # steps away"
Seed 2: room 2 unreachable, room 3 unreachable, room 4 unreachable, room 5 unreachable, room 6 unreachable, boss only 7 steps away. Crates are props, counted as solid. Hatched floor is floor the player cannot reach once the props are in. The check runs on the finished level, after decoration, because that is the level the player gets.
This runs the GDScript from this article, ported to the browser. The same seed gives the same result in Godot 4.7.2.
With crates placed anywhere, 5 of seeds 1 to 60 fail. Seed 2 loses five rooms to crates in doorways. Switch to Keep doorways clear, which refuses to place a crate on any room cell that a corridor opens onto, and all 60 pass.
The same holds at scale. On seeds 1 to 500, careless crates break 70 levels, about 1 in 7. Keeping doorways clear leaves 11 failures, and all 11 are a different problem: the room pass only fit 5 rooms. A check found that problem too, without anyone looking for it.
When a level fails: reject, repair, or prevent
There are three ways to respond, and good generators use all three:
- Prevent it. Fix the pass that causes it. Keeping doorways clear prevents the crate problem entirely. Prevention is best when you know the cause.
- Repair it. Patch the level. Part 3 repairs unreachable rooms by adding corridors. Repair is good when the fix is local and cannot break anything else.
- Reject it. Throw the level away and generate the next seed. It is the simplest and always works, as long as failures are rare. If 1 seed in 50 fails, the player never notices. If 1 in 2 fails, rejection hides a broken generator, and you should prevent instead.
Always log the seeds you reject. A seed that failed is a free test case.
Test hundreds of seeds at once
The demo checks 60 seeds in your browser. You can do the same in Godot, from the command line, with no window. This script loops over 500 seeds, runs the checks and prints every failure:
func _initialize() -> void: var keep_doorways_clear := not OS.get_cmdline_user_args().has("--props-anywhere") var failed := 0 for seed_value in range(1, 501): var layout = DungeonLayout.new() layout.generate(seed_value) var props: Dictionary = layout.scatter_props(keep_doorways_clear) var problems: Array[String] = layout.validate(props) if not problems.is_empty(): failed += 1 print("seed %d: %s" % [seed_value, ", ".join(problems)]) print("%d of 500 seeds failed" % failed) quit(1 if failed > 0 else 0)Save it as seed_report.gd. Above the function it needs two lines: extends SceneTree, and const DungeonLayout := preload("res://dungeon_layout.gd"). Run it with godot --headless --script res://seed_report.gd from your project folder. --headless runs Godot with no window. A script that extends SceneTree runs as the whole program, and _initialize() is its entry point. quit(1) makes the run fail when any seed fails, so a build server can stop a broken generator before players see it.
On the generator in this series, it prints "11 of 500 seeds failed". With -- --props-anywhere on the end, it prints "70 of 500 seeds failed".
In Godot: the checks themselves
validate() returns a list of problems in plain words. An empty list means the level passed:
func validate(props: Dictionary) -> Array[String]: var problems: Array[String] = [] if rooms.size() < min_rooms: problems.append("only %d rooms" % rooms.size()) var start := rooms[0].get_center() if props.has(start): problems.append("spawn cell blocked") var dist := walk_distances(start, props) for i in unreachable_rooms(dist): problems.append("room %d unreachable" % i) var boss := farthest_room_on_foot(dist) var steps := dist[index(rooms[boss].get_center())] if steps < min_boss_steps: problems.append("boss only %d steps away" % steps) return problemsProps are a Dictionary used as a set: each key is a cell with a prop on it. Passing them to walk_distances(), the flood fill from part 3, makes the fill treat those cells as solid. That one argument is what makes the check see the decorated level instead of the bare layout.
The rule that prevents the crate problem asks whether a cell is a doorway, meaning a room cell with floor just outside the room next to it:
func is_doorway(cell: Vector2i, rect: Rect2i) -> bool: for dir in NEIGHBORS_4: var outside := cell + dir if not rect.has_point(outside) and is_floor(outside): return true return falseRect2i.has_point() reports whether a cell is inside the room. A neighbour that is outside the room, yet still floor, must be a corridor, so the cell next to it is a doorway.
The complete generator, with the decoration pass, is dungeon_layout.gd.
Build it next
Procedural Dungeon & Run
A quest on CodingQuests: 12 interactive lessons that build one part of a game in Godot, in your browser, checked as you go.
No CodingQuests quest teaches level validation yet. The closest practice is Procedural Dungeon & Run, which builds the generator these checks would test. Lesson 2 builds the rooms and corridors and is free. Lesson 8 adds themed decoration, the pass most likely to break a level, and is a member lesson.
The course itself does not check reachability or reject seeds. Its chain corridors cannot strand a room. Add validate() from this article once you add anything that can block a path.
- Lesson 2: Rooms & CorridorsFree
- Lesson 8: Themes & DecorationMembers
Lessons 1 to 3 are free to open, no account needed. Guild Hall, the membership, opens the other 9.
FAQ
How do I test that a procedurally generated level is playable?
Write each requirement as a check the code can run, such as every room reachable from the spawn, and run all of them on the finished, decorated level. Reject levels that fail, and run the checks on hundreds of seeds whenever the generator changes.
How do I find the parts of a generated map the player cannot reach?
Flood fill from the player's start, treating walls and solid props as blocked. Any floor cell the fill never reaches is unreachable. If a whole room is unreachable, the level fails.
Should I fix a broken generated level or make a new one?
If failures are rare, make a new one with the next seed. It is simple and always works. If failures are common, find the pass that causes them and prevent it, because rejecting most levels hides a broken generator.
Sources and testing
- Godot docs: Command line tutorial (headless, running a script), SceneTree, Rect2i.has_point()
- RogueBasin, Cellular Automata Method: flood fill to find and remove unreachable pockets
- Developer questions this answers: How to test if procedurally generated level for infinite runner can be passed? and How to guarantee that player fits rooms of a cave created by Cellular Automata? on Game Development Stack Exchange
How this was tested. Every GDScript block on this page was run in Godot 4.7.2 (headless, on Windows) on 31 October 2026. The seed report printed "11 of 500 seeds failed", and "70 of 500 seeds failed" with --props-anywhere. An automated check confirms the browser demo fails the same seeds, for the same reasons, as that Godot run.

