# LevelSpec

**The AI writes a level program. A deterministic compiler owns every coordinate.**

This is a working implementation of that architecture, a browser you can fly
through the results in, and a roguelike FPS built on top of it.

Two packages:

| | |
| --- | --- |
| `packages/levelspec` | The compiler, the validators, the navmesh bake, the exporters. Nine benchmark LevelSpecs — eight compile clean, one is built to fail so you can read what the error channel returns — and 380 authored floor plans. |
| `packages/game` | **Descent**: a roguelike FPS whose every floor is one of those plans, compiled and gated before you see it. |

```bash
npm install
npm run dev        # http://localhost:5173 — the inspector; the game is at /game/
npm run compile    # build + validate every level, write artifacts to generated/
npm run test       # 414 assertions across both packages
npm run check      # typecheck + test + compile
```

---

## The claim, and how to check it yourself

Open the viewer and press **V**. The same LevelSpec is rebuilt the way an
unconstrained generator writes geometry: a loop that emits four walls, a floor
and a ceiling per room, and a door panel nudged a millimetre in front of the wall
it never cut.

| Level | Solids | Compiled | | Naive build of the identical spec | |
| --- | ---: | ---: | ---: | ---: | ---: |
| | | coplanar | overlapping | coplanar | overlapping |
| Compiler Demo Hall | 175 | **0** | **0** | 214 | 214 |
| Dust II (approx.) | 300 | **0** | **0** | 344 | 538 |
| Cache (approx.) | 237 | **0** | **0** | 302 | 532 |
| Clubhouse (approx.) | 386 | **0** | **0** | 446 | 446 |
| Ring Arena (original) | 124 | **0** | **0** | 190 | 190 |
| Three-Lane Two-Site | 179 | **0** | **0** | 217 | 256 |
| Three-Storey Siege | 251 | **0** | **0** | 321 | 321 |
| Vertical Stack Tower | 302 | **0** | **0** | 163 | 163 |

In every naive build, **each box individually passes every test you would think
to run on it**: watertight, consistently wound, correct outward normals, and it
contains its own centroid when converted to six Quake half-spaces. The report
panel says so explicitly. The collection is still unusable.

That is the whole argument. Primitive validity does not imply collection
validity, and a screenshot cannot tell you which one you have.

---

## The second derivation

The navigation graph is built from the spec — occupied cells, boundary records,
portal openings. `src/core/navmesh.ts` never looks at the spec. It voxelises the
solids the player actually collides with, finds the surfaces a 0.7 m × 1.8 m
agent can stand on, and connects them by walking and stepping. Two derivations
of the same question, and they are free to disagree.

Baked over every shipped level, at 0.25 m:

| Level | samples | playable floor reachable from spawn | islands | sealed |
| --- | ---: | ---: | ---: | ---: |
| Compiler Demo Hall | 19,955 | **100%** | 0 | 100% |
| Dust II (approx.) | 51,138 | **100%** | 0 | 100% |
| Cache (approx.) | 46,041 | **100%** | 0 | 100% |
| Clubhouse (approx.) | 75,795 | **100%** | 0 | 100% |
| Ring Arena (original) | 28,423 | **100%** | 0 | 100% |
| Three-Lane Two-Site | 50,348 | **100%** | 0 | 100% |
| Three-Storey Siege | 49,689 | **100%** | 0 | **44%** |
| Vertical Stack Tower | 24,576 | **100%** | 0 | 100% |
| Broken By Design | 9,256 | 23% | **5** | 23% |

355,000 samples across the set, no unreachable playable space in any level that
claims to be finished, and five in the one that does not. Press **J** in the
viewer to bake and draw it: green is reachable, red is an island inside a
declared space, grey is a roof or ledge nobody was meant to walk on.

**"Sealed" is the same bake with barricades up, hatches closed and soft walls
intact.** The Three-Storey Siege benchmark drops to 44% there — every exterior
door on it is barricadable, so at round start the building genuinely is a sealed
box you have to breach. That number is a property of the level, not a fault: it
is exactly what the static/dynamic split was for. The hard gate runs on the
breached state, because the question worth failing on is whether a space is
unreachable *in principle*.

The bake found two real things on its first run. Both were flaws in the checker
rather than the levels, and both are the kind you only find by having two
derivations: stair treads were being eroded away by a naive agent-radius test
(anything within a step is a step, not an obstruction), and crate tops in a
sunken lane were being classified as unreachable rooms (an island is only
playable if it stands on a declared space's *own* floor, which is not the layer
elevation when the space is offset). `tests/navmesh.test.ts` pins both, and
proves the check can still fail: a 1.2 m sill is a traversable opening to the
spec graph — a player vaults it — and a wall to an agent that only walks. The
test asserts the two disagree, and that the bake names the room you cannot reach.

