procedural-generation

Use when implementing procedural generation — noise-based terrain, BSP dungeons, cellular automata caves, wave function collapse, and seeded randomness in Godot 4.3+

Procedural Generation in Godot 4.3+

All examples target Godot 4.3+ with no deprecated APIs. GDScript is shown first, then C#.

Related skills: 2d-essentials for TileMapLayer usage, 3d-essentials for 3D terrain meshes, math-essentials for vectors and transforms, godot-optimization for chunk loading and performance.


1. Seeded Randomness

Always use seeds for reproducible generation. This enables shareable seeds, replay, and deterministic testing.

GDScript

# RandomNumberGenerator — per-instance, seedable
var rng := RandomNumberGenerator.new()

func generate_level(level_seed: int) -> void:
    rng.seed = level_seed

    var width: int = rng.randi_range(20, 40)
    var height: int = rng.randi_range(15, 30)
    var enemy_count: int = rng.randi_range(3, 8)
    var treasure_chance: float = rng.randf_range(0.05, 0.15)

# AVOID: Global randf()/randi() — not reproducible across calls
# USE: rng.randf(), rng.randi(), rng.randf_range(), rng.randi_range()

C#

private RandomNumberGenerator _rng = new();

public void GenerateLevel(ulong levelSeed)
{
    _rng.Seed = levelSeed;

    int width = _rng.RandiRange(20, 40);
    int height = _rng.RandiRange(15, 30);
    int enemyCount = _rng.RandiRange(3, 8);
    float treasureChance = _rng.RandfRange(0.05f, 0.15f);
}

Tip: Generate a seed from a string for shareable level codes: var seed: int = "MyLevel".hash()


2. Noise-Based Generation (FastNoiseLite)

Godot's built-in FastNoiseLite resource provides Simplex, Perlin, Cellular, and Value noise.

Basic Noise Map

var noise := FastNoiseLite.new()

func setup_noise(gen_seed: int) -> void:
    noise.seed = gen_seed
    noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
    noise.frequency = 0.02           # lower = larger features
    noise.fractal_type = FastNoiseLite.FRACTAL_FBM
    noise.fractal_octaves = 4        # detail layers
    noise.fractal_lacunarity = 2.0   # frequency multiplier per octave
    noise.fractal_gain = 0.5         # amplitude multiplier per octave

func get_height(x: int, y: int) -> float:
    # Returns -1.0 to 1.0
    return noise.get_noise_2d(float(x), float(y))
private FastNoiseLite _noise = new();

public void SetupNoise(int genSeed)
{
    _noise.Seed = genSeed;
    _noise.NoiseType = FastNoiseLite.NoiseTypeEnum.SimplexSmooth;
    _noise.Frequency = 0.02f;
    _noise.FractalType = FastNoiseLite.FractalTypeEnum.Fbm;
    _noise.FractalOctaves = 4;
    _noise.FractalLacunarity = 2.0f;
    _noise.FractalGain = 0.5f;
}

public float GetHeight(int x, int y) => _noise.GetNoise2D(x, y);

Noise Type Reference

TypeCharacterUse For
TYPE_SIMPLEX_SMOOTHSmooth, organicTerrain height, clouds, temperature
TYPE_PERLINClassic smoothSimilar to Simplex, slightly different artifacts
TYPE_CELLULARVoronoi cellsCave systems, biome boundaries, crystal patterns
TYPE_VALUEBlocky, aliasedRetro terrain, pixel-art maps
TYPE_VALUE_CUBICSmoothed blockySmoother value noise

2D Terrain with TileMapLayer

extends Node2D

@onready var tile_map: TileMapLayer = $TileMapLayer

var noise := FastNoiseLite.new()
var width: int = 80
var height: int = 60

func _ready() -> void:
    noise.seed = 42
    noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
    noise.frequency = 0.05
    generate_map()

func generate_map() -> void:
    for x in width:
        for y in height:
            var value: float = noise.get_noise_2d(float(x), float(y))
            var tile_coords: Vector2i = _noise_to_tile(value)
            tile_map.set_cell(Vector2i(x, y), 0, tile_coords)  # source_id=0

