Procedural Generation in a Game Without Losing Design
Add procedural generation to a game without losing design control. A seeded Godot 4 room generator, a solvability check, fixed anchors and failures to test.

A procedural generation game stays designed when you generate the parts where variety matters, hand-author the parts where meaning matters, seed every random call, and validate each level before the player sees it. That is the whole method. In Godot 4.7 it takes a RandomNumberGenerator with a string seed, a TileMapLayer to write cells into, and an AStarGrid2D to prove the exit is reachable. The code below builds a room-and-corridor level, drops hazards that can break it, and regenerates until the level passes or a retry cap trips.
The two essays that rank for this phrase argue about whether generated worlds feel empty. Fair argument. Neither one shows a line of code, a seed, or a check that the level is playable, and that gap is where most solo dev generators actually fail.
What Procedural Generation Is and Is Not
Procedural generation means content produced by rules and random numbers at runtime or build time instead of placed by hand. Room layouts, terrain, loot tables, enemy placement, quest text. The rules are yours, the numbers come from a seeded generator, and the same seed produces the same result every time.
It is not AI. That question shows up in the related searches and the answer matters for your bug reports. A seeded algorithm is deterministic. Seed 12345 gives you the same dungeon on your machine and on a player's machine, which is how you reproduce the "I fell through the floor on level 3" report. A model that samples from a prompt does not give you that guarantee, and I'd keep the two ideas in separate boxes.
On the history question people also ask, Game Developer's design essay puts Beneath Apple Manor in 1978, two years before Rogue. Rogue gets the credit in most retellings. Which one counts as first depends on who is counting, and it doesn't change how you build yours.
Decide What to Generate Before Writing a Generator
The mistake I see most is generating everything and then wondering why the game has no memorable rooms. So decide first. This table is my split for a small 2D action or roguelike project.
| Element | Generate | Hand-author | Why |
|---|---|---|---|
| Room layout and corridors | yes | no | variety per run is the point |
| Start room and exit room | no | yes | the player needs a fixed frame to learn the level language |
| One set piece per level | no | yes | this is what people remember and screenshot |
| Hazard placement | yes | no, but budgeted | count and spacing rules keep it fair |
| Enemy count per depth | rule-driven | the rule itself | the ramp is design, the roll is not |
| Item drops | yes, from a weighted table | the table | rarity is a decision |
| Boss rooms | no | yes | a generated boss arena is a coin flip on fairness |
| Story beats and named places | no | yes | Game Developer's essay compares Daggerfall's 62,000 generated square miles with Skyrim's 16 authored ones, and people remember the 16 |
The right column is smaller than the left one and it costs more per item. That is fine. Generation buys volume. Authorship buys meaning. Your scope decision should count the authored items as real content, because they are.
Seed Everything First
Godot's random number tutorial recommends a RandomNumberGenerator instance per system rather than the global functions, so each system has its own seed and state. I follow that. The generator gets one instance, combat gets another, and loot gets a third. A change to how many enemies spawn then stops shifting the dungeon layout, which is the bug that makes seeded replays useless.
A string seed is easier to share than an integer. The docs show "Hello world".hash() as the pattern, and the RandomNumberGenerator reference confirms that "a given seed will give a reproducible sequence of pseudo-random numbers."
extends Node2D
@export var seed_text: String = "shipagame"
@export var daily_seed: bool = false
var rng := RandomNumberGenerator.new()
func _ready() -> void:
if daily_seed:
seed_text = Time.get_date_string_from_system(true)
rng.seed = seed_text.hash()
print("level seed: ", seed_text)
if generate():
_place_set_piece()
Time.get_date_string_from_system(true) returns the UTC date as YYYY-MM-DD, so every player who opens the game on the same day gets the same layout. Print the seed on every generation. When a tester says a level was broken, the seed is the bug report.
One caveat from the docs themselves. The RNG has no avalanche property, so similar seeds can produce similar streams. Hashing the string spreads them out, which is another reason to hash rather than feed short integers directly.
A Room and Corridor Generator in GDScript
The scene is a Node2D with a TileMapLayer child. Godot's docs mark the old TileMap node as deprecated, so use the layer node. The tile set has one atlas source with id 0 and three tiles at atlas coordinates (0,0) floor, (1,0) wall, (2,0) pit. Swap the coordinates for your own tiles.
const MAP_WIDTH := 48
const MAP_HEIGHT := 32
const ROOM_COUNT := 8
const ROOM_MIN := 4
const ROOM_MAX := 9
const HAZARD_COUNT := 40
const MAX_ATTEMPTS := 20
const FLOOR_ATLAS := Vector2i(0, 0)
const WALL_ATLAS := Vector2i(1, 0)
const PIT_ATLAS := Vector2i(2, 0)
@onready var layer: TileMapLayer = $TileMapLayer
var rooms: Array[Rect2i] = []
func generate() -> bool:
for attempt in range(MAX_ATTEMPTS):
rooms.clear()
layer.clear()
_fill_walls()
_place_rooms()
_carve_corridors()
_place_hazards()
if _is_solvable():
print("layout ok, attempt %d, seed %s" % [attempt + 1, seed_text])
return true
push_error("no solvable layout in %d attempts for seed %s" % [MAX_ATTEMPTS, seed_text])
return false
func _fill_walls() -> void:
for x in range(MAP_WIDTH):
for y in range(MAP_HEIGHT):
layer.set_cell(Vector2i(x, y), 0, WALL_ATLAS)
func _place_rooms() -> void:
var tries := 0
while rooms.size() < ROOM_COUNT and tries < 200:
tries += 1
var w := rng.randi_range(ROOM_MIN, ROOM_MAX)
var h := rng.randi_range(ROOM_MIN, ROOM_MAX)
var x := rng.randi_range(1, MAP_WIDTH - w - 2)
var y := rng.randi_range(1, MAP_HEIGHT - h - 2)
var candidate := Rect2i(x, y, w, h)
var padded := candidate.grow(1)
var blocked := false
for room in rooms:
if padded.intersects(room):
blocked = true
break
if blocked:
continue
rooms.append(candidate)
_carve_rect(candidate)
func _carve_rect(rect: Rect2i) -> void:
for x in range(rect.position.x, rect.end.x):
for y in range(rect.position.y, rect.end.y):
layer.set_cell(Vector2i(x, y), 0, FLOOR_ATLAS)
Rooms are placed in order and rejected when the padded rectangle overlaps an existing room. Rect2i.grow(1) extends every side by one tile, which keeps a wall between rooms, and intersects() excludes edges, so two rooms can share a wall line without being counted as overlapping. The tries cap stops the loop when the map is too full for eight rooms of that size, and you'll notice it before the player does because rooms.size() will be short.
Corridors connect each room to the next one in placement order with an L shape. Two carving orders, chosen by the RNG, keep the bends from all pointing the same way.
func _carve_corridors() -> void:
for i in range(1, rooms.size()):
var a := rooms[i - 1].get_center()
var b := rooms[i].get_center()
if rng.randf() < 0.5:
_carve_line(a.x, b.x, a.y, true)
_carve_line(a.y, b.y, b.x, false)
else:
_carve_line(a.y, b.y, a.x, false)
_carve_line(a.x, b.x, b.y, true)
func _carve_line(from: int, to: int, fixed: int, horizontal: bool) -> void:
var lo := mini(from, to)
var hi := maxi(from, to)
for v in range(lo, hi + 1):
var cell := Vector2i(v, fixed) if horizontal else Vector2i(fixed, v)
layer.set_cell(cell, 0, FLOOR_ATLAS)
At this point every level is connected by construction. Corridors never leave the map because rooms sit at least one tile inside the border. If you stopped here, a solvability check would pass every time and teach you nothing.
So break it on purpose.
func _place_hazards() -> void:
var floor_cells := layer.get_used_cells_by_id(0, FLOOR_ATLAS)
var start_room := rooms[0]
var exit_room := rooms[rooms.size() - 1]
var placed := 0
while placed < HAZARD_COUNT and not floor_cells.is_empty():
var index := rng.randi_range(0, floor_cells.size() - 1)
var cell: Vector2i = floor_cells[index]
floor_cells.remove_at(index)
if start_room.has_point(cell) or exit_room.has_point(cell):
continue
layer.set_cell(cell, 0, PIT_ATLAS)
placed += 1
Forty pits on a 48 by 32 map with one-tile corridors will sometimes cut the only path, and that is the realistic case rather than a contrived one, because I'd bet a second pass like this shows up in your generator within a month of the first playtest. Any rule that removes floor after the layout is connected, whether pits, locked doors, collapsed tiles, a corridor cull for atmosphere or a boss gate that opens later, can produce an unwinnable level, and the only honest defense is to check every level before it is shown rather than trusting that the numbers are small enough. They will not stay small. Somebody on the team, probably you at midnight, will raise the hazard count because the level felt easy.
Validate Before the Player Sees It
AStarGrid2D is the cheapest check I know of in Godot. Build a grid the size of the map, mark every non-floor cell solid, and ask for a path from the start room's center to the exit room's center. The class reference requires update() after setting region or cell_size, and says marking points solid needs no further update call.
func _is_solvable() -> bool:
if rooms.size() < 2:
return false
var grid := AStarGrid2D.new()
grid.region = Rect2i(0, 0, MAP_WIDTH, MAP_HEIGHT)
grid.cell_size = Vector2(16, 16)
grid.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_NEVER
grid.update()
for x in range(MAP_WIDTH):
for y in range(MAP_HEIGHT):
var cell := Vector2i(x, y)
grid.set_point_solid(cell, layer.get_cell_atlas_coords(cell) != FLOOR_ATLAS)
var start := rooms[0].get_center()
var exit := rooms[rooms.size() - 1].get_center()
return not grid.get_id_path(start, exit).is_empty()
get_id_path() returns an empty array when no route exists. DIAGONAL_MODE_NEVER matters because the pathfinder will otherwise squeeze diagonally between two pits that a player with four-direction movement cannot pass. Match the check to the movement rule of your game, which in my view is the most common mismatch in homemade validators. If the player can jump one tile, the grid has to know that too, or the check will reject levels that are actually fine.
The retry cap is the other half.
Twenty attempts is generous for these numbers. If a seed fails all twenty, the print in generate() tells you which one, and that seed goes straight into a test so the failure never comes back silently. When the cap trips in a shipped build, fall back to a stored known-good layout rather than showing a broken one.
Keep the Design Anchors Fixed
Now the part where I think the essays are right. A level that is all generator has nothing to remember.
Three anchors survive generation in this setup without any extra code. Room zero is the start, the last room is the exit, and both are protected from hazards. That already gives the player a fixed frame at both ends. The middle is where the set piece goes.
const SET_PIECE := preload("res://rooms/altar.tscn")
func _place_set_piece() -> void:
var middle := rooms[floori(rooms.size() / 2.0)]
var piece := SET_PIECE.instantiate() as Node2D
piece.position = layer.map_to_local(middle.get_center())
add_child(piece)
map_to_local() returns the centered position of a cell in the layer's local space, which is the right coordinate for a child of the same parent. The altar scene is hand-built. Its lighting, its dialogue, its one enemy that behaves differently, all authored. The generator only decides which room it lands in.
Call _place_set_piece() after generate() returns true, never before. Placing authored content into a layout that then gets regenerated is how you end up with an altar inside a wall.
Difficulty is the other anchor. Keep ROOM_COUNT and HAZARD_COUNT as constants while you tune, then move them into a small per-depth table you write by hand. Depth 1 gets 6 rooms and 15 pits, depth 5 gets 9 rooms and 50, and the ramp is a design decision you can read in one place instead of a formula buried in the roll.
Terrain With Noise Under the Same Rules
For an overworld instead of a dungeon, FastNoiseLite replaces the room placer and the rest of the method stays. The class reference defaults to smooth simplex noise at a frequency of 0.01 with five fractal octaves, and get_noise_2d() returns a value roughly between -1 and 1.
const WATER_ATLAS := Vector2i(3, 0)
const GRASS_ATLAS := Vector2i(4, 0)
const ROCK_ATLAS := Vector2i(5, 0)
func _generate_terrain() -> void:
var noise := FastNoiseLite.new()
noise.seed = seed_text.hash()
noise.frequency = 0.05
for x in range(MAP_WIDTH):
for y in range(MAP_HEIGHT):
var value := noise.get_noise_2d(x, y)
var atlas := GRASS_ATLAS
if value < -0.2:
atlas = WATER_ATLAS
elif value > 0.4:
atlas = ROCK_ATLAS
layer.set_cell(Vector2i(x, y), 0, atlas)
The thresholds and the 0.05 frequency are tuning values for a 48 by 32 map, not constants from the docs. Lower frequency gives bigger continents. Then run the same solvability check with water and rock marked solid, and place the same fixed anchors. A generated island with no authored village on it is the empty world the essays complain about, and they are right to.
Failure Modes Worth a Test Each
These are the ways I'd expect this generator to break, in the order I'd test them.
| Symptom | Likely cause | Fix |
|---|---|---|
| Same layout every run in the editor, different in the export | seed set from an exported variable that the export preset overrides | print the seed at generation and compare |
| Layout changes when you add an enemy type | one shared RNG across systems | one RandomNumberGenerator per system |
Fewer rooms than ROOM_COUNT |
map too full for the room size range | lower ROOM_MAX or raise the map size, and log rooms.size() |
| Exit unreachable but the check passed | check allows diagonals or ignores a hazard tile type | match diagonal_mode and the solid test to the movement rule |
| Retry cap trips on a specific seed | hazard count too high for corridor width | add that seed to a test, lower the count, or widen corridors |
| Set piece inside a wall | placed before the final regeneration | place authored content only after generate() returns true |
| Level is technically solvable and feels unfair | validation only checks reachability | add a second check for path length or hazard density near the start |
The last row is the one to sit with. Reachable is the floor, not the goal. A prototype built around the question "can generated layouts stay solvable under the movement rule" is worth a weekend before you commit a whole game to a generator, and the code above is small enough to be that prototype.
Games That Get the Balance Right
People also ask which games do this well, so briefly. The Game Developer essay lists Minecraft, Terraria, No Man's Sky, The Binding of Isaac and ARK as modern examples, and SUPERJUMP's design piece traces the lineage from Rogue and Elite through Daggerfall and Dwarf Fortress to Starfield. The stevetech level generation post describes Dead Cells as a hybrid of authored skeleton and generated fill, which is the same split as the table near the top of this article.
I have not reverse-engineered any of those generators and I'm not going to pretend I know their internals. What they share from the outside is the pattern here. Something fixed for the player to hold on to, something generated for the player to explore, and a rule that keeps the two from colliding.
If you're new to the engine, the Godot 2D tutorial on this site builds the scene, input and tile basics that this generator assumes. Start there, then come back and break some levels on purpose.
Related Articles

How to Make Money Making Video Games as a Solo Dev
How to make money making video games as a solo dev, with net-per-copy math from official Steam, itch.io, Apple and Google pages, fixed costs and payout timing.

How to Publish a Game on itch.io Without Errors
Learn how to publish a game on itch.io with a preflight for page assets, files, HTML5 archives, pricing, visibility, and a clean incognito test.

How to Get Steam Wishlists Without a Magic Number
Learn how to get Steam wishlists with a source ledger for demos, events, creators, and store improvements, then read the data without vanity targets.