Free Tutorial · Procedural Generation

Godot 4 Procedural Generation — Dungeons, Terrain
& Infinite Worlds

SScriptSnap/March 2026/16 min read

Hand-crafting every level is a bottleneck. Whether you're building a roguelike dungeon crawler, an open-world survival game, or a puzzle platformer with endless replayability, procedural generation lets your game create content on the fly — infinitely, and differently every time.

In this comprehensive Godot 4 procedural generation tutorial, you'll master six core techniques: noise-based terrain with FastNoiseLite, BSP dungeon generation, wave function collapse concepts, chunk-based infinite worlds, seeded randomness, and procedural enemy and loot spawning. Every example uses complete GDScript you can paste directly into your project.

If you're new to Godot's tilemap system, start with our Godot 4 Tilemap Tutorial first — procedural generation builds heavily on tilemap concepts. Also grab our free GDScript cheat sheet before diving in.

Tutorial

1. Noise-Based Terrain with FastNoiseLite

Noise functions produce smooth, organic-looking randomness — perfect for terrain heightmaps, cave systems, and biome maps. Godot 4's built-in FastNoiseLite resource gives you Perlin, Simplex, Cellular, and Value noise out of the box with zero plugins required.

The core idea: loop over a 2D grid, sample the noise value at each cell (a float between -1.0 and 1.0), and assign a tile type based on configurable thresholds. Values above the threshold become ground; values below become walls, water, or stone. Adjusting the frequency property controls the "zoom level" — lower frequencies produce sweeping continents while higher frequencies create tight cave-like formations.

terrain_generator.gd — FastNoiseLite Terrain
# terrain_generator.gd — Noise-based terrain generation
extends Node2D

@export var width: int = 120
@export var height: int = 80
@export var ground_threshold: float = 0.0
@export var water_threshold: float = -0.25

@onready var tile_map: TileMapLayer = $TileMapLayer

const GROUND := Vector2i(0, 0)
const WALL   := Vector2i(1, 0)
const WATER  := Vector2i(2, 0)

func _ready() -> void:
    generate_terrain()

func generate_terrain() -> void:
    var noise := FastNoiseLite.new()
    noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
    noise.frequency = 0.04
    noise.seed = randi()

    # Optional: fractal layering for richer detail
    noise.fractal_type = FastNoiseLite.FRACTAL_FBM
    noise.fractal_octaves = 4
    noise.fractal_lacunarity = 2.0

    for x in range(width):
        for y in range(height):
            var value := noise.get_noise_2d(float(x), float(y))
            var tile: Vector2i

            if value > ground_threshold:
                tile = GROUND
            elif value > water_threshold:
                tile = WALL
            else:
                tile = WATER

            tile_map.set_cell(Vector2i(x, y), 0, tile)

Fractal layering (FBM — Fractal Brownian Motion) stacks multiple noise octaves at increasing frequencies. The result is terrain with both large-scale landmass variation and fine coastal detail. Increase fractal_octaves for more detail at the cost of slightly more computation.

Swap TYPE_SIMPLEX_SMOOTH for TYPE_CELLULAR to get Voronoi-style cell patterns — perfect for organic cave walls and biome boundaries. You can also layer two noise instances at different frequencies for even richer results.

For tilemap setup basics like TileSet resources and autotile terrain rules, see our Godot 4 Tilemap Tutorial — everything in this guide builds on those foundations.

Want to pair procedural levels with clean signal architecture? Our Mastering GDScript Signals course teaches you how to decouple game systems like a pro.

View Course →

2. BSP Dungeon Generation

Binary Space Partitioning (BSP) is the gold-standard algorithm for Godot dungeon generator implementations. Unlike random room scattering, BSP recursively splits the map into smaller rectangles, guarantees zero room overlap, and produces layouts with natural spatial hierarchy — think "wing of a castle" rather than "rooms thrown at a wall."

The algorithm works in three phases: split the map recursively (alternating horizontal and vertical cuts), place a room inside each leaf node, and connect sibling rooms with corridors as you walk back up the tree. This guarantees every room is reachable.