---

## Architecture

```
LevelSpec JSON  (rooms, adjacency, portals, wall roles, verticals, objectives)
      │              the model edits this, and only this
      ▼
compile()  ── 11 deterministic passes ─────────────────────────────────────────
      1  normalise units and coordinate convention
      2  rasterise spaces onto an integer lattice
      3  assign exactly ONE owner to every boundary edge
      4  group edges into maximal wall runs
      4b assign every junction square a single owner
      5  resolve portals onto real shared runs
      6  split walls around openings, emit sills and lintels
      7  generate floors and roofs, and their holes
      8  generate stairs, ramps, ladders, hatches
      9  emit cover and gameplay volumes
     10  split static shell from dynamic surfaces
     11  build the navigation graph from the same boundary records
      ▼
validate()  ── hard gates ─────────────────────────────────────────────────────
      geometry   coplanar-same-normal pairs · overlapping volumes · winding ·
                 degenerate solids · brush half-space tests
      movement   capsule clearance · step rise · tread depth · headroom ·
                 connectivity · marker reachability
      tactical   route existence · edge-disjoint route count · route time
                 windows · sightline distribution · spawn line of sight
      ▼
structured diagnostics ──► back to whatever authored the spec
      ▼
exporters: Quake .map · OBJ · OpenSCAD union · CadQuery/STEP · DXF · SVG plan ·
           Three.js runtime
```

### The rule everything else follows from

> A shared boundary is one semantic object with one owner, not two
> independently generated surfaces.

Two rooms never emit "their" wall. The compiler rasterises rooms onto an integer
lattice, walks the lattice edges, and records each edge exactly once —
`V:17:4` is one record whether you reach it from the cell to its left or the
cell to its right. Doors are cut from that single record. The navigation graph
reads the same record. The 2D plan draws the same record. They cannot disagree.

The same argument holds one dimension down: where two walls meet, the little
square where their thickness bands cross is claimed by exactly one of them
(pass 4b). Without that rule every corner of every map is an overlapping pair.

---

## What is in the box

| | |
| --- | --- |
| `levels/*.json` | Nine benchmark LevelSpecs. |
| `maps/**/*.plan` | 380 floor plans in the authoring DSL — what the game's floors are. |
| `src/core/types.ts` | The schema. |
| `src/core/compiler.ts` | The eleven passes. Nothing else emits a coordinate. |
| `src/core/mesh.ts` | The single source of truth for winding and half-spaces. |
| `src/core/validate.ts` | The hard gates. |
| `src/core/navmesh.ts` | The navmesh bake — a second derivation, from geometry. |
| `src/core/naive.ts` | The control condition. |
| `src/export/` | Quake brushes, OBJ, OpenSCAD, CadQuery, DXF, SVG plans. |
| `src/app/` | The viewer: adapter, scene, player, plans, and the wave shooter. |
| `tools/build.ts` | CLI. Exits non-zero when a level fails. |
| `tests/` | 244 assertions, including a Quake `.map` round-trip. |
| `generated/` | Everything the CLI writes. |
| `public/research/` | The original research write-up this implements. |

---

## The viewer

Click to look, `WASD` to move. Pointer lock if the browser allows it, drag-to-look
if it does not.

