Under the hood of Natural Language

A rules engine owns the game. It holds the state of the world and rolls every die, and nothing changes unless the engine says so. Language models handle the words. They read what you typed, describe what the engine decided, speak for the people you meet, choose what your companions do, and fill in places nobody wrote by hand. Natural Language is early and under active construction, and nothing is playable yet.

01The session, annotated

Illustrative. The session from the front page.Black: what you'd read.Red: what the pipeline did.

A tavern · Fighter 12/12 hp · Day 1 21:40 · your turn Party: Fighter 12/12 (you) · Cleric 10/10 (AI) · Rogue 9/9 (AI)

  1. look

    A TAVERN Low beams. A fire that smokes more than it warms. Exits: north (alley door), down (cellar door). You see: the bar, a notice board. The bartender is here.

    • classic command
    • the engine describes the room
    • 0 model calls
  2. challenge the fire to a staring contest

    The fire wins. You blame the smoke.

    • interpret -> meta
    • a one-line aside
    • nothing in the state changes
  3. ask the bartender what's scratching under the floor

    "Rats," the bartender says, not looking up. "Big ones. Nobody's been down there in a month, and nobody's been sorry."

    • interpret -> talk(bartender)
    • the bartender owes a reply
    • npcReply, checked against what the bartender knows
  4. open the door

    Which door? 1. the alley door 2. the cellar door or say it in your own words

    • two doors fit
    • it asks instead of guessing
    • 1 model call
  5. 2

    The cellar door opens on stairs and a smell of wet grain.

    • a numbered answer
    • no interpreter
  6. down

    A CELLAR Barrels. Damp. Two giant rats look up from a split sack.

    • hostiles here, so a fight starts
    • the engine rolls initiative
  7. hit the nearer rat with my longsword

    You swing hard and bury your longsword in a ceiling beam. The rat watches you work it free.

    • interpret -> attack(rat 1)
    • d20 1: a natural 1 misses
    • narrate, checked against the outcome
  8. The nearer rat takes the opening, and your ankle. The other one tries the cleric and gets a mouthful of chain shirt.

    • whoActs -> the rats, engine tactics
    • bite 15 +5 = 20 vs AC 18: hit, 5 damage
    • bite 4 +5 = 9 vs AC 15: miss
  9. The rogue sighs at your swordwork and puts a dagger in the rat that bit you.

    • whoActs -> rogue (AI)
    • companionDecide -> attack(rat 1)
    • dagger 12 +5 = 17 vs AC 13: hit
    • 1d4 4 +3 = 7: rat 1 down
  10. The cleric brings a mace down on the other rat, then says a short prayer over it, which seems generous.

    • whoActs -> cleric (AI)
    • companionDecide -> attack(rat 2)
    • mace 11 +4 = 15 vs AC 13: hit
    • 1d6 5 +2 = 7: the fight ends
  11. . . . later, out the alley door . . .

  12. east

    A NARROW YARD A pump with no handle. Washing that has been out too long. Exits: west (alley). You see: a rain barrel, a torch in a bracket.

    • the yard is a stub, never generated
    • generateLocation, 1 model call
    • canonized with the move
  13. . . . the next night, back in the alley . . .

  14. east

    The yard again. The pump still has no handle, and the washing still isn't dry. You see: a rain barrel, a torch in a bracket.

    • read from the saved state
    • no generation call
    • same barrel, same torch

02One function changes the world

Every change to game state goes through one pure function, resolve(state, action), in the engine package. It checks that the action is legal, draws dice from a seeded cursor kept in the state, applies the rules, and returns the new state, an outcome, and the events. An illegal action isn't an error. It comes back rejected, with a reason.

The models never write state. The engine imports nothing but its schema library, and replaying the same actions from the same start produces byte-identical state, which a test checks on every engine test sequence.

03The turn pipeline

Boxes drawn with # are the engine, and boxes drawn with ~ are a model. A free-form input costs at most two model calls, and a clarifying question costs one. Classic commands skip the interpreter, and looks and rejections skip the narrator.

