Skip to main content

Procedural Generation

Creating Infinite Worlds

Generate infinite, unique game content algorithmically! Master Perlin noise, terrain generation, dungeon algorithms, procedural textures, and create endless worlds that feel handcrafted! ๐ŸŒ๐Ÿฐ๐ŸŒฒ

Understanding Procedural Generation

๐ŸŽฒ The Recipe Analogy

Think of procedural generation like cooking with a recipe:

Procedural Generation Implementation in Python

import numpy as np
import random
from noise import pnoise2, snoise2
from typing import List, Tuple, Dict, Optional
from dataclasses import dataclass
from enum import Enum

class BiomeType(Enum):
    OCEAN = 0
    BEACH = 1
    GRASSLAND = 2  
    FOREST = 3
    DESERT = 4
    MOUNTAIN = 5
    SNOW = 6

@dataclass
class TerrainConfig:
    """Configuration for terrain generation"""
    width: int = 256
    height: int = 256
    scale: float = 50.0
    octaves: int = 4
    persistence: float = 0.5
    lacunarity: float = 2.0
    seed: int = 0
    
    water_level: float = 0.3
    mountain_level: float = 0.7

class TerrainGenerator:
    """Generates procedural terrain using Perlin noise"""
    
    def __init__(self, config: TerrainConfig) -> None:
        self.config: TerrainConfig = config
        self.height_map: Optional[np.ndarray] = None
        self.moisture_map: Optional[np.ndarray] = None
        self.temperature_map: Optional[np.ndarray] = None
        self.biome_map: Optional[np.ndarray] = None
        
        # Set random seed
        random.seed(config.seed)
        np.random.seed(config.seed)
    
    def generate_noise_map(self, offset_x: float = 0, offset_y: float = 0) -> np.ndarray:
        """Generate a noise map using Perlin noise"""
        noise_map = np.zeros((self.config.height, self.config.width))
        
        for y in range(self.config.height):
            for x in range(self.config.width):
                amplitude = 1
                frequency = 1
                noise_value = 0
                
                for _ in range(self.config.octaves):
                    sample_x = (x + offset_x) / self.config.scale * frequency
                    sample_y = (y + offset_y) / self.config.scale * frequency
                    
                    perlin_value = pnoise2(sample_x, sample_y, 
                                          repeatx=self.config.width, 
                                          repeaty=self.config.height)
                    noise_value += perlin_value * amplitude
                    
                    amplitude *= self.config.persistence
                    frequency *= self.config.lacunarity
                
                noise_map[y][x] = noise_value
        
        # Normalize to 0-1
        noise_map = (noise_map - noise_map.min()) / (noise_map.max() - noise_map.min())
        return noise_map
    
    def apply_island_mask(self, height_map: np.ndarray) -> np.ndarray:
        """Apply circular gradient to create island"""
        center_x = self.config.width / 2
        center_y = self.config.height / 2
        max_distance = min(center_x, center_y)
        
        for y in range(self.config.height):
            for x in range(self.config.width):
                distance = np.sqrt((x - center_x)**2 + (y - center_y)**2)
                gradient = max(0, 1 - distance / max_distance)
                height_map[y][x] *= gradient
        
        return height_map
    
    def generate_terrain(self, island: bool = False) -> np.ndarray:
        """Generate complete terrain"""
        # Generate height map
        self.height_map = self.generate_noise_map()
        
        if island:
            self.height_map = self.apply_island_mask(self.height_map)
        
        # Generate moisture map
        self.moisture_map = self.generate_noise_map(1000, 1000)
        
        # Generate temperature map (decreases with altitude)
        self.temperature_map = 1 - self.height_map * 0.5
        
        # Add latitude-based temperature variation
        for y in range(self.config.height):
            latitude_temp = 1 - abs(y - self.config.height/2) / (self.config.height/2)
            self.temperature_map[y] *= latitude_temp
        
        # Generate biomes
        self.biome_map = self.generate_biomes()
        
        return self.height_map
    
    def generate_biomes(self) -> np.ndarray:
        """Determine biome based on height, moisture, and temperature"""
        biome_map = np.zeros((self.config.height, self.config.width), dtype=int)
        
        for y in range(self.config.height):
            for x in range(self.config.width):
                height = self.height_map[y][x]
                moisture = self.moisture_map[y][x]
                temperature = self.temperature_map[y][x]
                
                biome = self.get_biome(height, moisture, temperature)
                biome_map[y][x] = biome.value
        
        return biome_map
    
    def get_biome(self, height: float, moisture: float, temperature: float) -> BiomeType:
        """Determine biome type based on parameters"""
        if height < self.config.water_level:
            return BiomeType.OCEAN
        elif height < self.config.water_level + 0.05:
            return BiomeType.BEACH
        elif height > self.config.mountain_level:
            if temperature < 0.3:
                return BiomeType.SNOW
            return BiomeType.MOUNTAIN
        else:
            # Lowland biomes based on moisture and temperature
            if temperature < 0.3:
                return BiomeType.SNOW
            elif moisture < 0.3:
                return BiomeType.DESERT
            elif moisture < 0.6:
                return BiomeType.GRASSLAND
            else:
                return BiomeType.FOREST

