Procedural Content Generation — Game Development Guide
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
What is the advantage of seed-based generation? Determinism — same seed produces identical output, enabling multiplayer sync, replays, and shared level codes.
How does BSP differ from WFC? BSP recursively splits space into rooms; WFC propagates adjacency constraints to generate coherent tile patterns.
What is octave noise? Layered noise at different frequencies — low frequency for large features (mountains), high frequency for detail (rocks).
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.
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.
Related Tutorials
- 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
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro