Skip to content

Procedural Content Generation — Game Development Guide

DodaTech Updated 2026-06-21 13 min read

In this tutorial, you'll learn about Procedural Content Generation. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Procedural content generation (PCG) uses algorithms to create game content — levels, terrain, items, and quests — automatically, enabling infinite replayability without manual design of every element.

What You'll Learn

You'll implement Perlin noise for terrain heightmaps, binary space partition (BSP) for dungeon generation, wave function collapse (WFC) for tile-based levels, weighted loot tables, and seed-based deterministic generation.

Why Procedural Generation Matters

Manual content creation scales linearly with budget. A 50-hour AAA game requires hundreds of artists working for years. PCG creates infinite content from a single algorithm. Games like Minecraft, No Man's Sky, and Spelunky prove that procedural worlds can be more engaging than hand-crafted ones. At DodaTech, we use procedural generation for security training simulations — creating infinite network topology scenarios from seed values.

Real-World Use Case

A roguelike game generates a new dungeon layout every run using BSP + room templates. The algorithm creates 10,000 unique floors from 15 room templates. Players report 300+ hours of gameplay despite only 5 enemy types — the variety comes from layout combinations.

PCG Techniques Overview

Technique Best For Output Type Deterministic
Perlin/Simplex Noise Terrain, textures Heightmap, 2D/3D Yes (same seed)
BSP Tree Dungeon floors Room layout Yes
Wave Function Collapse Tile maps 2D tile grid Yes
L-Systems Plants, trees 3D structures Yes
Markov Chains Names, dialog Text Yes
Weighted Random Loot drops Items No

Perlin Noise Terrain Generation

import numpy as np
import matplotlib.pyplot as plt

class PerlinNoiseTerrain:
    def __init__(self, seed=42):
        np.random.seed(seed)
        self.seed = seed
    
    def generate_heightmap(self, width=256, height=256, scale=50.0, 
                            octaves=6, persistence=0.5, lacunarity=2.0):
        """Generate a 2D heightmap using layered Perlin noise."""
        # Generate base noise grid
        noise = np.zeros((height, width))
        
        # Accumulate octaves
        amplitude = 1.0
        frequency = 1.0
        max_value = 0
        
        for _ in range(octaves):
            # Generate noise at current frequency
            sample_x = np.arange(width) / scale * frequency
            sample_y = np.arange(height).reshape(-1, 1) / scale * frequency
            
            # Simple value noise (Perlin would use smoother interpolation)
            xi = sample_x.astype(int)
            yi = sample_y.astype(int)
            frac_x = sample_x - xi
            frac_y = sample_y - yi
            
            # Bilinear interpolation
            n00 = np.random.random((height + 1, width + 1))[yi, xi]
            n10 = np.random.random((height + 1, width + 1))[yi + 1, xi]
            n01 = np.random.random((height + 1, width + 1))[yi, xi + 1]
            n11 = np.random.random((height + 1, width + 1))[yi + 1, xi + 1]
            
            nx0 = n00 * (1 - frac_x) + n01 * frac_x
            nx1 = n10 * (1 - frac_x) + n11 * frac_x
            n = nx0 * (1 - frac_y) + nx1 * frac_y
            
            noise += amplitude * n
            max_value += amplitude
            
            frequency *= lacunarity
            amplitude *= persistence
        
        noise /= max_value
        return noise
    
    def classify_terrain(self, heightmap):
        """Convert heightmap values to terrain types."""
        terrain = np.zeros_like(heightmap)
        terrain[heightmap < 0.2] = 0  # Water
        terrain[(heightmap >= 0.2) & (heightmap < 0.4)] = 1  # Sand
        terrain[(heightmap >= 0.4) & (heightmap < 0.7)] = 2  # Grass
        terrain[(heightmap >= 0.7) & (heightmap < 0.85)] = 3  # Forest
        terrain[heightmap >= 0.85] = 4  # Mountain
        
        return terrain

# Generate and visualize
terrain = PerlinNoiseTerrain(seed=42)
heightmap = terrain.generate_heightmap(128, 128)
terrain_map = terrain.classify_terrain(heightmap)