| | |
| --- | --- |
| `W A S D` | Move. Strafing stays level; in fly mode forward follows the camera. |
| `E` / `Q` | Rise / descend (Unity's convention; `Space` / `C` also work) |
| `F` | Noclip fly ↔ walk |
| `V` | **Compiled ↔ naive** |
| `X` | Paint every solid involved in a geometry defect red |
| `J` | Bake and draw the navmesh (green reachable · red island · grey ledge) |
| `N` | Navigation graph (green walk, yellow through an opening, orange vertical) |
| `B` | Boundary runs, coloured by wall role |
| `G` | Breach: remove every dynamic surface, collision included |
| `H` | Hide roofs · `P` isolate a storey · `L` room labels · `K` markers |
| `M` | All floor plans · `T` next marker · `R` drop in at spawn · `O` overview |
| `Enter` | **Play the level** — infinite waves, enemies pathing on the bake |

`G` is the one worth trying. Soft walls block you; press `G` and they are gone —
including their collision — and the static shell behind them has a real hole,
because the panel was never fused into it in the first place.

---

## Play mode

Press **Play** in the top bar, or **Enter**. Infinite waves, on any level.

| | |
| --- | --- |
| `W A S D` | Move. **A strafes to the camera's left, D to its right**, at every heading and pitch. |
| `Mouse` | Look (pointer lock, or drag if the browser refuses it) |
| `Left click` | Shoot — hold for automatic fire |
| `R` | Reload · `Shift` sprint · `Space` jump · `C` crouch |
| `Enter` | Restart at wave 1 · `P` back to exploring |

**The enemies path on the baked navmesh** — the same surface `npm run compile`
gates reachability on. That makes the mode a test as well as a toy: if a level
passes the bake, a thing that only walks and steps should be able to find you
anywhere you can stand, and anywhere it cannot is a level defect you can see at
60 fps. A `stuck` counter in the game state tracks enemies with no path; across
60 simulated seconds on all nine levels it stayed at 0 on eight of them and hit
1 for a single frame on the ninth.

Play always runs the **breached** state, so the solids the player collides with
and the ones the enemies path on are the same set. Otherwise the Three-Storey
Siege benchmark would be unplayable — every exterior door on it is barricadable,
and at round start the building is a sealed box.

Handedness is pinned by `tests/controls.test.ts`, which checks the movement
against the camera's own basis rather than against a hardcoded vector: at eight
headings, in both modes, `D` must land within 0.001 of `forward × up` and `A` on
its exact negative, and pitch must contribute nothing at all to a strafe. The
same check run through the live keyboard handler in the browser returns cos =
±1.0000 across 36 heading/pitch combinations with zero vertical drift.

---

## The levels

| Level | What it is for |
| --- | --- |
| **Compiler Demo Hall** | Every feature in one three-storey walk: shared-boundary walls, cut openings, soft and reinforced panels, a glass partition, barricadable windows, a rappel entry, stairs up and down, a destructible hatch, a ladder, cover. Start here. |
| **Ring Arena (original)** | An original generated layout, not a study of an existing map: a sealed centre ringed by four halls, two sites on opposite corners, an overwatch balcony. |
| **Three-Lane Two-Site** | Approximate CS-style topology: three lanes, two sites, mid, and a catwalk stacked over an open lane. Outdoor layer with open route bands. |
| **Three-Storey Siege** | Approximate R6-style topology: basement / ground / first on aligned footprints, a surrounding courtyard, two staircases, three destructible hatches, 16 soft walls, 12 reinforcement slots, rappel entries. |
| **Vertical Stack Tower** | Five storeys, alternating stair flights, a long ramp, two ladders, four hatches, railed balconies, an open roof deck. Stresses floor holes, storey alignment and cross-layer navigation. |
| **Cache (approximate)** | A reconstruction of the Cache *layout*: two sites either side of a mid spine, with T Spawn, A Main, Squeaky, Garage, Quad, Highway, Mid, White Box, Checkers, Vents, Z, Sunroom, Toxic, Connector, Heaven and CT Spawn. The least precise of the three. |
| **Dust II (approximate)** | A reconstruction of the Dust II *layout*: every named area and every adjacency between them — T Spawn, Outside Long, Long Doors, Pit, Long Corner, A Cross, Catwalk, A Short, Xbox, Mid Doors, Top Mid, CT Mid, B Doors, Back Plat, Upper and Lower Tunnels — with per-area elevation and a ramp into Pit. |
| **Clubhouse (approximate)** | A reconstruction of the Clubhouse *layout*: basement, 1F and 2F on an aligned footprint plus the yard, with Church, Arsenal, Blue Bar, Bar, Stock Room, Kitchen, Construction, Central Stairs, Gym, Bedroom, CCTV, Master Bedroom, Logistics — 40 soft walls, 17 reinforcement slots, 5 destructible hatches, 9 rappel windows. |
| **Broken By Design** | Fails every gate at once. A door between rooms that never touch, a sealed room, a marker outside every space, a staircase with no room to run, a route that cannot meet its budget, cover buried in a wall, two rooms claiming the same cells. Open the **Diagnostics** tab. |

### About the three reconstructions

The Dust II, Cache and Clubhouse levels are **topology reconstructions, not
measured copies**. They were authored from public knowledge of those layouts, so
the callouts, room adjacencies and route structure are right, while room
proportions and route lengths are approximate — for Clubhouse the exact set of
soft walls and hatches is a plausible placement rather than a verified one, and
Cache is the least precise of the three: its area set and their adjacencies are
right, several of the finer connections are a reasonable guess.

Making any of them dimensionally accurate is a different job, and the pipeline is
built for it: put an authoritative source in as the LevelSpec input. For Dust II
that means the CS2 Workshop Tools / Hammer source or a validated BSPSource
decompile; for Cache the same; for Clubhouse, the official blueprint or the shipped level data. The
compiler does not care where the spec came from — that is the point of keeping
the spec separate from the geometry.

They contain no textures, props, models, collision or map files from either
shipped game, and reproduce no proprietary asset. The other benchmark levels are
approximate topology studies and are not reproductions of any shipped map.

---

## Descent — the game on top of it

`npm run dev`, then **http://localhost:5173/game/**. No menu, no seed box: the
floor is compiled on load and you are standing in it.

Every floor is one of the 380 authored plans, compiled and gated before you see
it — fully connected, at least two independent routes from spawn to exit — so a
random floor is never an unfair one. 255 distinct maps are in the run library
(a plan and its variants collapse to one entry), and a run draws up to thirty of
them in six size bands, so the floors get bigger as you get deeper.

| | |
| --- | --- |
| **Floors** | 255 maps, three contract types — sweep a zone, hunt a marked target, hold a ring. |
| **Weapons** | 427, from real firearms modelled at their published lengths. Class, size, muzzle and both hand holds are placed per gun rather than derived. |
| **Enemies** | 15 types across six behaviours — chase, swarm, ranged, brute, floater, bomber, shielder — plus four elite modifiers, and pairings that decide which turn up together. |
| **Cards** | 59, drafted between floors: stat lines, weapon-class and era bonuses, and transforms that change how a shot behaves. |

### The art direction is a test, not a style guide

The look is one palette — porcelain shell, graphite trim, one accent per map —
under six lighting rigs. That is easy to write down and impossible to hold
across 255 maps by eye, so it is asserted instead.

`/look.html?sweep=1` builds every map, stands the camera at twelve points along
the route with three enemies in frame, renders, reads the pixels back and judges
each frame on nine gates: the light and dark masses and the distance between
them, how much of the frame is weakly coloured, whether the accent is one colour
in one place, whether anything is blown out. `tests/look.test.ts` asserts the
structural half — what the world is made of — without needing a screen.

206 of 255 maps are clean on every one of their twelve views. The gates know
which rig they are judging: holding a night exterior to a daylight frame's
bright mass is a category error, and the numbers that say so are calibrated
against frames somebody looked at rather than against a target count.

`/anchors.html` is the same idea for the arsenal — every gun drawn at the pose
the game will use it in, over a close-up of the last few centimetres of barrel,
with a crosshair where the flash comes out and a ring at each hand.

### Is it any good to play

`npm run sim` answers that without a person. A bot plays twenty-four runs,
picks cards, fetches weapons it can reach safely, and reports the shape of a
floor: how long each contract takes, what share of the time is combat, how far
you walk for how much progress, which enemy did the damage, which guns got
carried. The median run reaches floor eleven.

---

## What this implementation does not do

Stated plainly, because the gap between an architecture diagram and a working
system is where the interesting problems live:

- **Rectangles only.** Spaces are unions and differences of integer-grid
  rectangles. Arbitrary polygons, ramps as first-class surfaces, and splines are
  the documented next steps, not present.
- **No constraint solver.** The metric layout is authored, not solved. There is
  no CP-SAT stage placing rooms to satisfy route-time targets; the validators
  check those constraints but do not repair them.
- **Boolean bake is offline.** `generated/*.scad` and `generated/*.cadquery.py`
  are emitted and are correct, but running OpenSCAD or CadQuery is your step —
  neither is bundled. The compiler's own output needs no union to be valid,
  which is the point.
- **Elevated slabs must clear the storey below.** A catwalk floor that ends
  flush inside a lower storey's wall band produces an overlapping pair. The
  validator reports it precisely (`PLACED_SOLID_CLIPS_WALL`); the compiler does
  not yet trim the slab for you. Same for stairs and cover placed into a wall.
- **Destruction is a visibility and collision toggle**, not fracture. Hatches
  and soft panels are separate solids with stable ids and their own collision,
  which is the part that has to be right first.
- **Sightlines are sampled on the grid**, per storey, at a fixed stride. They
  are a descriptor, not a substitute for bot simulation.
- **The navmesh is a sampled bake, not a polygon mesh.** It answers
  connectivity, clearance and reachability, which is what it is gated on. It
  does not emit navigation polygons, off-mesh links, or jump/vault connections —
  an agent that can only walk and step is a deliberately conservative reader of
  the geometry.

---

## Extending the spec

`public/research/docs/LEVELSPEC.md` is the original contract.
`docs/LEVELSPEC-1.1.md` records what this implementation added and why.

The rule for extending it has not changed: when a real design need appears, add
a compiler-owned primitive to the DSL. Do not hand the model raw vertices to buy
expressiveness.
