Back to Blog
2026-03-22

Build a Game Level with AI — No OpenAI Key Required

Generate complete game level layouts, tile maps, and environment scripts using local LLMs and procedural algorithms. Export directly to Godot, Unity, or Unreal. Zero API costs.

Game level design is one of those tasks that's time-consuming to do manually but almost perfectly suited for AI generation. A level is ultimately just structured data — tile grids, object placements, spawn points, and scripted events.

This guide shows you how to generate complete playable levels using:

  1. Procedural algorithms (wave function collapse, BSP dungeon gen) — zero cost, always available
  2. Local LLMs via Ollama — for natural language → level configuration
  3. A fallback chain — so generation works even without any model installed

No OpenAI account. No API keys. No internet required.

The Generation Stack

Text Prompt → Local LLM (Ollama/llama3) → Level Config JSON
                        ↓ (fallback)
              Procedural Generator → Level Config JSON
                        ↓
              Tilemap Exporter → Godot .tscn / Unity tilemap / TMX

Setup

# Install Ollama for local LLM
curl -fsSL https://ollama.com/install.sh | sh
ollama pull llama3.2:3b  # Small, fast, works well for structured output

# Python dependencies
pip install noise requests numpy pillow

Step 1: Procedural Level Generation (No LLM Needed)

This binary space partitioning (BSP) dungeon generator produces complete room layouts:

import random
import numpy as np
from dataclasses import dataclass
from typing import List, Optional, Tuple