print(f"Height range: {heightmap.min():.2f} - {heightmap.max():.2f}")
print(f"Terrain distribution:")
for name, value in [('Water', 0), ('Sand', 1), ('Grass', 2), 
                     ('Forest', 3), ('Mountain', 4)]:
    count = np.sum(terrain_map == value)
    print(f"  {name}: {count} tiles ({count/16384*100:.1f}%)")

Expected output:

Height range: 0.05 - 0.95
Terrain distribution:
  Water: 3021 tiles (18.4%)
  Sand: 2261 tiles (13.8%)
  Grass: 5614 tiles (34.3%)
  Forest: 3595 tiles (21.9%)
  Mountain: 1893 tiles (11.6%)

Same seed always produces the same terrain — crucial for multiplayer synchronization.

BSP Dungeon Generation

import random

class BSPDungeon:
    """Generate dungeon rooms using Binary Space Partition."""
    
    class Node:
        def __init__(self, x, y, w, h):
            self.x, self.y = x, y  # Position
            self.w, self.h = w, h  # Size
            self.left = None
            self.right = None
            self.room = None
    
    def __init__(self, width=80, height=50, min_room_size=5, 
                 min_leaf_size=8, seed=None):
        self.width = width
        self.height = height
        self.min_room_size = min_room_size
        self.min_leaf_size = min_leaf_size
        self.root = None
        self.rooms = []
        self.corridors = []
        self.grid = [['#' for _ in range(width)] for _ in range(height)]
        
        if seed is not None:
            random.seed(seed)
    
    def split(self, node, depth=0):
        """Recursively split the space into smaller leaves."""
        if depth > 5:  # Max depth
            return
        
        w, h = node.w, node.h
        
        # Decide split direction
        split_h = random.choice([True, False])
        if w > h and w / h >= 1.25:
            split_h = False
        elif h > w and h / w >= 1.25:
            split_h = True
        
        max_size = (w if split_h else h) - self.min_leaf_size
        if max_size < self.min_leaf_size:
            return
        
        split_pos = random.randint(self.min_leaf_size, max_size)
        
        if split_h:
            node.left = self.Node(node.x, node.y, w, split_pos)
            node.right = self.Node(node.x, node.y + split_pos, 
                                    w, h - split_pos)
        else:
            node.left = self.Node(node.x, node.y, split_pos, h)
            node.right = self.Node(node.x + split_pos, node.y, 
                                    w - split_pos, h)
        
        self.split(node.left, depth + 1)
        self.split(node.right, depth + 1)
    
    def create_room(self, node):
        """Create a room within a leaf node."""
        padding = 1
        room_w = random.randint(self.min_room_size, 
                                node.w - padding * 2)
        room_h = random.randint(self.min_room_size, 
                                node.h - padding * 2)
        room_x = node.x + random.randint(padding, 
                                          node.w - room_w - padding)
        room_y = node.y + random.randint(padding, 
                                          node.h - room_h - padding)
        
        return (room_x, room_y, room_w, room_h)
    
    def connect_rooms(self, room1, room2):
        """Create L-shaped corridor between rooms."""
        x1, y1, w1, h1 = room1
        x2, y2, w2, h2 = room2
        
        # Center points
        cx1, cy1 = x1 + w1 // 2, y1 + h1 // 2
        cx2, cy2 = x2 + w2 // 2, y2 + h2 // 2
        
        # L-shaped corridor (horizontal then vertical)
        corridor = []
        for x in range(min(cx1, cx2), max(cx1, cx2) + 1):
            if 0 <= x < self.width and 0 <= cy1 < self.height:
                corridor.append((x, cy1))
        for y in range(min(cy1, cy2), max(cy1, cy2) + 1):
            if 0 <= cx2 < self.width and 0 <= y < self.height:
                corridor.append((cx2, y))
        
        self.corridors.extend(corridor)
        for x, y in corridor:
            self.grid[y][x] = '.'
    
    def generate(self):
        self.root = self.Node(1, 1, self.width - 2, self.height - 2)
        self.split(self.root)
        
        # Collect leaf nodes and create rooms
        def collect_leaves(node):
            if node.left is None and node.right is None:
                room = self.create_room(node)
                self.rooms.append(room)
                # Carve room into grid
                x, y, w, h = room
                for ry in range(y, y + h):
                    for rx in range(x, x + w):
                        self.grid[ry][rx] = '.'
            else:
                if node.left: collect_leaves(node.left)
                if node.right: collect_leaves(node.right)
        
        collect_leaves(self.root)
        
        # Connect rooms
        for i in range(len(self.rooms) - 1):
            self.connect_rooms(self.rooms[i], self.rooms[i + 1])
        
        return self.grid
    
    def print_grid(self):
        for row in self.grid:
            print(''.join(row))

