Write thegamebook.Lute proves it.
Marina looks up from the ramen counter.‘Oh! Mr. Fixer!’
One command, from any repo
The language
You ask flatly. “Marina. Your number.” She writes it down, and you think: I am, apparently, taking that woman somewhere this evening.
Section 1, as written
Section titled “Section 1, as written”Scenes read like scripts. Section 1 is this .lute document: dialogue, a
<branch> of choices, and a <match> that calls back to what you chose.
<match> is first-match-wins with a mandatory <otherwise>, so every branch
is total — the branch always gets the number; asking gently just warms her.
---kind: scenecharacter: marinaseason: 1episode: 2title: The Hostess with a Namepov: fixerstate: scene.affect.marina: { type: int, default: 0 }enums: emotion: [neutral, surprised, delighted, shy, content, angry, sad] framing: [close, tight]defs: fond: { type: bool, cel: "scene.affect.marina >= 1" }---
## The Hostess with a Name::camera{focus="marina" framing="close" duration="0.5"}
@marina{emotion="surprised"}: Oh!@marina{emotion="delighted"}: Mr. Fixer!@marina{emotion="delighted"}: From the ramen counter — you eat at Mechageki too, isn't it wonderful?
/* a choice of TONE — both paths get the number; the gentle ask warms her */<branch id="number"> <choice id="blunt" text="Just ask, flatly"> @fixer: Marina. Your number. </choice> <choice id="soft" text="Ask gently"> @fixer: Marina — would you mind terribly if I had your number? ::set{scene.affect.marina += 1} </choice></branch>
/* state-driven callback — first-match-wins; <otherwise> is mandatory */<match subject="scene.choices.number"> <when test="@fond"> @fixer{mono}: I asked nicely, which I am electing not to examine. </when> <otherwise> @fixer{mono}: I am, apparently, taking that woman somewhere this evening. </otherwise></match>Leave out the <otherwise> and the checker refuses the scene, naming the
choices nothing covers:
scene.lute:36:1: error [E-NONEXHAUSTIVE] non-exhaustive `<match>`: `blunt`, `soft` are not covered and there is no `<otherwise>`failed: scene.lute (1 error(s), 0 warning(s))One beat, many tracks
Section titled “One beat, many tracks”Split a single subject across property-scoped tracks (pos, opacity)
alongside an independent camera track:
---kind: scenecharacter: marinaseason: 1episode: 1enums: anchor: { members: [left, center, right], default: center } action: { members: [slideInLeft, fadeInUp, fadeOut], exits: [fadeOut] } framing: [close, tight] cameraMove: [shake]---
## Beat
<timeline duration="1.2"> <track subject="marina" property="pos"> ::actor{character="marina" anchor="left" action="slideInLeft"} </track> <track subject="marina" property="opacity"> ::actor{character="marina" action="fadeInUp"} </track> <track subject="camera"> ::camera{focus="marina" framing="tight" duration="0.6"} ::camera{move="shake" duration="0.3" at="0.7"} </track></timeline>Investigation, checked
Section titled “Investigation, checked”Story-driven RPGs run on world state, not just lines. A <hub> re-presents its
choices until you leave; a choice is offered only when its when guard holds; a
clue is a relational fact you ::assert; and a closing <match> reacts to how
far the case has moved. The checker validates every relation signature, guard,
and match arm before a player ever sees it.
---kind: scenecharacter: detectiveseason: 1episode: 1pov: detectiveentities: clue: { members: [cufflink, ledger] } suspect: { members: [mireille] }relations: found: { args: [clue], tier: run } accused: { args: [suspect], tier: run }state: run.caseProgress: { type: int, default: 0 }---
## The Study
@narrator: The lamp still burned. The ledger had already given you one name.
<hub id="investigate"> <choice id="desk" text="Search the writing desk" once> @detective: A cufflink, wedged behind the drawer. ::assert{ found(cufflink) } </choice> <choice id="confront" text="Confront Mireille with the ledger" when="holds('found', ['ledger'])"> @mireille: ...You've read it, then. Fine. ::assert{ accused(mireille) } ::set{run.caseProgress += 1} </choice> <choice id="leave" text="Step back into the hall" exit> @detective: Enough for now. </choice></hub>
<match subject="run.caseProgress"> <when is="1.."> @detective{mono}: A name, a motive. The case has a spine now. </when> <otherwise> @detective{mono}: Still only shadows. Keep looking. </otherwise></match>- Clue discovered
found(cufflink)lands in the run fact store; theconfrontchoice unlocks becauseholds('found', ['ledger'])already holds.- Quest updated
run.caseProgressadvances, and the closing<match>flips to “A name, a motive. The case has a spine now.”
What the checker holds you to
Section titled “What the checker holds you to”- Total, not Turing-complete
- Every scenario provably terminates — no infinite loops, no unbounded recursion — and
::endnames the ending it reached. Code after it in the same straight-line body is reported as dead (W-CODE-AFTER-END). - CEL + Datalog facts
- Guards are plain
CELexpressions; a Datalog fact layer lets you derive higher-order truths and query them across a scene. - Typed plugin capabilities
- Directives and providers are declared with typed manifests, so the checker knows exactly what each plugin can emit and consume — and rejects a manifest that mistypes an option or claims a name the core owns.
Your engine
You ask gently. “Marina — would you mind terribly if I had your number?”
scene.affect.marina goes from 0 to 1, so the callback takes its first arm:
I asked nicely, which I am electing not to examine.
Lute never runs your game
Section titled “Lute never runs your game”Lute is the author-time half: a grammar, a checker, and a compiler. Everything it knows becomes a versioned JSON IR plus CEL that your engine loads through a documented runtime contract — engines never embed the Lute parser or compiler.
writer │ writes ▼.lute source ──▶ Lute [ parse · check · analyze · trace · compile · LSP ] │ emits ▼ versioned JSON IR + CEL │ loaded by ▼ engine adapter [ command loop · CEL eval · facts · quest lifecycle · bridges ] │ drives ▼ UI · animation · save · gameplay| Lute (author + toolchain) | Your engine (runtime) |
|---|---|
| Grammar + type checking | Rendering + input |
Choice / <match> control data | Command execution |
| Quest declarations | Quest ticking |
| Facts + Datalog rules as data | Fact store + rule evaluation |
| Timeline data | Animation playback |
| Bridge signature checking | Minigame / QTE execution |
| Connectivity analysis | Scene entry + saves |
The IR is a gamebook too
Section titled “The IR is a gamebook too”Section 1’s choice compiles to a record whose options name the position to
turn to. This is lute compile scene.lute, cut to that one command:
{ "kind": "choice", "family": "control", "position": "001-0500", "branchId": "number", "selectionKey": "scene.choices.number", "options": [ { "id": "blunt", "text": "Just ask, flatly", "lineId": "marina.s01ep02.number.blunt", "target": "001-0600" }, { "id": "soft", "text": "Ask gently", "lineId": "marina.s01ep02.number.soft", "target": "001-0800" } ], "converge": "001-1100"}Embed the official runtime (@lute-lang/runtime, a pure step function) or
interpret the IR yourself against the conformance corpus. The player
(@lute-lang/player, @lute-lang/player-web) renders the story; the game owns
the world. Read the full split on the runtime contract page.
Tools
Before anyone plays section 1, walk it. lute trace runs one deterministic,
mock-driven path — state, facts, choices, events — and reports every decision
and why. It walks that path, not every path.
trace: scene.lute (seeds: 0 paths, 0 facts; 1 selection) ## The Hostess with a Name ::camera{focus="marina" framing="close" duration=0.5} @marina{emotion="surprised"} Oh! @marina{emotion="delighted"} Mr. Fixer! @marina{emotion="delighted"} From the ramen counter — you eat at Mechageki too, isn't it wonderful? <branch number> eligible: blunt, soft -> soft @fixer Marina — would you mind terribly if I had your number? ::set scene.affect.marina = 1 <match scene.choices.number> -> arm 1 (@fond) @fixer{mono} I asked nicely, which I am electing not to examine.trace complete: 2 decisions; choices 1/2 (number), arms 1/2 (scene.choices.number @36:1)lute check- Statically validates a document — types, guards, relation signatures, match totality — and says what to change when it refuses.
lute trace- Previews one deterministic path against the mocks you supply, so you watch a scenario play out before anyone does.
lute check --project- Reachability over your declared
after:routes —visited,completed,active— returns Reachable / Unreachable / Unknown; relational gates surface as human-review boundaries, never false certainty. lute context- Emits a structured view of a project built for agents — the language was designed to be written with a model, not despite one.
- Language server
- Diagnostics, hovers, and completions in your editor, so mistakes surface as you type, not at runtime.
Docs
Every page of the reference is numbered the way this one is. Start with the first scene, or open the chapter you came for.
- 1 Getting Started 7 pages
- 2 Cheatsheet 1 page
- 3 Diagnostics 1 page
- 4 Reserved names 1 page
- 5 Guides 3 pages
- 6 Language 14 pages
- 7 State & Logic 4 pages
- 8 Connectivity 3 pages
- 9 Tooling 11 pages
- 10 Player 5 pages
- 11 Plugin System 4 pages
- 12 Examples 2 pages
- 13 Specification 2 pages
Or write Lute right now — no install — in the playground: live diagnostics, the compiled JSON IR, and a mock-driven trace, all in your browser.