Under the hood of Natural Language
UU UU NN NN DDDDDD EEEEEEEE RRRRRRR TTTTTTTT HH HH EEEEEEEE UU: UU: NNN NN: DD:::DD EE::::::: RR::::RR ::TT:::: HH: HH: EE::::::: UU: UU: NNNN NN: DD: DD EE: RR: RR: TT: HH: HH: EE: UU: UU: NN:NN NN: DD: DD: EEEEEE RRRRRRR:: TT: HHHHHHHH: EEEEEE UU: UU: NN: NNNN: DD: DD: EE::::: RR::RR:: TT: HH::::HH: EE::::: UU: UU: NN: NNN: DD: DD:: EE: RR: RR TT: HH: HH: EE: UUUUUU:: NN: NN: DDDDDD:: EEEEEEEE RR: RR TT: HH: HH: EEEEEEEE :::::: :: :: :::::: :::::::: :: :: :: :: :: :::::::: HH HH OOOOOO OOOOOO DDDDDD HH: HH: OO::::OO OO::::OO DD:::DD HH: HH: OO: OO: OO: OO: DD: DD HHHHHHHH: OO: OO: OO: OO: DD: DD: HH::::HH: OO: OO: OO: OO: DD: DD: HH: HH: OO: OO: OO: OO: DD: DD:: HH: HH: OOOOOO:: OOOOOO:: DDDDDD:: :: :: :::::: :::::: ::::::
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)
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
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
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
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
2
The cellar door opens on stairs and a smell of wet grain.
- a numbered answer
- no interpreter
down
A CELLAR Barrels. Damp. Two giant rats look up from a split sack.
- hostiles here, so a fight starts
- the engine rolls initiative
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
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
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
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
. . . later, out the alley door . . .
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
. . . the next night, back in the alley . . .
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
> pick the cellar lock
|
#=====================v=====================#
# PARSE engine #
# look, i, n s e w, up, down, wait, or a #
# number answering a question: no model #
#=====+===============================+=====#
| free text | classic
.~~~~~v~~~~~~~~~~~~~~~~~~~~~~~~~~~~. |
( INTERPRET model ) |
( one tool, validated, one retry ) |
( -> intent | question | aside ) |
'~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~' |
|<------------------------------'
#=====v=====================================#
# NEEDS engine #
# does a legal move step into a stub? #
#=====+===============================+=====#
| no | yes
| .~~~~~~~~~~~~~~~v~~~~~~~.
| ( GENERATE LOCATION )
| ( templates + flavor )
| '~~~~~~~~~~~~~~~+~~~~~~~'
|<------------------------------'
#=====v=====================================#
# CANONIZE + RESOLVE engine #
# seeded dice -> state, outcome, events #
# committed in one transaction #
#=====+=====================================#
|
.~~~~~v~~~~~~~~~~~~~~~~~~~~~~~~~~~~. #================#
( NARRATE model ) # DESCRIBE #
( streamed, checked against the ) # engine, for #
( outcome; fails over to describe ) # looks and #
'~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~' # rejections #
| #=======+========#
|<--------------------------------------'
#=====v=====================================#
# WHO ACTS engine #
# a companion, an NPC, a monster: go #
# again (bounded). a human: wait. #
#===========================================#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
| Step | Job |
|---|---|
| interpret | your words -> one intent, a numbered question, or an aside |
| narrate | an outcome -> prose for one viewer, streamed |
| npcReply | an NPC's line, checked against the facts it knows |
| companionDecide | an AI companion's next action |
| generateLocation | an 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
#=========# #=========# +---------+ . . .
# tavern #========# alley #--------| a yard |- - - - : ? :
#====#====# #=========# +---------+ ' . '
#
#====#====#
# cellar #
#=========#
#==# written by hand +--+ made up, then kept ? not made up yet#=========# #=========#
# tavern #=====# alley #
#====#====# #====#====#
# |
#====#====# +----+----+
# cellar # | a yard |
#=========# +----+----+
:
. : .
: ? :
' . '
#==# written by hand
+--+ made up, then kept
? not made up yetA 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, latencynl 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.
| Grader | Passes when |
|---|---|
| schema_valid | the output fits the schema the step offered |
| action_legal | every proposed action resolves against the fixture state without a rejection |
| targets_in_scope | every id in those actions was in what the model was shown |
| matches_expected | the 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