func _noise_to_tile(value: float) -> Vector2i:
    # Map noise value (-1 to 1) to tile atlas coordinates
    if value < -0.3:
        return Vector2i(0, 0)   # deep water
    elif value < -0.1:
        return Vector2i(1, 0)   # shallow water
    elif value < 0.2:
        return Vector2i(2, 0)   # grass
    elif value < 0.5:
        return Vector2i(3, 0)   # forest
    else:
        return Vector2i(4, 0)   # mountain
public partial class TerrainGenerator : Node2D
{
    private TileMapLayer _tileMap;
    private FastNoiseLite _noise = new();
    private int _width = 80, _height = 60;

    public override void _Ready()
    {
        _tileMap = GetNode<TileMapLayer>("TileMapLayer");
        _noise.Seed = 42;
        _noise.NoiseType = FastNoiseLite.NoiseTypeEnum.SimplexSmooth;
        _noise.Frequency = 0.05f;
        GenerateMap();
    }

    private void GenerateMap()
    {
        for (int x = 0; x < _width; x++)
            for (int y = 0; y < _height; y++)
            {
                float value = _noise.GetNoise2D(x, y);
                Vector2I tileCoords = NoiseToTile(value);
                _tileMap.SetCell(new Vector2I(x, y), 0, tileCoords);
            }
    }

    private Vector2I NoiseToTile(float value) => value switch
    {
        < -0.3f => new(0, 0),
        < -0.1f => new(1, 0),
        < 0.2f  => new(2, 0),
        < 0.5f  => new(3, 0),
        _       => new(4, 0),
    };
}

3. BSP Dungeon Generation

Binary Space Partitioning recursively splits a rectangle into rooms, then connects them with corridors. Produces classic roguelike dungeon layouts.

GDScript

class_name BSPDungeon
extends RefCounted

var rng := RandomNumberGenerator.new()
var min_room_size: int = 5
var rooms: Array[Rect2i] = []

func generate(bounds: Rect2i, gen_seed: int) -> Array[Rect2i]:
    rng.seed = gen_seed
    rooms.clear()
    _split(bounds)
    return rooms

func _split(area: Rect2i) -> void:
    # Stop splitting if area is small enough to be a room
    if area.size.x <= min_room_size * 2 and area.size.y <= min_room_size * 2:
        # Shrink to create room with margins
        var room := Rect2i(
            area.position + Vector2i(1, 1),
            area.size - Vector2i(2, 2)
        )
        if room.size.x >= min_room_size and room.size.y >= min_room_size:
            rooms.append(room)
        return

    # Choose split direction based on aspect ratio
    var split_horizontal: bool
    if area.size.x > area.size.y * 1.25:
        split_horizontal = false  # split vertically (wide room)
    elif area.size.y > area.size.x * 1.25:
        split_horizontal = true   # split horizontally (tall room)
    else:
        split_horizontal = rng.randi() % 2 == 0

    if split_horizontal:
        var split_y: int = rng.randi_range(
            area.position.y + min_room_size,
            area.end.y - min_room_size
        )
        _split(Rect2i(area.position, Vector2i(area.size.x, split_y - area.position.y)))
        _split(Rect2i(Vector2i(area.position.x, split_y), Vector2i(area.size.x, area.end.y - split_y)))
    else:
        var split_x: int = rng.randi_range(
            area.position.x + min_room_size,
            area.end.x - min_room_size
        )
        _split(Rect2i(area.position, Vector2i(split_x - area.position.x, area.size.y)))
        _split(Rect2i(Vector2i(split_x, area.position.y), Vector2i(area.end.x - split_x, area.size.y)))

Connecting Rooms with Corridors

func connect_rooms(tile_map: TileMapLayer, floor_tile: Vector2i) -> void:
    for i in range(rooms.size() - 1):
        var center_a: Vector2i = rooms[i].position + rooms[i].size / 2
        var center_b: Vector2i = rooms[i + 1].position + rooms[i + 1].size / 2

        # L-shaped corridor: horizontal then vertical
        if rng.randi() % 2 == 0:
            _carve_horizontal(tile_map, center_a.x, center_b.x, center_a.y, floor_tile)
            _carve_vertical(tile_map, center_a.y, center_b.y, center_b.x, floor_tile)
        else:
            _carve_vertical(tile_map, center_a.y, center_b.y, center_a.x, floor_tile)
            _carve_horizontal(tile_map, center_a.x, center_b.x, center_b.y, floor_tile)