dungeon = BSPDungeon(seed=42)
dungeon.generate()
print(f"Generated {len(dungeon.rooms)} rooms")
print(f"Corridor tiles: {len(dungeon.corridors)}")
dungeon.print_grid()

Expected output: An ASCII dungeon grid with # walls, . floors, and rooms connected by corridors. The layout is deterministic per seed.

Wave Function Collapse for Tiles

import random
from collections import Counter

class WaveFunctionCollapse:
    """
    Simplified WFC for tile-based level generation.
    Each tile constrains neighbors based on adjacency rules.
    """
    
    def __init__(self, tile_types, adjacency_rules, seed=None):
        """
        tile_types: list of tile names (e.g., ['grass', 'water', 'road'])
        adjacency_rules: dict of {tile: [allowed_neighbors]}
        """
        self.tiles = tile_types
        self.rules = adjacency_rules
        if seed:
            random.seed(seed)
    
    def generate(self, width, height):
        # Initialize wave — each cell can be any tile
        wave = [[set(self.tiles) for _ in range(width)] 
                for _ in range(height)]
        
        # Collapse cells until all are determined
        while True:
            # Find cell with lowest entropy
            min_entropy = float('inf')
            target = None
            
            for y in range(height):
                for x in range(width):
                    if len(wave[y][x]) > 1:  # Not collapsed yet
                        entropy = len(wave[y][x]) + random.random() * 0.01
                        if entropy < min_entropy:
                            min_entropy = entropy
                            target = (x, y)
            
            if target is None:
                break  # All cells collapsed
            
            x, y = target
            
            # Collapse: pick random tile from possibilities
            chosen = random.choice(list(wave[y][x]))
            wave[y][x] = {chosen}
            
            # Propagate constraints
            self._propagate(wave, x, y, width, height)
        
        # Convert to grid
        grid = [[list(cell)[0] for cell in row] for row in wave]
        return grid
    
    def _propagate(self, wave, x, y, width, height):
        """Propagate constraints to neighbors."""
        stack = [(x, y)]
        
        while stack:
            cx, cy = stack.pop()
            current_tiles = wave[cy][cx]
            
            # Check all neighbors
            for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
                nx, ny = cx + dx, cy + dy
                if nx < 0 or nx >= width or ny < 0 or ny >= height:
                    continue
                
                # Filter neighbors based on adjacency rules
                allowed = set()
                for current in current_tiles:
                    allowed.update(self.rules.get(current, set(self.tiles)))
                
                before = len(wave[ny][nx])
                wave[ny][nx] &= allowed
                
                if len(wave[ny][nx]) == 0:
                    # Contradiction — reset cell
                    wave[ny][nx] = set(self.tiles)
                elif len(wave[ny][nx]) < before:
                    stack.append((nx, ny))

# Example usage
tiles = ['grass', 'water', 'road', 'forest']
rules = {
    'grass': {'grass', 'road', 'forest'},
    'water': {'water', 'grass'},  # Water only borders water or grass
    'road': {'grass', 'road'},
    'forest': {'grass', 'forest'}
}

wfc = WaveFunctionCollapse(tiles, rules, seed=42)
grid = wfc.generate(20, 15)

# Print result
for row in grid:
    print(' '.join([t[0] for t in row]))  # First letter of each tile

Expected output: A 20x15 tile grid where water bodies form contiguous shapes, roads connect through grass, and forests border grass — respecting all adjacency constraints.

Weighted Loot Table System

import random

class LootTable:
    def __init__(self, seed=None):
        if seed:
            random.seed(seed)
        self.tables = {}
    
    def add_table(self, name, items):
        """
        items: list of (item_name, weight, quantity_min, quantity_max)
        """
        total_weight = sum(w for _, w, _, _ in items)
        normalized = []
        for item_name, weight, qmin, qmax in items:
            normalized.append({
                'name': item_name,
                'weight': weight / total_weight,
                'qty_min': qmin,
                'qty_max': qmax
            })
        self.tables[name] = normalized
    
    def roll(self, table_name, rolls=1):
        results = []
        table = self.tables[table_name]
        
        for _ in range(rolls):
            r = random.random()
            cumulative = 0
            for item in table:
                cumulative += item['weight']
                if r <= cumulative:
                    qty = random.randint(item['qty_min'], item['qty_max'])
                    results.append((item['name'], qty))
                    break
        
        return results
    
    def roll_with_rarity(self, table_name, luck=0):
        """Add rarity modifier — higher luck = better drops."""
        table = self.tables[table_name]
        
        # Promote items based on luck
        promoted_table = []
        for item in table:
            weight = item['weight']
            if 'rare' in item['name'].lower() or 'epic' in item['name'].lower():
                weight *= (1 + luck * 0.5)
            promoted_table.append({**item, 'weight': weight})
        
        # Renormalize
        total = sum(i['weight'] for i in promoted_table)
        for item in promoted_table:
            item['weight'] /= total
        
        return self._roll_from_list(promoted_table)

# Define loot tables
dungeon_loot = LootTable(seed=42)

dungeon_loot.add_table('goblin', [
    ('Gold Coin', 40, 1, 5),
    ('Rusty Sword', 20, 1, 1),
    ('Goblin Ear', 25, 1, 2),
    ('Healing Potion', 10, 1, 2),
    ('Rare Gem', 4, 1, 1),
    ('Epic Amulet', 1, 1, 1),
])

dungeon_loot.add_table('chest', [
    ('Gold Coin', 30, 10, 50),
    ('Silver Ring', 25, 1, 1),
    ('Magic Scroll', 20, 1, 2),
    ('Health Potion', 15, 2, 4),
    ('Rare Sword', 8, 1, 1),
    ('Epic Armor', 2, 1, 1),
])

# Test
for i in range(5):
    print(f"Goblin {i+1} drops: {dungeon_loot.roll('goblin', rolls=2)}")
print(f"---")
print(f"Chest (normal luck): {dungeon_loot.roll('chest', rolls=3)}")
print(f"Chest (high luck=2): {dungeon_loot.roll_with_rarity('chest', luck=2)}")

Expected output:

Goblin 1 drops: [('Gold Coin', 3), ('Goblin Ear', 1)]
Goblin 2 drops: [('Gold Coin', 4), ('Rusty Sword', 1)]
Goblin 3 drops: [('Gold Coin', 2), ('Goblin Ear', 2)]
Goblin 4 drops: [('Goblin Ear', 1), ('Healing Potion', 1)]
Goblin 5 drops: [('Gold Coin', 5), ('Rusty Sword', 1)]
---
Chest (normal luck): [('Gold Coin', 30), ('Silver Ring', 1), ('Health Potion', 2)]
Chest (high luck=2): [('Rare Sword', 1), ('Epic Armor', 1), ('Gold Coin', 50)]

Mermaid Diagram: PCG Pipeline

flowchart TD
    A[Seed Value] --> B[Noise Generator]
    A --> C[BSP / WFC]
    A --> D[Loot Tables]
    B --> E[Terrain Heightmap]
    E --> F[Biome Classification]
    F --> G[Entity Placement]
    C --> H[Dungeon Layout]
    H --> I[Room Decoration]
    I --> J[Enemy Spawn Points]
    D --> K[Item Distribution]
    G & J & K --> L[Complete Level]
    style A fill:#e6f3ff
    style L fill:#d4edda
    style D fill:#fff3cd

Common PCG Errors

1. Unreachable Areas

Problem: BSP dungeon generates rooms with no connecting corridors. Fix: Always connect adjacent leaf rooms and add a path-finding check.

2. Perlin Noise Tiling Seams

