Skip to content

Playing a story

With --json, lute play returns one envelope object, not a bare transcript array: command, schemaVersion, ok, and the command payload. A failed invocation returns ok: false and an error object with a stable code; inspect the exit status before decoding stdout. --events remains NDJSON and is a separate stream contract.

A narrative game picks its next piece of story at moments of its own: a visit to town, entering a room, talking to an NPC, a new day, the start of a run. Lute calls those moments occasions and the pieces of story that answer them beats (dsl 0.21.0). The engine raises occasions; Lute defines which beats are eligible and which one wins. lute play is the reference player for that contract: give it a script of raised occasions and it walks the whole project, printing every candidate beat, its verdict, and the winner, and playing the winner through the same reference runner as lute run.

Since 0.22.0 a play script also stands in for the rest of the engine. An engine: step writes the state and facts the engine owns — a day advancing, a run counter, a kill — a script can start from a save, decisions can differ per step, a step can fire a world event, and expect: turns a playthrough into an assertion that lute test runs beside the scenario tests. A project no longer needs scenes, occasions, or plugins that exist only to fake the engine.

Since 0.23.0 an occasion can present every eligible beat in turn (select: sequence), a beat can ride along after the winner (also: true), an objective can have a deadline (by=) or wait for one target (target=), and a lore document can hold several scene-like bundle beats. To see which beat would play in every state rather than walk one playthrough, use the story overviews — lute calendar evaluates this page’s eligibility over a grid of state values.

Since 0.24.0 a script moves a declared clock forward (advance:), shares a routine between routes (include:), and answers the plugin bridge calls a real engine would hand to a service (bridges:). The transcript names why each quest failed, shows a judge: before occasion settling its quests before its beats, and prints what a newRun snapshots and what it applies from the run before.

Since dsl 0.26.0 a script advances the clock to the next named slot or weekday (advance: { to: night }) and asserts where it stands (expect: { clock: … }), so a shared steps file states the time it expects; an engine: step accepts an accept-driven quest mid-play; one beat can answer every member of an entity kind (target="kind:trainer"); a guarded directive that did not run is shown as skipped; an entry may be named <document id>.<entry id>; a bridge answer is typed by its result slot even when a component makes the call; and a transcript needle may keep the line attributes it was copied with.

The normative text is the 0.21.0 proposal, extended by the 0.22.0 proposal (the play and test harness), the 0.23.0 proposal (composing occasions, deadlines, bundles), the 0.24.0 proposal (the clock, quest structure, bridge answers), the 0.26.0 proposal (clock targets, engine accepts, kind targets, test and play parity), the 0.27.0 proposal (one runtime, engine gates and terminal states, finite clocks, seasons, beat templates, the manifest’s scene chain, now chapters:), the 0.28.0 proposal (chapters:, a latching spentBy, select: sequence judged at each turn, expect.end and expect.options, outsideRun: occasions, clock.ended), the 0.29.0 proposal (an ending that persists across runs, terminal: { when, persists: true }), and the 0.30.0 proposal (a name that is not an identifier written as a quoted index, state: { 'run.visits["lab-b2"]': 1 }); the engine-side contract (the IR fields and the selection algorithm an engine implements) is docs/runtime/beats-and-occasions.md. Before 0.21.0, lute play walked a tick-clock schedule file; that layer, its clock/lane/placement model, and its flags are removed. Time is now one input to a beat’s condition, not the frame.

In 0.34.0, project commands load one ProjectModel per nearest manifest root. play and test result JSON carries evidence: witnessed for a supplied script/run, proven for a static proof, or unknown when the run does not decide. Human output uses evidence suffixes only where useful; a bounded analysis is explicitly scoped.

An occasion is engine vocabulary: a named moment the engine raises, optionally for a target (talk → npc.achilles). A plugin declares its occasions through an occasions export — list it in plugin.yaml (exports: { occasions: occasions/ }) and put the map in occasions/*.yaml:

occasions:
townVisit: { select: first }
talk: { select: first, target: { prefix: npc, entity: person } }
roomEnter: { select: first, target: true }
inbox: { select: all, description: Letters waiting at the fountain }
  • select: first (the default) — the engine presents the single winning beat.
  • select: all — the engine offers every eligible beat, in selection order, and the player picks one (a message inbox, an area map, quest givers “in any order”).
  • select: sequence (dsl 0.23.0) — the engine presents every eligible beat, one after another, in selection order: a routine followed by the day’s event, a run’s opening followed by a recap. See Composing occasions.
  • target: { prefix, entity } (dsl 0.22.0) — the occasion is raised for something, and its targets are a vocabulary: <prefix>.<member>, where <member> is a member of the entities: kind entity (any id for an open: kind). talk above is raised for npc.achilles or npc.patroclus when the schema declares person: { members: [achilles, patroclus] }.
  • target: true — raised for a target, shape only: any dotted id. Default false, not raised for anything.
  • description — optional prose for tooling.
  • judge: before (dsl 0.24.0 §2) — the occasion judges its on= objectives and settles its quests before its beats are decided, so an epilogue on it reads how its quests ended. Only the judging moves: the handler bodies the raise answers — its same-named <on event> handlers, and the questComplete / questFailed handlers of the quests it settles — still run after the beats, so their narration follows the scene. The default, after, judges after the presentations. See Quest structure.

When no resolved plugin declares occasions, occasion names are shape-only: any name is accepted, so you can write beats before the engine’s plugin exists. A story with no engine plugin at all plays this way: Connect scenes into a story answers one shape-only occasion with every scene and plays it with lute play. Once any plugin declares them, a beat naming an undeclared occasion is E-OCCASION-UNKNOWN, and a target on an occasion declared without a target is E-BEAT-ATTR. On an occasion with a target domain, a beat target outside it — npc.achiles for npc.achilles — is E-BEAT-ATTR with a did-you-mean, and so is a domain naming an entity kind the project does not declare. The export folds into the capability snapshot as a guarded section, so a project whose plugins declare no occasions keeps its capabilitySnapshot. A target domain is part of the snapshot; target: true hashes exactly as it did in 0.21.

Quest objectives judged at an occasion (<objective on="runEnd">) take on=, and since 0.23.0 an optional target= — <objective on="talk" target="npc.maud"> is judged only when talk is raised for npc.maud, and is checked like a beat target (E-BEAT-ATTR: a dotted id, only beside on, on an occasion that takes a target, inside its domain). See Deadlines and targeted objectives.

A scene becomes a beat by naming the occasion it answers in its frontmatter:

---
kind: scene
id: achilles.gift
on: talk
target: npc.achilles
when: 'user.runs >= 10'
priority: 50
once: user
---
KeyMeaning
onThe occasion this scene answers. Makes the scene a beat.
targetOptional. The scene is a candidate only when the occasion is raised for this target (a dotted id, the <entry target> shape; <prefix>.<member> when the occasion declares a target domain). Since dsl 0.26.0, kind:<kind> answers every member of a kind — see Kind targets.
whenOptional CEL condition over run / user / app state, quest.*, entry.<id>.read / entry.<id>.everRead, and fact queries. The scene’s own scene.* state does not exist yet and is rejected.
priorityOptional integer, default 0. Higher wins.
oncerun (the default — once per run), user (once ever), or false (repeatable). With a declared clock, day or slot (dsl 0.24.0 §1): spent until the clock’s day or slot changes.
alsoOptional true (dsl 0.23.0): on a select: first occasion the beat never wins — it is presented after the winner, as a side remark, and even when no main beat is eligible. See Composing occasions.

after: keeps its meaning — the structural prerequisite over visited / completed / active that connectivity analyzes — and a beat is eligible only when both after: and when hold. when, target, priority, once, or also without on is E-BEAT-ATTR. A scene that answers no occasion is never presented by an occasion: your engine starts it itself, by its scene id, lute trace and lute run walk it on its own, and lute play never presents it. To chain scenes into a story you can play with lute play, see Connect scenes into a story. An also that is not a bool is E-BEAT-ATTR too, and so is also: true on a select: all or select: sequence occasion, where every eligible beat is already presented or offered.

A lore entry answers an occasion with on= and priority= beside its existing target and when, and since 0.22.0 an optional once=:

<entry id="achillesBark" on="talk" target="npc.achilles" category="bark" priority="10" once="run" when="user.runs >= 3">
@achilles: Back again, lad.
</entry>

Without once, an entry is repeatable: re-presentation is its nature, and its effects apply on the first read only. once="run" makes it ineligible once its run-tier entry.<id>.read flag is set, so it is heard once per run — a newRun resets the flag. once="user" makes it ineligible once entry.<id>.everRead is set: a reserved user-tier flag beside read, set on the first read and never reset by a new run, readable wherever entry.<id>.read is. With a declared clock, once="day" / once="slot" spend it until the clock’s day or slot changes (dsl 0.24.0 §1). A bad once, or once without on, is E-BEAT-ATTR.

A scene is one beat per file. When a character has many short scenes — an NPC’s lines at each visit, a set of interview questions — a lore document can hold them as bundle beats (dsl 0.23.0): <beat> blocks with a full scene body — lines, branches, hubs, <match>, directives — and the same attributes a scene beat puts in its frontmatter (on, target, when, priority, once, also, since dsl 0.25.0 share and after, and a title for a select: all menu). In the tower the examples below climb, lore/oskar.lute declares id: oskar and gives Oskar two:

<beat id="hunt" on="talk" target="npc.oskar" priority="10" title="The hound">
@oskar: The hound took my dog's collar. Bring it back before you pass floor four.
::accept{quest="houndHunt"}
</beat>
<beat id="rumor" on="talk" target="npc.oskar" also="true" once="false" when="run.floor >= 2">
@oskar: They say the warden sleeps on floor six.
</beat>

A bundle beat behaves like a scene beat, not an entry: its id is <document id>.<beat id> — oskar.hunt, oskar.rumor — so the document needs an id:; its once defaults to run and is spent by presentation; presenting it marks that id visited, so visited('oskar.hunt') reads it in any condition, an after: included; since dsl 0.25.0 it may carry its own after="…", which lute play checks like a scene’s (✗ keeper.greeting [beat, priority 0] — after: prerequisite not satisfied); and check --project judges it like any beat (E-BEAT-UNREACHABLE, W-BEAT-SHADOWED, W-BEAT-PRIORITY-TIE, W-BEAT-ONCE-RUN-USER). The language rules are on Beats. In a transcript a bundle beat’s kind is beat; lute trace --beat and lute run --beat present one on its own (see Tracing).

A beat may answer a whole entity kind instead of one member (dsl 0.26.0 §5): target: kind:<kind> in a scene’s frontmatter, target="kind:<kind>" on an entry or a bundle beat. The kind is a closed kind inside the occasion’s target domain — the domain’s own kind, or one declared subsetOf: it — and the beat is a candidate whenever the occasion is raised for any of its members. The member raised is readable as occasion.target in the beat’s when, its guards and its text, typed by the kind, and bound only while the beat is presented. A kind outside the domain is E-BEAT-ATTR with a did-you-mean, and a read of occasion.target in a beat that targets no kind is E-UNDECLARED. One challenge answers every trainer on a route:

entities:
person: { members: [wren, sol] }
trainer: { subsetOf: person, members: [gus, r16Gus] }
<beat id="challenge" on="talk" target="kind:trainer" title="A trainer squares up" once="false">
@narrator: {{occasion.target}} squares up. "You look like you've never lost."
</beat>
<beat id="gusRematch" on="talk" target="npc.gus" title="Gus wants a rematch" once="false">
@gus: Back for another round?
</beat>

At equal priority a kind beat ranks after the other candidates, so Gus’s own beat wins at npc.gus — with no W-BEAT-PRIORITY-TIE between them — and the kind beat answers every other trainer. lute play renders {{occasion.target}} as the kind’s label for the member, else as its cast name: when the member is a cast id, else as the id; the compiled placeholder is {"kind": "occasionTarget", "entityKind": "trainer"}, so an engine renders its own display name. With r16Gus cast as “Hiker Brom”, in the lore document trainers:

── step 1 · talk → npc.r16Gus ──────────────
✓ trainers.challenge [beat, priority 0]
→ trainers.challenge
@narrator: Hiker Brom squares up. "You look like you've never lost."
── step 2 · talk → npc.gus ──────────────
✓ trainers.gusRematch [beat, priority 0]
✓ trainers.challenge [beat, priority 0]
→ trainers.gusRematch
@gus: Back for another round?

lute beats lists a kind beat in the ladder of every member some beat names, and in a kind:<kind> ladder for the members no beat names on its own (see Story overviews); lute trace and lute test read the member from a state: { occasion.target: r16Gus } seed (see lute test). The language rules are on Beats.

On an untargeted select: sequence occasion, for="kind:<kind>" (dsl 0.27.0 §3; for: in a scene’s frontmatter) presents one beat once per member whose when holds, in member order, binding occasion.target to each. The step lists one candidate per member, and --json names it in the candidate’s for:

── step 1 · dailyReset (select: sequence) ──────────────
✓ g.bday for aria [beat, priority 0]
✓ g.bday for cyra [beat, priority 0]
✗ g.bday for bram [beat, priority 0] — when: false
→ g.bday for aria
→ g.bday for cyra
@narrator: Happy birthday, aria!
@narrator: Happy birthday, cyra!

A step’s winner, offered, notOffered and presented name one member’s presentation the way the transcript does, <beat id> for <member>. With a beat wave in the lore document greet, for="kind:npc" over mara and tomas, once="run":

steps:
- occasion: morning
expect: { presented: [greet.wave for mara, greet.wave for tomas] }
- occasion: morning
expect: { notOffered: [greet.wave for mara], winner: none }

A member the beat is not presented for is a usage error with a did-you-mean: `expect.notOffered` names `greet.wave for mra`, and `mra` is no member `greet.wave` is presented for — did you mean `mara`? (members: mara, tomas). once counts each member separately, so a once: run beat that played for Mara can still play for Tomas; members spent for the same reason print as one line (✗ greet.wave for mara, tomas [beat, priority 0] — once: run — already presented this run).

See Once per member.

When the engine raises occasion O, optionally for target T:

  1. Candidates are the beats with on: O whose target is absent or equal to T, and since dsl 0.26.0 the beats targeting kind:<K> for a kind T’s member belongs to (Kind targets). An occasion raised without a target has only untargeted candidates.
  2. A candidate is eligible when its after: holds (scene beats; a bundle beat’s after=), its when holds, and its once is not spent. A scene’s (or bundle beat’s) once: run — not yet presented this run; user — never presented; day / slot — not yet presented since the clock’s day / slot last changed (dsl 0.24.0 §1); false — never spent. An entry’s once: run — entry.<id>.read not set; user — entry.<id>.everRead not set; day / slot — as a scene’s; absent — never spent. A beat with a share key (dsl 0.25.0 §2) is also spent when any other beat of its key was presented (an entry: read) within its once period: ✗ talks.solRoof [beat, priority 0] — once: day — `share: solWarm` already spent today by talks.solRadio.
  3. Eligible beats are ordered by priority, descending, then project order: document path, then declaration order within the document — the order of beats in project.index.json. Scene, entry, and bundle beats on the same occasion compete in one list. At equal priority a kind beat comes after the other candidates (dsl 0.26.0 §5).
  4. select: first presents the first eligible beat that is not also, then every eligible also beat; select: all offers the ordered list and presents the one the player picks; select: sequence presents the whole ordered list.
  5. No eligible beat — the occasion passes with no story, and the engine’s default behavior for that moment applies.

Selection is deterministic: the same state, facts, and presentation history pick the same beat in every engine. Weighted randomness or cooldowns are engine policy layered on top; the reference tooling implements exactly this order. When file order is what decides a select: first winner — two beats with equal priority whose whens are not provably exclusive — check --project warns W-BEAT-PRIORITY-TIE; see Beats for it and the other beat advisories.

For select: sequence, the candidates are listed when the occasion is raised, and once a beat of the raise has played, each later beat is judged again just before its turn: a beat whose when an earlier beat made false is skipped, and one it made true plays. Such a candidate’s line ends (judged at its turn, after an earlier beat of this raise). also beats are judged once, when the occasion is raised. The quest lifecycles settle after each presentation, so a deadline is judged between beats, and the <on event> handlers the raise answers run after all of its beats. W-BEAT-SHADOWED and W-BEAT-PRIORITY-TIE ignore also beats, which never compete for the win.

Terminal window
$ lute play --project <DIR> --script <FILE> [--json] [--ir] [--quiet] [--no-derive] [--events] [--derivation <ATOM>]… [--conditions-output <FILE>]

--events writes a resumable runtime stream as compact JSON Lines. The first line contains the seed and its output; every following line pairs the input the player took with its output, including choose and bridgeResult answers. Each output is stamped with eventVersion: "0.41.1". The stream is exclusive with --json, --ir, --derivation, and --conditions-output.

A short real stream from the hub conformance project looks like this (the first choice opens a second await):

{"seed":{"derive":true},"output":{"await":{"type":"idle"},"eventVersion":"0.41.1","events":[]}}
{"input":{"occasion":"visit","type":"raiseOccasion"},"output":{"await":{"menu":{"construct":"hub","document":"hub.lute","id":"desk","options":[{"exit":false,"id":"take","once":true,"verdict":"open"},{"exit":true,"id":"leave","once":false,"verdict":"open"}],"position":"001-0200","presentation":0},"request":1,"type":"awaitChoice"},"eventVersion":"0.41.1","events":[{"beat":"hub","document":"hub.lute","kind":"scene","occasion":"visit","type":"presentation"},{"document":"hub.lute","record":{"kind":"line","lineId":"hub.narrator_0010","position":"001-0100","role":"narration","speaker":"narrator","text":"Before the hub.","voiceKey":"hub.narrator-0010"},"type":"record"}]}}
{"input":{"option":"take","request":1,"type":"choose"},"output":{"await":{"menu":{"construct":"branch","document":"hub.lute","id":"answer","options":[{"exit":false,"id":"yes","once":false,"verdict":"open"},{"exit":false,"id":"no","once":false,"verdict":"open"}],"position":"001-0400","presentation":0},"request":2,"type":"awaitChoice"},"eventVersion":"0.41.1","events":[{"document":"hub.lute","record":{"chose":"take","hub":"desk","kind":"hub","position":"001-0200"},"type":"record"},{"document":"hub.lute","record":{"kind":"set","path":"run.count","position":"001-0300","value":1},"type":"record"}]}}
  • --project <DIR> — the project root (lute.project.yaml and its plugins). The project is compiled whole, in memory, with the same gate and declaration union compile --project uses (scene, quest, and lore documents). A project whose documents declare one state path with two types fails that gate (E-STATE-DECL-CONFLICT, dsl 0.26.0 §2.1), and the play refuses to start (exit 1). The project is loaded once per play; since dsl 0.26.0 its fact analysis is prepared once per project root instead of once per beat, so loading Monster League (818 beats, 211 static facts) fell from about 12 s to about 1.5 s, and a long play is bound by its steps, not its load.
  • --script <FILE> — required: the play script, a *.play.yaml file.
  • --json — the same transcript as one JSON object on stdout.
  • --ir — print staging as the lowered IR records (::bg, ::actor, and the preloads and pose resets the compiler injects) instead of the directives as authored. See The transcript.
  • --quiet — leave out the candidates that were not eligible at each raise; winners, lines, quests and expectations still print (dsl 0.27.0, round-5 T3-16). Without it, five or more when: false candidates at one raise print as one line, ✗ 8 beats — when: false: a, b, c, …; --json always lists every candidate.
  • --no-derive — do not apply the project’s Datalog rules (dsl 0.22.0 §6); overrides the script’s derive:. See Derivation and --derivation.
  • --derivation <ATOM> — repeatable: after the play, print why a ground atom holds or does not.
  • --conditions-output <FILE> — write the conditions the play evaluated to FILE.

There is nothing else to seed on the command line: state, facts, the save, decisions, and assertions all live in the script, so a playthrough is one reviewable file.

The examples in this section play a small roguelike project: a tower the player climbs run after run. Its plugin declares four occasions, one world event (an events export — see Manifests), and one reward kind whose grants credit state (dsl 0.23.0):

occasions:
townVisit: { select: first }
talk: { select: first, target: { prefix: npc, entity: person } }
board: { select: all, description: Notices pinned by the stair }
runStart: { select: sequence, description: A run begins at the foot of the stair }
events:
- name: storm
rewardKinds:
EMBERS: { credits: user.embers }

Its world schema gives the engine the floor and the run counter, the player a purse of embers, a reserved kill fact, and one rule — the warden is a threat until it is slain:

state:
run.floor: { type: int, default: 0, owner: engine }
user.runs: { type: int, default: 0, owner: engine }
user.embers: { type: int, default: 0 }
entities:
person: { members: [maud, oskar] }
foe: { members: [warden, hound] }
relations:
boss: { args: [foe], tier: run }
slew: { args: [foe], tier: run, reserved: true }
threat: { args: [foe], derive: true }
facts:
- "boss(warden)"
rules:
- "threat(F) :- boss(F), not slew(F)"

The beats: town.idle answers townVisit every time (once: false); town.victory (priority 10, when: "!holds('threat', ['warden'])", also once: false) outranks it as soon as the warden is no longer a threat; maud.talk answers talk for npc.maud; Oskar’s two bundle beats, oskar.hunt and the side remark oskar.rumor, answer talk for npc.oskar; start.gear (priority 10, once: false) and start.recap (once: false, when: "has(prev.run.floor)", the previous run’s floor) answer runStart; and three entries answer board — notice (once="user"), memo (once="run"), and old (when="entry.notice.everRead"). One quest document holds three quests, all start="true":

<quest id="climb" title="Reach the fifth floor" start="true" tier="run">
<objective id="high" title="Reach floor five" done="run.floor >= 5"/>
<on event="storm">
@narrator: Thunder rolls over the stair.
</on>
</quest>
<quest id="veteran" title="Three runs" start="true">
<objective id="three" title="Climb three times" done="user.runs >= 3"/>
</quest>
<quest id="notices" title="Read the board" start="true">
<objective id="looked" title="Look at the board" on="board" done="true"/>
</quest>

A second, quests/hound.lute, holds the quest oskar.hunt accepts. Its collar has a deadline, the report waits for Oskar, and the reward pays out in embers:

<quest id="houndHunt" title="The hound's collar">
<reward kind="EMBERS" amount="50"/>
<objective id="collar" title="Take the collar before floor four" done="holds('slew', ['hound'])" by="run.floor >= 4"/>
<objective id="report" title="Bring it to Oskar" on="talk" target="npc.oskar" done="holds('slew', ['hound'])"/>
<on event="questFailed">
@oskar: Floor four already? Then it's gone to ground.
</on>
</quest>

A play script is a YAML mapping. steps is required; every other top-level key is optional:

KeyMeaning
stepsRequired, non-empty: what happens, in order.
stateSeeds: state path → scalar literal, over the declared defaults.
factsGround facts added to the project’s seed facts.
chooseBranch/hub id → choice id (a list for a hub’s visit sequence or a branch that decides differently each time): the decision for every presentation.
visited, presented, quests, entriesReadThe save the play starts from — see Starting from a save.
expectAssertions about the end of the play — see Expectations.
derivefalse stops applying the project’s Datalog rules — see Derivation and --derivation.
bridgesAnswers for the plugin calls that read a bridge result, consumed in call order across the play (dsl 0.24.0 §5) — see Answering bridge calls.

For a timed branch, a step may answer expiry with timeout: true instead of choose. This sends the runtime timeout input and selects the branch’s declared default option:

steps:
- occasion: crossing
timeout: true

timeout: true is valid only when the current await is a timed choice. The branch must declare both a positive timeout and a default; hubs cannot be timed. A malformed or premature timeout is a script error.

Every step does exactly one thing — raises an occasion, applies engine writes, starts a newRun, fires an event, moves the declared clock (advance), or ends the playthrough (end: true) — and any step may carry a label, a repeat count, an expect:, and its own bridges: answers; an end step takes only a label. The one pairing: an advance may carry the engine: writes of the same moment. A step may also be include: <file>, which splices that file’s steps in its place (see Advancing the clock); since dsl 0.27.0 it MAY carry repeat: n (the file is spliced n times) and its own choose: / bridges:, which script every step it splices in over the script’s own — key by key, tag by tag, consumed from their start in each repetition and dropped when the segment ends — so each term of a multi-term play states its own choices. A key the include overrides leaves the script’s own list for that key where it was: with a top-level choose: { firstLook: [soren, mika] } and a first term that includes with choose: { firstLook: ren }, the second term still takes soren, the list’s first entry, not mika. Convert a positional list to per-include choose: for every term at once, not one term at a time; a label or expect: beside it is a usage error. A usage error in a step names the file, line and column of the key it is about — in the included file when the step came from one, followed by (included from <play>:<line>:<col>) — with a did-you-mean for occasion names and keys. A tour of every shape, against the tower:

state: { user.runs: 2 } # path -> scalar literal, over the declared defaults
facts: ["slew(hound)"] # ground facts, added to the project's seed facts
entriesRead: { user: [notice] } # the save this play starts from
steps: # required, non-empty
- occasion: townVisit # raise an occasion
expect: { winner: town.idle } # assert what this step did
- occasion: talk
target: npc.maud # a targeted occasion: a target in its domain
- occasion: board
pick: none # `select: all`: a beat id, or `none`
- event: storm # fire a declared world event
- label: the warden falls on floor six # printed in the step header
engine: # write what the engine owns
state: { run.floor: 6, user.runs: { add: 1 } }
facts: [slew(warden)]
- newRun: { state: { run.floor: 1 } } # start a new run (`newRun: true` without a seed)
- occasion: townVisit
repeat: 2 # the same step, twice
expect: # assert the end of the play
quests: { climb: active, veteran: complete }
notFacts: [slew(warden)]

Its transcript is the example in The transcript.

state:, facts:, and choose: use exactly the grammar of a lute trace --mock file. So does bridges:, whose answers in a play script may also take the long form with host writes (Answering bridge calls).

  • A state: seed names a declared path — never scene.* — and its value must fit the declared type (an int or double for a numeric path, a member for an enum); anything else is a usage error (exit 2). A quest.<id>.state seed (state: { quest.lostCup.state: active }) is that quest’s lifecycle status from the start, exactly as a quests: entry, and a quest.<id>.objectives.<oid>.done: true seed is an objective the save has already done — see Starting from a save. A prev.run.<path> seed (dsl 0.23.0) is the value run.<path> had when the previous run ended, and follows that run path’s type — see Run boundaries.
  • A facts: entry is a ground atom of a declared, non-derived relation at its arity, with members of its closed argument domains. A reserved relation is allowed — the engine is exactly who asserts one.
  • A single choose: decision answers every presentation of its branch or hub. A list for a hub is one visit sequence, replayed at each presentation. A list of two or more for a branch is consumed one entry per presentation, in order, across the whole playthrough — so a scene that plays on three nights can decide differently each night. When the list runs out, the walk halts incomplete (exit 3) and says so.
  • A decision the menu does not offer at that moment halts the walk with an error (exit 1): a choice whose guard is false, or a once hub option already taken (E-TRACE-CHOICE, as in lute trace).

A bounded backward ::jump{to="…" max="N"} (directives) makes a <branch> or a <hub> inside the loop present more than once in a single scene. The lists above apply per presentation:

  • A branch reached again in a later iteration is a new menu (a new awaitChoice request), and its list is consumed one decision per presentation. choose: { retry: [yes, yes, no] } drives three iterations: two yes picks that take the jump back, then a no that leaves the loop. If the loop is exhausted first, the remaining entries are simply unused; if the list runs out while the walk still asks, the walk halts incomplete (exit 3), as above.
  • A hub inside a loop starts each visit at presentation 0, and its list is per visit, as in 0.49 — each time the loop brings the walk back to the hub, the list is replayed from its first entry. A once option taken on an earlier visit stays spent, so a list that names it again halts with E-TRACE-CHOICE (exit 1).
choose: { retry: [yes, yes, no] } # a <branch id="retry"> inside a ::jump{max="3"} loop
steps:
- occasion: lockPicked

A save at a choice inside a loop’s second iteration resumes to the same transcript: the loop counters are walk-local and are rebuilt by replaying the answers already given, so the runtime snapshot gains no field.

{ occasion, target?, payload?, engine?, pick?, choose?, expect?, bridges? } raises an occasion, exactly as the engine would.

  • target — required on an occasion declared with a target, refused on one without. With a target domain, the target must be <prefix>.<member> of it; outside it is a usage error with a did-you-mean (target `npc.mawd` is outside occasion `talk`'s domain `npc.<person>` (`npc.maud`, `npc.oskar`) — did you mean `npc.maud`?). A member that no beat answers is legal: the occasion passes. The target also decides which targeted objectives the step judges.
  • payload — the raise’s typed values, on an occasion that declares payload: (dsl 0.27.0 §3): payload: { copies: 2 }. The beats of this raise read them as occasion.payload.copies; the next raise starts without them. A field the occasion does not declare, a value its type refuses, or a payload on an occasion that declares none is a usage error (step 1: `payload.copy` — occasion `summon` declares no payload field `copy` (declared: `copies`)). See Occasion payloads.
  • engine — the engine’s writes of the same moment (dsl 0.27.0 §4), the same { state?, facts?, retract?, accept? } as an engine step: they land first, as their own · engine record of the step, the quests settle, and then the occasion is raised, so its beats and its gate see them. - occasion: enter with target: room.office and engine: { facts: [canEnter(office)] } opens the door and walks in, in one step.
  • An occasion that declares a raisedWhen: gate is raised only while the gate holds (dsl 0.27.0 §4). A step raising it while the gate is false halts the play, exit 1: step 1: E-OCCASION-GATE: the engine raises `enter` for `room.office` only when `holds('canEnter', [occasion.target])` (its `raisedWhen`), which is false here since `canEnter(office)` does not hold — make it hold first (an `engine:` write, an earlier step), or drop the step. The halt is located at the step as written (plays/locked.play.yaml:2:5: step 1: …), and names the reads the gate is false over — a derived fact with the rule premise it misses. lute test judges a beat of a gated occasion by the same seam: eligible: true on it misses naming the gate and its false reads, or the holding terminal: (--json: the expectation carries notRaised: { occasion, reason: "gate" | "terminal", condition, falseReads }). After the game is over (the schema’s terminal: holds) every occasion: step is refused the same way.
  • pick — refused on select: first and select: sequence (step 1: `pick: start.gear` applies only to a `select: all` occasion; `runStart` is `select: sequence`): the id of a beat answering the occasion (a pick that is not eligible at that moment is an error, exit 1), or pick: none. A select: all step needs one whenever its list is not empty — without it the walk halts there with an error (exit 1) naming what was offered (step 1: occasion `board` is `select: all` and offers [notice, memo] — name the beat the player takes with `pick:` (or `pick: none` to close the list)). With nothing eligible there is nothing to pick: the step passes as pick: none, its header reading (select: all, pick: none (nothing offered)) (dsl 0.24.0). none closes the list — the player walks past the board: nothing is presented and nothing is spent, but the occasion’s <objective on> objectives are still judged:
steps:
- occasion: board
pick: none
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · board (select: all, pick: none) ──────────────
✓ notice [entry, priority 0]
✓ memo [entry, priority 0]
✗ old [entry, priority 0] — when: false
→ (pick: none — the list closes; nothing presented)
notices.looked done
quest notices -> complete
── end: complete (1 step) ──────────────
  • choose — this step’s own decisions. For this presentation it replaces the script’s choose: key by key; the script’s map stays the default for every other step. A step-local list starts from its first entry and leaves the consumption of the script-wide list for the same id where it was. The worked example uses one.
  • expect — what this step must have done; see Expectations.
  • An entry may be named <document id>.<entry id> (dsl 0.26.0 §8) wherever a script names one — pick:, the save’s entriesRead:, and a step’s winner, offered, notOffered and presented — so lore.tomas.tomasOil says which document Tomas’s oil entry lives in; the transcript and a miss print the entry id. A bundle beat is always named by its canonical <document id>.<beat id>.

{ engine: { state?, facts?, retract?, accept? } } writes what the engine owns, as the engine would between occasions:

  • state: — declared path → literal, or { add: <int> } to add to an int path’s current value. A path is refused when it is undeclared, scene.*, or a quest.* path: quest status belongs to the quest lifecycle, whose transitions fire handlers and grants — seed a save’s quest status with top-level quests: instead.
  • facts: / retract: — ground atoms of declared base relations, reserved ones included; the same checks as top-level facts:. Retracting an atom that does not hold is recorded, not refused.
  • accept: (dsl 0.26.0) — quest ids the engine accepts at this moment, as it does when the player takes a notice off a board: each must be an accept-driven quest (no start; typically accept="external"), and it activates in the settle right after the step. The transcript prints quest <id> accepted (engine), and --json records { "kind": "accept", "quest": "<id>", "by": "engine" } among the step’s writes. A quest that is already active, complete or failed is not accepted again: the transcript prints note: quest <id> is already active — engine accept ignored (with its status), --json records { "kind": "acceptIgnored", "quest": "<id>", "status": "<status>" }, and nothing changes. A quest with a start, or an id no quest declares, is a usage error (exit 2): step 1: `engine.accept` names `auto`, which is no accept-driven quest of this project (a quest with no `start`, e.g. `accept="external"`). Before 0.26.0 a script could only seed such a quest with top-level quests:, active from step 1.

Writes apply in that order — state, then facts, then retractions, then accepts. The step presents nothing and raises no occasion; the quest lifecycle settles right after it, so a write can complete or fail a quest at that step. owner: engine state is exactly what these steps are for: content may not ::set it, but an engine: step may — as it may any other declared state outside scene.* and quest.*.

steps:
- occasion: townVisit
- label: the warden falls on floor six
engine:
state: { run.floor: 6 }
facts: [slew(warden)]
- occasion: townVisit
- engine:
retract: [slew(warden), slew(hound)]
- occasion: townVisit
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · townVisit ──────────────
✓ town.idle [scene, priority 0]
✗ town.victory [scene, priority 10] — when: false
→ town.idle
@maud: Quiet night.
── step 2 (the warden falls on floor six) · engine ──────────────
set run.floor = 6
assert slew(warden)
climb.high done
quest climb -> complete
── step 3 · townVisit ──────────────
✓ town.victory [scene, priority 10]
✓ town.idle [scene, priority 0]
→ town.victory
@maud: The warden is dead. I never thought I'd say it.
── step 4 · engine ──────────────
retract slew(warden)
retract slew(hound) (did not hold)
── step 5 · townVisit ──────────────
✓ town.idle [scene, priority 0]
✗ town.victory [scene, priority 10] — when: false
→ town.idle
@maud: Quiet night.
── end: complete (5 steps) ──────────────

The kill makes threat(warden) stop deriving, so town.victory becomes eligible at step 3; the retraction closes it again.

An accept is the engine’s too. Add to the worked example’s town a notice-board quest, parcel, declared accept="external" with one objective on="talk" target="npc.tomas"; the player takes the notice mid-play:

steps:
- occasion: townVisit
- label: the player takes a notice from the board
engine: { accept: [parcel] }
expect: { quests: { parcel: active } }
- occasion: talk
target: npc.tomas
expect:
quests: { parcel: complete }
── step 1 · townVisit ──────────────
✓ town.welcome [scene, priority 10]
✗ town.morning [scene, priority 0] — when: false
→ town.welcome
::bg{location="town" time="day"}
@narrator: The lamps along the square are lit — all but the one by the door.
── step 2 (the player takes a notice from the board) · engine ──────────────
quest parcel accepted (engine)
quest parcel -> active
── step 3 · talk → npc.tomas ──────────────
✓ tomasBusy [entry, priority 0]
✗ tomasOil [entry, priority 10] — when: false
→ tomasBusy
entry tomasBusy (first read)
@tomas: Busy.
parcel.deliver done
quest parcel -> complete
── end: complete (3 steps) ──────────────
── expect: every expectation held ──────────────

{ event: <name> } fires a world event some plugin declares, exactly as trace’s events: does: the <on event="<name>"> handlers of every active quest run, then the lifecycle settles. An event: naming an occasion, or an occasion: naming a world event no plugin declares as an occasion, is a usage error that names the right key (`event: dayEnd` names no declared world event — `dayEnd` is an occasion; raise it with `occasion: dayEnd`). The quest lifecycle events questActive / questComplete / questFailed fire on transitions and cannot be fired by a script.

steps:
- event: storm
- engine: { state: { run.floor: 5 } }
- event: storm
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · event storm ──────────────
@narrator: Thunder rolls over the stair.
── step 2 · engine ──────────────
set run.floor = 5
climb.high done
quest climb -> complete
── step 3 · event storm ──────────────
<on event=storm> of quest climb skipped — quest complete
── end: complete (3 steps) ──────────────

climb completed at step 2, so its handler no longer answers the second storm, and the step says so rather than printing nothing (dsl 0.24.0).

An occasion and a world event can share a name — bossDefeated declared both in occasions: and in events:, because the engine raises both at once. Raising such an occasion also fires the event: every active quest’s <on event="bossDefeated"> handlers run first, then the occasion judges its on="bossDefeated" objectives, so an objective’s done can read what a handler just wrote. The step still presents its beats before either. lute run and lute trace raise an occasion the same way.

end: true ends the playthrough, complete (exit 0). The step does nothing else; every later step is listed as skipped, and the ── end: line names the step:

steps:
- occasion: townVisit
- end: true
- label: never reached
occasion: townVisit
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · townVisit ──────────────
✓ town.idle [scene, priority 0]
✗ town.victory [scene, priority 10] — when: false
→ town.idle
@maud: Quiet night.
── step 2 · end (the playthrough ends) ──────────────
── step 3 (never reached) · skipped (the playthrough ended) ──────────────
── end: `end: true` at step 2 (1 later step skipped) ──────────────

In --json the step is { "step": 2, "end": true, … }, endReason is the text after ── end:, and skipped lists the steps that did not play ([{ "step": 3, "label": "never reached" }]). Only true is accepted — end: false is a usage error (exit 2), and so is repeat or expect beside end: put the expectation on the step before it, or at the top level, where it still judges the end of the play.

A ::end in content does not end the playthrough. It ends only the presentation it runs in — or, in a quest’s handler, that quest document’s advance — and the play goes on with the next step; see What each step does. Before 0.23.1 a scene’s ::end stopped the whole play; a script that relied on that adds - end: true after the step whose scene ends.

A schema may declare when the game is over: terminal: "run.fate == 'taken'" (dsl 0.27.0 §4; see Schemas). Once it holds the engine raises no occasion, and lute play honours it. The step after which it holds says so, and a playthrough whose steps all played ends in that state, exit 0:

── step 1 · knock ──────────────
✓ taken [scene, priority 0]
→ taken
set run.fate = "taken"
@narrator: Something takes you.
note: the game is over — `terminal: run.fate == 'taken'` holds, so the engine raises no occasion from here (`occasion:` / `advance:` steps are refused; `newRun: true` starts a new run)
── end: terminal — `terminal: run.fate == 'taken'` holds ──────────────

In --json the root carries "end": "terminal" beside "exit": "complete", and a top-level expect: { end: terminal } asserts it. A later occasion: step is E-OCCASION-GATE (exit 1: step 2: E-OCCASION-GATE: the game is over — `terminal: run.fate == 'taken'` holds, so the engine raises no occasion (`enter` for `room.lobby` included); start a new run (`newRun: true`) to play on, or, if the engine raises `enter` outside a run too (a title screen, a gallery), declare it `outsideRun: true`), and so is a later advance: — the clock does not move on either. An engine: step is still accepted: it is the engine’s own write. An occasion declared outsideRun: true (a title screen, a gallery between runs) is raised anyway.

newRun: true starts a new run, and the play goes on only if the new run makes terminal: false. A terminal: over run state (run.*, a run-tier quest or relation) is reset by it. One that reads what a new run keeps (user.*, visited(…)) still holds, check --project warns W-TERMINAL-PERSISTENT at the schema, and the next raise halts: step 4: E-OCCASION-GATE: the game is over — `terminal: user.crowned` holds, so the engine raises no occasion (`townVisit` included); it still holds after a new run: it reads `user.crowned`, which a new run keeps.

An ending meant to outlive every run says so in the schema: terminal: { when: "user.crowned", persists: true } (Schemas). check --project then stays silent, and lute play says the game is over for good instead of offering a new run: the step that ends it, a newRun: true after it, and a later raise all say so, and none suggests newRun:

── step 1 · knock ──────────────
✓ crown [scene, priority 0]
→ crown
set user.crowned = true
@narrator: The crown is yours.
note: the game is over for good — `terminal: user.crowned` holds and persists (`persists: true`), so the engine raises no occasion from here, in this run or any later one (`occasion:` / `advance:` steps are refused; a new run does not reopen it)
── step 2 · new run ──────────────
note: the game is over for good — the new run does not reopen it: `terminal: user.crowned` holds and persists (`persists: true`), so the engine raises no occasion
── step 3 · visit ──────────────
(not raised: the game is over)
── halted: s.play.yaml:4:5: step 3: E-OCCASION-GATE: the game is over — `terminal: user.crowned` holds, so the engine raises no occasion (`visit` included); the ending persists (`persists: true`), so the game is over for good and no new run reopens it — drop the step, or, if the engine raises `visit` outside a run too (a title screen, a gallery), declare it `outsideRun: true` ──────────────

terminal: is judged when an occasion is raised, so the raise that makes it hold finishes: the beat whose write ends the game plays to its last line, the rest of a select: sequence raise plays, and the questComplete / questFailed handlers the raise settles run after them. An epilogue therefore goes in one of two places: after the write in the beat that makes terminal: hold, or in the <on event="questComplete"> handler of the quest whose completion it reads:

── step 5 · dayEnd ──────────────
✓ town.dayEnd [scene, priority 0]
→ town.dayEnd
@narrator: One by one, the lamps go out.
note: the game is over — `terminal: quest.lampOut.state == 'complete'` holds, so the engine raises no occasion from here (`occasion:` / `advance:` steps are refused; `newRun: true` starts a new run)
lampOut.wait done
quest lampOut -> complete
@narrator: By morning the lamp by the door is burning again.

“End” means five things in a playthrough, and only terminal: ends the game:

WrittenEndsPlay goes on?
::end in contentthe presentation it runs in (in a quest handler, that quest document’s advance)yes, with the next step
- end: true in a scriptthe playthrough, at that step; later steps are skippedno — end: complete
terminal: in a schemathe game: the engine raises no occasion (but outsideRun ones) until a new run makes it false; with persists: true, for goodonly after a newRun that resets it — end: terminal; never with persists: true
last: / days: on the clockthe clock: clock.ended turns true, and an advance: past the last position halts the play (E-CLOCK-END)yes, with steps that do not move the clock; write terminal: "clock.ended" to end the game there
expect: { end: … }nothing: it asserts how the walk ended (complete, terminal, incomplete, error)—

When a schema declares a clock (dsl 0.24.0 §1), advance: slot, advance: <n> (that many slots), or advance: day (the first slot of the next day, from whatever slot the clock stands on) moves it forward in one step. The step writes the clock’s day and slot paths — wrapping past the last slot into the next day — settles every quest (a by deadline the new time passes fails here, before anything is presented), and then raises the clock’s raise occasion, when it declares one, exactly as an occasion: step would: once, where the clock stops, never at the slots it passes. So the pair

- engine: { state: { run.slot: night } }
- occasion: slotStart

is one - advance: slot (from the afternoon). An advance step’s choose: answers every occasion it raises, its pick: the slot occasion it raises where the clock stops, and its expect: may judge the selection: winner, offered and notOffered judge the step’s last raise — the slot occasion where the clock stops, or, on a clock that raises only dayStart / dayEnd, the last midnight’s — while presented lists every beat the step presented — each midnight raise’s (below), then the final raise’s, in order — and options unions them all. Since dsl 0.27.0 (round-5 T3-8) a miss places the step at its last raise, and a presented miss tags each beat with the raise that presented it: ✗ step 1 at advance day → slotStart: expect presented: expected [day.slot], actual [day.close (dayEnd at day 1 (Mon) morning), day.slot (slotStart at day 2 (Tue) morning)]. pick on a clock whose raise: names no slot occasion is a usage error (step 1: `pick` answers the slot occasion an `advance:` raises where the clock stops, and the clock declares no `raise.slot` occasion), and so are choose and the selection keys on a clock that raises nothing (step 1: `expect.winner` judges what an `advance:` raises, and the clock declares no `raise:` occasion — the advance presents nothing).

When an advance passes positions without raising the slot occasion — advance: 2, or { to: night } from the morning — and some beat answers that occasion, the step says so in a note naming them by day: note: passed day 1 (Mon) afternoon without raising `slotStart` (2 beats answer it; an `advance:` raises it only where the clock stops). The position it started at and the one it stopped at are not listed, and advance: day passes none: it sleeps through the rest of the day. The count is of the beats that answer the occasion, eligible there or not. --json carries the note in the step’s notes and the positions under advance.passed.

The examples here use a small day-clock project rather than the tower. The engine owns the day and the slot, and the clock raises slotStart (a select: sequence occasion) after every advance:

state:
run.day: { type: int, default: 1, owner: engine }
run.slot: { type: { enum: [morning, afternoon, night] }, default: morning, owner: engine }
clock:
day: run.day
slot: run.slot
slots: [morning, afternoon, night]
raise: slotStart
week: { length: 7, first: 0, labels: [Mon, Tue, Wed, Thu, Fri, Sat, Sun] }

routine.morning and routine.night answer slotStart (priority 5, once: slot, each gated on its run.slot; the morning prints {{clock.weekdayLabel}} morning.), cafe.wren answers visit with once: day, and the quest fest (start="true") has one objective, on="visit" done="run.slot == 'night'" by="run.day >= 3":

steps:
- occasion: visit
- occasion: visit
- advance: slot
- advance: slot
- advance: slot
expect: { presented: [routine.morning] }
- advance: day
── start ──────────────
quest fest -> active
── step 1 · visit ──────────────
✓ cafe.wren [scene, priority 0]
→ cafe.wren
@wren: Back again?
── step 2 · visit ──────────────
✗ cafe.wren [scene, priority 0] — once: day — already presented today
→ (no eligible beat — the occasion passes)
── step 3 · advance slot: day 1 (Mon) morning → day 1 (Mon) afternoon ──────────────
set run.slot = "afternoon"
── step 3 · slotStart (select: sequence) ──────────────
✗ routine.morning [scene, priority 5] — when: false
✗ routine.night [scene, priority 5] — when: false
→ (no eligible beat — the occasion passes)
── step 4 · advance slot: day 1 (Mon) afternoon → day 1 (Mon) night ──────────────
set run.slot = "night"
── step 4 · slotStart (select: sequence) ──────────────
✓ routine.night [scene, priority 5]
✗ routine.morning [scene, priority 5] — when: false
→ routine.night
@narrator: The lamps go out on day 1.
── step 5 · advance slot: day 1 (Mon) night → day 2 (Tue) morning ──────────────
set run.day = 2
set run.slot = "morning"
── step 5 · slotStart (select: sequence) ──────────────
✓ routine.morning [scene, priority 5]
✗ routine.night [scene, priority 5] — when: false
→ routine.morning
@narrator: Tue morning. The kettle sings.
── step 6 · advance day: day 2 (Tue) morning → day 3 (Wed) morning ──────────────
set run.day = 3
fest.go failed (by)
quest fest -> failed (by)
── step 6 · slotStart (select: sequence) ──────────────
✓ routine.morning [scene, priority 5]
✗ routine.night [scene, priority 5] — when: false
→ routine.morning
@narrator: Wed morning. The kettle sings.
── end: complete (6 steps) ──────────────
── expect: every expectation held ──────────────
  • Step 2 — cafe.wren is once: day, so the second visit on day 1 finds it spent: once: day — already presented today (once: slot reads already presented this slot).
  • Steps 3–6 — each advance prints the move first — advance <slot | day | n>: <from> → <to>, a position being day <n> (<weekday label>) <slot>, the label only when the clock’s week: declares labels — with its writes and the settle beneath it; the raise follows under the same step number. Step 5 wraps the night into day 2’s morning, and once: slot lets the morning routine play again.
  • Step 6 — advance: day goes to the first slot of day 3, and the settle right after the move fails fest: its deadline is a moment, judged whether or not the player ever raises visit again (see Deadlines).

In --json an advance step carries advance: { by, from, to, writes, quests } — by is "slot", "day", or the slot count as a string; writes the day and slot paths it moved; quests the settle right after the move — beside the raised occasion’s own fields (occasion, candidates, winner, …), whose quests are the transitions after the raise. An advance that crosses positions settles the quests at each of them too (dsl 0.27.0 §5): a crossed position whose settle moved anything puts the clock’s set records of the move there (an entry with "document": "") and then that settle in quests, and the rest of the move follows there as well. Step 6’s move:

{
"by": "day",
"from": "day 2 (Tue) morning",
"to": "day 3 (Wed) morning",
"writes": [ { "kind": "set", "path": "run.day", "value": 3 } ],
"quests": [
{ "document": "quests/fest.lute", "commands": [
{ "kind": "objective", "quest": "fest", "objective": "go", "failed": true, "failedBy": "by" },
{ "kind": "quest", "quest": "fest", "state": "failed", "failedBy": "by" }
] }
]
}

An advance that passed positions without its slot raise adds "passed": { "occasion": "slotStart", "beats": 2, "at": [ { "day": 1, "slot": "afternoon" } ] } to advance (slot left out on a clock without slots).

Midnight: dayEnd and dayStart. raise: may also be a map naming the occasion for each moment, every key optional — raise: { slot, dayStart, dayEnd }; the scalar raise: slotStart is the slot form. An advance raises dayEnd at every midnight it crosses, at the day’s last slot with the day not yet advanced, then dayStart at the next day’s first slot, then slot once where it stops. Each midnight is a stop: the clock moves there, the quests settle, then the occasion is raised. advance: <n> never skips a day’s close — it walks to each day’s last slot to raise dayEnd — and advance: day closes the day where the clock stands, skipping the rest of it. An engine: write to the clock’s paths raises none of the three. Give the day clock the map — the plugin declares dayEnd and dayStart as two more select: first occasions — with day.close answering dayEnd (Day {{run.day}} closes.) and day.open answering dayStart ({{clock.weekdayLabel}} begins.), both once: false:

state:
run.day: { type: int, default: 1, owner: engine }
run.slot: { type: { enum: [morning, afternoon, night] }, default: morning, owner: engine }
clock:
day: run.day
slot: run.slot
slots: [morning, afternoon, night]
raise: { slot: slotStart, dayStart: dayStart, dayEnd: dayEnd }
week: { length: 7, first: 0, labels: [Mon, Tue, Wed, Thu, Fri, Sat, Sun] }
steps:
- advance: slot
- advance: 2
- advance: day
expect:
transcriptContains: ["Day 1 closes.", "Wed begins."]
── start ──────────────
quest fest -> active
── step 1 · advance slot: day 1 (Mon) morning → day 1 (Mon) afternoon ──────────────
set run.slot = "afternoon"
── step 1 · slotStart (select: sequence) ──────────────
✗ routine.morning [scene, priority 5] — when: false
✗ routine.night [scene, priority 5] — when: false
→ (no eligible beat — the occasion passes)
── step 2 · advance 2: day 1 (Mon) afternoon → day 2 (Tue) morning ──────────────
set run.slot = "night"
── step 2 · day 1 (Mon) night · dayEnd ──────────────
✓ day.close [scene, priority 0]
→ day.close
@narrator: Day 1 closes.
set run.day = 2
set run.slot = "morning"
── step 2 · day 2 (Tue) morning · dayStart ──────────────
✓ day.open [scene, priority 0]
→ day.open
@narrator: Tue begins.
── step 2 · slotStart (select: sequence) ──────────────
✓ routine.morning [scene, priority 5]
✗ routine.night [scene, priority 5] — when: false
→ routine.morning
@narrator: Tue morning. The kettle sings.
note: passed day 1 (Mon) night without raising `slotStart` (2 beats answer it; an `advance:` raises it only where the clock stops)
── step 3 · advance day: day 2 (Tue) morning → day 3 (Wed) morning ──────────────
── step 3 · day 2 (Tue) morning · dayEnd ──────────────
✓ day.close [scene, priority 0]
→ day.close
@narrator: Day 2 closes.
set run.day = 3
fest.go failed (by)
quest fest -> failed (by)
── step 3 · day 3 (Wed) morning · dayStart ──────────────
✓ day.open [scene, priority 0]
→ day.open
@narrator: Wed begins.
── step 3 · slotStart (select: sequence) ──────────────
✓ routine.morning [scene, priority 5]
✗ routine.night [scene, priority 5] — when: false
→ routine.morning
@narrator: Wed morning. The kettle sings.
── end: complete (3 steps) ──────────────
── expect: every expectation held ──────────────
  • Step 2 — advance: 2 from the afternoon stops at the night to close day 1 (── step 2 · day 1 (Mon) night · dayEnd), crosses midnight to open day 2, and raises slotStart once, in the morning where it ends: the night routine is not raised on the way, and the note under the step says so. Each move’s writes and settle print just before the stop they lead to.
  • Step 3 — advance: day from the morning closes day 2 where the clock stands; the move to day 3 fails fest in the settle before dayStart.
  • A midnight raise plays content like any other step: transcriptContains / transcriptLacks match its lines, --derivation names a fact asserted there (asserted by scene day.close, step 3), and under lute test --coverage a document it presented counts as covered. On step 2, expect: { presented: [day.close, day.open, routine.morning] } holds, and winner: routine.morning judges the final raise.
  • An advance that raises several occasions may key presented by occasion: on step 2, expect: { presented: { dayStart: [day.open], slotStart: [routine.morning] } } judges only those two raises, each in order, and leaves dayEnd unjudged. A miss names the occasion, expect presented dayEnd: expected [day.open], actual [day.close (dayEnd at day 1 (Mon) night)], and a key the clock does not raise is a usage error with a suggestion (`expect.presented` names `dayStrat`, which the clock's `advance:` does not raise — did you mean `dayStart`? (it raises: slotStart, dayStart, dayEnd)). The list form still judges every beat of the step; an occasion: step raises one occasion, so its presented is always the list.

The clock raises dayEnd and dayStart itself, so an occasion: step that raises one of them again plays that moment twice once an advance passes it. The step still plays, with a note:

── step 1 · dayEnd ──────────────
✓ day.close [scene, priority 0]
→ day.close
@narrator: Day 1 closes.
note: `dayEnd` is the clock's `raise: { dayEnd: dayEnd }` — an `advance:` raises it at each midnight it crosses, before the clock leaves the day; this step raises it again, so the same day's `dayEnd` runs twice once an `advance:` passes it (drop the step and let `advance:` raise it; dsl 0.24.0 §1)

--json carries it in the step’s notes.

Where the run starts no advance has stopped, so the clock raises neither its slot occasion nor dayStart there. An occasion: step raising one of them at that position plays what only the engine may raise, and says so: note: `dayStart` is the clock's `raise: { dayStart: dayStart }`, which the clock does not raise at day 1 (Mon) morning, where the run starts — if the engine raises it when a run starts, declare `raiseAtStart: true` on the clock. With raiseAtStart: true the step stands for the engine’s own raise and carries no note; play still raises nothing by itself at the start.

In --json each midnight stop is one entry of the advance’s days, in order: at (the position), occasion, writes (the move that brought the clock there), settled (the settle after that move), and the raised occasion’s own fields — select, candidates, winner, presented, then, and quests (the transitions after its raise). The advance’s own writes and quests are then the last move, to where it stops. Step 3’s second stop:

{
"at": "day 3 (Wed) morning",
"occasion": "dayStart",
"writes": [ { "kind": "set", "path": "run.day", "value": 3 } ],
"settled": [
{ "document": "quests/fest.lute", "commands": [
{ "kind": "objective", "quest": "fest", "objective": "go", "failed": true, "failedBy": "by" },
{ "kind": "quest", "quest": "fest", "state": "failed", "failedBy": "by" }
] }
],
"select": "first",
"candidates": [ { "id": "day.open", "kind": "scene", "document": "scenes/day-start.lute", "priority": 0, "eligible": true } ],
"winner": "day.open",
"presented": { "id": "day.open", "kind": "scene", "document": "scenes/day-start.lute", "commands": […], "stateDelta": {} },
"quests": []
}

A clock that counts days. A clock without slot: / slots: (declared together or not at all) counts whole days: a position reads day 3, advance: slot and advance: day both move one day, clock.index is day - 1, and once: slot spends like once: day. A walking trip:

state:
run.day: { type: int, default: 1, owner: engine }
run.leg: { type: int, default: 0, owner: engine }
clock:
day: run.day
raise: { slot: morning, dayEnd: dusk }

road.morning answers morning (once: day, Day {{run.day}}, leg {{run.leg}}.), road.camp answers dusk (Camp on day {{run.day}}.), and the quest trek (start="true") has one objective, done="run.leg >= 3" by="clock.index >= 2" — the third leg walked before day 3.

The engine’s writes of the same moment. An advance: step may carry engine: — what the engine writes as the clock moves, here the leg the party finished overnight. Since 0.41 the writes land first, before the clock moves: they are applied and the quests settle, then (unless terminal now holds, when the clock stays where it is) the advance moves the clock, so every dayEnd / dayStart and the final raise read them. Writes that must land after the move’s raises go in a following engine: step. Since done is judged before by, an objective the write completes is done before the move’s deadline passes:

steps:
- advance: slot
- advance: day
engine: { state: { run.leg: 3 } }
expect:
quests: { trek: complete }
── start ──────────────
quest trek -> active
── step 1 · advance slot: day 1 → day 2 ──────────────
── step 1 · day 1 · dusk ──────────────
✓ road.camp [scene, priority 0]
→ road.camp
@narrator: Camp on day 1.
── step 1 · day 2 ──────────────
set run.day = 2
── step 1 · morning ──────────────
✓ road.morning [scene, priority 0]
→ road.morning
@narrator: Day 2, leg 0.
── step 2 · advance day: day 2 → day 3 ──────────────
set run.leg = 3
trek.leg3 done
quest trek -> complete
── step 2 · day 2 · dusk ──────────────
✓ road.camp [scene, priority 0]
→ road.camp
@narrator: Camp on day 2.
── step 2 · day 3 ──────────────
set run.day = 3
── step 2 · morning ──────────────
✓ road.morning [scene, priority 0]
→ road.morning
@narrator: Day 3, leg 3.
── end: complete (2 steps) ──────────────
── expect: every expectation held ──────────────

The engine’s write and its settle print first, under the step’s header, before the dusk raise; the clock declares no dayStart, so the move across midnight prints under its own position header — ── step 2 · day 3 — before the morning raise, and every raise of the step already reads leg 3. Written as a separate engine: step after the advance, the same write comes a settle too late: the move to day 3 fails the quest first (trek.leg3 failed (by)). In --json the writes come first in the advance’s own writes, before the move’s, and their settle is in its quests. The writes follow an engine: step’s rules, except that a write to the clock’s own day or slot path beside advance: is a usage error (exit 2): step 1: `engine:` writes `run.day`, which the `advance:` beside it moves — write the clock in a step of its own, or let `advance:` move it.

The clock only moves forward. An engine: step that moves clock.index backward stops the walk at that step — a usage error found when the step runs, exit 2 — naming both positions:

── step 2 · engine ──────────────
set run.slot = "morning"
── halted: step 2: `engine:` moves the clock backward, from day 1 (Mon) afternoon to day 1 (Mon) morning (clock.index 1 → 0) — the clock only moves forward; `advance:` moves it, a `newRun` starts it over ──────────────

A newRun resets the clock with the rest of the run tier. advance: on a project that declares no clock, advance: 0, or anything but slot, day, a whole number ≥ 1, or a { to: … } form is refused before anything plays.

Advancing to a named moment (dsl 0.26.0 §7). advance: { to: <slot> } and advance: { to: { weekday: <label or number>, slot: <slot> } } (either key, or both) move the clock to the next position after the current one with that slot and/or weekday — a week.labels label or a clock.weekday number; a weekday alone lands on that day’s first slot. It moves forward only, and never zero steps: standing at night, { to: night } goes on to tomorrow night, as every advance moves the clock. It is one advance, raised like advance: <n>: the slot occasion is raised once, where the clock stops — the slots it passes are not — while every midnight it crosses raises its dayEnd and dayStart. So { to: night } from the morning raises one slotStart, where two advance: slot steps raise two; write separate advance: slot steps when content answers the slot in between. A step’s expect: { clock: { weekday, slot, day } } (any of the three) judges where the clock stands after the step. On the day clock above:

steps:
- occasion: visit
- advance: { to: night }
expect: { clock: { weekday: Mon, slot: night } }
- advance: { to: night }
expect: { clock: { day: 2, slot: night } }
- advance: { to: { weekday: Fri, slot: morning } }
expect: { clock: { weekday: Fri, slot: morning, day: 5 } }
── start ──────────────
quest fest -> active
── step 1 · visit ──────────────
✓ cafe.wren [scene, priority 0]
→ cafe.wren
@narrator: Back again?
── step 2 · advance to night: day 1 (Mon) morning → day 1 (Mon) night ──────────────
set run.slot = "night"
── step 2 · slotStart (select: sequence) ──────────────
✓ routine.night [scene, priority 5]
✗ routine.morning [scene, priority 5] — when: false
→ routine.night
@narrator: The lamps go out on day 1.
note: passed day 1 (Mon) afternoon without raising `slotStart` (2 beats answer it; an `advance:` raises it only where the clock stops)
── step 3 · advance to night: day 1 (Mon) night → day 2 (Tue) night ──────────────
set run.day = 2
── step 3 · slotStart (select: sequence) ──────────────
✓ routine.night [scene, priority 5]
✗ routine.morning [scene, priority 5] — when: false
→ routine.night
@narrator: The lamps go out on day 2.
note: passed day 2 (Tue) morning, afternoon without raising `slotStart` (2 beats answer it; an `advance:` raises it only where the clock stops)
── step 4 · advance to Fri morning: day 2 (Tue) night → day 5 (Fri) morning ──────────────
set run.day = 3
set run.slot = "morning"
fest.go failed (by)
quest fest -> failed (by)
set run.day = 5
── step 4 · slotStart (select: sequence) ──────────────
✓ routine.morning [scene, priority 5]
✗ routine.night [scene, priority 5] — when: false
→ routine.morning
@narrator: Fri morning. The kettle sings.
note: passed day 3 (Wed) to day 4 (Thu), every slot without raising `slotStart` (2 beats answer it; an `advance:` raises it only where the clock stops)
── end: complete (4 steps) ──────────────
── expect: every expectation held ──────────────

Step 2 skips the afternoon: the afternoon’s slotStart is never raised. Step 3 is already at night, so it goes to the next night. Step 4 crosses three midnights to Friday morning; this clock names no dayEnd / dayStart, so only the final slotStart is raised. The quests still settle at every position the advance passes, so fest’s deadline fails on Wednesday morning, where the clock first reaches day 3: the transcript prints the move there, that settle, then the rest of the move. A season that opens and closes on the way, or a rearm= that turns true there, acts at its own position the same way (see Seasons and rearmed quests). In --json the advance’s by is "to night" or "to Fri morning". A slot or weekday the clock does not declare is a usage error before anything plays (step 2: `advance:` to slot `dusk` — the clock's slots are: morning, afternoon, night; step 2: `advance:` to weekday `Fry` — a weekday is a number 0..6 or one of: Mon, Tue, Wed, Thu, Fri, Sat, Sun).

When the clock ends (dsl 0.27.0 §4). A clock that declares last: { day: 1, slot: h05 } (or days: N) stops there. An advance: that lands exactly on the last position is ordinary. One that would go past it moves only to the last position, raises the last day’s dayEnd once and no slot occasion, and its header says · the clock ends (its last position) (--json: advance.ended: true); its engine: writes land before the move, as on every advance. Any later advance: — or one starting past the end because an engine: step moved the day on — halts the play with E-CLOCK-END (exit 1, in lute test too) until a newRun starts the clock over:

── step 2 · advance slot: day 1 h05 → day 1 h05 · the clock ends (its last position) ──────────────
── step 2 · day 1 h05 · dawn ──────────────
✓ ward.dawn [scene, priority 0]
→ ward.dawn
@narrator: Dawn breaks on night 1.
── step 3 · advance slot: day 1 h05 → day 1 h05 ──────────────
── halted: s.play.yaml:4:5: step 3: `advance:` past the clock's last position (day 1 h05) — the clock ended; a `newRun` starts it over (E-CLOCK-END) ──────────────

A gated clock raise (dsl 0.27.0 §4). When the occasion a clock raises — raise.slot, dayStart or dayEnd — declares a raisedWhen: gate that is false where the clock stands, the advance does not raise it. That is no error: the clock still moves, the quests still settle, and the step carries a note (--json: the step’s notes):

── step 3 · advance slot: day 1 h00 → day 1 h01 ──────────────
set run.hour = "h01"
note: `hourStrikes` was not raised at day 1 h01 — its `raisedWhen: run.hp > 1` is false there; the clock moved on without it

After the game is over no advance: runs at all.

A beat that advances the clock (0.41.1). A beat declaring advances: moves the clock as an advance: step would, once it has been presented. The transcript shows that movement right after the beat, in the advance step’s spelling, then each raise it made — the beats dayEnd and dayStart presented, in order, then the clock’s slot occasion — and a raise whose gate was closed at its place, with the same reason as the step’s note. --events is unchanged (its clock event carries the movement):

── step 2 · work ──────────────
✓ work [scene, priority 0]
→ work
@narrator: Work advances the clock.
↻ advance slot: day 1 dusk → day 2 dawn
↻ night (clock raise)
@narrator: Night falls.
↻ morning (clock raise — not raised: its `raisedWhen: run.ready` is false there since `run.ready` is false; the clock moved on without it)
↻ tick (clock raise)
set run.ticks = 1
@narrator: The slot is raised.

Sharing a routine. include: <file> (a step whose action is include; since 0.27.0 it may also carry repeat, choose and bridges) splices another file’s steps in its place: the file is a list of steps, or a mapping whose only key is steps:. The path resolves against the including file, and includes may nest. A day of routine lives in one file that several routes share:

# plays/routes/day.yaml — one day of routine, shared by every route
- occasion: visit
- advance: slot
- advance: slot
plays/tuesday.play.yaml
steps:
- include: routes/day.yaml
- label: the next morning
advance: slot
- include: routes/day.yaml

Steps are numbered after the splice, so this plays as seven steps — the label lands on step 4 (── step 4 (the next morning) · advance slot: day 1 (Mon) night → day 2 (Tue) morning), and an expectation miss names the spliced step’s number. An unreadable file, one of the wrong shape, an include: beside a key other than label, repeat, choose and bridges, and a file already being included are usage errors: plays/routes/loop.yaml: `include: ../loop.play.yaml` is a cycle — plays/routes/../loop.play.yaml is already being included.

A label: on the include: labels every step it splices in that has no label of its own, the steps of a nested include too, so - include: routes/day.yaml with label: first day prints ── step 1 (first day) · townVisit, while a spliced step that says label: own label keeps it.

When an include: item ends — over every repetition of a repeat: — and none of its steps presented a choose: key of its own, or no call took an answer of one of its own bridges: tags, a note under its last step names the include: line and the unused keys (a decision written on the wrong include:); the decisions and answers are dropped with it. A step’s own choose: gets the same note when none of the step’s presentations presented the key:

note: `include: routes/day.yaml` at plays/inc.play.yaml:3:5 never used its own `choose: maraAsk` (no step of the include presented `maraAsk`) — a decision or answer for a step outside the include belongs on that step, or on the script
…
note: step 3 never used its own `choose: maraAsk` (no presentation of the step presented `maraAsk`) — a decision for a later step belongs on that step, or on the script

A play that halts at a choice with no scripted decision names the ended include: that carried that decision unused.

A steps file as an interface. When several writers share one playthrough — each area owns an include:d steps file, and the lead’s script includes them in map order — a steps file states its own contract, so a change upstream fails at the file it broke rather than three files later at a missing beat. Its first step’s expect: { clock: … } states the time it expects to arrive at (dsl 0.26.0 §7), and its last step’s expect: is the hand-off: the facts, state, quest statuses and clock the next file relies on. Give its plugin calls a step-level bridges: and its decisions a step-level choose:, so its answers are never consumed by another file’s steps. See the multi-author guide. A market that expects Wednesday morning, included after a route that advanced only one day:

# plays/steps/market.steps.yaml — arrives Wednesday morning, leaves Wednesday night
steps:
- occasion: visit
label: the market opens
expect: { clock: { weekday: Wed, slot: morning } }
- advance: { to: night }
label: market hand-off
expect: { clock: { weekday: Wed, slot: night } }
── expect: 2 missed ──────────────
✗ step 2 (the market opens) at visit: expect clock weekday: expected Wed, actual Tue (1)
✗ step 3 (market hand-off) at slotStart: expect clock weekday: expected Wed, actual Tue (1)

label: <text> names a step: it is printed in the step header (── step 4 (the engine closes the day) · engine), carried in --json, and named by every expectation miss on the step. repeat: <n> (a whole number ≥ 1) runs the step n times — ── step 7 [1/2] · townVisit, ── step 7 [2/2] · townVisit — and each repetition is its own step record, settles the quest lifecycle on its own, and counts in ── end: complete (<n> steps). repeat suits the engine’s routine: three runs ending (engine: { state: { user.runs: { add: 1 } } }, repeat: 3), or a visit the player makes every day.

Four top-level keys seed the playthrough’s history before step 1, so a script can begin where a real player’s save stands instead of replaying everything before it:

KeyMeaning
visited: [scene ids]Scenes presented in this save — read by visited('<id>') and after: visited(…). A scene visited in the save was presented, so its once: user is spent (and its share key with it); once: run is not, since the visit may be from an earlier run — list it under presented: { run: […] } for that.
presented: { run: [beat ids], user: [beat ids] }Scene beats already presented: user — in an earlier run, so a once: user beat is spent; run — in the current run, so once: run and once: user are both spent. Every listed scene also counts as visited.
quests: { <id>: unset | active | complete | failed }Quest lifecycle status. The start settle resumes it rather than starting the quest over. Every objective starts undone — seed objective progress under state: (below).
entriesRead: { run: [entry ids], user: [entry ids] }run — read in the current run: entry.<id>.read and entry.<id>.everRead; user — read in an earlier run: entry.<id>.everRead only.

An id the project does not declare is a usage error with a did-you-mean (`visited:` names `town.welcom`, which is no scene in this project — did you mean `town.welcome`?), and so is an entry under presented: (an entry’s read history is entriesRead:) or a status outside the four.

quests: { veteran: complete }
entriesRead: { user: [notice], run: [memo] }
steps:
- occasion: board
pick: old
── start ──────────────
quest climb -> active
quest notices -> active
── step 1 · board (select: all, pick: old) ──────────────
✓ old [entry, priority 0]
✗ notice [entry, priority 0] — once: user — already read
✗ memo [entry, priority 0, read] — once: run — already read this run
→ old
entry old (first read)
@maud: The same notice as ever.
notices.looked done
quest notices -> complete
── end: complete (1 step) ──────────────

veteran stays complete — it is not activated again at the start — and the save’s reads spend both entries while opening old. memo is marked read (dsl 0.23.0): an entry beat whose entry.<id>.read is set in this run says so in its candidate line, eligible or not, so a select: all menu can show which entries the player has already seen. notice was read in an earlier run only, so it is not.

quests: holds a status only. A save in which some objectives are already done seeds each one under the top-level state: as quest.<id>.objectives.<oid>.done: true, beside the quest’s status:

quests: { houndHunt: active }
state:
run.floor: 3
quest.houndHunt.objectives.collar.done: true

The start settle counts collar as done without completing it again — no houndHunt.collar done line, no objective body replayed — and a done objective’s by deadline no longer applies, so passing floor four fails nothing; report is still open. The value must be a bool, and the quest and objective must be declared, as for any state: seed. There is no long form of quests: for this.

newRun: true starts a new run. First it snapshots every run.* value as prev.run.* (dsl 0.23.0) — the value each path had when the run ended — says so on its first line (…; prev.run.* holds the ended run (1 value)), and prints each value it took, prev.run.floor = 6 (dsl 0.24.0; --json: the step’s prevRun, path → value); then it resets:

  • run.* state to its declared defaults, and every run-tier entry.<id>.read flag, so an entry’s effects apply again on its first read in the new run and a once="run" entry is eligible again;
  • run-tier facts to the project’s seed facts;
  • every <quest tier="run"> quest to unset, with its objectives undone — a start-having one activates again at the settle that follows, an accept-driven one waits for a new accept. Each one that had left unset is listed with the status the run left it in, quest climb -> unset (tier: run; was complete); one still unset is reset silently. An accept-driven one the reset finds active with no objective done or failed adds a note: (dsl 0.24.0; --json: resetUnjudged) — work the ended run accepted and never played out, which is what a quest taken at a hub between runs looks like, so the note names ::accept{… at="nextRun"} (see Quest structure). A start= quest such as climb gets no note: it starts over on its own, and at="nextRun" could not take it anyway;
  • once: run spending.

user.* / app.* state, user-tier quests (the default tier), entry.<id>.everRead, user- and app-tier facts, the visited history, and once: user spending persist. once: user across separate lute play invocations is not modelled — put the runs in one script separated by newRun steps, or start from a save.

The long form newRun: { state: {…}, facts: […] } then applies its writes as the new run’s seed — the same state: (literal or { add: n }) and facts: rules as an engine: step, without retract:. Any acceptance queued with ::accept{… at="nextRun"} applies just before those writes. Then the quest lifecycle settles.

steps:
- label: a run ends
engine:
state: { run.floor: 6, user.runs: { add: 1 } }
facts: [slew(warden)]
- newRun: { state: { run.floor: 1 } }
- occasion: townVisit
- label: a run ends
engine:
state: { user.runs: { add: 1 } }
repeat: 2
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 (a run ends) · engine ──────────────
set run.floor = 6
set user.runs = 1
assert slew(warden)
climb.high done
quest climb -> complete
── step 2 · new run ──────────────
run.* state, run-tier facts and once: run reset; prev.run.* holds the ended run (1 value)
prev.run.floor = 6
quest climb -> unset (tier: run; was complete)
set run.floor = 1
quest climb -> active
── step 3 · townVisit ──────────────
✓ town.idle [scene, priority 0]
✗ town.victory [scene, priority 10] — when: false
→ town.idle
@maud: Quiet night.
── step 4 (a run ends) [1/2] · engine ──────────────
set user.runs = 2
── step 4 (a run ends) [2/2] · engine ──────────────
set user.runs = 3
veteran.three done
quest veteran -> complete
── end: complete (5 steps) ──────────────

slew is a run-tier relation, so the kill does not survive the new run and town.victory is closed again; climb (tier="run") starts over, while veteran keeps counting.

prev.run.<path> is read-only and unset until a run has ended, so content must guard it (has(prev.run.floor)); the tower’s start.recap does, and Composing occasions shows it after a newRun. A script that starts mid-save seeds it like any path — state: { prev.run.floor: 5 } — and the first runStart then plays the recap with Floor 5 last time.

Entry once across a run boundary, from the board entries:

steps:
- occasion: board
pick: notice
- occasion: board
pick: memo
- occasion: board
pick: old
- newRun: true
- occasion: board
pick: memo
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · board (select: all, pick: notice) ──────────────
✓ notice [entry, priority 0]
✓ memo [entry, priority 0]
✗ old [entry, priority 0] — when: false
→ notice
entry notice (first read)
@maud: "Climbers wanted. No refunds."
notices.looked done
quest notices -> complete
── step 2 · board (select: all, pick: memo) ──────────────
✓ memo [entry, priority 0]
✓ old [entry, priority 0]
✗ notice [entry, priority 0, read] — once: user — already read
→ memo
entry memo (first read)
@maud: "Floor three is flooded again."
── step 3 · board (select: all, pick: old) ──────────────
✓ old [entry, priority 0]
✗ notice [entry, priority 0, read] — once: user — already read
✗ memo [entry, priority 0, read] — once: run — already read this run
→ old
entry old (first read)
@maud: The same notice as ever.
── step 4 · new run ──────────────
run.* state, run-tier facts and once: run reset; prev.run.* holds the ended run (1 value)
prev.run.floor = 0
quest climb -> unset (tier: run; was active)
quest climb -> active
── step 5 · board (select: all, pick: memo) ──────────────
✓ memo [entry, priority 0]
✓ old [entry, priority 0]
✗ notice [entry, priority 0] — once: user — already read
→ memo
entry memo (first read)
@maud: "Floor three is flooded again."
── end: complete (5 steps) ──────────────

At step 4 climb is active with its objective neither done nor failed; it is a start= quest, so the reset lists it and it activates again, without a note. prev.run.floor = 0 is the untouched floor — the snapshot takes every run.* value, written or not.

Two resets are not tied to a run (dsl 0.27.0 §5). A schema’s seasons open when their live condition turns true, and the play prints the moment under the step that caused it: season harvest opens — season.harvest.* reset to defaults; last window: prev.season.harvest.tokens = 2 (and season harvest closes when it turns false again). An opening moves season.harvest.* into prev.season.harvest.*, resets it to its defaults, and makes the season’s once: season:harvest beats and tier="season:harvest" quests fresh again (quest missions -> unset (season:harvest opened; was complete)). A quest with rearm= returns to unset each time its condition turns true after the first settle, printed as quest festival -> unset (rearmed; was complete), and a start that holds activates it again in the same settle. One advance: that crosses several days watches both at every position it passes: a window that opens and closes on the way starts and fails its quests there, and a rearm fires on the day its condition turns true, each printed after the clock’s move to that position. A beat spent by its spentBy condition is listed with the reason spentBy: `run.solved` holds while the condition holds and spentBy: `run.solved` held — spent this run once it has held (it stays spent for its once period when the condition turns false again), and a spent once: week beat with once: week — already presented this week.

A select: first occasion answers with one beat. Two additions (dsl 0.23.0) let an occasion say more without giving up that order.

select: sequence presents every eligible beat, in selection order. The tower raises runStart as a run begins: start.gear (priority 10) is the routine, and start.recap recalls the previous run’s floor, so it is eligible only once a run has ended:

steps:
- occasion: runStart
- label: the run ends on floor three
engine: { state: { run.floor: 3, user.runs: { add: 1 } } }
- newRun: true
- occasion: runStart
expect: { presented: [start.gear, start.recap] }
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · runStart (select: sequence) ──────────────
✓ start.gear [scene, priority 10]
✗ start.recap [scene, priority 0] — when: false
→ start.gear
@maud: Rope, lamp, bread. Up you go.
── step 2 (the run ends on floor three) · engine ──────────────
set run.floor = 3
set user.runs = 1
── step 3 · new run ──────────────
run.* state, run-tier facts and once: run reset; prev.run.* holds the ended run (1 value)
prev.run.floor = 3
quest climb -> unset (tier: run; was active)
quest climb -> active
── step 4 · runStart (select: sequence) ──────────────
✓ start.gear [scene, priority 10]
✓ start.recap [scene, priority 0]
→ start.gear
→ start.recap
@maud: Rope, lamp, bread. Up you go.
@maud: Floor 3 last time. Beat it.
── end: complete (4 steps) ──────────────
── expect: every expectation held ──────────────
  • Step 1 — no run has ended, so prev.run.floor is unset and only the routine plays.
  • Step 3 — the newRun snapshots run.floor (3) into prev.run.floor before resetting it, and prints it; climb starts over, as in Run boundaries.
  • Step 4 — both beats are eligible: each gets its own → line, and each plays in turn and spends its own once. The step’s presented: expectation asserts the whole list, in order.

Once a beat of the list has played, each later beat is judged again just before its turn: a supper beat that moves run.day to 2 lets a when: 'run.day >= 2' letter later in the same raise play, and a beat whose when an earlier beat made false is skipped. The candidate line of a beat judged that way ends (judged at its turn, after an earlier beat of this raise). The quest lifecycles settle after each presentation, so an objective — or a deadline — is judged between two beats of one step, and the raise’s <on event> handlers run after all of its beats. There is nothing to pick, so pick: on a sequence occasion is a usage error.

also: true makes a beat a side remark on a select: first occasion. It never wins, whatever its priority; when it is eligible it is presented after the winner — or on its own, when no main beat is eligible. Oskar’s rumor bundle beat is one:

steps:
- engine: { state: { run.floor: 2 } }
- occasion: talk
target: npc.oskar
- label: the hound falls
engine: { facts: [slew(hound)] }
- occasion: talk
target: npc.maud
- occasion: talk
target: npc.oskar
expect:
quests: { houndHunt: complete }
state: { user.embers: 50 }
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · engine ──────────────
set run.floor = 2
── step 2 · talk → npc.oskar ──────────────
✓ oskar.hunt [beat, priority 10]
✓ oskar.rumor [beat, priority 0, also]
→ oskar.hunt
+ oskar.rumor (also)
@oskar: The hound took my dog's collar. Bring it back before you pass floor four.
quest houndHunt accepted
@oskar: They say the warden sleeps on floor six.
quest houndHunt -> active
── step 3 (the hound falls) · engine ──────────────
assert slew(hound)
houndHunt.collar done
── step 4 · talk → npc.maud ──────────────
✓ maud.talk [scene, priority 0]
→ maud.talk
@maud: Up again?
── step 5 · talk → npc.oskar ──────────────
✓ oskar.rumor [beat, priority 0, also]
✗ oskar.hunt [beat, priority 10] — once: run — already presented this run
→ (no eligible main beat)
+ oskar.rumor (also)
@oskar: They say the warden sleeps on floor six.
houndHunt.report done
quest houndHunt -> complete
grant houndHunt EMBERS 50 (credits user.embers = 50)
── end: complete (5 steps) ──────────────
── expect: every expectation held ──────────────
  • Step 2 — oskar.hunt wins; the eligible also beat is listed + oskar.rumor (also) and plays after it. Both are bundle beats: the → and + lines name them, and their beat records appear only in --json and --ir. The hunt accepts houndHunt, which activates in the settle after the step.
  • Step 5 — the hunt is spent (once defaults to run), so no main beat is eligible — → (no eligible main beat) — and the side remark still plays. The rest of the transcript is the quest: see Deadlines and targeted objectives.

An also beat spends its once when presented, like any beat; oskar.rumor is once="false", so it repeats. In --json, a step’s presented is the winner — or, with no winner, the first also beat — and then lists the beats presented after it, in order, each with "also": true when it is one; the candidate records carry "also": true too:

{
"step": 2,
"occasion": "talk",
"target": "npc.oskar",
"select": "first",
"candidates": [
{ "id": "oskar.hunt", "kind": "beat", "document": "lore/oskar.lute", "priority": 10, "eligible": true },
{ "id": "oskar.rumor", "kind": "beat", "document": "lore/oskar.lute", "priority": 0, "eligible": true, "also": true }
],
"winner": "oskar.hunt",
"presented": { "id": "oskar.hunt", "kind": "beat", "document": "lore/oskar.lute", "commands": […], "stateDelta": {} },
"then": [
{ "id": "oskar.rumor", "kind": "beat", "document": "lore/oskar.lute", "also": true, "commands": […], "stateDelta": {} }
]
}

A select: sequence step uses the same two keys: presented is the first beat of the list and then the rest.

Two objective attributes (dsl 0.23.0) change when an objective is judged — the language side is in Quests & scenes:

  • by="<condition>" — a deadline. While the objective is not done, the first time by holds the objective fails and is never judged again. A failed required objective fails its quest: failed rewards, the questFailed handlers, and the cascade to a parent quest. done is judged before by, so an objective done at the moment its deadline passes does not fail. by is judged at every settle — after a presentation, an engine: write, an advance:, a newRun — on every objective, on= or not (dsl 0.24.0 §2.1): a deadline is a moment, so a player who never raises the objective’s occasion still misses it.
  • until="<condition>", beside on= — the place-bound deadline (0.23.1’s rule for an on= objective’s by): judged only when the objective is — when its occasion is raised (for its target, when it has one), right after its done — so between raises it waits. It prints failed (until).
  • target="<target>", beside on= — the objective is judged only at an occasion step raised for that target. An occasion step with another target, or none, leaves it alone.

Because by is judged at every settle and an on= objective’s done only when its occasion is raised, a by that the done implies (done="run.v == 'fell'" by="run.v != 'undecided'") fails the objective at the settle it comes true, before done is ever judged. check warns W-DEADLINE-BEFORE-DONE at such a by= and suggests until= with the same condition; see Quests & scenes.

In the also example above, the collar objective completes in the settle after step 3, when the engine asserts the kill. Step 4 raises talk for Maud, so the report objective (on="talk" target="npc.oskar") is not judged; step 5 raises it for Oskar, and the quest completes. Without the kill, the climb passes the deadline:

steps:
- occasion: talk
target: npc.oskar
- label: the climber passes floor four
engine: { state: { run.floor: 4 } }
expect: { quests: { houndHunt: failed } }
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · talk → npc.oskar ──────────────
✓ oskar.hunt [beat, priority 10]
✗ oskar.rumor [beat, priority 0, also] — when: false
→ oskar.hunt
@oskar: The hound took my dog's collar. Bring it back before you pass floor four.
quest houndHunt accepted
quest houndHunt -> active
── step 2 (the climber passes floor four) · engine ──────────────
set run.floor = 4
houndHunt.collar failed (by)
quest houndHunt -> failed (by)
@oskar: Floor four already? Then it's gone to ground.
── end: complete (2 steps) ──────────────
── expect: every expectation held ──────────────

houndHunt.collar failed (by) is the deadline (--json: an objective record with "failed": true, "failedBy": "by"), and the required objective fails the quest — -> failed (by) names its failedBy — whose questFailed handler plays; the step’s own expect: checks the status right after that settle. The failure is readable (quest.houndHunt.objectives.collar.failed, quest.houndHunt.failedBy); a newRun clears it for a tier="run" quest. A quest fails for three other reasons — its own fail, a failed parent, a sibling alternative — each named the same way; see Quest structure.

Rewards that credit state. The tower’s EMBERS reward kind declares credits: user.embers, so when step 5 above grants the quest’s reward, the scalar amount is added to that path — grant houndHunt EMBERS 50 (credits user.embers = 50), and the end-of-play state: { user.embers: 50 } holds. A whole number prints without a decimal point. In --json, the grant record carries "credited": { "path": "user.embers", "value": 50 }. A range amount is the engine’s roll and credits nothing here, and a ::set of the same path in one of the quest’s own <on> or objective bodies would pay twice (W-REWARD-DOUBLE-CREDIT).

Four quest and occasion attributes (dsl 0.24.0 §2) change how a lifecycle moves, and the transcript shows each — the language side is in Quests & scenes:

  • <quest activate="accept"> on a child quest — it does not activate with its parent: it waits for an ::accept and activates only while its parent is active. An accept while the parent is not active is spent without effect, and the transcript says so under the presentation — for a child haggle accepted before its accept-driven parent market was: note: accept of quest haggle spent — its parent quest market is not active yet; an `activate="accept"` child activates only while its parent is active (--json: an acceptSpent record, { "kind": "acceptSpent", "quest": "haggle", "parent": "market", "parentStatus": "unset" }, in the step’s quests).
  • <quest complete="any"> on a parent — it completes when any required objective is done, and its other still-active children fail as superseded (a child nobody accepted stays unset).
  • judge: before on an occasion — the occasion judges its on= objectives and settles the quests before its beats are decided. Those transitions print right under the step header, before the candidates (--json: the step’s judgedBefore; its quests are the ones after, with the handler bodies). Only the judging moves: the handlers the raise answers — its same-named <on event> handlers, and the questComplete / questFailed handlers of the quests it settles — run after the beats, so their narration follows the scene.
  • <on event="E" target="…"> — the handler runs only when the occasion E is raised for that target: never for a plain event: step, another target, or a lifecycle transition.

Every failure names its reason: quest X -> failed (by) or (until) for a deadline, (fail) when its own fail= held, (subquest) when a required subquest failed, (cascade) when its parent failed, and (superseded). The quest record in --json carries the same failedBy, and content reads it as quest.<id>.failedBy (unset until the quest fails).

A river crossing puts them together. The plugin declares chapterEnd: { select: first, judge: before }; road completes on either of two children, each taken in town, toll has a questFailed handler, and purse fails when the engine writes run.robbed:

<quest id="road" title="Cross the river" start="true" complete="any">
<objective id="words" title="Talk your way over" quest="parley"/>
<objective id="silver" title="Pay the toll" quest="toll"/>
</quest>
<quest id="parley" title="Parley" activate="accept">
<objective id="terms" title="Agree terms by nightfall" on="chapterEnd" done="run.talked"/>
</quest>
<quest id="toll" title="Toll" activate="accept">
<objective id="pay" title="Pay the ferryman" done="run.paid"/>
<on event="questFailed">
@maud: The ferryman pockets his rope and goes home.
</on>
</quest>
<quest id="purse" title="Keep your purse" start="true" fail="run.robbed">
<objective id="count" title="Count the coin" quest="coin"/>
</quest>
<quest id="coin" title="Count the coin">
<objective id="counted" title="Counted" done="run.paid"/>
</quest>

The town scene’s branch offer accepts toll (silver) or parley (words, which also sets run.talked), and the scene answering chapterEnd reads why toll ended:

<match subject="quest.toll.failedBy">
<when is="superseded">
@maud: You talked your way over. Keep your silver.
</when>
<otherwise>
@maud: Night falls on the river.
</otherwise>
</match>
choose: { offer: [silver, words] }
steps:
- occasion: townVisit
- occasion: townVisit
- label: robbers on the road
engine: { state: { run.robbed: true } }
- occasion: chapterEnd
expect:
quests: { road: complete, toll: failed, purse: failed, coin: failed }
── start ──────────────
quest road -> active
quest purse -> active
quest coin -> active
── step 1 · townVisit ──────────────
✓ town.offer [scene, priority 0]
→ town.offer
@maud: Two ways across.
▷ choice offer: words [silver] ← chosen: silver
@maud: Pay, then.
quest toll accepted
quest toll -> active
── step 2 · townVisit ──────────────
✓ town.offer [scene, priority 0]
→ town.offer
@maud: Two ways across.
▷ choice offer: [words] silver ← chosen: words
@maud: Talk, then.
set run.talked = true
quest parley accepted
quest parley -> active
── step 3 (robbers on the road) · engine ──────────────
set run.robbed = true
quest purse -> failed (fail)
quest coin -> failed (cascade)
── step 4 · chapterEnd ──────────────
parley.terms done
quest parley -> complete
road.words done
quest road -> complete
quest toll -> failed (superseded)
✓ chapter.nightfall [scene, priority 0]
→ chapter.nightfall
match -> arm 1
@maud: You talked your way over. Keep your silver.
@maud: The ferryman pockets his rope and goes home.
── end: complete (4 steps) ──────────────
── expect: every expectation held ──────────────
  • Start — coin has no activate="accept", so it activates with purse; parley and toll wait.
  • Steps 1–2 — each accept activates its child in the settle after the scene, because road is active.
  • Step 3 — the engine’s write makes purse’s fail hold: (fail); its child coin, still active, fails with it: (cascade).
  • Step 4 — chapterEnd is judge: before, so parley.terms (on="chapterEnd") is judged first: road completes on it, and toll, accepted and still active, is superseded — all before the candidate list, so the night scene’s <match> already reads superseded. toll’s questFailed handler still plays after the scene (--json: under the step’s quests, not judgedBefore). On a default (after) occasion the quest lines would follow the scene, and the <match> would read unset.

Taking a quest for the next run. ::accept{quest="…" at="nextRun"} queues the acceptance: it applies right after the next newRun reset, so a run-tier quest taken at a hub between runs survives the reset. In the tower, give Maud’s scene ::accept{quest="relic" at="nextRun"} for an accept-driven tier="run" quest relic whose objective is done="run.floor >= 6":

steps:
- occasion: talk
target: npc.maud
- newRun: true
- engine: { state: { run.floor: 6 } }
expect:
quests: { relic: complete }
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · talk → npc.maud ──────────────
✓ maud.talk [scene, priority 0]
→ maud.talk
@maud: Up again? Next time, look for the warden's key.
quest relic accepted (queued: applies after the next newRun)
── step 2 · new run ──────────────
run.* state, run-tier facts and once: run reset; prev.run.* holds the ended run (1 value)
prev.run.floor = 0
quest climb -> unset (tier: run; was active)
quest relic accepted (queued at="nextRun")
quest relic -> active
quest climb -> active
── step 3 · engine ──────────────
set run.floor = 6
relic.key done
quest relic -> complete
climb.high done
quest climb -> complete
── end: complete (3 steps) ──────────────
── expect: every expectation held ──────────────

The accept prints where it ran, queued; the newRun applies it after its resets — quest relic accepted (queued at="nextRun"), --json the step’s accepted — and relic activates in the new run’s first settle. In --json the scene’s accept record carries "at": "nextRun". Any other at is E-ACCEPT-TARGET.

Without at="nextRun" the accept activates relic in the run that is ending, and the reset takes it away — the note: from Run boundaries, since an accept-driven quest was reset active and unjudged:

── step 2 · new run ──────────────
run.* state, run-tier facts and once: run reset; prev.run.* holds the ended run (1 value)
prev.run.floor = 0
quest climb -> unset (tier: run; was active)
quest relic -> unset (tier: run; was active)
note: quest relic was accepted this run and is still active with no objective done or failed — the reset discards it; a run-tier quest taken between runs is `::accept{quest="relic" at="nextRun"}`
quest climb -> active

relic then waits for a new accept, and the same script ends with it unset.

A plugin directive that calls a host service — a skill check, a minigame — writes the service’s answer into scene.* result slots through its bridgeResult effects (see Manifests). The reference player invokes no service, so the script answers the calls itself (dsl 0.24.0 §5): bridges: { <tag>: [ {<field>: value}, … ] }, where <tag> is the directive’s name and each list item answers one call of that tag, in call order.

The examples add a skill check to the worked example’s town: the plugin declares ::check{skill dc resultKey}, whose effects write scene.check.<key>.passed and .margin from the dice service’s passed and margin, and a scene gate.guards answering townVisit at priority 50 makes two checks, each followed by a <match> over its result; a gated line also reads the first check’s margin:

::check{skill="persuasion" dc="12" resultKey="guards"}
<match subject="scene.check.guards.passed">
<when is="true">
@narrator: The guards wave you through.
@narrator{when="scene.check.guards.margin > 5"}: They barely look up.
</when>
<when is="false">
@narrator: The guards bar the way.
</when>
</match>
::check{skill="stealth" dc="10" resultKey="sneak"}
<match subject="scene.check.sneak.passed">
<when is="true">
@narrator: Nobody sees you slip past the stalls.
</when>
<when is="false">
@narrator: A stallholder shouts after you.
</when>
</match>

Top-level answers are consumed in call order across the whole play:

bridges:
check:
- { passed: true, margin: 3 }
- { passed: false, margin: -2 }
steps:
- occasion: townVisit
── step 1 · townVisit ──────────────
✓ gate.guards [scene, priority 50]
✓ town.welcome [scene, priority 10]
✗ town.morning [scene, priority 0] — when: false
→ gate.guards
::check{skill="persuasion" dc=12 resultKey="guards"} (bridge answered: passed=true, margin=3)
set scene.check.guards.passed = true (effect of ::check)
set scene.check.guards.margin = 3 (effect of ::check)
match -> arm 1
@narrator: The guards wave you through.
skip @narrator "They barely look up." — when: false
::check{skill="stealth" dc=10 resultKey="sneak"} (bridge answered: passed=false, margin=-2)
set scene.check.sneak.passed = false (effect of ::check)
set scene.check.sneak.margin = -2 (effect of ::check)
match -> arm 2
@narrator: A stallholder shouts after you.
── end: complete (1 step) ──────────────
  • Fields content reads, checked at load. Each answer gives the bridgeResult fields content reads through any call of the tag, each a literal of its result slot’s declared type. Since dsl 0.25.0 §7 a field that no content reads may be left out: here the gated line reads margin, so every check answer gives it, but without that line - { passed: true } would be a complete answer, and the hints below would ask for passed only. An answer may still give an unread field. An unknown tag, a field no effect reads, a missing field content reads, or a value that does not fit is a usage error before anything plays (exit 2): top level: `bridges.check` answer 1 lacks `margin`, which content reads — an answer gives every bridge result `::check` content reads: `{ passed: <bool>, margin: <number> }`; a misspelt tag gets a did-you-mean.
  • Step answers first. A step’s own bridges: is consumed by that step’s calls before the top-level queue. Answers the step leaves unconsumed fail it, exit 1 — an answer written to decide something decided nothing: ── halted: <play>:<line>:<col>: step 1: its `bridges:` answers were not all consumed — no plugin call of the step took `check` {passed: false, margin: 9}.
  • Into scene.*. The answers land in the scene’s result slots — the one way a script writes scene.*. A state: seed of a result slot is refused before anything plays (exit 2: `state.scene.check.guards.passed` is `scene.*`, which resets at every scene boundary and cannot be written); a 0.23.1 script that seeded one answers the call with bridges: instead.
  • Typed by the slot (dsl 0.26.0 §3.1). An answer is typed by the result slot its effect writes — declared by the directive’s state: shape, also when the call comes from a component’s body, whose plugin directives declare their result slots in the host at each ::use — or else by the bridge capability’s result: shape. An answer that neither types is refused at the call, never stored as a string (before 0.26.0 a component’s ::battle stored won: true as "true", and its <match> took the other arm). The refusal stops the walk, exit 2:
── halted: scene `ping.s` (scenes/ping.lute): the `bridges.ping` answer to plugin call `ping`: `ok` lands on `scene.ping.k.ok`, which no state slot of this artifact declares, and no bridge capability declares a `result:` type for it — an untyped answer is refused ──────────────
  • No answer, no guess. A call with no answer left halts the walk at the call, incomplete (exit 3), before anything after it — the <match> and its default arm included — is walked. Before 0.24 the walk printed the default arm and only then halted:
→ gate.guards
::check{skill="persuasion" dc=12 resultKey="guards"} (bridge unanswered: passed, margin)
── halted: scene `gate.guards` (scenes/gate/guards.lute): plugin call `check` reads a bridge result and has no answer — give one with `bridges: { check: [ { passed: <bool>, margin: <number> } ] }` (top level or on the step) ──────────────

In --json the call’s plugin record carries "answered": [{ "field": "passed", "value": true }, …], or "unanswered": ["passed", "margin"] where the walk halted. lute trace, lute test and lute run --mock take the same bridges: key.

Host writes with an answer (0.41.1). A host may answer a bridge call with writes of its own (bridgeResult.writes, applied at the call after its declared effects). A play script spells such an answer in the long form: fields is the flat field map above, and writes is the runtime’s writes shape — state entries { path, value } or { path, add }, and facts, retract and accept lists, each optional:

bridges:
battle:
- fields: { outcome: win, hpFloor: 3 }
writes:
state:
- { path: run.score, value: 1 }
facts: [pulled(rope)]
steps:
- occasion: duel
target: foe.kato

The writes show in the transcript as effects of the call, after the result fields land:

::battle{foe=occasion.target mode="ambush" resultKey="fight"} (bridge answered: outcome="win", hpFloor=3)
set scene.battle.fight.outcome = "win" (effect of ::battle)
set scene.battle.fight.hpFloor = 3 (effect of ::battle)
set run.score = 1 (effect of ::battle)
assert pulled(rope) (effect of ::battle)

The long form works at the top level, on a step and on an include:, beside flat answers in the same list; queue order, step-before-top consumption and leftover answers are unchanged. lute play --events sends the writes with the answer (bridgeResult { request, fields, writes }). A long-form answer with another key, fields that are not scalars, or malformed writes is a usage error (exit 2), as is a write the runtime would refuse — an undeclared path, or the clock’s own day / slot (move the clock with an advance: step): top level: `bridges.battle` answer 1 `writes`: state `run.nope` is not a declared state path in this project. Trace mocks — lute trace, lute run --mock and *.test.yaml scenario tests — take flat answers only.

A step may carry expect:, judged against what that step did. Four keys judge an occasion’s selection, so they belong on a step that raises one: an occasion step, or an advance step, where winner, offered and notOffered judge the raise where the clock stops and presented lists every beat the step presented, in order:

KeyHolds when
winner: <beat id>that beat was presented; winner: none — nothing was (no eligible beat, or pick: none)
offered: [beat ids]every listed beat was eligible at the step — a subset, in any order
notOffered: [beat ids]none of the listed beats was eligible
presented: [beat ids]exactly these beats were presented, in this order (dsl 0.23.0): the whole list of a select: sequence step, or a winner followed by its also beats; [] — nothing was
presented: { <occasion>: [beat ids] }on an advance step: for each named occasion the clock raises, exactly these beats were presented by its raises in this step, in this order; occasions not named are not judged (Advancing the clock)
refused: E-OCCASION-GATEon an occasion: step only, holds when the occasion’s gate refuses the raise; the state is unchanged and the play continues

refused is only accepted with E-OCCASION-GATE. It may be combined with state, facts, notFacts, clock, and quests, which are checked against the unchanged state. It cannot be combined with winner, offered, notOffered, presented, or options, because a refused raise presents nothing. On a refused step, lute play prints a note such as note: the raise was refused with E-OCCASION-GATE — not raised: gate false. If the occasion is accepted instead, the play continues but the expectation misses with expected the raise to be refused with E-OCCASION-GATE; it was accepted; lute test reports that play as FAIL. refused is not legal on advance, engine, newRun, event, or end steps. --events keeps the rejected raise in the output as the occasion step with no presentation, then continues with following inputs when refused matches, so a host sees the same rejected input/output pair without losing the rest of the stream.

Five more judge the world right after the step settled — for an occasion step, after its presentations, its occasion’s objective judging, and the settle that follows — and are legal on any step kind (not beside end). Each but clock means what it means at the top level, below, at that moment:

KeyHolds when
quests: { <id>: <status> }the quest is in that status after the step
state: { <path>: <value> }the path’s effective value after the step equals the value, compared typed
facts: [atoms] / notFacts: [atoms]each atom holds / does not hold after the step, after derivation
clock: { weekday, slot, day, ended }the clock stands there after the step (dsl 0.26.0 §7): weekday a week.labels label or a clock.weekday number, slot a declared slot, day the day number, ended whether a clock that ends has ended (clock.ended; a usage error on a clock that never ends) — any of them

So an engine: step can assert what its write did — expect: { quests: { houndHunt: failed } } in the deadline example — and an occasion step can check its quest progress without waiting for the end. winner, offered, notOffered, or presented on any other step is a usage error (exit 2): step 2: `expect.winner` applies only to an `occasion` or `advance` step, not `engine` (a `engine` step may expect quests, state, facts, notFacts).

A clock: miss names the key and prints the weekday’s number beside its label (expect clock weekday: expected Wed, actual Tue (1)); on a project that declares no clock every clock: key misses (actual no clock position …). See Advancing the clock for how an include:d steps file uses it.

One more step key is legal on any step: options: { <branch or hub>: [ids] } (dsl 0.24.0) holds when exactly those options were offered at that branch or hub during the step — a set, every option eligible at some presentation, a hub’s visits unioned (and on an advance, every raise of the step). A branch or hub the step did not present is a miss (expect options offer: expected [words], actual no branch or hub `offer` was presented in this step).

A top-level expect: judges the end of the play:

KeyHolds when
end: complete | terminal | incomplete | errorthe walk ended that way: complete — every step played; terminal — the project’s terminal: held where it ended; incomplete — it halted on an undecided choice or when; error — it stopped on an error. exit: is the old name and says so
quests: { <id>: <status> }the quest ended in that status (unset for one nothing activated)
state: { <path>: <value> }the path’s final effective value — the last write, else the seed, else the declared default — equals the value, compared typed (1 is not "1")
facts: [atoms] / notFacts: [atoms]each atom holds / does not hold at the end, after derivation
transcriptContains: [text] / transcriptLacks: [text]each text is / is not a substring of the content lines that played, each in one form, @speaker: text (dsl 0.24.0). A needle with no attribute block matches a line whatever its delivery attributes, so "@mara: Any luck with the lamp?" matches the line printed @mara{emotion="shy"}: Any luck with the lamp?; a needle line that carries a block matches only a line carrying those attributes (see below). Step headers, candidates, staging, notes and skip lines are never matched — a guarded line that did not play satisfies no transcriptContains. A report line is asserted with its own key: quests: { wire: failed } for quest wire -> failed, state: { quest.wire.objectives.sent.failed: true } for a missed deadline, state: for a set line. A needle shaped like a report line can never match and is a usage error (`expect.transcriptContains` needle "quest lampOut -> active" is the shape of a line the engine's report prints, not of a content line — needles judge only what is said, so this one can never match; assert the quest with `quests: { <id>: <state> }`). lute test matches the same form, whatever --ir prints

An expectation on a repeat: step is judged at every repetition; one on a step the walk never reached is itself a miss. Each expect: is validated before anything plays — an unknown key is a usage error (exit 2) listing the legal keys, with a did-you-mean, and saying when the key belongs at the other level (unknown top-level `expect:` key `winner` (`winner` belongs in a step `expect:`)).

After the transcript, a script with any expectation prints ── expect: every expectation held or ── expect: <n> missed, one line per miss naming the step, its label, the occasion, the repetition, and the actual value. A state miss quotes a string on both sides, so "3" and 3 read apart:

── expect: 3 missed ──────────────
✗ step 3 (ask about the oil) at talk npc.tomas: expect winner: expected tomasOil, actual tomasBusy
✗ end of play: expect quests lampOut: expected active, actual unset
✗ end of play: expect state user.bond.mara: expected 1, actual 0

A presented: miss prints both lists, so an order mistake is visible at a glance:

── expect: 1 missed ──────────────
✗ step 1 at runStart: expect presented: expected [start.recap, start.gear], actual [start.gear, start.recap]

Since dsl 0.27.0 a needle’s attribute block is judged, not dropped (0.26.0 dropped it): a needle line written @speaker{…}: text must start at a presented line whose attributes include every attribute the block names, with the same value (the line may carry more). A line copied from the transcript — "@mara{emotion=\"content\"}: You're new." — therefore matches the line it was copied from, but not the same words said with another emotion or with none. For transcriptLacks that makes the guard narrower: '@ren{emotion="sad"}: X' holds when X is never said sadly, even if it is said plainly; write '@ren: X' to mean “X is never said”. A block is checked before anything plays, in transcriptContains and transcriptLacks alike: a key a transcript line never shows (emotoin=, when=, code=), a value outside its domain (emotion="sadd"), a variant that is not a whole number, or a value on a delivery flag is a usage error (exit 2) with a did-you-mean — `emotoin` is not an attribute a transcript line shows — did you mean `emotion`? (a line shows: mono, os, vo, emotion, variant, action, dialogMotion, as) — so a misspelt block can no longer make a transcriptLacks hold by matching nothing. A transcriptContains miss quotes the presented line nearest to the needle, as it was said:

── expect: 1 missed ──────────────
✗ end of play: expect transcriptContains: expected "@mara: Tomas keeps the oil. Ask her." present, actual absent (nearest line: "@mara{emotion=\"delighted\"}: Would you? Tomas keeps the oil. Ask him.")

The nearest line is chosen for the needle’s first line no presented line contains (round-5 T3-16): a line of the needle’s speaker first, then one the step said where the needle’s other lines were said, then the one the needle needs the fewest edits to occur in — the rest of a long line is free, so a short line is not “near” a long needle just because it has little to differ in.

A miss makes lute play exit 1, unless the walk itself already ended in an error (exit 1) or a runner failure on a malformed artifact (exit 2). A walk that halted incomplete (3) with every expectation holding still exits 3.

lute test runs every *.play.yaml under its directory that carries an expect: — on a step or at the top — alongside the *.test.yaml scenario tests, against --project or else the nearest lute.project.yaml above the play, with a PASS / FAIL line each (--json: entries with "kind": "play" and their misses). A play without expect: is not a test and is skipped. A play that halts fails unless its top-level expect: declares the exit (expect: { end: incomplete }). With --coverage, every document a play presented — through an occasion: step or through the occasions an advance: raises — and every quest document whose lifecycle it moved, counts as covered — at beat granularity since 0.27.0, each bundle beat and entry its own unit; the options a play picks count in the branch/hub rows beside the ones traced paths chose, and the arm rows come from traced paths alone. The report ends with the beats no play presented. Since dsl 0.26.0 lute test compiles a play project once for all its plays — its compile diagnostics print once, not once per play — and runs the plays in parallel on every logical core (RAYON_NUM_THREADS respected), reporting them in file order. See lute test.

lute play evaluates every when, done, and guard over the live facts with the project’s Datalog rules applied (stratified negation) — the same evaluator lute run, and since 0.22.0 lute trace and lute test, use. The project’s seed facts are loaded, and the script’s facts:, engine: writes, and every ::assert / ::retract a presentation makes feed the fixpoint.

derive: false in the script, or --no-derive on the command line (the flag wins), stops applying the rules: the seed facts still load, but every atom of a derived relation reads unknown — a script cannot assert one. A when that depends on one halts the walk incomplete (exit 3), and the end-of-play facts: / notFacts: expectations see base facts only:

Terminal window
$ lute play --project tower --script tower/plays/night.play.yaml --no-derive
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · townVisit ──────────────
✓ town.idle [scene, priority 0]
? town.victory [scene, priority 10] — when: unknown (`!holds('threat', ['warden'])` evaluates unknown: fact `threat(warden)` is undetermined)
── halted: step 1: the `when` of scene `town.victory` (scenes/town-victory.lute) decides the townVisit outcome but `!holds('threat', ['warden'])` evaluates unknown: fact `threat(warden)` is undetermined ──────────────

--derivation <ATOM> (repeatable) prints, after the play, why a ground atom holds at the end or why it does not. When it holds: the rule used, and each premise’s own support — a seed fact, asserted during the play (naming who asserted it, below), or derived in turn, indented beneath it — with a negated premise shown (absent). When it does not: every rule that could conclude it, with its premises marked — ✗ <atom> (absent) for a missing base fact, ✗ <atom> (not derived) for a missing derived one (explained in turn), ✗ not <atom> (but it holds: …) for a negated premise that is present, ✗ <test> (false) / ? <test> (undecided) for a comparison or guard, and · <premise> (not reached) for premises after the first failure. With plays/night.play.yaml a single townVisit step:

Terminal window
$ lute play --project tower --script tower/plays/night.play.yaml --derivation "threat(warden)" --derivation "threat(hound)"
── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · townVisit ──────────────
✓ town.idle [scene, priority 0]
✗ town.victory [scene, priority 10] — when: false
→ town.idle
@maud: Quiet night.
── end: complete (1 step) ──────────────
explain threat(warden): holds
threat(warden) ⇐ threat(F) :- boss(F), not slew(F)
├─ boss(warden) (seed fact)
└─ not slew(warden) (absent)
explain threat(hound): does not hold
threat(F) :- boss(F), not slew(F)
├─ ✗ boss(hound) (absent)
└─ · not slew(hound) (not reached)

An asserted fact names who asserted it and when (dsl 0.24.0). After plays/kill.play.yaml — a townVisit, then the step label: the warden falls on floor six with engine: { state: { run.floor: 6 }, facts: [slew(warden)] } — the same atom reads:

explain threat(warden): does not hold
threat(F) :- boss(F), not slew(F)
├─ boss(warden) (seed fact)
└─ ✗ not slew(warden) (but it holds: asserted by engine step 2)

A content assertion reads the same way — knows(lamp) (asserted by entry tomasOil, step 3) after the worked example’s first day — and --json carries it as the proof’s assertedBy. A negated premise that holds because its atom is absent is expanded one level when some rule could conclude that atom: each such rule is listed with its premises, so the one that keeps it from firing is marked (JSON: the premise’s attempts). With a rule safe(F) :- boss(F), not threat(F) added to the tower, the same play explains safe(warden):

explain safe(warden): holds
safe(warden) ⇐ safe(F) :- boss(F), not threat(F)
├─ boss(warden) (seed fact)
└─ not threat(warden) (absent — no rule concludes it:)
threat(F) :- boss(F), not slew(F)
├─ boss(warden) (seed fact)
└─ ✗ not slew(warden) (but it holds: asserted by engine step 2)

The explanation is printed before the ── expect: block, and --json carries it as explain. An atom that is not ground (knows(X), slew(_)) is a usage error (exit 2). --derivation evaluates the rules over the final facts and state even under --no-derive.

Relations declared excludes: (dsl 0.25.0 §1) never hold together on the same arguments. check --project proves what it can; where both are only possible — one scene’s branch asserts panicked(maren), a later scene asserts calm(maren) — the play checks the live facts, derived ones included, after every write. When both hold, it prints the violation right under the write and halts there, exit 1, even if a later write in the scene would undo it:

── step 2 · morning ──────────────
✓ dawn [scene, priority 0]
→ dawn
▷ choice look: saw [nothing] ← chosen: nothing
@narrator: Nobody answers.
skip @narrator "Elias walks on." — when: false
assert calm(maren)
✗ exclusive: calm(maren) and panicked(maren) both hold
── halted: scene `dawn` (scenes/dawn.lute): exclusive relations hold together — calm(maren) and panicked(maren) both hold ──────────────

The pair is named in alphabetical order. A script whose choices never make both hold plays on silently. The same check covers facts a rule derives, so a derived relation that excludes another is caught at the write that made its rule fire. An engine: step that makes two exclusive facts hold halts at the step, ── halted: <play>:<line>:<col>: step <n>: exclusive relations hold together — …, and a script whose own facts: (with the project’s seeds and what the rules derive from them) already break an exclusion halts before step 1: ── halted: the script's seeded world holds exclusive relations together before step 1 — …; fix the script's `facts:` (or the `excludes:` declaration). lute trace and lute test refuse the walk at the same write.

The script is rejected before anything plays — a usage error, exit 2, naming the step — when:

  • it is unreadable or malformed YAML, has an unknown top-level key, or steps is missing or empty;
  • a step names no action or more than one (an advance with engine: beside it is one), has an unknown key, carries target on a step that is not an occasion or pick / choose on one that is neither an occasion nor an advance, carries an occasion-only expect: key (winner, offered, notOffered, presented) on one, or has a repeat that is not a whole number ≥ 1;
  • an advance is not slot, day, a whole number ≥ 1, { to: <slot> }, or { to: { weekday, slot } }; its to names a slot the clock does not declare or a weekday that is neither a week.labels label nor a number 0..6; the project declares no clock; it carries pick while the clock’s raise: names no slot occasion, or choose / a selection expect: while the clock raises nothing; or its engine: writes the clock’s own day or slot path; an include: names an unreadable file or one of the wrong shape, sits beside a key other than repeat, choose and bridges, or closes a cycle;
  • a bridges: answer, at the top level or on a step, names a tag no plugin call of the project makes with an effect that reads a bridge result, gives a field no such effect reads, lacks one it reads, or gives a value that does not fit the result slot; or an end step carries bridges;
  • an end step is anything but end: true, or carries repeat or expect;
  • an occasion step names an occasion no resolved plugin declares (when some plugin declares occasions) or, in a shape-only project, one that neither a beat answers nor an <objective on> judges; raises a targeted occasion without target, or an untargeted one with it; names a target outside the occasion’s domain; carries pick on a select: first or select: sequence occasion; or picks a beat that does not answer that occasion;
  • an event: names no declared world event, or a quest lifecycle event;
  • an engine: or newRun write names an undeclared or scene.* / quest.* path, a value that does not fit the declared type, { add: … } on a path that is not an int, or a fact that is not ground, names an undeclared or derived relation, has the wrong arity, or names a non-member of a closed domain; an engine: { accept } names a quest that is not accept-driven; or it writes nothing;
  • a state: / facts: seed fails the same checks, or a save seed names an unknown id or quest status;
  • an expect: has an unknown key or a malformed value.

The message names the key and what fits: step 1: `engine.state.quest.climb.state`: quest state is written by the quest lifecycle, not the engine — seed a save's quest status with top-level `quests:`.

An unquoted atom with two arguments is the usual slip: YAML reads facts: [heard(tavi, regent)] as two entries, and the message says so (`facts:` entry `heard(tavi` is not a ground fact `rel(arg, …)` — quote the atom: YAML splits an unquoted `[a(b, c)]` at the comma (write `["a(b, c)"]`)), in a seed and in an engine: or newRun write alike. An engine: step that moves the clock backward is found only when it runs: the walk stops there, also with exit 2 (see Advancing the clock).

An occasion step:

  1. Candidates — every beat in the project’s beat list with on equal to the step’s occasion whose target is absent, equals the step’s target, or (dsl 0.26.0 §5) is kind:<K> for a kind the step’s target member belongs to — ranked after the other candidates of its priority, and reading that member as occasion.target while it is presented.
  2. Verdicts — a candidate is eligible when its once is not spent (a scene or bundle beat: run — not presented since the last newRun; user — never presented in this play or the save; day / slot — not presented since the clock’s day / slot last changed; false — never spent. An entry: run — entry.<id>.read not set; user — entry.<id>.everRead not set; day / slot — as a scene’s; no once — never spent), its after: holds (scene beats; evaluated against the live visited set of presented scenes and the completed / active sets of real quest states), and its when holds (evaluated by the reference runner’s CEL evaluator over live state and facts, with the Datalog rules applied). A when that evaluates to unknown — validAt(…), now(), or a derived atom under --no-derive — halts the walk incomplete (exit 3) naming the beat, unless a definitely-eligible beat outranks it on a select: first occasion, where it cannot change the winner. Verdicts are decided once, here: nothing a presentation does in (5) changes which of this step’s beats play.
  3. Order — eligible beats by priority descending, then project order.
  4. Select — the occasion’s select comes from the resolved plugins’ occasions export (an undeclared occasion is first). first: the first eligible beat that is not also wins, and every eligible also beat follows it (or plays alone when nothing else is eligible); none eligible, and the occasion passes with no story. all: the step’s pick is presented; a pick that is not eligible at that moment is an error (exit 1); pick: none presents nothing, and so does a step without pick when nothing is eligible — with an eligible list and no pick, the walk halts with an error (exit 1) naming the offered beats. sequence: every eligible beat, in order.
  5. Present — a scene beat runs through the reference runner (lute run’s evaluator): scene.* resets to the scene’s own defaults, run.* / user.* / app.* / quest.* state and facts carry over, and the script’s choose: — with the step’s own choose: over it — decides branches and hubs; an unscripted decision halts incomplete (exit 3). A bundle beat runs the same way, from its beat record in the lore artifact. An entry beat is presented by the lore-entry rules: its effects apply on the first read only, then entry.<id>.read and entry.<id>.everRead become true. A ::end ends only the presentation it runs in: the quest advance (6), the step’s other presentations (also beats, the rest of a sequence), and the occasion’s objective judging (7) still run, and the playthrough goes on with the next step — only an end: true step ends it. A scene’s ::accept{quest="<id>"} prints quest <id> accepted; the quest activates at the advance right after the presentation. A scene or bundle beat counts as visited — for after: and for visited('<id>') in any condition — once its presentation finished. A plugin call whose effects read a bridge result takes the next bridges: answer for its tag — the step’s own first, then the top-level queue — and with none left the walk halts incomplete (exit 3) at the call. When a step presents several beats (sequence, also), (5) and (6) run for each in turn.
  6. Quests — after every presentation, every quest lifecycle advances exactly as lute run advances a quest artifact: activation (start, or — for a quest with no start — an accept from a presented scene’s ::accept, an activate="accept" child only while its parent is active; a start-less quest never activates on its own), objective completion (monotone; objective bodies play once), then the by deadline of every open objective (a first true fails it, failed (by)), fail before completion, <on> handlers, and <reward> grants — adding a scalar amount to the reward kind’s credits: path. A failed quest fails its active children (cascade), and a complete="any" quest that completes fails its other active children (superseded). A ::end in a handler or objective body ends that quest document’s advance, not the step. So a later when over quest.*, or an after: completed(…) / active(…), sees real progress.
  7. Raise — then the step’s occasion is raised to the quests; on an occasion declared judge: before (dsl 0.24.0 §2) the judging and its settle happen first, before (1), so the step’s beats see its transitions — while the handler bodies the raise answers (the same-named <on event> handlers below, and the questComplete / questFailed handlers of the quests it settled) still run after the presentations. When a world event of the same name is declared, every active quest’s <on event="<occasion>"> handlers run first — one with target= only when the step’s target equals it (dsl 0.24.0 §2), and a quest that already settled says so: <on event=storm> of quest climb skipped — quest complete. Then the occasion judges the <objective on="<occasion>"> objectives of every active quest (dsl 0.21.0 §7a.2) — an objective that also carries target= only when the step’s target equals it (dsl 0.23.0) — each one’s done, then its until; and the lifecycles settle again, so a quest can complete (or fail) at exactly that step. An on objective is judged at no other time. An occasion that only objectives judge is a legal step even in a shape-only project; with no beat to present it prints (no candidates) and passes, then judges.

The lifecycles also settle once before step 1 (with the save’s quest statuses and objective seeds already in place), after every engine: step, after every newRun (reset, then the queued at="nextRun" accepts, then seed, then settle), and after every advance:, before its raise. An event: step raises its event once for every quest document, then settles. An end step does nothing but end the playthrough.

CodeMeaning
0Complete — every step played, or an end: true step ended the playthrough, or the play ended in the schema’s terminal state — and every expectation held.
1Error — the project fails to compile (since dsl 0.26.0 also when its documents declare one state path two ways, E-STATE-DECL-CONFLICT), a vocabulary conflict, a pick that is not eligible, a select: all step with eligible beats and no pick, a choose: decision the menu does not offer (an ineligible choice, or a spent once hub option), a step whose own bridges: answers were not all consumed, two exclusive relations holding together (dsl 0.25.0), a step raising an occasion whose raisedWhen gate is false or an occasion: / advance: step after the game is over (E-OCCASION-GATE, dsl 0.27.0 §4), an advance: past a finite clock’s end (E-CLOCK-END), or a missed expectation.
2Usage or I/O — a bad script (see Usage errors), an unknown occasion or world event, a missing or out-of-domain target, an invalid seed or engine: write, an engine: { accept } of a quest that is not accept-driven, an advance: { to } naming no declared slot or weekday, an engine: step that moves the clock backward, a bridges: answer that fits no call or that no result slot or capability result: types (dsl 0.26.0), a non-ground --derivation atom, an unreadable project, a malformed artifact.
3Incomplete — an unscripted choice or hub, a branch choose: list that ran out, a when or quest objective that evaluates to unknown, an unresolved now() / validAt(), or a plugin call whose bridge result has no bridges: answer.

The human transcript names every step, lists its candidates with their verdicts, and marks the winner before the presented beat plays. The tour script from The play script prints:

── start ──────────────
quest climb -> active
quest veteran -> active
quest notices -> active
── step 1 · townVisit ──────────────
✓ town.idle [scene, priority 0]
✗ town.victory [scene, priority 10] — when: false
→ town.idle
@maud: Quiet night.
── step 2 · talk → npc.maud ──────────────
✓ maud.talk [scene, priority 0]
→ maud.talk
@maud: Up again?
── step 3 · board (select: all, pick: none) ──────────────
✓ memo [entry, priority 0]
✓ old [entry, priority 0]
✗ notice [entry, priority 0] — once: user — already read
→ (pick: none — the list closes; nothing presented)
notices.looked done
quest notices -> complete
── step 4 · event storm ──────────────
@narrator: Thunder rolls over the stair.
── step 5 (the warden falls on floor six) · engine ──────────────
set run.floor = 6
set user.runs = 3
assert slew(warden)
climb.high done
quest climb -> complete
veteran.three done
quest veteran -> complete
── step 6 · new run ──────────────
run.* state, run-tier facts and once: run reset; prev.run.* holds the ended run (1 value)
prev.run.floor = 6
quest climb -> unset (tier: run; was complete)
set run.floor = 1
quest climb -> active
── step 7 [1/2] · townVisit ──────────────
✓ town.idle [scene, priority 0]
✗ town.victory [scene, priority 10] — when: false
→ town.idle
@maud: Quiet night.
── step 7 [2/2] · townVisit ──────────────
✓ town.idle [scene, priority 0]
✗ town.victory [scene, priority 10] — when: false
→ town.idle
@maud: Quiet night.
── end: complete (8 steps) ──────────────
── expect: every expectation held ──────────────
  • Every header is its text followed by a fixed ────────────── rule. ── start carries the quest transitions made before step 1. Each step opens with ── step <n>, then its (label), then [k/n] for a repetition, then what it does: · <occasion> — plus → <target> for a targeted step, (select: all, pick: <id>) on a select: all occasion (pick: none (nothing offered) when a step without pick found nothing eligible), and (select: sequence) on a select: sequence one — · engine, · new run, · event <name>, · advance <slot | day | n>: <from> → <to> (since dsl 0.26.0 · advance to night: … or · advance to Fri morning: … for a { to: … } form; the clock’s raises follow under the same step number: ── step <n> · <position> · dayEnd / · dayStart at each midnight stop, a bare ── step <n> · <position> for a move that raises nothing there, then the slot occasion’s own header), or · end (the playthrough ends). A step an end: true step left unplayed prints ── step <n> (label) · skipped (the playthrough ended).
  • Candidates are listed eligible first (✓), in selection order, then the rest, in selection order, each with its kind — scene, entry, or beat for a bundle beat — and priority, then also for an also beat and read for an entry already read in this run (dsl 0.23.0). An ineligible candidate (✗) carries its reason: once: run — already presented this run, once: user — already presented, once: day — already presented today, once: slot — already presented this slot, once: run — already read this run, once: user — already read, after: prerequisite not satisfied, or when: false; a candidate whose when evaluated to unknown is marked ? with when: unknown (<detail>). A step whose occasion no beat answers lists (no candidates). On a judge: before occasion the quest transitions it judged come first, right under the header. At a roster’s scale (round-5 T3-16), five or more when: false candidates at one raise print as one line — ✗ 8 beats — when: false: a, b, c, … (`lute play --json` lists every candidate) — and --quiet leaves out every ✗ candidate; a ? one still prints, since it is why a play halts.
  • → <id> names the winner — one → line per beat on a select: sequence step — and + <id> (also) each also beat that follows it. With no winner the line reads → (no eligible beat — the occasion passes), → (no eligible main beat) when only also beats play, or → (pick: none — the list closes; nothing presented).
  • The presented beat’s own transcript follows, written the way the source reads: content lines as @speaker: text, keeping their delivery (@wren{mono}: …, @maud{as="Barkeep"}: …) and rendering interpolation as an engine would — {{user.deaths:ordinal}} as 2nd, a path typed against an enum with labels: as its member’s label (dsl 0.24.0 §1, §4), {{occasion.target}} as the raised member’s cast name: (dsl 0.26.0 §5); a line whose when= is false as skip @maud "You again." — when: false, and a guarded write that did not apply as skip set run.aff += 1 — when: false — since dsl 0.26.0 every other directive with a false when= the same way: skip ::give{item="potion"} — when: false, skip assert metWren(wren) — when: false, and skip ::use{component="…" …} — when: false for a ::use that plays none of its expansion; staging directives as authored — ::bg{location="parlor"}, ::actor{character="maud" anchor="left"}, ::vfx{type=…}, and a plugin’s own directive by its own name — while compiler-injected staging (preloads, pose resets, the ::bg auto-hide) is left out; a plugin directive the reference player cannot run adds (plugin call, not invoked), and one whose bridge result a bridges: answer decided adds (bridge answered: passed=true, margin=3) ((bridge unanswered: passed, margin) where it halted the walk); a ::end as written, followed by (this presentation ends; the play goes on) — or (this presentation ends) in the step that ends the game, whose note follows; decisions as ▷ choice <id>: … ← chosen: <id> (or ▷ hub <id>: …), where the menu marks the chosen option [table], an option whose guard is false piano✗, and a once option already taken table(spent); state writes as set <path> = <value>, a scene’s ::accept as quest <id> accepted — quest <id> accepted (queued: applies after the next newRun) for at="nextRun" — (JSON: an {"kind": "accept", "quest": "<id>"} record in presented.commands), and an entry’s entry <id> (first read) — or entry <id> (re-read: effects skipped), with each skipped effect marked (skipped: re-read). A bundle beat’s beat record is not printed; the → or + line already names it. Several presented beats follow one another in presentation order. Quest transitions come last — those the presentations caused, then those the step’s occasion judged: <quest>.<objective> done, <quest>.<objective> failed (by) or failed (until) for a missed deadline, quest <id> -> <state> — a failure naming its reason, failed (by), (until), (fail), (cascade) or (superseded) (dsl 0.24.0 §2) — and reward grants — grant <quest> <KIND> <amount>, followed by (credits <path> = <value>) when the reward kind credits state (a whole number without a decimal point: = 50). In JSON both land in the step’s quests.
  • A write that a plugin directive’s declared effects: made (dsl 0.27.0 §4) prints under the call like any other write, naming the call it came from: set scene.check.sneak.passed = false (effect of ::check), assert holding(brassKey) (effect of ::give), retract holding(lamp) (effect of ::give) — retractions first, then the call’s asserts. In --json the record carries "effectOf": "<tag>".
  • Since 0.51 a plugin call or a grant whose declaration has effects.raises ends its line with → raises <occasion> <target> per declared raise (::playScene{scene="chapter2"} (plugin call, not invoked) → raises cutscene scene.chapter2, grant[#1 i0] finale CUTSCENE 1 -> chapter2 → raises cutscene scene.chapter2). lute play does not raise the occasion itself: the script’s next step does, as a host does. In --json the plugin record carries "raises": [{ "occasion", "target"? }].
  • --ir prints the lowered records instead: each staging record as ::<kind>{…} in its IR form (::bg{location="parlor"} for that ::bg, ::actor{character="lise" posReset=true} for a pose reset), the ones the compiler injected included and marked (injected: <by>), and a bundle beat’s beat record as a beat line. --ir changes only what is printed: transcriptContains / transcriptLacks always judge the content lines, and --json carries every record either way.
  • An engine: step lists its writes: set <path> = <value>, assert <atom>, retract <atom> — retract <atom> (did not hold) when it was not a fact — and since dsl 0.26.0 quest <id> accepted (engine) for each accept: — or, for a quest already active, complete or failed, note: quest <id> is already active — engine accept ignored, which changes nothing.
  • A newRun step prints run.* state, run-tier facts and once: run reset, then ; prev.run.* holds the ended run (<n> values) when the ended run left any run.* value to snapshot, then each snapshotted value, prev.run.<path> = <value> (dsl 0.24.0), then quest <id> -> unset (tier: run; was <status>) for each run-tier quest that had left unset — with a note: after an accept-driven one that was active with no objective done or failed — then quest <id> accepted (queued at="nextRun") for each queued acceptance, then its seed’s writes.
  • An event: step prints the handlers that ran, and <on event=<name>> of quest <id> skipped — quest complete for a quest that already settled; the lifecycle’s transitions follow every step kind.
  • In --json, every record in presented.commands carries its position in the document; a line record carries role (dialogue, narration, mono, os, or vo), lineId, and voiceKey — every line has a voiceKey, narration included — plus as and emotion where they apply, and a choice or hub record lists the options not offered under ineligible and the taken once options under spent.
  • The walk ends with ── end: complete (<n> steps) — every repetition counted — or ── end: `end: true` at step <n> (<k> later steps skipped) after an end step, or ── end: terminal — `terminal: <condition>` holds when the game is over after the last step (dsl 0.27.0 §4), or ── halted: <message> when it stops early. Then the --derivation trees, then the ── expect: block.

A shape-only project (no plugins) with a town scene that offers a side job, and a quest whose calm objective is judged at runEnd:

<quest id="holdLine" title="Hold the line" start="true">
<objective id="sawShed" title="See the shed" done="visited('haven.shed')"/>
<objective id="calm" title="Keep it calm" on="runEnd" done="run.pressure < 2"/>
</quest>
<quest id="sideJob" title="Side job">
<objective id="mind" title="Mind the shed" done="run.pressure < 5"/>
</quest>

With steps: [{occasion: townVisit}, {occasion: runEnd}] and choose: { offer: take } — the choice whose body is ::accept{quest="sideJob"} — the playthrough accepts the side job during the presentation, activates it right after, and completes holdLine only when runEnd is raised:

── start ──────────────
quest holdLine -> active
── step 1 · townVisit ──────────────
✓ haven.shed [scene, priority 0]
→ haven.shed
@vesna: Somebody has to mind the shed.
▷ choice offer: [take] leave ← chosen: take
quest sideJob accepted
@vesna: Good. It's yours.
quest sideJob -> active
holdLine.sawShed done
sideJob.mind done
quest sideJob -> complete
── step 2 · runEnd ──────────────
(no candidates)
→ (no eligible beat — the occasion passes)
holdLine.calm done
quest holdLine -> complete
── end: complete (2 steps) ──────────────

--json emits the same walk as one object:

type PlayTranscript = {
exit: "complete" | "incomplete" | "error";
start: { quests: QuestGroup[] }; // transitions made before step 1
steps: Step[]; // one record per repetition
skipped?: { step: number; label?: string }[]; // the steps an `end: true` step left unplayed
endReason?: string; // "complete (8 steps)", or "`end: true` at step 2 (1 later step skipped)"
end: "complete" | "terminal" | "incomplete" | "error"; // how the walk ended — the values `expect.end` names; `terminal`: the schema's `terminal:` held
error?: { message: string };
expect?: { misses: ExpectMiss[] }; // when the script carries an `expect:`
explain?: Explanation[]; // one per `--derivation` atom
};
type Step = (OccasionStep | EngineStep | NewRunStep | EventStep | AdvanceStep | EndStep) & {
step: number; // the script step (shared by its repetitions)
label?: string;
iteration?: number; // 1-based repetition, when `repeat` > 1
repeat?: number;
quests: QuestGroup[]; // transitions this step caused
};
type OccasionStep = {
occasion: string;
target?: string;
select: "first" | "all" | "sequence";
pick?: string; // a beat id, or "none"
candidates: {
id: string;
kind: "scene" | "entry" | "beat"; // "beat": a bundle beat
document: string;
priority: number;
eligible: boolean | null; // null: the `when` evaluated to unknown
reason?: string; // e.g. "when: false", "when: unknown (<detail>)"
also?: true; // an `also` beat
read?: true; // an entry already read in this run
for?: string; // a `for="kind:<kind>"` beat's member (dsl 0.27.0 §3): one candidate per member
}[];
judgedBefore?: QuestGroup[]; // a `judge: before` occasion's quest transitions, made before the candidates were decided
// (the handler bodies it answers follow the beats, in `quests`)
winner: string | null;
presented?: Presentation; // the winner, else the first `also` beat; a sequence's first beat
then?: Presentation[]; // the beats presented after it, in order
};
type Presentation = {
id: string;
kind: "scene" | "entry" | "beat";
document: string;
also?: true;
commands: RunnerRecord[]; // the records `lute run --json` emits
stateDelta: Record<string, unknown>; // path -> value
};
type EngineStep = { engine: WriteRecord[] };
type NewRunStep = {
newRun: true;
seed: WriteRecord[];
resetQuests?: Record<string, string>; // run-tier quest -> the status it had before the reset
resetUnjudged?: string[]; // accept-driven run-tier quests reset while active with no objective done or failed (dsl 0.24.0)
prevRun?: Record<string, unknown>; // the `prev.run.*` snapshot: path -> value (dsl 0.24.0)
accepted?: string[]; // the `::accept{… at="nextRun"}` quests applied at this run start
};
type EventStep = { event: string };
type EndStep = { end: true };
// dsl 0.24.0 §1: the occasion the clock raised where it stopped, when `raise:` names a `slot`
// occasion, is carried as the OccasionStep fields beside `advance`.
type AdvanceStep = {
advance: {
by: string; // "slot", "day", the slot count, or "to night" / "to Fri morning" (dsl 0.26.0)
from: string; // "day 1 (Mon) afternoon"
to: string;
days?: DayStop[]; // each midnight stop, in order, when `raise:` names `dayEnd` / `dayStart`
ended?: true; // 0.27.0: the advance reached a finite clock's end (its last `dayEnd` is in `days`; no slot raise)
passed?: { // the positions it passed without raising its `raise.slot` occasion (neither start nor stop)
occasion: string;
beats: number; // how many beats answer that occasion
at: { day: number; slot?: string }[];
};
writes: WriteRecord[]; // the last move, to where the clock stops, then an `engine:` beside the advance
quests: QuestGroup[]; // the settle right after that move
};
} & Partial<OccasionStep>;
type DayStop = {
at: string; // "day 1 (Mon) night"
writes: WriteRecord[]; // the move to this stop
settled: QuestGroup[]; // the settle after that move
} & OccasionStep; // the `dayEnd` / `dayStart` occasion raised here
type WriteRecord =
| { kind: "set"; path: string; value: unknown }
| { kind: "assert"; fact: string }
| { kind: "retract"; pattern: string; held: boolean }
| { kind: "accept"; quest: string; by: "engine" }; // an `engine: { accept }` (dsl 0.26.0)
type QuestGroup = {
document: string; // the quest document
commands: RunnerRecord[]; // its `objective` (`failed: true` and `failedBy: "by" | "until"` on a missed
// deadline) / `quest` (`failedBy` when it failed) /
// `grant` (`credited: { path, value }`) / `acceptSpent` (`quest`, `parent`,
// `parentStatus`: an accept spent while the parent was not active) / handler records
};
type ExpectMiss = {
step: number | null; // null: the top-level `expect:`
label: string | null;
occasion: string | null; // "talk npc.tomas"
repetition: number | null;
key: string; // "winner", "quests lampOut", "state run.day", …
expected: string;
actual: string;
};
type Explanation =
| { atom: string; holds: true; proof: Proof }
| { atom: string; holds: false; derived: boolean; attempts: Attempt[] };
type Proof =
| { fact: string; support: "seed fact" }
| { fact: string; support: "asserted"; assertedBy?: string } // "engine step 2", "entry `tomasOil`, step 3"
| { fact: string; support: "derived"; rule: string; premises: Premise[] };
type Attempt = { rule: string; premises: Premise[] };
type Premise =
| { status: "holds"; proof: Proof }
| { status: "missing"; atom: string; attempts: Attempt[] }
| { status: "absent"; negated: string; attempts?: Attempt[] } // the rules that could conclude it, when any
| { status: "present"; proof: Proof }
| { status: "test"; test: string; holds: boolean | null }
| { status: "unreached"; premise: string };

lute init --template beats scaffolds a small project that already passes check --project, test, and play: the first day in a town square, a lamp that has gone dark, and the two people who know why.

Terminal window
$ lute init --template beats town
town/
├── lute.project.yaml
├── world.schema.yaml
├── vocabulary.schema.yaml
├── plugins/game.occasions/
│ ├── plugin.yaml
│ └── occasions/game.yaml
├── scenes/
│ ├── town/welcome.lute
│ ├── town/morning.lute
│ ├── town/day-end.lute
│ ├── talk/mara-first.lute
│ └── talk/mara-idle.lute
├── quests/lamp.lute
├── lore/tomas.lute
├── plays/first-day.play.yaml
├── tests/
│ ├── mara-first.test.yaml
│ └── lamp-quest.test.yaml
└── README.md

The manifest activates one plugin whose only export is the engine’s occasions, and its defaults: give every document its language version and both schema imports. The occasions, plugins/game.occasions/occasions/game.yaml:

occasions:
townVisit: { select: first, description: The player arrives in town }
talk: { select: first, target: { prefix: npc, entity: npc }, description: The player talks to someone (npc.<name>) }
dayEnd: { select: first, description: "The engine closed the day; run.day is already advanced" }

The shared state, world.schema.yaml. The day is the engine’s: content reads run.day, but only the engine — and so only an engine: step — writes it:

state:
run.day: { type: int, default: 1, owner: engine }
user.bond.mara: { type: int, default: 0 }
entities:
npc: { members: [mara, tomas] }
item: { members: [lamp] }
relations:
knows: { args: [item], tier: run }
defs:
firstDay: "run.day == 1"
trusted: "user.bond.mara >= 1"

The beats:

BeatDocumentAnswersConditionsonce
town.welcomescenes/town/welcome.lutetownVisitpriority 10user
town.morningscenes/town/morning.lutetownVisitwhen: '!@firstDay' — prints Day {{run.day}}.false
town.dayEndscenes/town/day-end.lutedayEnd—false
mara.firstscenes/talk/mara-first.lutetalk → npc.marapriority 10user
mara.idlescenes/talk/mara-idle.lutetalk → npc.mara—false
tomasOil (entry)lore/tomas.lutetalk → npc.tomaspriority 10, when="quest.lampOut.state == 'active'"; asserts knows(lamp)—
tomasBusy (entry)lore/tomas.lutetalk → npc.tomas——

mara.first asks the question the day turns on:

<branch id="maraAsk" prompt="What do you say?">
<choice id="lamp" text="Offer to find out why">
@mara{emotion="delighted"}: Would you? Tomas keeps the oil. Ask him.
::set{ user.bond.mara += 1 }
::accept{quest="lampOut"}
</choice>
<choice id="leave" text="Say nothing">
@mara: Suit yourself.
</choice>
</branch>

The quest it accepts, quests/lamp.lute, belongs to the run (tier="run") and finishes only when the engine closes the day:

<quest id="lampOut" title="The lamp by the door" tier="run">
<objective id="ask" title="Ask Tomas about the oil" done="holds('knows', ['lamp'])"/>
<objective id="wait" title="Wait for the day to end" on="dayEnd" done="run.day >= 2"/>
<on event="questComplete">
@narrator: By morning the lamp by the door is burning again.
</on>
</quest>

The scaffold’s play script, plays/first-day.play.yaml, plays the day and asserts what it should do:

choose:
maraAsk: lamp
steps:
- occasion: townVisit
expect: { winner: town.welcome }
- occasion: talk
target: npc.mara
expect: { winner: mara.first }
- occasion: talk
target: npc.tomas
expect: { winner: tomasOil, offered: [tomasOil, tomasBusy] }
- label: the engine closes the day
engine:
state: { run.day: { add: 1 } }
- occasion: dayEnd
- occasion: townVisit
expect: { winner: town.morning, notOffered: [town.welcome] }
expect:
end: complete
quests: { lampOut: complete }
state: { run.day: 2, user.bond.mara: 1 }
facts: [knows(lamp)]

From the project directory:

Terminal window
$ lute play --project . --script plays/first-day.play.yaml
── step 1 · townVisit ──────────────
✓ town.welcome [scene, priority 10]
✗ town.morning [scene, priority 0] — when: false
→ town.welcome
::bg{location="town" time="day"}
@narrator: The lamps along the square are lit — all but the one by the door.
── step 2 · talk → npc.mara ──────────────
✓ mara.first [scene, priority 10]
✓ mara.idle [scene, priority 0]
→ mara.first
@mara{emotion="content"}: You're new. The lamp by the door has been dark for a week.
▷ choice maraAsk "What do you say?": [lamp] leave ← chosen: lamp
@mara{emotion="delighted"}: Would you? Tomas keeps the oil. Ask him.
set user.bond.mara = 1
quest lampOut accepted
quest lampOut -> active
── step 3 · talk → npc.tomas ──────────────
✓ tomasOil [entry, priority 10]
✓ tomasBusy [entry, priority 0]
→ tomasOil
entry tomasOil (first read)
@tomas: Oil? Top shelf. Tell Mara it's the wick, not the oil.
assert knows(lamp)
lampOut.ask done
── step 4 (the engine closes the day) · engine ──────────────
set run.day = 2
── step 5 · dayEnd ──────────────
✓ town.dayEnd [scene, priority 0]
→ town.dayEnd
@narrator: One by one, the lamps go out.
lampOut.wait done
quest lampOut -> complete
@narrator: By morning the lamp by the door is burning again.
── step 6 · townVisit ──────────────
✓ town.morning [scene, priority 0]
✗ town.welcome [scene, priority 10] — once: user — already presented
→ town.morning
@narrator: Day 2. The square is already awake.
── end: complete (6 steps) ──────────────
── expect: every expectation held ──────────────

Reading it step by step:

  • Start — lampOut has no start, so nothing activates before the first step and there is no ── start block.
  • Step 1 — it is day 1, so town.morning’s @firstDay guard keeps it out and the welcome plays.
  • Step 2 — talk is raised for npc.mara, a member of the npc kind; both of Mara’s scenes are candidates and the first meeting outranks the fallback. Choosing lamp raises the bond and accepts the quest, which activates right after the presentation.
  • Step 3 — with the quest active, Tomas’s oil entry is eligible and outranks his bark. Its first read asserts knows(lamp), which completes the first objective.
  • Step 4 — the engine closes the day. An engine: step writes run.day, which content may not (owner: engine); nothing is presented and no occasion is raised.
  • Step 5 — dayEnd presents the night scene, then judges lampOut.wait (on="dayEnd"), and the quest completes; its questComplete handler plays.
  • Step 6 — town.welcome is spent for good (once: user), and run.day is 2, so the morning plays.

A step’s own choose: changes one presentation and nothing else. Here the player says nothing to Mara, while the expectations still describe the first day — so they miss:

choose:
maraAsk: lamp
steps:
- occasion: townVisit
- occasion: talk
target: npc.mara
choose: { maraAsk: leave }
- occasion: talk
target: npc.tomas
label: ask about the oil
expect: { winner: tomasOil }
expect:
quests: { lampOut: active }
state: { user.bond.mara: 1 }
── step 1 · townVisit ──────────────
✓ town.welcome [scene, priority 10]
✗ town.morning [scene, priority 0] — when: false
→ town.welcome
::bg{location="town" time="day"}
@narrator: The lamps along the square are lit — all but the one by the door.
── step 2 · talk → npc.mara ──────────────
✓ mara.first [scene, priority 10]
✓ mara.idle [scene, priority 0]
→ mara.first
@mara{emotion="content"}: You're new. The lamp by the door has been dark for a week.
▷ choice maraAsk "What do you say?": lamp [leave] ← chosen: leave
@mara: Suit yourself.
── step 3 (ask about the oil) · talk → npc.tomas ──────────────
✓ tomasBusy [entry, priority 0]
✗ tomasOil [entry, priority 10] — when: false
→ tomasBusy
entry tomasBusy (first read)
@tomas: Busy.
── end: complete (3 steps) ──────────────
── expect: 3 missed ──────────────
✗ step 3 (ask about the oil) at talk npc.tomas: expect winner: expected tomasOil, actual tomasBusy
✗ end of play: expect quests lampOut: expected active, actual unset
✗ end of play: expect state user.bond.mara: expected 1, actual 0

The walk itself completed; the misses make lute play exit 1.

A later session does not replay the first day: the script starts from the save a player who finished it would have, and the engine starts run two on day 3:

state: { user.bond.mara: 1 }
presented: { user: [town.welcome, mara.first] }
quests: { lampOut: complete }
steps:
- label: the engine starts run two on day 3
newRun: { state: { run.day: 3 } }
- occasion: townVisit
expect: { winner: town.morning, notOffered: [town.welcome] }
- occasion: talk
target: npc.mara
expect: { winner: mara.idle }
- occasion: talk
target: npc.tomas
expect: { winner: tomasBusy, notOffered: [tomasOil] }
- label: a quiet day passes
engine:
state: { run.day: { add: 1 } }
repeat: 2
- occasion: dayEnd
expect: { winner: town.dayEnd }
expect:
end: complete
state: { run.day: 5 }
transcriptContains: ["Any luck with the lamp?", "Day 3."]
transcriptLacks: ["You're new."]
── step 1 (the engine starts run two on day 3) · new run ──────────────
run.* state, run-tier facts and once: run reset; prev.run.* holds the ended run (1 value)
prev.run.day = 1
quest lampOut -> unset (tier: run; was complete)
set run.day = 3
── step 2 · townVisit ──────────────
✓ town.morning [scene, priority 0]
✗ town.welcome [scene, priority 10] — once: user — already presented
→ town.morning
@narrator: Day 3. The square is already awake.
── step 3 · talk → npc.mara ──────────────
✓ mara.idle [scene, priority 0]
✗ mara.first [scene, priority 10] — once: user — already presented
→ mara.idle
@mara{emotion="shy"}: Any luck with the lamp?
skip @mara "Mm." — when: false
── step 4 · talk → npc.tomas ──────────────
✓ tomasBusy [entry, priority 0]
✗ tomasOil [entry, priority 10] — when: false
→ tomasBusy
entry tomasBusy (first read)
@tomas: Busy.
── step 5 (a quiet day passes) [1/2] · engine ──────────────
set run.day = 4
── step 5 (a quiet day passes) [2/2] · engine ──────────────
set run.day = 5
── step 6 · dayEnd ──────────────
✓ town.dayEnd [scene, priority 0]
→ town.dayEnd
@narrator: One by one, the lamps go out.
── end: complete (7 steps) ──────────────
── expect: every expectation held ──────────────
  • Save — presented.user spends both once: user scenes, quests: resumes lampOut as complete, and the user.bond.mara seed makes Mara’s @trusted line play.
  • Step 1 — the long-form newRun seeds the new run: the ended run’s run.day is snapshotted (prev.run.day = 1), the run-tier lampOut starts over as unset (so the oil entry’s when stays false), run.day resets to its default, then the seed sets it to 3.
  • Step 5 — one engine: step, repeated: each repetition is its own record, and the run ends with run.day at 5.

Every play that carries an expect: runs under lute test, beside the scenario tests. With the two plays above saved as plays/say-nothing.play.yaml and plays/returning.play.yaml:

Terminal window
$ lute test --project .
PASS ./tests/lamp-quest.test.yaml (./quests/lamp.lute)
PASS ./tests/mara-first.test.yaml (./scenes/talk/mara-first.lute)
PASS ./plays/first-day.play.yaml (play of .)
PASS ./plays/returning.play.yaml (play of .)
FAIL ./plays/say-nothing.play.yaml (play of .)
step 3 (ask about the oil) at talk npc.tomas: expect winner: expected tomasOil, actual tomasBusy
end of play: expect quests lampOut: expected active, actual unset
end of play: expect state user.bond.mara: expected 1, actual 0
4 passed, 1 failed

With only the scaffold’s own play, lute test --project . --coverage counts it among the tests:

PASS ./tests/lamp-quest.test.yaml (./quests/lamp.lute)
PASS ./tests/mara-first.test.yaml (./scenes/talk/mara-first.lute)
PASS ./plays/first-day.play.yaml (play of .)
3 passed, 0 failed
coverage over 2 traced path(s) and 1 play(s) (plays count toward what they presented and the choices they picked, not match arms):
branch/hub maraAsk (scenes/talk/mara-first.lute:maraAsk): 1/2 chosen [lamp]; never chosen [leave]
2 untested unit(s) under . — no *.test.yaml presents them and no play presents them:
lore/tomas.lute: tomasBusy
scenes/talk/mara-idle.lute
every other beat under . is presented by a play

The play presented the welcome, the morning, the night scene, Mara’s first meeting, and Tomas’s oil entry, so mara-idle.lute and the tomasBusy entry are left — both of which plays/returning.play.yaml presents.