class DungeonGenerator:
    """Generates procedural dungeons using BSP"""
    
    def __init__(self, width: int, height: int, min_room_size: int = 6) -> None:
        self.width: int = width
        self.height: int = height
        self.min_room_size: int = min_room_size
        self.rooms: List = []
        self.corridors: List = []
        self.dungeon_map: Optional[np.ndarray] = None
    
    def generate(self) -> np.ndarray:
        """Generate dungeon layout"""
        self.dungeon_map = np.ones((self.height, self.width), dtype=int)
        
        # Create BSP tree
        root = BSPNode(2, 2, self.width - 4, self.height - 4)
        self.split_node(root, 5)
        
        # Create rooms in leaf nodes
        self.create_rooms(root)
        
        # Connect rooms
        self.connect_rooms(root)
        
        # Carve out rooms and corridors
        for room in self.rooms:
            for y in range(room.y, room.y + room.height):
                for x in range(room.x, room.x + room.width):
                    if 0 <= x < self.width and 0 <= y < self.height:
                        self.dungeon_map[y][x] = 0
        
        for corridor in self.corridors:
            for point in corridor:
                if 0 <= point[0] < self.width and 0 <= point[1] < self.height:
                    self.dungeon_map[point[1]][point[0]] = 0
        
        return self.dungeon_map

Cave Generation Using Cellular Automata

class CaveGenerator:
    """Generates caves using cellular automata"""
    
    def __init__(self, width: int, height: int, fill_prob: float = 0.45) -> None:
        self.width: int = width
        self.height: int = height
        self.fill_prob: float = fill_prob
        self.cave_map: Optional[np.ndarray] = None
    
    def generate(self, iterations: int = 5) -> np.ndarray:
        """Generate cave system"""
        # Initialize random map
        self.cave_map = np.random.random((self.height, self.width)) < self.fill_prob
        self.cave_map = self.cave_map.astype(int)
        
        # Apply cellular automata
        for _ in range(iterations):
            self.apply_rules()
        
        # Clean up small caves
        self.remove_small_caves(50)
        
        return self.cave_map
    
    def apply_rules(self) -> None:
        """Apply cellular automata rules"""
        new_map = np.zeros_like(self.cave_map)
        
        for y in range(self.height):
            for x in range(self.width):
                neighbors = self.count_neighbors(x, y)
                
                if neighbors > 4:
                    new_map[y][x] = 1
                elif neighbors < 4:
                    new_map[y][x] = 0
                else:
                    new_map[y][x] = self.cave_map[y][x]
        
        self.cave_map = new_map
    
    def count_neighbors(self, x: int, y: int) -> int:
        """Count wall neighbors"""
        count = 0
        
        for dy in range(-1, 2):
            for dx in range(-1, 2):
                if dx == 0 and dy == 0:
                    continue
                
                nx, ny = x + dx, y + dy
                
                # Out of bounds counts as wall
                if nx < 0 or nx >= self.width or ny < 0 or ny >= self.height:
                    count += 1
                elif self.cave_map[ny][nx] == 1:
                    count += 1
        
        return count

