godot-procedural-generation

Original🇺🇸 English
Translated

Expert blueprint for procedural content generation (dungeons, terrain, loot, levels) using FastNoiseLite, random walks, BSP trees, Wave Function Collapse, and seeded randomization. Use when creating roguelikes, sandbox games, or dynamic content. Keywords procedural, generation, FastNoiseLite, Perlin noise, BSP, drunkard walk, Wave Function Collapse, seeding.

9installs
Added on

NPX Install

npx skill4agent add thedivergentai/gd-agentic-skills godot-procedural-generation

Procedural Generation

Seeded algorithms, noise functions, and constraint propagation define replayable content generation.

Available Scripts

wfc_level_generator.gd

Expert Wave Function Collapse implementation with tile adjacency rules.

NEVER Do in Procedural Generation

  • NEVER forget to seed RNG
    randi()
    without seed = same dungeon every time. Use
    seed(hash(Time.get_ticks_msec()))
    OR expose seed for speedrunning.
  • NEVER use
    randf()
    in
    _ready()
    for multiplayer
    — Each client calls
    _ready()
    at different times = desynced RNG = different dungeons. Use shared seed from server.
  • NEVER skip validation — Drunkard's walk dungeon with no exit? Playability fail. ALWAYS validate (e.g., A* from start to end) OR regenerate.
  • NEVER use noise.get_noise_2d() every frame — Calling noise 10,000x/frame = lag. Pre-generate heightmap in
    _ready()
    , cache in Array.
  • NEVER use BSP without minimum room size — Infinite splits = 1x1 rooms = crash. Set
    min_size
    (e.g., 6x6) to prevent over-subdivision.
  • NEVER ignore WFC contradictions — Wave Function Collapse fails when no valid tiles remain. MUST detect contradiction, backtrack OR restart generation.
  • NEVER block main thread for large generations — Generating 1000x1000 terrain in
    _ready()
    = freeze. Use worker thread OR split across frames with
    await
    .

gdscript
func generate_dungeon(width: int, height: int, fill_percent: float = 0.4) -> Array:
    var grid := []
    for y in height:
        var row := []
        for x in width:
            row.append(1)  # 1 = wall
        grid.append(row)
    
    # Start in center
    var x := width / 2
    var y := height / 2
    var floor_tiles := 0
    var target_floor := int(width * height * fill_percent)
    
    while floor_tiles < target_floor:
        if grid[y][x] == 1:
            grid[y][x] = 0  # Create floor
            floor_tiles += 1
        
        # Random walk
        var dir := randi() % 4
        match dir:
            0: x = clampi(x + 1, 0, width - 1)
            1: x = clampi(x - 1, 0, width - 1)
            2: y = clampi(y + 1, 0, height - 1)
            3: y = clampi(y - 1, 0, height - 1)
    
    return grid

Perlin Noise Terrain

gdscript
var noise := FastNoiseLite.new()

func generate_terrain(width: int, height: int) -> Array:
    noise.seed = randi()
    noise.frequency = 0.05
    
    var terrain := []
    for y in height:
        var row := []
        for x in width:
            var value := noise.get_noise_2d(x, y)
            
            # Map noise to tile types
            var tile: int
            if value < -0.2:
                tile = 0  # Water
            elif value < 0.2:
                tile = 1  # Grass
            else:
                tile = 2  # Mountain
            
            row.append(tile)
        terrain.append(row)
    
    return terrain

BSP Rooms

gdscript
class_name BSPRoom

var x: int
var y: int
var width: int
var height: int
var left: BSPRoom = null
var right: BSPRoom = null

func split(min_size: int = 6) -> bool:
    if left or right:
        return false  # Already split
    
    # Choose split direction
    var split_horizontal := randf() > 0.5
    
    if width > height and float(width) / float(height) >= 1.25:
        split_horizontal = false
    elif height > width and float(height) / float(width) >= 1.25:
        split_horizontal = true
    
    var max := (height if split_horizontal else width) - min_size
    if max <= min_size:
        return false  # Too small
    
    var split_pos := randi_range(min_size, max)
    
    if split_horizontal:
        left = BSPRoom.new()
        left.x = x
        left.y = y
        left.width = width
        left.height = split_pos
        
        right = BSPRoom.new()
        right.x = x
        right.y = y + split_pos
        right.width = width
        right.height = height - split_pos
    else:
        left = BSPRoom.new()
        left.x = x
        left.y = y
        left.width = split_pos
        left.height = height
        
        right = BSPRoom.new()
        right.x = x + split_pos
        right.y = y
        right.width = width - split_pos
        right.height = height
    
    return true

func generate_bsp_dungeon(width: int, height: int, iterations: int = 4) -> Array[BSPRoom]:
    var root := BSPRoom.new()
    root.x = 0
    root.y = 0
    root.width = width
    root.height = height
    
    var rooms: Array[BSPRoom] = [root]
    
    for i in iterations:
        var new_rooms: Array[BSPRoom] = []
        for room in rooms:
            if room.split():
                new_rooms.append(room.left)
                new_rooms.append(room.right)
            else:
                new_rooms.append(room)
        rooms = new_rooms
    
    return rooms

Random Loot

gdscript
func generate_loot(loot_level: int) -> Array[Item]:
    var items: Array[Item] = []
    var roll_count := randi_range(1, 3)
    
    for i in roll_count:
        var rarity := roll_rarity()
        var item := get_random_item(rarity, loot_level)
        items.append(item)
    
    return items

func roll_rarity() -> String:
    var roll := randf()
    if roll < 0.6:
        return "common"
    elif roll < 0.85:
        return "uncommon"
    elif roll < 0.95:
        return "rare"
    else:
        return "legendary"

Wave Function Collapse

gdscript
# Simplified WFC for tile patterns
# Load compatible tile adjacency rules
var tile_rules := {
    "grass": ["grass", "path", "water_edge"],
    "water": ["water", "water_edge"],
    "path": ["grass", "path"]
}

func wfc_generate(width: int, height: int) -> Array:
    var grid := []
    for y in height:
        var row := []
        for x in width:
            row.append(null)  # Uncollapsed
        grid.append(row)
    
    # Collapse cells until complete
    while has_uncollapsed(grid):
        var pos := find_lowest_entropy(grid)
        collapse_cell(grid, pos)
        propagate_constraints(grid, pos)
    
    return grid

Best Practices

  1. Seeding - Use seeds for reproducibility
  2. Validation - Ensure playable levels
  3. Performance - Generate async if needed

Reference

  • Related:
    godot-tilemap-mastery
    ,
    godot-resource-data-patterns

Related

  • Master Skill: godot-master