Build a Game Level with AI — No OpenAI Key Required
Back to Blog
Game Dev· 9 min min read

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.

NA
By NEPA AI
NEPA AI · Building autonomous systems for creators and businesses
#game development#procedural generation#AI#python#Godot#Unity#local llm

Game level design? Time-consuming but perfect for AI. Here's how to do it:

  1. Procedural Algorithms: Use wave function collapse or BSP dungeon gen—no cost, always available.
  2. Local LLMs via Ollama: For natural language → level config. Fallback chain if no model installed.

No OpenAI account needed. 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)

Binary space partitioning (BSP) dungeon generator:

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:
        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:
            cx, cy = self.rooms[0].center
            self.grid[cy][cx] = self.SPAWN
            
            cx, cy = self.rooms[-1].center
            self.grid[cy][cx] = self.EXIT
            
            if add_chests:
                for room in self.rooms[1:-1]:
                    if random.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
        
        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")

Step 2: Local LLM for Level Configuration

import requests
import json

def prompt_to_level_config(prompt: str, model: str = "llama3.2:3b") -> dict:
    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()
            start = raw.find("{")
            end = raw.rfind("}") + 1
            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"
):
    
    tile_atlas = {
        0: (-1, -1),  # Empty (no tile)
        1: (0, 0),    # Floor
        2: (0, 1),    # Wall
        3: (1, 0),    # Door
        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
            
            cell_key = x | (y << 16)
            tile_entries.append(f"{cell_key}, 0, {atlas_x | (atlas_y << 16)}")
    
    lines.append(", ".join(tile_entries))
    lines.append(")")
    
    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):
    height, width = grid.shape
    
    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")

Ollama not running? No worries—procedural fallback still gives you a well-structured level. Have Ollama available? Get theme-aware configurations from natural language descriptions.


Looking for more AI game dev tools? Check out my real AI tools at axon.nepa-ai.com.