func _carve_horizontal(tile_map: TileMapLayer, x1: int, x2: int, y: int, tile: Vector2i) -> void:
    for x in range(mini(x1, x2), maxi(x1, x2) + 1):
        tile_map.set_cell(Vector2i(x, y), 0, tile)

func _carve_vertical(tile_map: TileMapLayer, y1: int, y2: int, x: int, tile: Vector2i) -> void:
    for y in range(mini(y1, y2), maxi(y1, y2) + 1):
        tile_map.set_cell(Vector2i(x, y), 0, tile)

C#

public partial class BSPDungeon : RefCounted
{
    private RandomNumberGenerator _rng = new();
    private int _minRoomSize = 5;
    public Godot.Collections.Array<Rect2I> Rooms { get; } = new();

    public Godot.Collections.Array<Rect2I> Generate(Rect2I bounds, ulong genSeed)
    {
        _rng.Seed = genSeed;
        Rooms.Clear();
        Split(bounds);
        return Rooms;
    }

    private void Split(Rect2I area)
    {
        if (area.Size.X <= _minRoomSize * 2 && area.Size.Y <= _minRoomSize * 2)
        {
            var room = new Rect2I(
                area.Position + new Vector2I(1, 1),
                area.Size - new Vector2I(2, 2)
            );
            if (room.Size.X >= _minRoomSize && room.Size.Y >= _minRoomSize)
                Rooms.Add(room);
            return;
        }

        bool splitHorizontal;
        if (area.Size.X > area.Size.Y * 1.25f)
            splitHorizontal = false;
        else if (area.Size.Y > area.Size.X * 1.25f)
            splitHorizontal = true;
        else
            splitHorizontal = _rng.Randi() % 2 == 0;

        if (splitHorizontal)
        {
            int splitY = _rng.RandiRange(
                area.Position.Y + _minRoomSize,
                area.End.Y - _minRoomSize
            );
            Split(new Rect2I(area.Position, new Vector2I(area.Size.X, splitY - area.Position.Y)));
            Split(new Rect2I(new Vector2I(area.Position.X, splitY), new Vector2I(area.Size.X, area.End.Y - splitY)));
        }
        else
        {
            int splitX = _rng.RandiRange(
                area.Position.X + _minRoomSize,
                area.End.X - _minRoomSize
            );
            Split(new Rect2I(area.Position, new Vector2I(splitX - area.Position.X, area.Size.Y)));
            Split(new Rect2I(new Vector2I(splitX, area.Position.Y), new Vector2I(area.End.X - splitX, area.Size.Y)));
        }
    }

    public void ConnectRooms(TileMapLayer tileMap, Vector2I floorTile)
    {
        for (int i = 0; i < Rooms.Count - 1; i++)
        {
            Vector2I centerA = Rooms[i].Position + Rooms[i].Size / 2;
            Vector2I centerB = Rooms[i + 1].Position + Rooms[i + 1].Size / 2;

            if (_rng.Randi() % 2 == 0)
            {
                CarveHorizontal(tileMap, centerA.X, centerB.X, centerA.Y, floorTile);
                CarveVertical(tileMap, centerA.Y, centerB.Y, centerB.X, floorTile);
            }
            else
            {
                CarveVertical(tileMap, centerA.Y, centerB.Y, centerA.X, floorTile);
                CarveHorizontal(tileMap, centerA.X, centerB.X, centerB.Y, floorTile);
            }
        }
    }

    private static void CarveHorizontal(TileMapLayer tileMap, int x1, int x2, int y, Vector2I tile)
    {
        for (int x = Mathf.Min(x1, x2); x <= Mathf.Max(x1, x2); x++)
            tileMap.SetCell(new Vector2I(x, y), 0, tile);
    }

    private static void CarveVertical(TileMapLayer tileMap, int y1, int y2, int x, Vector2I tile)
    {
        for (int y = Mathf.Min(y1, y2); y <= Mathf.Max(y1, y2); y++)
            tileMap.SetCell(new Vector2I(x, y), 0, tile);
    }
}

4. Cellular Automata (Cave Generation)

Simulates natural-looking caves by iterating a simple rule: a cell becomes wall if most of its neighbors are walls.

GDScript

class_name CaveGenerator
extends RefCounted

var rng := RandomNumberGenerator.new()

func generate(width: int, height: int, gen_seed: int, fill_chance: float = 0.45, iterations: int = 5) -> Array[Array]:
    rng.seed = gen_seed

    # Step 1: Random fill
    var grid: Array[Array] = []
    for y in height:
        var row: Array[bool] = []
        for x in width:
            # true = wall, false = floor
            var is_edge: bool = x == 0 or y == 0 or x == width - 1 or y == height - 1
            row.append(is_edge or rng.randf() < fill_chance)
        grid.append(row)

    # Step 2: Smooth with cellular automata rules
    for _i in iterations:
        grid = _smooth(grid, width, height)

    return grid

func _smooth(grid: Array[Array], width: int, height: int) -> Array[Array]:
    var new_grid: Array[Array] = []
    for y in height:
        var row: Array[bool] = []
        for x in width:
            var wall_count: int = _count_neighbors(grid, x, y, width, height)
            # Rule: become wall if 5+ of 9 cells (self + 8 neighbors) are walls
            row.append(wall_count >= 5)
        new_grid.append(row)
    return new_grid

func _count_neighbors(grid: Array[Array], cx: int, cy: int, width: int, height: int) -> int:
    var count: int = 0
    for dy in range(-1, 2):
        for dx in range(-1, 2):
            var nx: int = cx + dx
            var ny: int = cy + dy
            if nx < 0 or ny < 0 or nx >= width or ny >= height:
                count += 1  # out of bounds counts as wall
            elif grid[ny][nx]:
                count += 1
    return count

Usage with TileMapLayer

func apply_cave_to_tilemap(tile_map: TileMapLayer, grid: Array[Array]) -> void:
    var wall_tile := Vector2i(0, 0)
    var floor_tile := Vector2i(1, 0)

    for y in grid.size():
        for x in grid[y].size():
            var tile := wall_tile if grid[y][x] else floor_tile
            tile_map.set_cell(Vector2i(x, y), 0, tile)

C#

public partial class CaveGenerator : RefCounted
{
    private RandomNumberGenerator _rng = new();

    public bool[][] Generate(int width, int height, ulong genSeed, float fillChance = 0.45f, int iterations = 5)
    {
        _rng.Seed = genSeed;

        // Step 1: Random fill
        bool[][] grid = new bool[height][];
        for (int y = 0; y < height; y++)
        {
            grid[y] = new bool[width];
            for (int x = 0; x < width; x++)
            {
                bool isEdge = x == 0 || y == 0 || x == width - 1 || y == height - 1;
                grid[y][x] = isEdge || _rng.Randf() < fillChance;
            }
        }

        // Step 2: Smooth with cellular automata rules
        for (int i = 0; i < iterations; i++)
            grid = Smooth(grid, width, height);

        return grid;
    }

    private static bool[][] Smooth(bool[][] grid, int width, int height)
    {
        bool[][] newGrid = new bool[height][];
        for (int y = 0; y < height; y++)
        {
            newGrid[y] = new bool[width];
            for (int x = 0; x < width; x++)
            {
                int wallCount = CountNeighbors(grid, x, y, width, height);
                // Rule: become wall if 5+ of 9 cells (self + 8 neighbors) are walls
                newGrid[y][x] = wallCount >= 5;
            }
        }
        return newGrid;
    }

    private static int CountNeighbors(bool[][] grid, int cx, int cy, int width, int height)
    {
        int count = 0;
        for (int dy = -1; dy <= 1; dy++)
            for (int dx = -1; dx <= 1; dx++)
            {
                int nx = cx + dx, ny = cy + dy;
                if (nx < 0 || ny < 0 || nx >= width || ny >= height)
                    count++;  // out of bounds counts as wall
                else if (grid[ny][nx])
                    count++;
            }
        return count;
    }
}

