AI Procedural Level Generation for Games
Procedural level generation (PCG-levels) creates game spaces algorithmically: dungeons, platformer levels, open worlds, puzzles. AI approaches add semantic understanding: the system knows where the first enemy encounter should occur, where secrets are hidden, what the optimal difficulty curve is.
Dungeon Generation Algorithms
from dataclasses import dataclass, field
from enum import Enum
import random
import numpy as np
from collections import deque
class TileType(Enum):
WALL = 0
FLOOR = 1
DOOR = 2
CHEST = 3
SPAWN = 4
EXIT = 5
TRAP = 6
BOSS_ROOM = 7
@dataclass
class DungeonConfig:
width: int = 64
height: int = 64
min_rooms: int = 8
max_rooms: int = 15
min_room_size: tuple = (4, 4)
max_room_size: tuple = (12, 10)
corridor_width: int = 1
difficulty: float = 0.5 # 0.0 – 1.0, affects enemies and traps
dungeon_type: str = "classic" # classic, cave, maze, ruins
class BSPDungeonGenerator:
"""Binary Space Partitioning — classic dungeon algorithm"""
def __init__(self, config: DungeonConfig):
self.config = config
self.grid = np.full((config.height, config.width), TileType.WALL.value)
self.rooms = []
self.rng = random.Random()
def generate(self, seed: int = None) -> np.ndarray:
if seed:
self.rng.seed(seed)
np.random.seed(seed)
# 1. BSP partitioning
root = {"x": 1, "y": 1, "w": self.config.width - 2, "h": self.config.height - 2}
leaves = self._split_bsp(root, depth=0, max_depth=4)
# 2. Create rooms in leaves
for leaf in leaves:
room = self._create_room_in_leaf(leaf)
if room:
self.rooms.append(room)
self._carve_room(room)
# 3. Connect rooms with corridors
for i in range(len(self.rooms) - 1):
self._connect_rooms(self.rooms[i], self.rooms[i + 1])
# 4. Place special tiles
self._place_spawn_and_exit()
self._place_interactive_elements()
return self.grid
def _split_bsp(self, node: dict, depth: int, max_depth: int) -> list:
if depth >= max_depth or (node["w"] < 14 and node["h"] < 14):
return [node]
split_horizontal = self.rng.random() > 0.5
if node["w"] > node["h"] * 1.25:
split_horizontal = False
elif node["h"] > node["w"] * 1.25:
split_horizontal = True
leaves = []
if split_horizontal:
split_pos = self.rng.randint(node["y"] + 6, node["y"] + node["h"] - 6)
child_a = {"x": node["x"], "y": node["y"], "w": node["w"], "h": split_pos - node["y"]}
child_b = {"x": node["x"], "y": split_pos, "w": node["w"], "h": node["y"] + node["h"] - split_pos}
else:
split_pos = self.rng.randint(node["x"] + 6, node["x"] + node["w"] - 6)
child_a = {"x": node["x"], "y": node["y"], "w": split_pos - node["x"], "h": node["h"]}
child_b = {"x": split_pos, "y": node["y"], "w": node["x"] + node["w"] - split_pos, "h": node["h"]}
leaves.extend(self._split_bsp(child_a, depth + 1, max_depth))
leaves.extend(self._split_bsp(child_b, depth + 1, max_depth))
return leaves
def _create_room_in_leaf(self, leaf: dict) -> dict | None:
max_w = min(self.config.max_room_size[0], leaf["w"] - 2)
max_h = min(self.config.max_room_size[1], leaf["h"] - 2)
if max_w < self.config.min_room_size[0] or max_h < self.config.min_room_size[1]:
return None
w = self.rng.randint(self.config.min_room_size[0], max_w)
h = self.rng.randint(self.config.min_room_size[1], max_h)
x = leaf["x"] + self.rng.randint(1, leaf["w"] - w - 1)
y = leaf["y"] + self.rng.randint(1, leaf["h"] - h - 1)
return {"x": x, "y": y, "w": w, "h": h}
def _carve_room(self, room: dict) -> None:
for y in range(room["y"], room["y"] + room["h"]):
for x in range(room["x"], room["x"] + room["w"]):
self.grid[y][x] = TileType.FLOOR.value
def _connect_rooms(self, room_a: dict, room_b: dict) -> None:
"""L-shaped corridor between room centers"""
cx_a = room_a["x"] + room_a["w"] // 2
cy_a = room_a["y"] + room_a["h"] // 2
cx_b = room_b["x"] + room_b["w"] // 2
cy_b = room_b["y"] + room_b["h"] // 2
if self.rng.random() > 0.5:
self._carve_horizontal(cy_a, min(cx_a, cx_b), max(cx_a, cx_b))
self._carve_vertical(cx_b, min(cy_a, cy_b), max(cy_a, cy_b))
else:
self._carve_vertical(cx_a, min(cy_a, cy_b), max(cy_a, cy_b))
self._carve_horizontal(cy_b, min(cx_a, cx_b), max(cx_a, cx_b))
def _carve_horizontal(self, y: int, x1: int, x2: int) -> None:
for x in range(x1, x2 + 1):
self.grid[y][x] = TileType.FLOOR.value
def _carve_vertical(self, x: int, y1: int, y2: int) -> None:
for y in range(y1, y2 + 1):
self.grid[y][x] = TileType.FLOOR.value
def _place_spawn_and_exit(self) -> None:
if self.rooms:
spawn_room = self.rooms[0]
self.grid[spawn_room["y"] + 1][spawn_room["x"] + 1] = TileType.SPAWN.value
exit_room = self.rooms[-1]
self.grid[exit_room["y"] + 1][exit_room["x"] + 1] = TileType.EXIT.value
# Boss room — largest room
boss_room = max(self.rooms, key=lambda r: r["w"] * r["h"])
mid_y = boss_room["y"] + boss_room["h"] // 2
mid_x = boss_room["x"] + boss_room["w"] // 2
self.grid[mid_y][mid_x] = TileType.BOSS_ROOM.value
def _place_interactive_elements(self) -> None:
trap_count = int(len(self.rooms) * self.config.difficulty * 0.3)
chest_count = max(1, int(len(self.rooms) * 0.4))
for room in self.rng.sample(self.rooms[1:-1], min(trap_count, len(self.rooms) - 2)):
x = self.rng.randint(room["x"] + 1, room["x"] + room["w"] - 2)
y = self.rng.randint(room["y"] + 1, room["y"] + room["h"] - 2)
self.grid[y][x] = TileType.TRAP.value
for room in self.rng.sample(self.rooms, min(chest_count, len(self.rooms))):
x = self.rng.randint(room["x"] + 1, room["x"] + room["w"] - 2)
y = self.rng.randint(room["y"] + 1, room["y"] + room["h"] - 2)
if self.grid[y][x] == TileType.FLOOR.value:
self.grid[y][x] = TileType.CHEST.value
Wave Function Collapse for Tile Levels
class WaveFunctionCollapse:
"""
WFC generates levels by example: analyzes patterns in a sample tile map
and generates new maps with the same local patterns.
Used in platformers, isometric RPGs, puzzle games.
"""
def __init__(self, sample_grid: np.ndarray, pattern_size: int = 3):
self.pattern_size = pattern_size
self.patterns, self.weights = self._extract_patterns(sample_grid)
self.adjacency = self._compute_adjacency()
def _extract_patterns(self, grid: np.ndarray) -> tuple:
patterns = {}
h, w = grid.shape
p = self.pattern_size
for y in range(h - p + 1):
for x in range(w - p + 1):
pattern = tuple(grid[y:y+p, x:x+p].flatten())
patterns[pattern] = patterns.get(pattern, 0) + 1
all_patterns = list(patterns.keys())
weights = [patterns[p] for p in all_patterns]
return all_patterns, weights
def _compute_adjacency(self) -> dict:
"""For each pattern, determine allowed neighbors in 4 directions"""
adjacency = {i: {d: set() for d in ["up", "down", "left", "right"]}
for i in range(len(self.patterns))}
p = self.pattern_size
for i, pat_a in enumerate(self.patterns):
grid_a = np.array(pat_a).reshape(p, p)
for j, pat_b in enumerate(self.patterns):
grid_b = np.array(pat_b).reshape(p, p)
# Check overlap compatibility
if np.array_equal(grid_a[1:, :], grid_b[:-1, :]):
adjacency[i]["down"].add(j)
adjacency[j]["up"].add(i)
if np.array_equal(grid_a[:, 1:], grid_b[:, :-1]):
adjacency[i]["right"].add(j)
adjacency[j]["left"].add(i)
return adjacency
def generate(self, output_size: tuple) -> np.ndarray:
h, w = output_size
# Each cell contains set of possible patterns
wave = [[set(range(len(self.patterns))) for _ in range(w)] for _ in range(h)]
result = np.zeros((h, w), dtype=int)
while True:
# Find cell with minimum entropy (not collapsed)
min_entropy = float("inf")
min_cell = None
for y in range(h):
for x in range(w):
if len(wave[y][x]) > 1:
entropy = len(wave[y][x])
if entropy < min_entropy:
min_entropy = entropy
min_cell = (y, x)
if min_cell is None:
break
# Collapse cell with minimum entropy
y, x = min_cell
possible = list(wave[y][x])
weights = [self.weights[p] for p in possible]
total = sum(weights)
chosen = random.choices(possible, weights=[w/total for w in weights])[0]
wave[y][x] = {chosen}
# Constraint propagation (BFS)
queue = deque([(y, x)])
while queue:
cy, cx = queue.popleft()
for dy, dx, direction, opposite in [(-1,0,"up","down"),(1,0,"down","up"),(0,-1,"left","right"),(0,1,"right","left")]:
ny, nx = cy + dy, cx + dx
if 0 <= ny < h and 0 <= nx < w and len(wave[ny][nx]) > 1:
allowed = set()
for pat_idx in wave[cy][cx]:
allowed |= self.adjacency[pat_idx][direction]
new_options = wave[ny][nx] & allowed
if new_options != wave[ny][nx]:
wave[ny][nx] = new_options
queue.append((ny, nx))
# Assemble result from first tile of each pattern
for y in range(h):
for x in range(w):
if wave[y][x]:
pat_idx = next(iter(wave[y][x]))
result[y][x] = self.patterns[pat_idx][0]
return result
AI Level Design Evaluation and Improvement
from openai import AsyncOpenAI
client = AsyncOpenAI()
async def evaluate_level_design(level_grid: np.ndarray, config: DungeonConfig) -> dict:
"""LLM analyzes ASCII representation of level and provides design evaluation"""
TILE_CHARS = {0: "#", 1: ".", 2: "+", 3: "C", 4: "S", 5: "E", 6: "^", 7: "B"}
ascii_map = "\n".join(
"".join(TILE_CHARS.get(int(cell), "?") for cell in row)
for row in level_grid
)
response = await client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "system",
"content": """You are a game designer, level design specialist.
Evaluate the dungeon by criteria and suggest improvements.
Notation: # wall, . floor, + door, C chest, S spawn, E exit, ^ trap, B boss
Return JSON: {
flow_score: 1-10, // route logic
pacing_score: 1-10, // tension distribution
exploration_score: 1-10, // motivation to explore
issues: ["issue description"],
improvements: ["specific fixes"],
estimated_playtime_minutes: int
}"""
}, {
"role": "user",
"content": f"Difficulty: {config.difficulty}\nMap:\n{ascii_map[:2000]}"
}],
response_format={"type": "json_object"}
)
import json
return json.loads(response.choices[0].message.content)
Integration with Unity and Unreal Engine
Unity: generated grid is serialized to JSON and read by MonoBehaviour script. Tilemap API fills TileBase by tile types, NavMesh is baked automatically via NavMeshSurface.
Unreal Engine 5: PCG Graph in PCG Framework accepts parameters as attributes, Procedural Mesh Component builds geometry from generator data, World Partition manages loading of large dungeons in chunks.
Comparison of Algorithms by Game Type
| Algorithm | Level Types | Determinism | Designer Control |
|---|---|---|---|
| BSP | Dungeons, buildings | Full (by seed) | High |
| WFC | Tile-based, platformers | Full (by seed) | Via sample map |
| Cellular Automata | Caves, organic | Full | Medium |
| Noise + Biomes | Open worlds | Full | Via parameters |
| ML-generation (GAN) | All types | Partial | Low |
PCG system with BSP dungeon and LLM evaluation — 3–4 weeks. Full-featured generator with WFC, biome theming support, and Unity plugin — 8–12 weeks.







