Build an investigation
Write your first scene built one file. This tutorial builds a whole small project: a three-scene whodunit gated by a quest, over a relational fact world. It is the shape of a real Lute game in miniature — and it shows what the checker proves about relational fact guards across scenes, so you can see exactly what it will and will not claim.
All you need is the lute command (Installation) —
no copy of the Lute repository and no Rust. Make an empty folder for the
project and its subfolders, and work from inside it:
mkdir investigation && cd investigationmkdir scenes quests mocks testsEvery command below runs from inside investigation/, and every path is
relative to it. Each step shows one file whole: create it at the path the
step names and paste or type it in, and by the end you have the entire project.
The same files, for reference, live in the repository at
docs/examples/investigation/.
This is not the project lute init --template investigation creates. That
template scaffolds a different, larger whodunit — beats driven by occasions, a
plugin, lore, plays and tests — worth exploring once you have finished here.
None of its files are the ones below.
1. The project manifest
Section titled “1. The project manifest”A project is any directory with a lute.project.yaml at its root. This one is
core-only — no plugins — so every document resolves against the built-in
lute.core capabilities. Create lute.project.yaml:
# The project root. Core-only: no plugins, so every document resolves# against Lute's built-in `lute.core` capabilities.defaultProfile: coreprofiles: core: plugins: {}The manifest is what turns a folder of .lute files into something
whole-project lute check can resolve as one unit.
2. The shared schema
Section titled “2. The shared schema”Every scene and the quest import one schema with uses: ../world.schema.yaml.
It declares the scalar state tiers and the relational vocabulary — a pure
declaration map, no ---/body. Create world.schema.yaml:
# The shared world every scene and the quest import with# `uses: ../world.schema.yaml`. Declarations only: no `---`, no body.
# --- Scalar state -----------------------------------------------------------# Every path has a `default:`, so every read has a value from the start.state: run.cluesLogged: { type: int, default: 0 } run.leadStrength: { type: int, default: 0 } run.suspectFocus: { type: { enum: [blake, cass, dana] }, default: blake } run.accused: { type: { enum: [none, blake, cass, dana] }, default: none } run.trueKiller: { type: { enum: [blake, cass, dana] }, default: blake } user.solved: { type: int, default: 0 }
# --- Relational facts -------------------------------------------------------entities: suspect: { members: [blake, cass, dana] } clue: { members: [ledger, letter, knife] }
relations: # `foundClue` is asserted by the crime scene as the detective logs evidence. foundClue: { args: [clue], tier: run } # `implicates` is the fixed case map: which clue points at which suspect. implicates: { args: [clue, suspect], tier: run, key: [0] } # `points` is DERIVED: a suspect the found clues implicate (see `rules:`). points: { args: [suspect], derive: true }
# Seed facts: true from the start of every playthrough.facts: - "implicates(ledger, blake)" - "implicates(letter, blake)" - "implicates(knife, cass)"
# One rule: a suspect is pointed at once a clue that implicates them is found.rules: - "points(S) :- foundClue(C), implicates(C, S)"The first half is scalar state. Every path carries a default:, so each read is
definitely assigned even in a standalone single-file check. (See
state schemas and the state model for
the tier rules.)
3. Facts, relations, and one Datalog rule
Section titled “3. Facts, relations, and one Datalog rule”The second half of the same file declares the relational world: two
entities (the suspects and the clues), three relations, three seed facts, and a
derivation rule. foundClue is written by the crime scene; implicates is the
fixed case map, true from the start because of the seed facts:; and points
is not stored at all — it is derived: a suspect is pointed at once a found
clue implicates them. See facts and Datalog for the
full model.
4. The entry scene — logging clues
Section titled “4. The entry scene — logging clues”Create scenes/crime-scene.lute:
---kind: sceneluteVersion: "0.53.0"id: detective.s01ep01title: The Study# No `after:`, so this scene is where the case starts.uses: ../world.schema.yamlpov: detective---
## The Study
@narrator: The victim's study, untouched since the coroner left.@detective{mono}: Two things in here don't belong. Log them.
::assert{ foundClue(ledger) }::set{ run.cluesLogged += 1 }@detective: A ledger — the balances scratched out in red ink.
::assert{ foundClue(letter) }::set{ run.cluesLogged += 1 }@detective: And a letter, signed once and then unsigned.
// No `when=`: both clues above are logged on every playthrough, so the// derived fact `points(blake)` always holds here.@detective{mono}: Both threads pull toward one name.The frontmatter names the scene with id:. The other scenes refer to it by that
name; detective.s01ep01 is just a name, and any dotted name works. pov: names
the player character: {mono}, the inner voice the detective’s thoughts use, is
reserved for that character and any speaker listed in monoSpeakers:. The scene
has no after:, so it is the graph root — where the case starts. It writes
relational-fact deltas with ::assert{ foundClue(...) } and bumps a scalar
counter the quest gates on.
The last line, “Both threads pull toward one name”, deliberately carries
no guard. when="holds('points', ['blake'])" would be redundant: both clues are
asserted on the only route through this scene and implicates(ledger, blake) is
a seed, so the rule derives points(blake) before the line on every
playthrough — and whole-project lute check proves exactly that kind of claim (step 8).
5. The interrogation hub
Section titled “5. The interrogation hub”Create scenes/interview.lute:
---kind: sceneluteVersion: "0.53.0"id: detective.s01ep02title: The Interview Room# Comes after the crime scene.after: 'visited("detective.s01ep01")'uses: ../world.schema.yamlpov: detective---
## The Interview Room
@narrator: Three chairs, one table, and the smell of cold coffee.
<hub id="interrogate"> <choice id="pressLedger" text="Press them on the ledger"> @detective: These numbers were bled dry. Explain them. ::set{ run.suspectFocus = "blake" } ::set{ run.leadStrength += 1 } </choice> <choice id="pressLetter" text="Press them on the letter"> @detective: You signed this, then you tried to unsign it. ::set{ run.suspectFocus = "blake" } ::set{ run.leadStrength += 1 } </choice> <choice id="askAlibi" text="Ask for an alibi" once> @detective: Walk me through last night. Slowly. </choice> <choice id="leave" text="End the interview" exit> @detective: We're done here. For now. </choice></hub>
## Reading the Room
<match subject="run.suspectFocus"> <when is="blake"> @detective{mono}: Blake keeps surfacing. A thread worth pulling. </when> <when is="cass"> @detective{mono}: Cass, then — the quiet ones keep the best books. </when> <when is="dana"> @detective{mono}: Dana. I'm not convinced, but I'll note it. </when></match>The interview is sequenced after: the crime scene (step 7) and uses a revisit
<hub> whose first two choices press the clues the crime scene logged. leave
is the unguarded exit that satisfies the no-dead-end guarantee — every hub
must offer a way out. See choices & hubs. After
the hub, a <match> reads the run.suspectFocus the choices wrote; it covers
every member of the enum, so it needs no <otherwise>.
pressLedger and pressLetter carry no when="holds('foundClue', ['…'])" guard for
the same reason as the crime scene’s last line: the crime scene asserts both
clues on its only route, and the interview is only reachable after: it, so
both facts already hold whenever this hub runs.
6. The quest — the goal machine
Section titled “6. The quest — the goal machine”Create quests/identify-killer.lute:
---kind: questluteVersion: "0.53.0"uses: ../world.schema.yamltitle: Identify the Killer---
<quest id="identifyKiller" tier="run" title="Identify the Killer" start="true"> <objective id="gatherEvidence" title="Log at least two clues" done="run.cluesLogged >= 2"/> <objective id="nameSuspect" title="Name a suspect at the confrontation" done="run.accused != 'none'"/> <objective id="followClues" title="Let the clues point at a suspect" done="holds('points', ['blake'])" optional/>
<on event="questComplete"> ::set{ user.solved += 1 } @narrator: The case file closes with a name on the cover. </on></quest>start="true" makes the quest active from the start, and tier="run" makes it a
quest of one playthrough: it resets with every new run. (Without tier= a quest is
user-tier and persists across runs; whole-project lute check warns W-QUEST-TIER-IMPLICIT
when such a quest reads only run state.) Completion is derived: the
quest completes once every non-optional objective is done.
gatherEvidence and nameSuspect gate on scalar run state the scenes write, so
both are statically satisfiable. The optional followClues gates on a
relational fact query over the derived points — hold that thought for step 8.
7. The confrontation, and sequencing with after:
Section titled “7. The confrontation, and sequencing with after:”Create scenes/confrontation.lute:
---kind: sceneluteVersion: "0.53.0"id: detective.s01ep03title: The Confrontation# Comes after the interview.after: 'visited("detective.s01ep02")'uses: ../world.schema.yamlpov: detective---
## The Confrontation
@narrator: Everyone in one room. The detective lays the file on the table.@detective{mono}: One name. I only get to say one.
<branch id="accuse"> <choice id="accuseBlake" text="Accuse Blake" into="run.accused" value="blake"> @detective: Blake. The ledger and the letter were both yours. </choice> <choice id="accuseCass" text="Accuse Cass" into="run.accused" value="cass"> @detective: Cass. You balanced these books in blood. </choice> <choice id="accuseDana" text="Accuse Dana" into="run.accused" value="dana"> @detective: Dana. You were never as far away as you claimed. </choice></branch>
## The Verdict
@narrator{when="run.accused == run.trueKiller"}: The cuffs close on the right wrists. Case closed.@detective{mono when="run.accused == run.trueKiller"}: Booked. The file can finally rest.@narrator{when="run.accused != run.trueKiller"}: The wrong suspect walks free. Somewhere, the real one exhales.@detective{mono when="run.accused != run.trueKiller"}: I got it wrong. The file stays open.The three scenes form a chain. interview.lute declares
after: 'visited("detective.s01ep01")' and confrontation.lute declares
after: 'visited("detective.s01ep02")', each naming the previous scene by its
id:. That is the whole prerequisite graph: crime-scene → interview →
confrontation. after: states what must come first; it does not move a player
from one scene to the next.
The confrontation closes the chain with a <branch> accusation that records the
pick into run.accused with the into=/value= record sugar. The verdict
lines then branch on whether the accusation matched run.trueKiller: a pair of
complementary when= guards, so exactly one of the success or failure ending
plays.
Your folder now holds the whole story:
investigation/├── lute.project.yaml├── world.schema.yaml├── quests/│ └── identify-killer.lute└── scenes/ ├── confrontation.lute ├── crime-scene.lute └── interview.lute8. Check the whole project
Section titled “8. Check the whole project”lute check --project .Exit 0, and nothing to report — no errors, no warnings:
ok: ./quests/identify-killer.lute (0 warning(s))ok: ./scenes/confrontation.lute (0 warning(s))ok: ./scenes/crime-scene.lute (0 warning(s))ok: ./scenes/interview.lute (0 warning(s))ok: . (4 file(s), 0 project-wide warning(s))That silence is a proof, not an absence of one. Whole-project lute check decides every
relational fact query (holds(…), count(…)) in every guard: impossible
(no seed, assert, rule, or engine relation can produce the fact), guaranteed
(it holds on every declared route to the guard), or possible — the normal
case for a guard, and silent. Put the guards back to see it. In
scenes/interview.lute, give pressLedger the guard
when="holds('foundClue', ['ledger'])", and give pressLetter a guard on a clue
nobody ever logs, when="holds('foundClue', ['knife'])":
<choice id="pressLedger" text="Press them on the ledger" when="holds('foundClue', ['ledger'])"> <choice id="pressLetter" text="Press them on the letter" when="holds('foundClue', ['knife'])">Run lute check --project . again. It now exits 1, and among its output are
these two project-wide diagnostics:
./scenes/interview.lute:17:66: warning [W-FACT-GUARANTEED] guard `holds('foundClue', ['ledger'])` is redundant: `holds('foundClue', ['ledger'])` is asserted on every route to here (./scenes/crime-scene.lute:16)./scenes/interview.lute:22:3: error [E-ARM-DEAD] choice can never fire: guard `holds('foundClue', ['knife'])` is provably false — no seed, assert, rule, or engine relation produces `holds('foundClue', ['knife'])` under your declared routesThe first guard can never close — the crime scene asserts the fact on its only
route, and the interview is only reachable after: it — so the warning names
the assert that settles it. The second can never open, so the choice is dead
code and the project fails. Remove both guards again before you go on.
The quest’s followClues objective is decided the same way: points(blake)
follows from the crime scene’s asserts and a seed fact, so the objective can
complete; give it a fact nothing produces, such as holds('foundClue', ['knife']),
and it becomes E-OBJECTIVE-UNSATISFIABLE. (A done is a predicate rather
than a guard, so an always-true one is never flagged as redundant.)
The analysis needs every document at once — an assert in a sibling scene is
invisible to one file — so it runs in whole-project lute check only; a single-file
lute check leaves relational queries undecided. See
facts and Datalog
for the full model.
9. Reachability & the scene graph
Section titled “9. Reachability & the scene graph”lute scenario reports pure graph structure over the declared after: routes —
no CEL, no Datalog. Ask about the last scene by its id::
lute scenario --project . reach --node detective.s01ep03project root: .reach scene(detective.s01ep03): verdict: Reachable — a satisfiable route exists under your declared routes. after: visited("detective.s01ep02") referenced node(s) (see `after` above for the && / || structure — this is NOT a flat requirement list): - scene(detective.s01ep02): Reachable — a satisfiable route exists under your declared routes.Note the hedge — “under your declared routes.” Reachability reasons about the
after: graph you declared, not about whether any given playthrough walks it.
See reachability.
10. Trace both endings
Section titled “10. Trace both endings”lute trace walks one document along one deterministic, mock-driven
path — a preview, not a proof of all paths. A mock is a small YAML file that
names the document it previews (file:, relative to the mock), seeds state,
and forces choices. Both mocks drive the confrontation scene: they seed the
truth and force the accuse branch to a different choice.
Create mocks/accuse-correctly.yaml:
# Preview the confrontation: accuse the RIGHT suspect.file: ../scenes/confrontation.lutestate: run.trueKiller: blakechoose: accuse: accuseBlakeand mocks/accuse-wrongly.yaml:
# Preview the confrontation: accuse the WRONG suspect.file: ../scenes/confrontation.lutestate: run.trueKiller: blakechoose: accuse: accuseCassAccuse the right suspect → the success ending:
lute trace scenes/confrontation.lute --mock mocks/accuse-correctly.yamltrace: scenes/confrontation.lute (seeds: 1 paths, 0 facts; 1 selection) ## The Confrontation @narrator Everyone in one room. The detective lays the file on the table. @detective{mono} One name. I only get to say one. <branch accuse> eligible: accuseBlake, accuseCass, accuseDana -> accuseBlake @detective Blake. The ledger and the letter were both yours. ::set run.accused = blake (into sugar) ## The Verdict guard `run.accused == run.trueKiller`: taken @narrator The cuffs close on the right wrists. Case closed. guard `run.accused == run.trueKiller`: taken @detective{mono} Booked. The file can finally rest. guard `run.accused != run.trueKiller`: skipped guard `run.accused != run.trueKiller`: skippedtrace complete: 5 decisions; choices 1/3 (accuse), guard `run.accused == run.trueKiller` @31:1: taken, guard `run.accused == run.trueKiller` @32:1: taken, guard `run.accused != run.trueKiller` @33:1: skipped, guard `run.accused != run.trueKiller` @34:1: skippedAccuse the wrong suspect → the failure ending. Same document, same seeded truth, one different forced choice:
lute trace scenes/confrontation.lute --mock mocks/accuse-wrongly.yamltrace: scenes/confrontation.lute (seeds: 1 paths, 0 facts; 1 selection) ## The Confrontation @narrator Everyone in one room. The detective lays the file on the table. @detective{mono} One name. I only get to say one. <branch accuse> eligible: accuseBlake, accuseCass, accuseDana -> accuseCass @detective Cass. You balanced these books in blood. ::set run.accused = cass (into sugar) ## The Verdict guard `run.accused == run.trueKiller`: skipped guard `run.accused == run.trueKiller`: skipped guard `run.accused != run.trueKiller`: taken @narrator The wrong suspect walks free. Somewhere, the real one exhales. guard `run.accused != run.trueKiller`: taken @detective{mono} I got it wrong. The file stays open.trace complete: 5 decisions; choices 1/3 (accuse), guard `run.accused == run.trueKiller` @31:1: skipped, guard `run.accused == run.trueKiller` @32:1: skipped, guard `run.accused != run.trueKiller` @33:1: taken, guard `run.accused != run.trueKiller` @34:1: takenThe ## lines are the scene’s section headings. Each guarded verdict line shows up
as a guard step: taken when its when= held and the line played, skipped
when it did not. Both traces exit 0 — a complete walk — and reach
visibly different endings. Trace also loads the schema’s seed facts and applies
its rule by default, so a fact-gated line would be decided with no extra flags;
these endings turn only on run state. See the tracing guide
for every mock surface and derivation.
11. Pin both endings with tests
Section titled “11. Pin both endings with tests”A trace is something you read; a scenario test asserts it. lute test runs
every *.test.yaml under the folder: each names a document (file:, relative
to the test), takes the same seeds and forced choices as a mock, and adds an
expect: block. Create three tests. tests/accuse-correctly.test.yaml:
# Accuse the RIGHT suspect: the success ending plays.file: ../scenes/confrontation.lutestate: run.trueKiller: blakechoose: accuse: accuseBlakeexpect: end: complete transcriptContains: - "The cuffs close on the right wrists. Case closed." - "Booked. The file can finally rest." state: run.accused: blaketests/accuse-wrongly.test.yaml:
# Accuse the WRONG suspect: the failure ending plays.file: ../scenes/confrontation.lutestate: run.trueKiller: blakechoose: accuse: accuseCassexpect: end: complete transcriptContains: - "The wrong suspect walks free. Somewhere, the real one exhales." - "I got it wrong. The file stays open." state: run.accused: cassand tests/interview-press-ledger.test.yaml, which walks the hub — press the
ledger, then leave — and checks where the <match> went:
# The interview: press the ledger, then leave.file: ../scenes/interview.lutechoose: interrogate: [pressLedger, leave]expect: end: complete transcriptContains: - "These numbers were bled dry. Explain them." - "Blake keeps surfacing. A thread worth pulling." state: run.suspectFocus: blake run.leadStrength: 1lute testlute: note: scenario tests use project . (nearest lute.project.yaml); pass --project to choose anotherPASS ./tests/accuse-correctly.test.yaml (./scenes/confrontation.lute)PASS ./tests/accuse-wrongly.test.yaml (./scenes/confrontation.lute)PASS ./tests/interview-press-ledger.test.yaml (./scenes/interview.lute)
3 passed, 0 failedExit 0 when every test passes and 1 when any expectation fails, naming what
was expected and what the walk produced. The note says which project the tests
resolved against; lute test --project . names it and silences the note. See
lute test for every expectation.
12. Compile
Section titled “12. Compile”Once a document checks clean it compiles to its JSON command-record artifact:
lute compile scenes/crime-scene.lute --project . -o crime-scene.jsonExit 0; crime-scene.json is stamped "lute": "0.53.0" /
"irVersion": "0.53.0". Every document in the project compiles. That artifact —
not the source — is what an engine plays through the
runtime contract.
Where to go next
Section titled “Where to go next”- Play several scenes in order as one story with
lute play: Connect scenes into a story. - Scaffold the larger whodunit with
lute init --template investigationand compare it with what you built here. - Trace the machine-driven version of check → fix in the AI harness guide.
- Pick a role-first reading order from the learning paths.