public static void ApplyCaveToTilemap(TileMapLayer tileMap, bool[][] grid)
{
    var wallTile = new Vector2I(0, 0);
    var floorTile = new Vector2I(1, 0);

    for (int y = 0; y < grid.Length; y++)
        for (int x = 0; x < grid[y].Length; x++)
            tileMap.SetCell(new Vector2I(x, y), 0, grid[y][x] ? wallTile : floorTile);
}

5. Wave Function Collapse (WFC) — Concept

WFC generates patterns by collapsing tile possibilities based on adjacency constraints. It produces visually coherent results from a small set of rules.

Core Algorithm (Simplified)

class_name SimpleWFC
extends RefCounted

# Each cell holds a set of possible tile indices
var grid: Array[Array] = []   # grid[y][x] = Array[int] (possible tiles)
var rules: Dictionary = {}     # rules[tile_id] = {"up": [...], "down": [...], "left": [...], "right": [...]}
var rng := RandomNumberGenerator.new()

func setup(width: int, height: int, tile_count: int, gen_seed: int) -> void:
    rng.seed = gen_seed
    grid.clear()
    for y in height:
        var row: Array[Array] = []
        for x in width:
            var possibilities: Array[int] = []
            for t in tile_count:
                possibilities.append(t)
            row.append(possibilities)
        grid.append(row)

func collapse() -> bool:
    while true:
        # Find cell with fewest possibilities (lowest entropy)
        var min_cell := Vector2i(-1, -1)
        var min_count := 999
        for y in grid.size():
            for x in grid[y].size():
                var count: int = grid[y][x].size()
                if count > 1 and count < min_count:
                    min_count = count
                    min_cell = Vector2i(x, y)

        if min_cell == Vector2i(-1, -1):
            return true  # all collapsed — success

        # Collapse: pick a random possibility
        var cell: Array[int] = grid[min_cell.y][min_cell.x]
        if cell.is_empty():
            return false  # contradiction — no valid tiles
        var chosen: int = cell[rng.randi() % cell.size()]
        grid[min_cell.y][min_cell.x] = [chosen]

        # Propagate constraints to neighbors
        _propagate(min_cell)

    return true

func _propagate(pos: Vector2i) -> void:
    var stack: Array[Vector2i] = [pos]
    while not stack.is_empty():
        var current: Vector2i = stack.pop_back()
        var current_tiles: Array[int] = grid[current.y][current.x]

        for dir in [Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0)]:
            var neighbor: Vector2i = current + dir
            if neighbor.x < 0 or neighbor.y < 0 or neighbor.y >= grid.size() or neighbor.x >= grid[0].size():
                continue

            var dir_name: String = _dir_to_name(dir)
            var allowed: Array[int] = []
            for tile in current_tiles:
                if rules.has(tile) and rules[tile].has(dir_name):
                    for allowed_tile in rules[tile][dir_name]:
                        if allowed_tile not in allowed:
                            allowed.append(allowed_tile)

            var neighbor_tiles: Array[int] = grid[neighbor.y][neighbor.x]
            var new_tiles: Array[int] = neighbor_tiles.filter(func(t: int) -> bool: return t in allowed)

            if new_tiles.size() < neighbor_tiles.size():
                grid[neighbor.y][neighbor.x] = new_tiles
                stack.append(neighbor)

func _dir_to_name(dir: Vector2i) -> String:
    if dir == Vector2i(0, -1): return "up"
    if dir == Vector2i(0, 1):  return "down"
    if dir == Vector2i(-1, 0): return "left"
    return "right"

C#

public partial class SimpleWFC : RefCounted
{
    // grid[y][x] = list of possible tile indices
    private List<int>[][] _grid = [];
    // rules[tileId]["up"|"down"|"left"|"right"] = list of allowed neighbour tile ids
    private Godot.Collections.Dictionary<int, Godot.Collections.Dictionary<string, Godot.Collections.Array<int>>> _rules = new();
    private RandomNumberGenerator _rng = new();

    public void Setup(int width, int height, int tileCount, ulong genSeed)
    {
        _rng.Seed = genSeed;
        _grid = new List<int>[height][];
        for (int y = 0; y < height; y++)
        {
            _grid[y] = new List<int>[width];
            for (int x = 0; x < width; x++)
            {
                _grid[y][x] = new List<int>();
                for (int t = 0; t < tileCount; t++)
                    _grid[y][x].Add(t);
            }
        }
    }

