Skip to content

Transition to user-generated levels and missions ​

The idea: once the game is published, a website lets players define their own levels and missions just by prompting an LLM. Under a set of rules (written as text, or exposed as tools through MCP), the LLM generates a JSON description of the level and the mission, and the game server turns it into a playable level.

Is it technically doable? Yes, and the current architecture is closer to it than most games would be. The main design choice: the LLM produces the level's intent, never its geometry.

What already exists ​

Facts from the repository as of 2026-10-04.

  • A level is already just data. The server loads LevelData JSON (meshes, placements, roads, spawns) and sends it to clients in the welcome message. The client bundles no level, and FOI_LEVEL already accepts a .json path. So "the server receives a JSON and it becomes playable" works today. main-hillside.json is 1.5 MB: 71 meshes, 1,236 placements and 25 roads.
  • Placements reuse shared meshes. A building placed 90 times is stored once, so a user level made of existing kit pieces is mostly a list of positions.
  • The AI adapts to any level. The road graph and the navigation grid are computed from the level's roads and triangles, so chasers and traffic would work on a user level without extra work.
  • There is a format at the right level. Recipes (*.recipe.json) are lists of "kit piece X at x, z, turned by yaw". That's close to what an LLM can produce reliably.
  • There are generators with knobs. assets/blender/levels/main_hillside.py already takes high-level parameters: DISTRICTS sets the building mix per district, BUILDINGS holds the footprints, and there's a fixed seed and road definitions. Its occupancy map refuses overlapping placements.
  • Missions from a sentence are already in the design. documents/GAME_DESIGN.md has a mission JSON with actors and places like "at": "docks.warehouse_7", and packages/missions and apps/api are planned (documents/TECHNICAL_SPECS.md §4).

The proposed architecture ​

prompt → LLM → high-level spec → deterministic generator → LevelData → validator → playable

  • The LLM decides only what it's good at: "a harbour district north of a downtown, a ring road, a bridge over the river, a chase mission from the bank to the docks". It produces a constrained JSON: districts, the road layout, landmarks, mission beats, all using names from a catalogue of the kit pieces.
  • Our code does the geometry: coordinates, road joins, ground that conforms to the roads, footprints that don't collide, no z-fighting. LLMs are bad at precise coordinates, and loose geometry is exactly what produced the flickering roofs (see 2-game-optimized-asset-generation.md).
  • A validator returns errors the LLM can fix ("spawn not connected to objective", "the road ends in a cliff") and the two loop. Structured output or tool calls make this reliable.
  • Never run code the LLM writes. Mission logic is data in a fixed vocabulary (triggers, objectives, actors, zones, timers), checked against a schema. That also makes prompt injection mostly harmless: the worst case is a valid but odd level.

What's missing ​

Roughly from hardest to easiest:

  1. A generator that runs on the server. Today generation happens in a Blender script (Python, about 165 s for the hillside). It can run headless on a server, and almost all of that time is one slow check that can be fixed. See the deep dive below.
  2. A mission runtime. Objectives, triggers, scoring and fail states all have to be built.
  3. Several levels per server. One server process runs one level today. It needs a room per match, each with its own Rapier world, or a process per room.
  4. Playability checks: spawns reach objectives, budgets on triangles and placements, and solvable timers. The game's own AI could drive the route headless to check that a time limit is achievable.
  5. Named places in levels, so a mission can say "the docks".
  6. Moderation and cost. The satire rule ("never mock real people") needs moderation of the names and texts the LLM produces, plus rate limits and caching of generations.

Deep dive: a generator that runs on the server ​

Measured on 2026-10-04 on the development PC. assets/blender/levels/main_hillside.py was run in a headless Blender 5.2 from a scratch harness that wrote its outputs outside the repository; the repository wasn't changed.

Does a headless Blender that runs Python exist? ​

Yes, in two forms:

  • Background mode: blender -b --factory-startup --python script.py -- <args> runs a script with no window, then exits. These measurements and the asset checks of 2-game-optimized-asset-generation.md were run that way. Starting and quitting Blender takes about 2 s. It runs in a container on a server, as render farms do.
  • bpy as a Python module: the Blender Foundation publishes Blender as an importable module on PyPI (pip install bpy), so an ordinary Python process can import bpy. Each build is tied to one Python version and follows Blender's releases, so check that one exists for the Blender version in use.

Blender is GPL. Running it as a tool on our own server doesn't distribute it, and the files it writes are ours. (Not legal advice: check before shipping anything that bundles it.)

Is the script just stitching prebuilt assets together? ​

Only partly. It does three kinds of work:

WorkWhat it producesTime
The level's own geometry (nothing prebuilt)25 roads with height profiles and grade limits, 47 junctions with 132 rounded kerb corners, one ground mesh of 21,340 triangles (2,480 of them walls: kerbs, retaining walls, abutments), the tunnel roof, zebra crossings~2.5 s
Placing prebuilt kit pieces (the stitching)1,208 placements: 92 buildings, 638 trees, 132 lamps, 101 traffic lights, 78 hydrants and the rest161 s
Exportthe level's own kit (main-hillside.glb + .foi.json) and main-hillside.recipe.json0.6 s