Advanced Techniques

# Wave Function Collapse for pattern-based generation
class WaveFunctionCollapse:
    """Generate content based on pattern constraints"""
    
    def __init__(self, tile_rules: Dict) -> None:
        self.tile_rules: Dict = tile_rules
        self.wave: Optional[List] = None
        
    def generate(self, width: int, height: int) -> np.ndarray:
        """Generate tilemap using WFC"""
        # Initialize with all possibilities
        self.wave = [[set(self.tile_rules.keys()) 
                     for _ in range(width)] 
                     for _ in range(height)]
        
        while not self.is_collapsed():
            # Find minimum entropy cell
            x, y = self.find_min_entropy()
            
            # Collapse to single state
            if x is not None:
                self.collapse_cell(x, y)
                self.propagate(x, y)
        
        return self.to_tilemap()

# L-Systems for vegetation
class LSystem:
    """Generate plants using L-Systems"""
    
    def __init__(self, axiom: str, rules: Dict[str, str]) -> None:
        self.axiom: str = axiom
        self.rules: Dict[str, str] = rules
    
    def generate(self, iterations: int) -> str:
        """Generate L-System string"""
        result = self.axiom
        
        for _ in range(iterations):
            new_result = ""
            for char in result:
                new_result += self.rules.get(char, char)
            result = new_result
        
        return result

# Example tree L-System
tree_rules = {
    'F': 'FF+[+F-F-F]-[-F+F+F]',
    'X': 'F[+X][-X]FX'
}

Best Practices

โšก Procedural Generation Tips

Key Takeaways

๐Ÿ‹๏ธโ€โ™‚๏ธ Practice Exercise

๐Ÿ‹๏ธโ€โ™‚๏ธ Exercise 1: One Seed, Two Layers, Six Biomes โ€” Seeded Multi-Octave Perlin + Independent Moisture Layer + Threshold-Rule Biome Composition in One Pygame Window

Objective: Build a runnable pygame program (~95 lines) that distills the lesson's `noise.pnoise2` + multi-octave fBm + biome-composition pattern into one runnable pygame demo so each procedural-generation discipline is visible per frame. The window is 1088ร—480 with a 272ร—107 internal map at 4ร— SCALE filling the upper area (1088ร—428 of the 480 height) and a 52-pixel HUD strip at the bottom showing seed and octave count. A single `generate(seed, octaves)` function is called on key press to produce two normalized [0, 1] map arrays โ€” a height map and a moisture map โ€” each computed from independent `random.uniform(-1000, 1000)`-derived (offset_x, offset_y) seed offsets so the same world-grid coordinate samples noise from completely different regions of pnoise2 space across the two layers. After generation, `render_biomes()` iterates every (x, y) cell, calls `biome(h, m)` to get a label, and draws a SCALEร—SCALE rect in the matching biome color โ€” the standard biome composition step from the lesson's `get_biome` reduced from (h, m, t) to (h, m) for the demo's tighter focus. Three orthogonal procedural-generation disciplines visible per frame: (a) seed-as-world-identity โ€” the same integer seed always reproduces the same biome map; pressing R picks a new random seed and the world entirely changes; pressing 1โ€“6 keeps the seed but changes the octave count and the map redraws with the same overall structure but more or less detail. The seed IS the world's identity at procedural-generation scope: saving the seed (one int) is sufficient to regenerate the entire 272ร—107 map deterministically โ€” same compact-persistence pattern Minecraft uses to save 100+ MB of world data as an 8-byte seed; same seed-as-contract shape as chat-58 architecture_save_load's schema-as-contract applied at world-state-recovery scope. (b) Multi-octave fBm โ€” `fbm(x, y, octaves, ox, oy)` sums N pnoise2 calls at each cell, with `amp *= PERSISTENCE` between iterations (so amplitude shrinks geometrically: 1.0, 0.5, 0.25, 0.125, ...) and `freq *= LACUNARITY` (so spatial frequency doubles: 1.0, 2.0, 4.0, 8.0, ...). The first octave gives the broad landmass shape; each subsequent octave adds higher-frequency lower-amplitude variation that compounds detail across scales without dominating the broad shape. The visible difference between 1 octave and 6 octaves at the same seed is dramatic: 1 octave is smooth blobs, 6 octaves is recognizably-terrain with ridges and inlets at multiple scales. Same many-things-sum-into-one accumulation pattern as chat-65 graphics_lighting's multi-light-additive composition (per-pixel lightmap sums N light contributions) applied here across spatial frequency rather than across light sources. (c) Layered-independent-noise biome composition โ€” `h_map` and `m_map` are TWO independent noise layers, each from a DIFFERENT `random.uniform()` seed-offset within the same seed scope. The `biome(h, m)` rule reads BOTH layers per pixel and returns biome from the (h, m) tuple via threshold cascade: ocean if h < WATER_LEVEL, beach in a thin band above, snow/mountain above MOUNTAIN_LEVEL, desert/grassland/forest in the lowlands selected by moisture (drierโ†’desert, wetterโ†’forest). The independence of the two layers IS the design intent: the same elevation can be desert OR forest depending on the moisture sample at that pixel. Deriving moisture from height (e.g., `moisture = 1 - h`) would collapse every low-elevation cell to one biome โ€” every coast wet (forest) and every plateau dry (desert) โ€” eliminating low-elevation desert and high-elevation forest as possibilities. Same independent-data-composes-via-shared-spatial-grid pattern from chat-66 graphics_postprocessing's ping-pong-effects-dict (multiple INDEPENDENT effects sequence into one final image) and chat-67 graphics_ui_hud's UIElement-protocol-and-UIManager-dispatch (multiple INDEPENDENT UI elements coexist via shared protocol) applied here at biome-data-from-(h, m) scope.