    public bool Collapse()
    {
        while (true)
        {
            // Find cell with fewest possibilities (lowest entropy)
            var minCell = new Vector2I(-1, -1);
            int minCount = 999;
            for (int y = 0; y < _grid.Length; y++)
                for (int x = 0; x < _grid[y].Length; x++)
                {
                    int count = _grid[y][x].Count;
                    if (count > 1 && count < minCount)
                    {
                        minCount = count;
                        minCell = new Vector2I(x, y);
                    }
                }

            if (minCell == new Vector2I(-1, -1))
                return true;  // all collapsed — success

            // Collapse: pick a random possibility
            var cell = _grid[minCell.Y][minCell.X];
            if (cell.Count == 0)
                return false;  // contradiction — no valid tiles
            int chosen = cell[(int)(_rng.Randi() % (uint)cell.Count)];
            _grid[minCell.Y][minCell.X] = [chosen];

            // Propagate constraints to neighbors
            Propagate(minCell);
        }
    }

    private void Propagate(Vector2I pos)
    {
        var stack = new Stack<Vector2I>();
        stack.Push(pos);
        while (stack.Count > 0)
        {
            var current = stack.Pop();
            var currentTiles = _grid[current.Y][current.X];

            foreach (var dir in new[] { new Vector2I(0, -1), new Vector2I(0, 1), new Vector2I(-1, 0), new Vector2I(1, 0) })
            {
                var neighbor = current + dir;
                if (neighbor.X < 0 || neighbor.Y < 0 || neighbor.Y >= _grid.Length || neighbor.X >= _grid[0].Length)
                    continue;

                string dirName = DirToName(dir);
                var allowed = new HashSet<int>();
                foreach (int tile in currentTiles)
                    if (_rules.TryGetValue(tile, out var tileRules) && tileRules.TryGetValue(dirName, out var allowedTiles))
                        foreach (int allowedTile in allowedTiles)
                            allowed.Add(allowedTile);

                var neighborTiles = _grid[neighbor.Y][neighbor.X];
                var newTiles = neighborTiles.Where(t => allowed.Contains(t)).ToList();

                if (newTiles.Count < neighborTiles.Count)
                {
                    _grid[neighbor.Y][neighbor.X] = newTiles;
                    stack.Push(neighbor);
                }
            }
        }
    }

    private static string DirToName(Vector2I dir)
    {
        if (dir == new Vector2I(0, -1)) return "up";
        if (dir == new Vector2I(0, 1))  return "down";
        if (dir == new Vector2I(-1, 0)) return "left";
        return "right";
    }
}

For production WFC, consider the community addon godot-wfc which provides editor integration, TileMap support, and 3D grid WFC.


6. Common Pitfalls

SymptomCauseFix
Same level every timeNot seeding the RNGSet rng.seed before generation
Different results on different platformsUsing global randf() / randi()Use a dedicated RandomNumberGenerator instance
Noise looks blockyFrequency too highLower frequency (try 0.01–0.05)
Caves are all wall or all floorfill_chance too extreme or too few iterationsUse fill_chance 0.40–0.50 and 4–6 iterations
BSP rooms overlapSplit position too close to edgeEnsure min_room_size buffer in split calculation
WFC contradiction (no valid tile)Adjacency rules too restrictiveAdd more allowed neighbors or implement backtracking
Generation takes too longProcessing entire map in one frameUse await get_tree().process_frame to spread across frames, or use a thread

7. Implementation Checklist

  • All generation uses a seedable RandomNumberGenerator, never global randf()/randi()
  • Seeds are stored with save data so levels can be reproduced
  • FastNoiseLite frequency and octaves are tuned for the game's tile/world scale
  • Large generation is spread across frames or run on a thread to avoid freezing
  • Generated TileMapLayer content uses terrain autotiling when possible (not hardcoded tile coords)
  • BSP dungeons verify all rooms are connected before finalizing
  • Cave generation runs a flood-fill to ensure reachability between key points
  • Player spawn point is validated to be on a floor tile, not inside a wall