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:
- Procedural algorithms (wave function collapse, BSP dungeon gen) — zero cost, always available
- Local LLMs via Ollama — for natural language → level configuration
- 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.