The pipeline is a durable task. Each phase reads committed state, does one effect, and commits its result with the next checkpoint. A crash mid-turn resumes where it stopped, and state changes land exactly once. If the narration claims something the outcome doesn't hold, the engine's own description replaces it.

04Five model steps

StepJob
interpretyour words -> one intent, a numbered question, or an aside
narratean outcome -> prose for one viewer, streamed
npcReplyan NPC's line, checked against the facts it knows
companionDecidean AI companion's next action
generateLocationan unvisited place, from templates plus flavor

Each step takes a typed input and returns a typed result plus a record of the call. Structured steps offer the model exactly one tool, validate the arguments, and retry once. Each step's model is set separately in config, and nothing in the code names a model. Three ports can answer a step: real providers through pi-ai, a scripted list for tests and offline play, and a replay port that answers from a trace.

05Lazy canonization

A place that exists only as a destination is a stub: an id, a brief, and a budget. Nothing uncanonical is ever in the state. The first legal move into a stub runs generateLocation. Its schema lists only the template ids that fit the budget, and its flavor fields have no numbers, so a model can't propose a sword that deals 99 damage.

canonize then builds the place from content with the state's own dice, clamps anything over budget, records where it came from, and turns its other exits into new stubs, so the frontier moves outward one room at a time. The new room and the move commit together, so a locked door never canonizes the room behind it, and a crash before the commit regenerates something nobody saw. A revisit reads the room from the state and makes no generation call.

06Traces and replay

Every turn appends three kinds of immutable record:

nl.turn       the input, the RNG cursor before, the full
              state after, the outcome, the events, any
              generation record, timings per phase
nl.narration  one viewer's narration, its fidelity check,
              its revision
nl.call       the typed step input, the verbatim prompt,
              a hash of the schema offered, the response,
              usage, cost, latency

nl replay <turnId> re-runs canonize and resolve from the recorded state and compares the hash of the result. nl export-fixture copies one recorded call's input and the state it came from into a self-contained fixture, pinned to the engine version and the content hash. The replay port answers from recorded calls, so a whole turn reproduces with zero network.

07Evals and graders

The runner takes one step, a set of fixtures, and one or more models, and grades every output. Deterministic graders run first, in order, and stop at the first failure. They ask the engine, which knows what's legal in each fixture's state.

GraderPasses when
schema_validthe output fits the schema the step offered
action_legalevery proposed action resolves against the fixture state without a rejection
targets_in_scopeevery id in those actions was in what the model was shown
matches_expectedthe output matches the fixture's hand label

Rubric graders, where a model judges prose or persona against a written rubric, run second and only on outputs that passed. Today they're an interface and a stub. The smoke suite has ten labeled interpretation fixtures. On the scripted port it passes 9 of 10, and the tenth answers wrong on purpose to prove the graders catch it.

There are no real-model results yet. A benchmark across every step, for running the game and for playing in the party, comes later. To run the smoke eval against a model, use bun run eval -- --model provider/model-id.

08Seats

seat 1  fighter  { kind: "human", userId }
seat 2  cleric   { kind: "ai", persona }
seat 3  rogue    { kind: "ai", persona }
                 later, the same seat:
                 { kind: "human", userId }

A seat binds a character to a controller, and the engine treats a human and an AI the same way. whoActs says who the world is waiting on, whether that's a human seat, an AI companion, a monster, or an NPC who owes a reply. Inputs from every seat enter one ordered inbox. Each event says who can see it, so narration is rendered per viewer, and gateways hold no state.

The first version has one human player. A second human would be a seat whose controller says human, with their own location and their own view of events. What's missing is product work: joining a room, pacing turns between people, and a lobby. None of it is built yet.

09Clients

The SSH gateway is the signature client. It treats your public key as your account, makes the account on a key's first login, and draws the game as a terminal UI: a status line, a scrolling narration log, a numbered panel for questions, and an input line. The web client starts as a terminal in the page over a WebSocket, reading the same protocol.

  • Terminal over SSHsignature
  • Web clientplanned
  • Mobilemaybe later
  • Steam desktop buildmaybe later

<- back to the front page