Instructions:

  1. Set up a 1088ร—480 pygame window plus a 272ร—107 internal biome-map grid at 4ร— SCALE filling the upper 428 pixels of the window. Define a BIOMES dict mapping seven labels (OCEAN/BEACH/DESERT/GRASSLAND/FOREST/MOUNTAIN/SNOW) to RGB color tuples, plus threshold constants WATER_LEVEL = 0.30, MOUNTAIN_LEVEL = 0.72, SNOW_LEVEL = 0.85.
  2. Implement multi-octave Perlin fBm as a helper `fbm(x, y, octaves, ox, oy)`: initialize amp=1.0, freq=1.0, total=0.0; for each of `octaves` iterations, compute `sx = (x + ox) / NOISE_SCALE * freq` and `sy = (y + oy) / NOISE_SCALE * freq`, accumulate `total += pnoise2(sx, sy) * amp`, then `amp *= PERSISTENCE` (e.g. 0.5) and `freq *= LACUNARITY` (e.g. 2.0). Return `total`.
  3. Build `generate(seed, octaves)` that calls `random.seed(seed)`, then picks FOUR `random.uniform(-1000, 1000)` offsets โ€” `(hx, hy)` for the height layer and `(mx, my)` for the moisture layer โ€” so the two layers vary independently across the same world-grid coordinates. Build `h_map[y][x] = fbm(x, y, octaves, hx, hy)` and `m_map[y][x] = fbm(x, y, octaves, mx, my)` for every (x, y), then min-max-normalize each map separately to [0, 1] so threshold constants apply consistently regardless of seed.
  4. Implement `biome(h, m)` as a threshold cascade returning a label string: OCEAN if h < WATER_LEVEL; BEACH if h < WATER_LEVEL + 0.04; SNOW if h > SNOW_LEVEL; MOUNTAIN if h > MOUNTAIN_LEVEL; otherwise pick from the lowland set by moisture: DESERT if m < 0.35, GRASSLAND if m < 0.65, FOREST otherwise.
  5. Implement `render_biomes(screen, h_map, m_map)`: nested loop over every (x, y), look up `BIOMES[biome(h_map[y][x], m_map[y][x])]` for the color, draw a `(x*SCALE, y*SCALE, SCALE, SCALE)` rect on screen. Below the biome map, draw the HUD strip with `seed`, `octaves`, and the key-binding hint; render a biome-color legend below the HUD line.
  6. Wire interactive controls in the event loop: R = `seed = random.randint(0, 99999)` then `h_map, m_map = generate(seed, octaves)` (re-roll the world); number keys 1โ€“6 = `octaves = e.key - pygame.K_0` then re-generate at the SAME seed (detail-level changes, world-identity persists at any fixed octave count). Verify that pressing R then pressing 1, 2, 3, ..., 6 in succession shows the same broad landmass with progressively-finer detail; pressing R again jumps to a completely new world.