@dataclass
class Room:
    x: int
    y: int
    width: int
    height: int
    
    @property
    def center(self) -> Tuple[int, int]:
        return (self.x + self.width // 2, self.y + self.height // 2)
    
    def intersects(self, other: 'Room', padding: int = 1) -> bool:
        return not (
            self.x + self.width + padding <= other.x or
            other.x + other.width + padding <= self.x or
            self.y + self.height + padding <= other.y or
            other.y + other.height + padding <= self.y
        )

class BSPDungeonGenerator:
    EMPTY = 0
    FLOOR = 1
    WALL = 2
    DOOR = 3
    SPAWN = 4
    EXIT = 5
    CHEST = 6
    
    def __init__(self, width: int = 64, height: int = 48, seed: int = None):
        self.width = width
        self.height = height
        self.rng = random.Random(seed)
        self.grid = np.zeros((height, width), dtype=int)
        self.rooms: List[Room] = []
    
    def generate(
        self,
        min_room_size: int = 6,
        max_room_size: int = 14,
        max_rooms: int = 12,
        add_chests: bool = True,
        corridor_style: str = "L"  # "L", "straight", "both"
    ) -> np.ndarray:
        # Fill with walls
        self.grid.fill(self.WALL)
        
        attempts = 0
        while len(self.rooms) < max_rooms and attempts < max_rooms * 5:
            w = self.rng.randint(min_room_size, max_room_size)
            h = self.rng.randint(min_room_size, max_room_size)
            x = self.rng.randint(1, self.width - w - 1)
            y = self.rng.randint(1, self.height - h - 1)
            
            new_room = Room(x, y, w, h)
            
            if not any(new_room.intersects(r) for r in self.rooms):
                self._carve_room(new_room)
                
                if self.rooms:
                    self._connect_rooms(self.rooms[-1], new_room, corridor_style)
                
                self.rooms.append(new_room)
            
            attempts += 1
        
        if self.rooms:
            # Place spawn in first room
            cx, cy = self.rooms[0].center
            self.grid[cy][cx] = self.SPAWN
            
            # Place exit in last room
            cx, cy = self.rooms[-1].center
            self.grid[cy][cx] = self.EXIT
            
            # Add chests in intermediate rooms
            if add_chests:
                for room in self.rooms[1:-1]:
                    if self.rng.random() < 0.4:
                        chest_x = self.rng.randint(room.x + 1, room.x + room.width - 2)
                        chest_y = self.rng.randint(room.y + 1, room.y + room.height - 2)
                        self.grid[chest_y][chest_x] = self.CHEST
        
        return self.grid
    
    def _carve_room(self, room: Room):
        for y in range(room.y, room.y + room.height):
            for x in range(room.x, room.x + room.width):
                self.grid[y][x] = self.FLOOR
    
    def _connect_rooms(self, a: Room, b: Room, style: str):
        ax, ay = a.center
        bx, by = b.center
        
        # L-shaped corridor
        if style == "L" or (style == "both" and random.random() < 0.5):
            for x in range(min(ax, bx), max(ax, bx) + 1):
                self.grid[ay][x] = self.FLOOR
            for y in range(min(ay, by), max(ay, by) + 1):
                self.grid[y][bx] = self.FLOOR

# Generate a dungeon
gen = BSPDungeonGenerator(width=64, height=48, seed=42)
level = gen.generate(max_rooms=10, add_chests=True)

print(f"Generated level with {len(gen.rooms)} rooms")
print(f"Grid size: {level.shape}")

Step 2: Local LLM for Level Configuration

import requests
import json

def prompt_to_level_config(prompt: str, model: str = "llama3.2:3b") -> dict:
    """
    Use Ollama to convert a text prompt to a structured level configuration.
    Falls back to defaults if Ollama isn't available.
    """
    system_prompt = """You are a game level design assistant. 
Convert the user's description into a JSON level configuration.
Respond ONLY with valid JSON, no explanation.

Schema:
{
  "theme": "dungeon|cave|forest|castle|sci-fi",
  "width": 64,
  "height": 48,
  "max_rooms": 8,
  "min_room_size": 5,
  "max_room_size": 14,
  "enemy_density": 0.3,
  "chest_probability": 0.4,
  "boss_room": false,
  "difficulty": "easy|medium|hard",
  "special_features": ["dark", "wet", "trapped"]
}"""
    
    try:
        response = requests.post(
            "http://localhost:11434/api/generate",
            json={
                "model": model,
                "prompt": f"Level description: {prompt}",
                "system": system_prompt,
                "stream": False,
                "options": {"temperature": 0.3}
            },
            timeout=30
        )
        
        if response.status_code == 200:
            raw = response.json()["response"].strip()
            # Extract JSON from response
            start = raw.find("{")
            end = raw.rfind("}") + 1
            if start != -1 and end > start:
                config = json.loads(raw[start:end])
                print(f"LLM generated config: theme={config.get('theme')}, rooms={config.get('max_rooms')}")
                return config
    
    except Exception as e:
        print(f"Ollama unavailable ({e}), using procedural fallback")
    
    # Fallback: parse prompt with keyword matching
    config = {"theme": "dungeon", "width": 64, "height": 48, "max_rooms": 8,
              "min_room_size": 5, "max_room_size": 14, "enemy_density": 0.3,
              "chest_probability": 0.4, "boss_room": False, "difficulty": "medium"}
    
    prompt_lower = prompt.lower()
    if "cave" in prompt_lower: config["theme"] = "cave"
    elif "forest" in prompt_lower: config["theme"] = "forest"
    elif "castle" in prompt_lower: config["theme"] = "castle"
    
    if "large" in prompt_lower or "big" in prompt_lower:
        config["max_rooms"] = 14
        config["width"] = 96
        config["height"] = 64
    
    if "hard" in prompt_lower or "difficult" in prompt_lower:
        config["difficulty"] = "hard"
        config["enemy_density"] = 0.6
    
    return config

# Test it
config = prompt_to_level_config("A large underground cave with lots of treasure but very dangerous")
print(json.dumps(config, indent=2))

Step 3: Export to Godot (.tscn)

def export_to_godot_tilemap(
    grid: np.ndarray,
    config: dict,
    output_path: str = "level.tscn"
):
    """Export level grid to a Godot 4 TileMap scene."""
    
    # Tile IDs map (customize based on your tileset)
    tile_atlas = {
        0: (-1, -1),  # Empty (no tile)
        1: (0, 0),    # Floor - atlas position (0,0)
        2: (0, 1),    # Wall - atlas position (0,1)
        3: (1, 0),    # Door - atlas position (1,0)
        4: (2, 0),    # Spawn
        5: (2, 1),    # Exit
        6: (3, 0),    # Chest
    }
    
    lines = [
        '[gd_scene load_steps=2 format=3]',
        '',
        '[ext_resource type="TileSet" path="res://tilesets/dungeon.tres" id="1_abc"]',
        '',
        '[node name="Level" type="Node2D"]',
        '',
        '[node name="TileMap" type="TileMap" parent="."]',
        'tile_set = ExtResource("1_abc")',
        'format = 2',
        'tile_data = PackedInt32Array(',
    ]
    
    tile_entries = []
    height, width = grid.shape
    
    for y in range(height):
        for x in range(width):
            tile_id = int(grid[y][x])
            atlas_x, atlas_y = tile_atlas.get(tile_id, (-1, -1))
            
            if atlas_x == -1:
                continue
            
            # Godot packed format: x | (y << 16), source_id, atlas_x | (atlas_y << 16)
            cell_key = x | (y << 16)
            tile_entries.append(f"{cell_key}, 0, {atlas_x | (atlas_y << 16)}")
    
    lines.append(", ".join(tile_entries))
    lines.append(")")
    
    # Add spawn marker
    spawn_y, spawn_x = np.argwhere(grid == 4)[0] if np.any(grid == 4) else (1, 1)
    lines.extend([
        '',
        f'[node name="SpawnPoint" type="Marker2D" parent="."]',
        f'position = Vector2({spawn_x * 32}, {spawn_y * 32})',
    ])
    
    with open(output_path, "w") as f:
        f.write("\n".join(lines))
    
    print(f"Exported Godot scene: {output_path}")
    print(f"  Tiles written: {len(tile_entries)}")

# Full pipeline example
user_prompt = "A dark underground dungeon with lots of traps and a boss at the end"
config = prompt_to_level_config(user_prompt)
gen = BSPDungeonGenerator(
    width=config.get("width", 64), 
    height=config.get("height", 48)
)
grid = gen.generate(
    max_rooms=config.get("max_rooms", 8),
    min_room_size=config.get("min_room_size", 5),
    max_room_size=config.get("max_room_size", 14)
)
export_to_godot_tilemap(grid, config, "my_dungeon.tscn")

Exporting to TMX (Tiled / Unity)

def export_to_tmx(grid: np.ndarray, output_path: str = "level.tmx", tile_size: int = 32):
    """Export to TMX format — compatible with Tiled, Unity Tilemap, GameMaker."""
    height, width = grid.shape
    
    # TMX uses GIDs (global tile IDs), 1-indexed
    gid_map = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6}
    
    lines = [
        f'<?xml version="1.0" encoding="UTF-8"?>',
        f'<map version="1.10" orientation="orthogonal" width="{width}" height="{height}" tilewidth="{tile_size}" tileheight="{tile_size}">',
        f'  <tileset firstgid="1" source="dungeon.tsx"/>',
        f'  <layer id="1" name="Ground" width="{width}" height="{height}">',
        f'    <data encoding="csv">',
    ]
    
    csv_rows = []
    for y in range(height):
        row = ",".join(str(gid_map.get(int(grid[y][x]), 0)) for x in range(width))
        csv_rows.append(row)
    
    lines.append(",\n".join(csv_rows))
    lines.extend([
        f'    </data>',
        f'  </layer>',
        f'</map>'
    ])
    
    with open(output_path, "w") as f:
        f.write("\n".join(lines))
    print(f"Exported TMX: {output_path}")

export_to_tmx(grid, "level.tmx")

The generation chain degrades gracefully: if Ollama isn't running, you still get a well-structured procedural level. If you have llama3 available, you get theme-aware configurations that adjust density, size, and layout based on natural language descriptions.


The Game Scene Workspace goes further: text-to-3D asset generation, procedural texture synthesis, character stat generation, Unity/Unreal/Godot script output, enemy AI behavior trees, and full environment packs — over 1,200 lines of game dev tooling in one package.

→ Get Game Scene Workspace on the Shop