Skip to content

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:

Terminal window
mkdir investigation && cd investigation
mkdir scenes quests mocks tests

Every 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.

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: core
profiles:
core:
plugins: {}

The manifest is what turns a folder of .lute files into something whole-project lute check can resolve as one unit.

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.)

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.

Create scenes/crime-scene.lute:

---
kind: scene
luteVersion: "0.53.0"
id: detective.s01ep01
title: The Study
# No `after:`, so this scene is where the case starts.
uses: ../world.schema.yaml
pov: 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).

Create scenes/interview.lute:

---
kind: scene
luteVersion: "0.53.0"
id: detective.s01ep02
title: The Interview Room
# Comes after the crime scene.
after: 'visited("detective.s01ep01")'
uses: ../world.schema.yaml
pov: 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.

Create quests/identify-killer.lute:

---
kind: quest
luteVersion: "0.53.0"
uses: ../world.schema.yaml
title: 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: scene
luteVersion: "0.53.0"
id: detective.s01ep03
title: The Confrontation
# Comes after the interview.
after: 'visited("detective.s01ep02")'
uses: ../world.schema.yaml
pov: 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.lute
Terminal window
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 routes

The 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.

lute scenario reports pure graph structure over the declared after: routes — no CEL, no Datalog. Ask about the last scene by its id::

Terminal window
lute scenario --project . reach --node detective.s01ep03
project 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.

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.lute
state:
run.trueKiller: blake
choose:
accuse: accuseBlake

and mocks/accuse-wrongly.yaml:

# Preview the confrontation: accuse the WRONG suspect.
file: ../scenes/confrontation.lute
state:
run.trueKiller: blake
choose:
accuse: accuseCass

Accuse the right suspect → the success ending:

Terminal window
lute trace scenes/confrontation.lute --mock mocks/accuse-correctly.yaml
trace: 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`: skipped
trace 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: skipped

Accuse the wrong suspect → the failure ending. Same document, same seeded truth, one different forced choice:

Terminal window
lute trace scenes/confrontation.lute --mock mocks/accuse-wrongly.yaml
trace: 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: taken

The ## 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.

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.lute
state:
run.trueKiller: blake
choose:
accuse: accuseBlake
expect:
end: complete
transcriptContains:
- "The cuffs close on the right wrists. Case closed."
- "Booked. The file can finally rest."
state:
run.accused: blake

tests/accuse-wrongly.test.yaml:

# Accuse the WRONG suspect: the failure ending plays.
file: ../scenes/confrontation.lute
state:
run.trueKiller: blake
choose:
accuse: accuseCass
expect:
end: complete
transcriptContains:
- "The wrong suspect walks free. Somewhere, the real one exhales."
- "I got it wrong. The file stays open."
state:
run.accused: cass

and 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.lute
choose:
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: 1
Terminal window
lute test
lute: note: scenario tests use project . (nearest lute.project.yaml); pass --project to choose another
PASS ./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 failed

Exit 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.

Once a document checks clean it compiles to its JSON command-record artifact:

Terminal window
lute compile scenes/crime-scene.lute --project . -o crime-scene.json

Exit 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.

  • Play several scenes in order as one story with lute play: Connect scenes into a story.
  • Scaffold the larger whodunit with lute init --template investigation and 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.