๐Ÿ’ก Hint

Re-generation only happens on key press โ€” every press calls a single `generate()` function that returns two normalized [0, 1] map arrays. The `biome(h, m)` function is a pure-functional threshold cascade โ€” same (h, m) inputs always produce the same biome label, so a fixed seed + fixed octaves always produces the exact same map (deterministic; the seed IS the world's identity), and changing the seed produces a new world while keeping the same algorithmic shape. The two `random.uniform()` calls inside `generate()` MUST happen AFTER `random.seed(seed)` so the (hx, hy) and (mx, my) offsets are themselves deterministic functions of the seed โ€” otherwise the offsets would drift between runs even with the same seed. Use `pygame.draw.rect` for the SCALEร—SCALE biome cells rather than per-pixel `screen.set_at` (rect is faster and produces visible biome blocks at SCALE=4). The min-max normalization (`(v - lo) / (hi - lo)`) is per-map and per-generate โ€” pnoise2's raw range is roughly [-0.7, +0.7] and varies slightly with octave count, so always normalize to the actual sampled range rather than assuming a fixed range.

โœ… Example Solution
import random
import pygame
from noise import pnoise2

W, H = 1088, 480
MAP_W, MAP_H, SCALE = 272, 107, 4
NOISE_SCALE = 28.0
PERSISTENCE = 0.5
LACUNARITY = 2.0

WATER_LEVEL = 0.30
BEACH_BAND = 0.04
MOUNTAIN_LEVEL = 0.72
SNOW_LEVEL = 0.85

BIOMES = {
    'OCEAN':     ( 30,  90, 180),
    'BEACH':     (220, 200, 130),
    'DESERT':    (210, 180,  90),
    'GRASSLAND': (110, 180,  90),
    'FOREST':    ( 40, 110,  60),
    'MOUNTAIN':  (140, 130, 120),
    'SNOW':      (240, 240, 245),
}

def fbm(x, y, octaves, ox, oy):
    amp, freq, total = 1.0, 1.0, 0.0
    for _ in range(octaves):
        sx = (x + ox) / NOISE_SCALE * freq
        sy = (y + oy) / NOISE_SCALE * freq
        total += pnoise2(sx, sy) * amp
        amp *= PERSISTENCE
        freq *= LACUNARITY
    return total

def _normalize(m):
    flat = [v for row in m for v in row]
    lo, hi = min(flat), max(flat)
    span = hi - lo if hi > lo else 1.0
    return [[(v - lo) / span for v in row] for row in m]

def generate(seed, octaves):
    random.seed(seed)
    # TWO INDEPENDENT noise layers โ€” each from its own seed-offset
    hx, hy = random.uniform(-1000, 1000), random.uniform(-1000, 1000)
    mx, my = random.uniform(-1000, 1000), random.uniform(-1000, 1000)
    h_map = [[fbm(x, y, octaves, hx, hy) for x in range(MAP_W)] for y in range(MAP_H)]
    m_map = [[fbm(x, y, octaves, mx, my) for x in range(MAP_W)] for y in range(MAP_H)]
    return _normalize(h_map), _normalize(m_map)

def biome(h, m):
    if h < WATER_LEVEL: return 'OCEAN'
    if h < WATER_LEVEL + BEACH_BAND: return 'BEACH'
    if h > SNOW_LEVEL: return 'SNOW'
    if h > MOUNTAIN_LEVEL: return 'MOUNTAIN'
    if m < 0.35: return 'DESERT'
    if m < 0.65: return 'GRASSLAND'
    return 'FOREST'

def render_biomes(screen, h_map, m_map):
    for y in range(MAP_H):
        row_h, row_m = h_map[y], m_map[y]
        for x in range(MAP_W):
            color = BIOMES[biome(row_h[x], row_m[x])]
            pygame.draw.rect(screen, color, (x * SCALE, y * SCALE, SCALE, SCALE))

def main():
    pygame.init()
    screen = pygame.display.set_mode((W, H))
    pygame.display.set_caption('One Seed, Two Layers, Six Biomes')
    font = pygame.font.SysFont('consolas', 16)
    clock = pygame.time.Clock()

    seed, octaves = 42, 4
    h_map, m_map = generate(seed, octaves)

    running = True
    while running:
        for e in pygame.event.get():
            if e.type == pygame.QUIT:
                running = False
            elif e.type == pygame.KEYDOWN:
                if e.key == pygame.K_r:
                    seed = random.randint(0, 99999)
                    h_map, m_map = generate(seed, octaves)
                elif pygame.K_1 <= e.key <= pygame.K_6:
                    octaves = e.key - pygame.K_0
                    h_map, m_map = generate(seed, octaves)

        screen.fill((10, 10, 14))
        render_biomes(screen, h_map, m_map)
        pygame.draw.rect(screen, (10, 10, 14), (0, MAP_H * SCALE, W, H - MAP_H * SCALE))
        hud = f'seed={seed}   octaves={octaves}   R: re-roll seed   1-6: octave count'
        screen.blit(font.render(hud, True, (220, 230, 240)), (12, MAP_H * SCALE + 12))
        legend = 'OCEAN  BEACH  DESERT  GRASS  FOREST  MTN  SNOW'
        screen.blit(font.render(legend, True, (160, 170, 185)), (12, MAP_H * SCALE + 30))

        pygame.display.flip()
        clock.tick(60)
    pygame.quit()

if __name__ == '__main__':
    main()

๐ŸŽฏ Quick Quiz

Question 1: This demo's R key picks a new `random.randint(0, 99999)` seed and re-runs `generate(seed, octaves)` to produce a brand-new biome map. If a particular run printed `seed=42` and you wrote that integer down, then closed and re-opened the demo and re-typed `seed = 42` into the source, the resulting biome map would be IDENTICAL to the original โ€” same coastlines, same desert patches, same forest borders, down to the per-pixel biome label. Why is this property โ€” that the seed alone determines the entire generated world โ€” central to procedural-content workflows in production games?

Question 2: This demo's `fbm(x, y, octaves, ox, oy)` runs `total += pnoise2(sx*freq, sy*freq) * amp` repeated N times per cell with `amp *= 0.5` (PERSISTENCE) and `freq *= 2.0` (LACUNARITY) between iterations. The visible effect of pressing 1 vs 6 in the demo is dramatic: 1 octave gives smooth blobs of biome with no detail, 6 octaves gives recognizable terrain with ridges and inlets at multiple scales overlaid on the same broad landmass. Why does this multi-octave structure produce more natural-looking terrain than a single `pnoise2(sx, sy)` call?

Question 3: The biome rule in this demo reads height AND moisture from TWO separately-generated noise maps (each computed from a different `random.uniform(-1000, 1000)`-derived seed offset within the same seed scope) and decides biome from the (h, m) tuple via a threshold cascade: ocean if h < WATER_LEVEL, beach in a thin band above, snow/mountain above MOUNTAIN_LEVEL, and desert/grassland/forest in the lowlands selected by moisture thresholds (drier โ†’ desert, wetter โ†’ forest). Why are two independent noise layers required for this biome rule to work, rather than deriving moisture from height (e.g., `moisture = 1 - h` so coasts are wet and plateaus are dry)?

What's Next?

Congratulations on completing the Advanced Graphics section! You've mastered shaders, lighting, post-processing, UI development, and procedural generation. Continue your journey in game development!