/ Game Dev With AI / Procedural Generation in a Game Without Losing Design
Game Dev With AI 14 min read

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.

Procedural Generation in a Game Without Losing Design

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.