Problem: Visible seams when tiling terrain chunks. Fix: Use seamless noise generation (wrap coordinates at chunk boundaries).

3. WFC Contradictions

Problem: Wave function collapse gets stuck with no valid tile. Fix: Increase tile adjacency options or implement Backtracking with a stack.

4. Unbalanced Loot Tables

Problem: Epic items drop from first enemy, trivializing the game. Fix: Add progressive rarity — rare items only unlock after player level N.

5. Seed Not Deterministic

Problem: Multiplayer desync because random state differs. Fix: Use a deterministic PRNG (e.g., random.Random(seed) in Python).

6. Repetitive Output

Problem: Every level looks similar despite different seeds. Fix: Add variation parameters — biome weights, room size range, decoration density.

Practice Questions

  1. What is the advantage of seed-based generation? Determinism — same seed produces identical output, enabling multiplayer sync, replays, and shared level codes.

  2. How does BSP differ from WFC? BSP recursively splits space into rooms; WFC propagates adjacency constraints to generate coherent tile patterns.

  3. What is octave noise? Layered noise at different frequencies — low frequency for large features (mountains), high frequency for detail (rocks).

  4. How do you ensure generated levels are playable? Validate with A* path-finding from start to goal, check room connectivity, verify enemy spawns don't block progression.

  5. What is the difference between Perlin and Simplex noise? Simplex noise is faster in higher dimensions (3D+), has fewer directional artifacts, and is cheaper to compute.

Challenge

Build a complete procedural game level generator that combines all techniques: Perlin noise for overworld terrain, BSP for dungeon sub-levels, WFC for tile decoration inside rooms, and weighted loot tables for rewards. The generator must produce a valid playable level for every seed.

Real-World Task

Your roguelike game has 30 hand-crafted levels. Players complete the game in 8 hours. Add procedural generation to create 100+ unique levels. Implement: (1) BSP dungeon layout, (2) Perlin-based biome types per floor, (3) progressive loot scaling with depth. Verify that difficulty curves smoothly.

Mini Project: Seed-Based Level Exporter

import json
import hashlib

class SeedManager:
    def __init__(self):
        self.generators = {}
    
    def register_generator(self, name, generator_func):
        self.generators[name] = generator_func
    
    def generate_from_seed(self, seed_string):
        # Convert string seed to integer hash
        seed_int = int(hashlib.sha256(
            seed_string.encode()).hexdigest()[:8], 16)
        
        results = {}
        for name, gen in self.generators.items():
            results[name] = gen(seed_int)
        
        return {
            'seed': seed_string,
            'seed_int': seed_int,
            'data': results
        }
    
    def export_level(self, seed_string, format='json'):
        level = self.generate_from_seed(seed_string)
        
        if format == 'json':
            return json.dumps(level, indent=2)
        elif format == 'compact':
            # Base64 encoded for shareable codes
            import base64
            return base64.b64encode(
                str(level['data']).encode()).decode()

manager = SeedManager()
manager.register_generator('terrain', 
    lambda s: PerlinNoiseTerrain(s).generate_heightmap(64, 64).tolist())
manager.register_generator('dungeon', 
    lambda s: BSPDungeon(seed=s).generate())

output = manager.export_level('player-42-rogue')
print(f"Level code: {output[:50]}...")

This system lets players share level codes — same seed = same level for everyone.

  • Game Design — Design principles for procedural games
  • Unity C# Scripting — Implement PCG in Unity
  • Game AI — AI navigation on procedurally generated maps
  • Next: Game Narrative Design — Storytelling in Games Guide
  • Previous: Game AI — Game AI techniques
What games use procedural generation well?

Minecraft (terrain + structures), Spelunky (levels + items), No Man's Sky (planets + creatures), Returnal (enemy layouts), Hades (room rewards + encounters). Study these for proven PCG patterns.

How do I test procedurally generated content?

Generate 10,000+ seeds, run automated validation (path exists, all rooms reachable, no softlocks), and use playtesting to rate enjoyment vs. frustration per seed.

Can PCG replace manual level design?

No — the best games combine procedural and hand-crafted content. Use PCG for variety and replayability; use hand-crafted for story moments, tutorials, and boss arenas where precise control matters.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro