AI Procedural Level Generation System for Games

We design and deploy artificial intelligence systems: from prototype to production-ready solutions. Our team combines expertise in machine learning, data engineering and MLOps to make AI work not in the lab, but in real business.
Showing 1 of 1 servicesAll 1566 services
AI Procedural Level Generation System for Games
Complex
~2-4 weeks
FAQ
AI Development Areas
AI Solution Development Stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1215
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_logo-advance_0.png
    B2B Advance company logo design
    561
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    823

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.