bsp_dungeon.gd — Binary Space Partitioning
# bsp_dungeon.gd — BSP dungeon generator
extends Node2D

@export var map_w: int = 80
@export var map_h: int = 60
@export var min_leaf: int = 10
@export var room_padding: int = 2

@onready var tile_map: TileMapLayer = $TileMapLayer
var rng := RandomNumberGenerator.new()
var rooms: Array[Rect2i] = []

const FLOOR := Vector2i(0, 0)
const WALL  := Vector2i(1, 0)

func _ready() -> void:
    rng.randomize()
    _fill_walls()
    _split(Rect2i(0, 0, map_w, map_h))

func _fill_walls() -> void:
    for x in range(map_w):
        for y in range(map_h):
            tile_map.set_cell(Vector2i(x, y), 0, WALL)

func _split(area: Rect2i) -> Rect2i:
    # Base case: leaf is small enough — place a room
    if area.size.x <= min_leaf * 2 and area.size.y <= min_leaf * 2:
        return _place_room(area)

    # Choose split direction based on aspect ratio
    var split_h := area.size.x < area.size.y
    if area.size.x > area.size.y * 1.25:
        split_h = false
    elif area.size.y > area.size.x * 1.25:
        split_h = true

    var a: Rect2i
    var b: Rect2i
    if split_h:
        var sy := rng.randi_range(min_leaf, area.size.y - min_leaf)
        a = Rect2i(area.position, Vector2i(area.size.x, sy))
        b = Rect2i(Vector2i(area.position.x, area.position.y + sy),
            Vector2i(area.size.x, area.size.y - sy))
    else:
        var sx := rng.randi_range(min_leaf, area.size.x - min_leaf)
        a = Rect2i(area.position, Vector2i(sx, area.size.y))
        b = Rect2i(Vector2i(area.position.x + sx, area.position.y),
            Vector2i(area.size.x - sx, area.size.y))

    var room_a := _split(a)
    var room_b := _split(b)
    _carve_corridor(room_a.get_center(), room_b.get_center())
    return room_a

func _place_room(leaf: Rect2i) -> Rect2i:
    var room := leaf.grow(-room_padding)
    rooms.append(room)
    for x in range(room.position.x, room.end.x):
        for y in range(room.position.y, room.end.y):
            tile_map.set_cell(Vector2i(x, y), 0, FLOOR)
    return room

func _carve_corridor(from: Vector2i, to: Vector2i) -> void:
    var x := from.x
    while x != to.x:
        tile_map.set_cell(Vector2i(x, from.y), 0, FLOOR)
        x += sign(to.x - from.x)
    var y := from.y
    while y != to.y:
        tile_map.set_cell(Vector2i(to.x, y), 0, FLOOR)
        y += sign(to.y - from.y)

Why BSP over random scattering? BSP guarantees every room is reachable through the tree structure. The corridor connection happens as the recursion unwinds, linking sibling leaves automatically. You also get natural "wings" and spatial clustering that feels like real architecture rather than randomly thrown boxes.

The room_padding parameter shrinks each room inward from its leaf boundary, leaving wall thickness between adjacent rooms. Increase it for a more spacious feel; decrease it for tight, claustrophobic layouts perfect for horror games.

For managing enemy AI in these procedural dungeons, a state machine pattern works beautifully with procedurally placed enemies.

3. Wave Function Collapse Concepts

Wave Function Collapse (WFC) is a constraint-based generation technique inspired by quantum mechanics. Instead of placing tiles randomly, WFC starts with every cell in a "superposition" of all possible tiles, then progressively collapses cells by propagating adjacency constraints. The result: procedural levels that always look hand-designed because every tile respects its neighbour rules.

WFC is perfect for generating towns, overworld maps, and platformer levels where visual coherence matters more than random variety. While a full production WFC implementation can be complex, the core loop is surprisingly straightforward in GDScript.

The algorithm has three steps that repeat until every cell is collapsed:

  • Observe — find the cell with the fewest remaining possibilities (lowest entropy)
  • Collapse — randomly choose one tile for that cell (weighted by frequency)
  • Propagate — remove incompatible options from all neighbours, recursively
wfc_simple.gd — Wave Function Collapse Core
# wfc_simple.gd — Simplified WFC for tilemap generation
extends Node2D

# Define tile adjacency rules: tile_id -> {direction: [valid_neighbours]}
const RULES := {
    "grass": {"up": ["grass", "path"], "down": ["grass", "path"],
        "left": ["grass", "path"], "right": ["grass", "path"]},
    "path": {"up": ["path", "grass", "house"], "down": ["path", "grass"],
        "left": ["path", "grass"], "right": ["path", "grass", "house"]},
    "house": {"up": ["grass"], "down": ["path"],
        "left": ["grass", "path"], "right": ["grass", "path"]},
}

const DIRS := {
    "up": Vector2i(0, -1), "down": Vector2i(0, 1),
    "left": Vector2i(-1, 0), "right": Vector2i(1, 0),
}

var grid_size := Vector2i(20, 20)
var cells: Dictionary = {} # Vector2i -> Array of possible tiles

func _ready() -> void:
    _init_grid()
    _solve()

func _init_grid() -> void:
    var all_tiles := RULES.keys()
    for x in range(grid_size.x):
        for y in range(grid_size.y):
            cells[Vector2i(x, y)] = all_tiles.duplicate()

func _solve() -> void:
    while true:
        var cell := _find_lowest_entropy()
        if cell == Vector2i(-1, -1):
            break # All cells collapsed!
        # Collapse: pick a random tile
        cells[cell] = [cells[cell].pick_random()]
        _propagate(cell)

func _find_lowest_entropy() -> Vector2i:
    var best := Vector2i(-1, -1)
    var best_count := 999
    for pos in cells:
        if cells[pos].size() > 1 and cells[pos].size() < best_count:
            best = pos
            best_count = cells[pos].size()
    return best

func _propagate(pos: Vector2i) -> void:
    var stack := [pos]
    while stack.size() > 0:
        var current := stack.pop_back()
        for dir_name in DIRS:
            var neighbour := current + DIRS[dir_name]
            if not cells.has(neighbour) or cells[neighbour].size() <= 1:
                continue
            # Collect valid tiles based on current cell's options
            var allowed: Array = []
            for tile in cells[current]:
                for valid in RULES[tile][dir_name]:
                    if valid not in allowed:
                        allowed.append(valid)
            var before := cells[neighbour].size()
            cells[neighbour] = cells[neighbour].filter(
                func(t): return t in allowed)
            if cells[neighbour].size() < before:
                stack.append(neighbour)

The power of WFC is in the rules dictionary. Houses must face paths. Grass can border grass or paths. These simple constraints produce surprisingly coherent villages without any explicit layout logic. You define the aesthetic once in the rules; the algorithm handles composition.

To use this with Godot's TileMapLayer, map each string tile ID to an atlas coordinate and call set_cell() after the solve completes. If you need a refresher on tileset atlas configuration, our tilemap tutorial covers it step by step.

Level up your GDScript with our free cheat sheet — 20+ copy-paste snippets for signals, tweens, exports, and common patterns.

Get Cheat Sheet →
📧

Want more Godot tricks?

Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.

GDSJoin 500+ Godot devs · No spam · Unsubscribe anytime

4. Chunk-Based Infinite Worlds

No one wants to generate 10,000 tiles up front. A Godot infinite world system loads and unloads terrain chunks around the player's position, creating the illusion of an endless map while keeping memory usage constant. This is the same pattern Minecraft, Terraria, and No Man's Sky use.

The key insight: derive each chunk's content deterministically from its grid coordinates and a global seed. Chunk (3, -7) always generates the same terrain regardless of when the player visits it. This means no save file bloat for unvisited chunks.

chunk_loader.gd — Infinite World System
# chunk_loader.gd — Chunk-based infinite terrain
extends Node2D

@export var chunk_size: int = 32
@export var render_distance: int = 3
@export var world_seed: int = 42

@onready var tile_map: TileMapLayer = $TileMapLayer
@onready var player: CharacterBody2D = $Player

var loaded_chunks: Dictionary = {} # Vector2i -> true
var noise := FastNoiseLite.new()

const GRASS := Vector2i(0, 0)
const DIRT  := Vector2i(1, 0)
const WATER := Vector2i(2, 0)

func _ready() -> void:
    noise.seed = world_seed
    noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
    noise.frequency = 0.03

func _process(_delta: float) -> void:
    var player_chunk := _world_to_chunk(player.global_position)
    _load_nearby_chunks(player_chunk)
    _unload_far_chunks(player_chunk)

func _world_to_chunk(pos: Vector2) -> Vector2i:
    var tile_size := 16.0 # your tile pixel size
    return Vector2i(
        floori(pos.x / (chunk_size * tile_size)),
        floori(pos.y / (chunk_size * tile_size)))

func _load_nearby_chunks(center: Vector2i) -> void:
    for dx in range(-render_distance, render_distance + 1):
        for dy in range(-render_distance, render_distance + 1):
            var chunk_pos := center + Vector2i(dx, dy)
            if not loaded_chunks.has(chunk_pos):
                _generate_chunk(chunk_pos)
                loaded_chunks[chunk_pos] = true

func _unload_far_chunks(center: Vector2i) -> void:
    var to_remove: Array[Vector2i] = []
    for chunk_pos in loaded_chunks:
        if absi(chunk_pos.x - center.x) > render_distance + 1 \
            or absi(chunk_pos.y - center.y) > render_distance + 1:
            to_remove.append(chunk_pos)
    for pos in to_remove:
        _clear_chunk(pos)
        loaded_chunks.erase(pos)

func _generate_chunk(chunk_pos: Vector2i) -> void:
    var origin := chunk_pos * chunk_size
    for x in range(chunk_size):
        for y in range(chunk_size):
            var wx := origin.x + x
            var wy := origin.y + y
            var v := noise.get_noise_2d(float(wx), float(wy))
            var tile := GRASS if v > 0.0 else (DIRT if v > -0.3 else WATER)
            tile_map.set_cell(Vector2i(wx, wy), 0, tile)

func _clear_chunk(chunk_pos: Vector2i) -> void:
    var origin := chunk_pos * chunk_size
    for x in range(chunk_size):
        for y in range(chunk_size):
            tile_map.erase_cell(Vector2i(origin.x + x, origin.y + y))

Performance tip: Move chunk generation to a background thread using Godot's WorkerThreadPool for truly seamless loading. The noise sampling itself is thread-safe since FastNoiseLite is a Resource, but you'll need to defer set_cell() calls back to the main thread using call_deferred.

For even larger worlds, combine this chunk system with the BSP dungeon generator above — use noise for the overworld, then switch to BSP when the player enters a dungeon entrance tile. The save system tutorial shows how to persist any modifications the player makes to generated chunks.

5. Seeded Randomness for Reproducible Worlds

Random is fun, but reproducible random is essential. Seeds let players share worlds, replay the same layout for testing, or compete in "daily challenge" runs where everyone gets the exact same level. Minecraft's seed system made this a player expectation.

Godot's RandomNumberGenerator class accepts a seed property. Set it before generating, and every call to randi(), randf(), or randi_range() produces the exact same sequence. Pair this with FastNoiseLite.seed and your entire world becomes deterministic from a single integer.

seeded_world.gd — Deterministic Generation
# seeded_world.gd — Fully deterministic world from a single seed
extends Node2D

@export var world_seed: int = 0  # 0 = random each run
@export var width: int = 100
@export var height: int = 80

@onready var tile_map: TileMapLayer = $TileMapLayer
@onready var seed_label: Label = $UI/SeedLabel

var rng := RandomNumberGenerator.new()

const GRASS  := Vector2i(0, 0)
const DIRT   := Vector2i(1, 0)
const STONE  := Vector2i(2, 0)
const TREE   := Vector2i(3, 0)

func _ready() -> void:
    if world_seed == 0:
        world_seed = randi()
    rng.seed = world_seed
    seed_label.text = "Seed: %d" % world_seed
    generate_world()