That's 165 s in total, plus about 2 s to start Blender. Two more steps then run outside Blender before the server can load the level: npm run level:recipe writes the Babylon.js Editor scene, and npm run level:export turns that scene into LevelData. Those two weren't timed.

Blender itself contributes only three things:

  • the constrained Delaunay triangulation of the ground (mathutils.geometry.delaunay_2d_cdt, used by citygen.py), the heaviest algorithm, at about 1 s;
  • meshes to hold the generated geometry;
  • the glTF exporter.

Everything else is plain Python. The server doesn't need the .glb or the editor scene at all: LevelData is triangle lists plus placements. The .glb and the editor are detours that exist for the hand-editing pipeline.

Why it takes so long ​

The stitching isn't copying files. Each candidate building has to find a legal spot: on the back line of the pavement, facing its road, inside the town, clear of every road, outside parks, ponds and paths, and not touching another footprint. The generator checks this by sampling the footprint every 2.5 m and testing each sample.

The slow test is reserved() (main_hillside.py:646), which asks whether a point lies in or near a park, pond or path. It measures the distance from the point to every edge of every such zone, in pure Python, with no early exit for zones that are far away. One run made 601,000 such calls: 6 million polygon distance checks and 146 million point-to-segment distances, 97% of the run.

That cost comes from how it's written, not from the task. In a scratch copy, skipping a zone whenever the point lies outside the zone's bounding box (grown by the margin) brought the run from 165 s to 21 s, and the output was byte-identical: the recipe matched the committed one. The remaining 17 s of placement is the same kind of brute-force distance testing, so a spatial grid would likely bring the whole run down to a few seconds (not measured).

Options ​

A. Keep the Python and run Blender headless as a worker.

  • A job queue starts blender -b … --python (or a bpy worker) for each generation, in a container.
  • It needs the bounding-box fix (or a spatial index), plus a direct recipe-to-LevelData step that skips the editor scene. apps/tools already has a glTF reader, a kit reader and the placement code.
  • For: it reuses about 2,000 tested lines, including the triangulation pitfalls already solved. It's the quickest way to a prototype.
  • Against: a second language and a heavy runtime in production (a Blender install is hundreds of MB, and each job needs its own process and memory). Generation can't run in the browser.

B. Port the generator to TypeScript.

  • citygen.py (919 lines) and the hillside generator (1,062 lines) become a package that writes LevelData directly, with an npm constrained-Delaunay library in place of Blender's.
  • For:
    • one language, running in the server process or a worker thread;
    • it also runs in the browser, so the website can preview instantly, and a level can travel as a small spec and seed instead of 1.5 MB;
    • vitest, coverage and the determinism gates apply, with seeds from createPrng.
  • Against:
    • the porting effort, and the triangulation edge cases the Python already solved have to be solved again (the project memory lists several);
    • about 2,000 more lines against a product budget of 20,000 that already stands at about 18,500, so the line budget needs a decision.
  • Blender keeps its role as the asset factory: it makes kit pieces offline, and the TypeScript generator assembles levels online.

C. Precomputed tiles.

  • Blender builds a library of terrain and road tiles (and district templates) offline, and each one is checked once (no z-fighting, correct colliders). Online, the server only places tiles and kit pieces: pure stitching, in milliseconds.
  • For: the fastest and safest option, with the simplest server.
  • Against: levels look tiled and are less free, and tiles need matching edges (heights, road ends).

A sensible path: A to prototype (with the fix, about 20 s or less), C or B for the "remix" stage, B in the long term.

Why the generation time matters ​

  • The LLM–validator loop regenerates on every repair round. Three rounds at 165 s take over eight minutes; at a few seconds each, the loop feels interactive.
  • A level is its spec, a seed and a generator version. Store the spec (kilobytes) and cache the output by its hash.
  • Pin the generator version, or shared levels change when the generator does: any change to the random picks reshuffles a city, the way adding a building to main-hillside's mix would.

Text rules or MCP? ​

  • For the website: call the Claude API directly, server-side, with tools such as list_pieces, add_district, add_road and validate. MCP isn't needed there.
  • An MCP server is interesting as an extra, for players who bring their own LLM (Claude Desktop and similar), so they pay for the tokens. The same tools can sit behind both.
  • Previews are nearly free. The client already runs in a browser, so the website could show the generated level in the real renderer in a spectator mode.

A sensible order ​

  1. Missions on existing levels first. That's the smallest step, and it's already the game design document's plan.
  2. Level remixes: the LLM sets only generator parameters (district mix, density, seed, roads drawn from a set of templates).
  3. Free layouts with terrain, tunnels and bridges described in text. This is the hardest step and the one that needs a fast server-side generator (option A or B above).

The LLM is the easy part. Most of the work is the deterministic generator, the validator and the mission runtime, and early versions of the first two already exist.