func generate_world() -> void:
    # Terrain noise — seeded
    var terrain_noise := FastNoiseLite.new()
    terrain_noise.seed = world_seed
    terrain_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
    terrain_noise.frequency = 0.03

    # Vegetation noise — offset seed for variety
    var tree_noise := FastNoiseLite.new()
    tree_noise.seed = world_seed + 9999
    tree_noise.noise_type = FastNoiseLite.TYPE_CELLULAR
    tree_noise.frequency = 0.08

    for x in range(width):
        for y in range(height):
            var t := terrain_noise.get_noise_2d(float(x), float(y))
            var v := tree_noise.get_noise_2d(float(x), float(y))

            if t < -0.2:
                tile_map.set_cell(Vector2i(x, y), 0, STONE)
            elif t < 0.1:
                tile_map.set_cell(Vector2i(x, y), 0, DIRT)
            elif v > 0.3:
                tile_map.set_cell(Vector2i(x, y), 0, TREE)
            else:
                tile_map.set_cell(Vector2i(x, y), 0, GRASS)

func regenerate(new_seed: int) -> void:
    world_seed = new_seed
    rng.seed = new_seed
    tile_map.clear()
    generate_world()

Key pattern: We use two separate noise layers with offset seeds. The terrain layer controls the base biome (stone, dirt, grass), while the vegetation layer decides where trees spawn on top of grass tiles. Because both instances are seeded from world_seed, the same input always produces the identical map.

The regenerate() function lets you swap seeds at runtime — perfect for a UI text field where players type in a seed string. Convert strings to integers with "my_world".hash().

Pro tip: display the seed on-screen (like we do with SeedLabel) so players can screenshot and share their favourite worlds. This is a free engagement loop.

6. Spawning Enemies & Loot Procedurally

A procedural map is lifeless without enemies, chests, and NPCs. The best approach: treat spawning as a post-processing pass that runs after terrain generation. Loop through your rooms (from the BSP dungeon or noise-based regions) and use the seeded RNG to decide what goes where.

Good procedural spawning follows a difficulty curve. Rooms closer to the player start are easier; rooms farther away are harder and contain better loot. You can express this with a simple distance-based weight system.

procedural_spawner.gd — Enemy & Loot Spawning
# procedural_spawner.gd — Spawn enemies and loot in generated rooms
extends Node2D

@export var enemy_scenes: Array[PackedScene] = []
@export var loot_scene: PackedScene
@export var max_enemies_per_room: int = 4
@export var loot_chance: float = 0.3

var rng := RandomNumberGenerator.new()

func populate_rooms(rooms: Array[Rect2i], seed: int) -> void:
    rng.seed = seed + 7777 # Offset so spawns differ from terrain

    for i in range(rooms.size()):
        if i == 0:
            continue # Skip spawn room — keep it safe

        var room := rooms[i]
        var difficulty := clampf(float(i) / rooms.size(), 0.1, 1.0)

        # Spawn enemies — more in later rooms
        var enemy_count := rng.randi_range(1,
            ceili(max_enemies_per_room * difficulty))
        for _e in range(enemy_count):
            var pos := _random_point_in_room(room)
            # Pick harder enemies for deeper rooms
            var tier := mini(
                rng.randi_range(0, ceili(enemy_scenes.size() * difficulty) - 1),
                enemy_scenes.size() - 1)
            var enemy := enemy_scenes[tier].instantiate()
            enemy.global_position = pos * 16.0 # tile_size
            add_child(enemy)

        # Spawn loot chest — rarer, better in deep rooms
        if rng.randf() < loot_chance * difficulty and loot_scene:
            var chest := loot_scene.instantiate()
            chest.global_position = room.get_center() * 16.0
            chest.set_meta("loot_tier", ceili(difficulty * 3.0))
            add_child(chest)

func _random_point_in_room(room: Rect2i) -> Vector2:
    return Vector2(
        rng.randi_range(room.position.x + 1, room.end.x - 2),
        rng.randi_range(room.position.y + 1, room.end.y - 2))

Design insight: The difficulty variable is a simple 0.0–1.0 ramp based on room index. This single float drives enemy count, enemy tier selection, and loot rarity. Players naturally get a difficulty curve just by exploring deeper into the dungeon.

Notice we offset the RNG seed by + 7777 so that spawning patterns differ from terrain patterns even with the same world seed. This is a common trick to get independent-but-deterministic random streams from a single master seed.

For a clean way to manage the inventory items that spawn from these chests, check out our inventory system tutorial. And for the enemy AI behaviors, our state machine tutorial covers Idle, Chase, and Attack patterns.

7. Combining Everything: A Complete PCG Pipeline

Real games rarely use a single technique. The most effective approach is to layer them into a pipeline:

  1. Seed the RNG — set a master seed that drives everything downstream
  2. Generate terrain with noise — create the base landscape (biomes, elevation, water)
  3. Stamp structures — carve BSP dungeons or WFC towns into the terrain
  4. Populate with entities — spawn enemies, loot, NPCs using the seeded RNG
  5. Post-process — smooth edges, add decorations, run cellular automata passes
world_pipeline.gd — Layered Generation Pipeline
# world_pipeline.gd — Full procedural generation pipeline
extends Node2D

signal world_ready(seed: int)

@export var world_seed: int = 0

var rng := RandomNumberGenerator.new()

func generate() -> void:
    if world_seed == 0:
        world_seed = randi()
    rng.seed = world_seed

    # Step 1 — Base terrain via FastNoiseLite
    var terrain := _generate_terrain()

    # Step 2 — Stamp rooms / structures via BSP
    var rooms := _generate_dungeon()

    # Step 3 — Populate with enemies & loot
    _spawn_entities(rooms)

    # Step 4 — Post-process: smooth walls, add decor
    _smooth_walls(terrain)
    _place_decorations()

    world_ready.emit(world_seed)
    print("World ready! Seed: ", world_seed)

func _generate_terrain() -> Dictionary:
    # Use noise-based terrain from Section 1
    var noise := FastNoiseLite.new()
    noise.seed = world_seed
    # ... (see terrain_generator.gd above)
    return {}

func _generate_dungeon() -> Array[Rect2i]:
    # Use BSP dungeon from Section 2
    return []

func _spawn_entities(rooms: Array[Rect2i]) -> void:
    # Use spawner from Section 6
    pass

func _smooth_walls(_terrain: Dictionary) -> void:
    # Cellular automata pass for organic edges
    pass

func _place_decorations() -> void:
    # Scatter props, torches, etc.
    pass

Architecture tip: Each generation step is a separate function that takes and returns data. This makes your pipeline testable, swappable, and easy to extend. Want to add a WFC village step? Slot it between BSP and entity spawning.

The world_ready signal is emitted when generation finishes. Wire it up to your camera, HUD, or loading screen using the signal bus pattern for clean decoupling. Pair it with a tween-based fade-in for a polished experience.

Where to Go From Here

You now have a complete toolkit for Godot procedural generation — from noise terrain to BSP dungeons to infinite chunk worlds. Here are some next steps to take your procgen further:

  • Cellular automata smoothing — run 3–5 iterations of a cell-counting pass to smooth jagged cave walls into organic shapes
  • Biome blending — use multiple noise layers to define temperature and moisture, then map those values to biome types (desert, forest, tundra)
  • Graph-based dungeon layout — define room connectivity as a graph first, then spatialise it for more controlled level pacing
  • Marching squares for smooth tiles — blend between tile types using lookup tables for polished autotiling (see our tilemap tutorial)
  • Threaded chunk generation — move heavy generation to WorkerThreadPool for hitching-free infinite worlds

Procedural generation is one of the most rewarding systems to build. Every playtest reveals something you didn't explicitly design — and that's the magic of it.

Level Up
Free download
+ weekly tips

Get the Free GDScript Cheat Sheet + Weekly Godot Tips

Join indie devs getting our GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines, and more — plus weekly Godot tips and architecture tricks straight to your inbox.

20+ GDScript snippets
Weekly Godot tips
Zero spam, cancel anytime
>
GDSJoin 500+ Godot devs. Unsubscribe in one click.