Playing a story
Machine-readable output (0.43)
Section titled “Machine-readable output (0.43)”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.
Occasions
Section titled “Occasions”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 theentities:kindentity(any id for anopen:kind).talkabove is raised fornpc.achillesornpc.patrocluswhen the schema declaresperson: { members: [achilles, patroclus] }.target: true— raised for a target, shape only: any dotted id. Defaultfalse, not raised for anything.description— optional prose for tooling.judge: before(dsl 0.24.0 §2) — the occasion judges itson=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 thequestComplete/questFailedhandlers 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.
Scene beats
Section titled “Scene beats”A scene becomes a beat by naming the occasion it answers in its frontmatter:
---kind: sceneid: achilles.gifton: talktarget: npc.achilleswhen: 'user.runs >= 10'priority: 50once: user---| Key | Meaning |
|---|---|
on | The occasion this scene answers. Makes the scene a beat. |
target | Optional. 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. |
when | Optional 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. |
priority | Optional integer, default 0. Higher wins. |
once | run (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. |
also | Optional 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.
Entry beats
Section titled “Entry beats”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.
Bundle beats
Section titled “Bundle beats”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).
Kind targets
Section titled “Kind targets”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.
Selection
Section titled “Selection”When the engine raises occasion O, optionally for target T:
- Candidates are the beats with
on: Owhosetargetis absent or equal toT, and since dsl 0.26.0 the beats targetingkind:<K>for a kindT’s member belongs to (Kind targets). An occasion raised without a target has only untargeted candidates. - A candidate is eligible when its
after:holds (scene beats; a bundle beat’safter=), itswhenholds, and itsonceis 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’sonce:run—entry.<id>.readnot set;user—entry.<id>.everReadnot set;day/slot— as a scene’s; absent — never spent. A beat with asharekey (dsl 0.25.0 §2) is also spent when any other beat of its key was presented (an entry: read) within itsonceperiod:✗ talks.solRoof [beat, priority 0] — once: day — `share: solWarm` already spent today by talks.solRadio. - Eligible beats are ordered by priority, descending, then project order: document path, then declaration order within the document — the order of
beatsinproject.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). select: firstpresents the first eligible beat that is notalso, then every eligiblealsobeat;select: alloffers the ordered list and presents the one the player picks;select: sequencepresents the whole ordered list.- 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.
lute play
Section titled “lute play”$ lute play --project <DIR> --script <FILE> [--json] [--ir] [--quiet] [--no-derive] [--events] [--derivation <ATOM>]… [--conditions-output <FILE>]Runtime event stream (--events)
Section titled “Runtime event stream (--events)”--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.yamland its plugins). The project is compiled whole, in memory, with the same gate and declaration unioncompile --projectuses (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.yamlfile.--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 morewhen: falsecandidates at one raise print as one line,✗ 8 beats — when: false: a, b, c, …;--jsonalways lists every candidate.--no-derive— do not apply the project’s Datalog rules (dsl 0.22.0 §6); overrides the script’sderive:. 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 toFILE.
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: stormrewardKinds: 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>The play script
Section titled “The play script”A play script is a YAML mapping. steps is required; every other top-level key is optional:
| Key | Meaning |
|---|---|
steps | Required, non-empty: what happens, in order. |
state | Seeds: state path → scalar literal, over the declared defaults. |
facts | Ground facts added to the project’s seed facts. |
choose | Branch/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, entriesRead | The save the play starts from — see Starting from a save. |
expect | Assertions about the end of the play — see Expectations. |
derive | false stops applying the project’s Datalog rules — see Derivation and --derivation. |
bridges | Answers 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: truetimeout: 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 defaultsfacts: ["slew(hound)"] # ground facts, added to the project's seed factsentriesRead: { user: [notice] } # the save this play starts fromsteps: # 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, twiceexpect: # 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 — neverscene.*— and its value must fit the declared type (anintordoublefor a numeric path, a member for an enum); anything else is a usage error (exit 2). Aquest.<id>.stateseed (state: { quest.lostCup.state: active }) is that quest’s lifecycle status from the start, exactly as aquests:entry, and aquest.<id>.objectives.<oid>.done: trueseed is an objective the save has already done — see Starting from a save. Aprev.run.<path>seed (dsl 0.23.0) is the valuerun.<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
oncehub option already taken (E-TRACE-CHOICE, as inlute trace).
Scripted decisions in a loop (0.51)
Section titled “Scripted decisions in a loop (0.51)”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
awaitChoicerequest), and its list is consumed one decision per presentation.choose: { retry: [yes, yes, no] }drives three iterations: twoyespicks that take the jump back, then anothat 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
onceoption taken on an earlier visit stays spent, so a list that names it again halts withE-TRACE-CHOICE(exit 1).
choose: { retry: [yes, yes, no] } # a <branch id="retry"> inside a ::jump{max="3"} loopsteps: - occasion: lockPickedA 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 steps
Section titled “Occasion steps”{ 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 declarespayload:(dsl 0.27.0 §3):payload: { copies: 2 }. The beats of this raise read them asoccasion.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· enginerecord of the step, the quests settle, and then the occasion is raised, so its beats and its gate see them.- occasion: enterwithtarget: room.officeandengine: { 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 testjudges a beat of a gated occasion by the same seam:eligible: trueon it misses naming the gate and its false reads, or the holdingterminal:(--json: the expectation carriesnotRaised: { occasion, reason: "gate" | "terminal", condition, falseReads }). After the game is over (the schema’sterminal:holds) everyoccasion:step is refused the same way. pick— refused onselect: firstandselect: 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), orpick: none. Aselect: allstep 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 aspick: none, its header reading(select: all, pick: none (nothing offered))(dsl 0.24.0).nonecloses 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’schoose: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’sentriesRead:, and a step’swinner,offered,notOfferedandpresented— solore.tomas.tomasOilsays 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 steps
Section titled “Engine steps”{ 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 anintpath’s current value. A path is refused when it is undeclared,scene.*, or aquest.*path: quest status belongs to the quest lifecycle, whose transitions fire handlers and grants — seed a save’s quest status with top-levelquests:instead.facts:/retract:— ground atoms of declared base relations, reserved ones included; the same checks as top-levelfacts:. 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 (nostart; typicallyaccept="external"), and it activates in the settle right after the step. The transcript printsquest <id> accepted (engine), and--jsonrecords{ "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 printsnote: quest <id> is already active — engine accept ignored(with its status),--jsonrecords{ "kind": "acceptIgnored", "quest": "<id>", "status": "<status>" }, and nothing changes. A quest with astart, 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-levelquests:, 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.tomasexpect: 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 ──────────────Events
Section titled “Events”{ 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.
Ending the playthrough
Section titled “Ending the playthrough”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.
The game is over
Section titled “The game is over”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:
| Written | Ends | Play goes on? |
|---|---|---|
::end in content | the presentation it runs in (in a quest handler, that quest document’s advance) | yes, with the next step |
- end: true in a script | the playthrough, at that step; later steps are skipped | no — end: complete |
terminal: in a schema | the game: the engine raises no occasion (but outsideRun ones) until a new run makes it false; with persists: true, for good | only after a newRun that resets it — end: terminal; never with persists: true |
last: / days: on the clock | the 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) | — |
Advancing the clock
Section titled “Advancing the clock”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: slotStartis 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.wrenisonce: day, so the second visit on day 1 finds it spent:once: day — already presented today(once: slotreadsalready presented this slot). - Steps 3–6 — each advance prints the move first —
advance <slot | day | n>: <from> → <to>, a position beingday <n> (<weekday label>) <slot>, the label only when the clock’sweek: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, andonce: slotlets the morning routine play again. - Step 6 —
advance: daygoes to the first slot of day 3, and the settle right after the move failsfest: its deadline is a moment, judged whether or not the player ever raisesvisitagain (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: dayexpect: 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: 2from the afternoon stops at the night to close day 1 (── step 2 · day 1 (Mon) night · dayEnd), crosses midnight to open day 2, and raisesslotStartonce, 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: dayfrom the morning closes day 2 where the clock stands; the move to day 3 failsfestin the settle beforedayStart. - A midnight raise plays content like any other step:
transcriptContains/transcriptLacksmatch its lines,--derivationnames a fact asserted there (asserted by sceneday.close, step 3), and underlute test --coveragea document it presented counts as covered. On step 2,expect: { presented: [day.close, day.open, routine.morning] }holds, andwinner: routine.morningjudges the final raise. - An advance that raises several occasions may key
presentedby occasion: on step 2,expect: { presented: { dayStart: [day.open], slotStart: [routine.morning] } }judges only those two raises, each in order, and leavesdayEndunjudged. 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; anoccasion:step raises one occasion, so itspresentedis 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 itAfter 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: slotsteps: - include: routes/day.yaml - label: the next morning advance: slot - include: routes/day.yamlSteps 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 scriptA 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 nightsteps: - 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)Labels and repetition
Section titled “Labels and repetition”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.
Starting from a save
Section titled “Starting from a save”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:
| Key | Meaning |
|---|---|
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: trueThe 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.
Run boundaries
Section titled “Run boundaries”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-tierentry.<id>.readflag, so an entry’s effects apply again on its first read in the new run and aonce="run"entry is eligible again;- run-tier facts to the project’s seed facts;
- every
<quest tier="run">quest tounset, with its objectives undone — astart-having one activates again at the settle that follows, an accept-driven one waits for a new accept. Each one that had leftunsetis listed with the status the run left it in,quest climb -> unset (tier: run; was complete); one stillunsetis reset silently. An accept-driven one the reset findsactivewith no objective done or failed adds anote:(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). Astart=quest such asclimbgets no note: it starts over on its own, andat="nextRun"could not take it anyway; once: runspending.
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.
Seasons and rearmed quests
Section titled “Seasons and rearmed quests”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.
Composing occasions
Section titled “Composing occasions”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.flooris unset and only the routine plays. - Step 3 — the
newRunsnapshotsrun.floor(3) intoprev.run.floorbefore resetting it, and prints it;climbstarts over, as in Run boundaries. - Step 4 — both beats are eligible: each gets its own
→line, and each plays in turn and spends its ownonce. The step’spresented: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.oskarexpect: 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.huntwins; the eligiblealsobeat is listed+ oskar.rumor (also)and plays after it. Both are bundle beats: the→and+lines name them, and theirbeatrecords appear only in--jsonand--ir. The hunt acceptshoundHunt, which activates in the settle after the step. - Step 5 — the hunt is spent (
oncedefaults torun), 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.
Deadlines and targeted objectives
Section titled “Deadlines and targeted objectives”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 timebyholds the objective fails and is never judged again. A failed required objective fails its quest:failedrewards, thequestFailedhandlers, and the cascade to a parent quest.doneis judged beforeby, so an objective done at the moment its deadline passes does not fail.byis judged at every settle — after a presentation, anengine:write, anadvance:, anewRun— 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>", besideon=— the place-bound deadline (0.23.1’s rule for anon=objective’sby): judged only when the objective is — when its occasion is raised (for itstarget, when it has one), right after itsdone— so between raises it waits. It printsfailed (until).target="<target>", besideon=— 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).
Quest structure
Section titled “Quest structure”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::acceptand activates only while its parent isactive. An accept while the parent is not active is spent without effect, and the transcript says so under the presentation — for a childhaggleaccepted before its accept-driven parentmarketwas: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: anacceptSpentrecord,{ "kind": "acceptSpent", "quest": "haggle", "parent": "market", "parentStatus": "unset" }, in the step’squests).<quest complete="any">on a parent — it completes when any required objective is done, and its other still-activechildren fail assuperseded(a child nobody accepted staysunset).judge: beforeon an occasion — the occasion judges itson=objectives and settles the quests before its beats are decided. Those transitions print right under the step header, before the candidates (--json: the step’sjudgedBefore; itsquestsare the ones after, with the handler bodies). Only the judging moves: the handlers the raise answers — its same-named<on event>handlers, and thequestComplete/questFailedhandlers 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 occasionEis raised for that target: never for a plainevent: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: chapterEndexpect: 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 —
coinhas noactivate="accept", so it activates withpurse;parleyandtollwait. - Steps 1–2 — each accept activates its child in the settle after the scene, because
roadis active. - Step 3 — the engine’s write makes
purse’sfailhold:(fail); its childcoin, still active, fails with it:(cascade). - Step 4 —
chapterEndisjudge: before, soparley.terms(on="chapterEnd") is judged first:roadcompletes on it, andtoll, accepted and still active, issuperseded— all before the candidate list, so the night scene’s<match>already readssuperseded.toll’squestFailedhandler still plays after the scene (--json: under the step’squests, notjudgedBefore). On a default (after) occasion the quest lines would follow the scene, and the<match>would readunset.
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 -> activerelic then waits for a new accept, and the same script ends with it unset.
Answering bridge calls
Section titled “Answering bridge calls”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
bridgeResultfields 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 readsmargin, so everycheckanswer gives it, but without that line- { passed: true }would be a complete answer, and the hints below would ask forpassedonly. 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 writesscene.*. Astate: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 withbridges: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’sresult:shape. An answer that neither types is refused at the call, never stored as a string (before 0.26.0 a component’s::battlestoredwon: trueas"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.katoThe 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.
Expectations
Section titled “Expectations”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:
| Key | Holds 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-GATE | on 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:
| Key | Holds 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:
| Key | Holds when |
|---|---|
end: complete | terminal | incomplete | error | the 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 0A 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.
Derivation and --derivation
Section titled “Derivation and --derivation”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:
$ 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:
$ 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.
Exclusive relations
Section titled “Exclusive relations”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.
Usage errors
Section titled “Usage errors”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
stepsis missing or empty; - a step names no action or more than one (an
advancewithengine:beside it is one), has an unknown key, carriestargeton a step that is not anoccasionorpick/chooseon one that is neither anoccasionnor anadvance, carries an occasion-onlyexpect:key (winner,offered,notOffered,presented) on one, or has arepeatthat is not a whole number ≥ 1; - an
advanceis notslot,day, a whole number ≥ 1,{ to: <slot> }, or{ to: { weekday, slot } }; itstonames a slot the clock does not declare or a weekday that is neither aweek.labelslabel nor a number 0..6; the project declares no clock; it carriespickwhile the clock’sraise:names noslotoccasion, orchoose/ a selectionexpect:while the clock raises nothing; or itsengine:writes the clock’s owndayorslotpath; aninclude:names an unreadable file or one of the wrong shape, sits beside a key other thanrepeat,chooseandbridges, 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 anendstep carriesbridges; - an
endstep is anything butend: true, or carriesrepeatorexpect; - 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 withouttarget, or an untargeted one with it; names a target outside the occasion’s domain; carriespickon aselect: firstorselect: sequenceoccasion; or picks a beat that does not answer that occasion; - an
event:names no declared world event, or a quest lifecycle event; - an
engine:ornewRunwrite names an undeclared orscene.*/quest.*path, a value that does not fit the declared type,{ add: … }on a path that is not anint, 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; anengine: { 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).
What each step does
Section titled “What each step does”An occasion step:
- Candidates — every beat in the project’s beat list with
onequal to the step’s occasion whosetargetis absent, equals the step’starget, or (dsl 0.26.0 §5) iskind:<K>for a kind the step’s target member belongs to — ranked after the other candidates of its priority, and reading that member asoccasion.targetwhile it is presented. - Verdicts — a candidate is eligible when its
onceis not spent (a scene or bundle beat:run— not presented since the lastnewRun;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>.readnot set;user—entry.<id>.everReadnot set;day/slot— as a scene’s; noonce— never spent), itsafter:holds (scene beats; evaluated against the livevisitedset of presented scenes and thecompleted/activesets of real quest states), and itswhenholds (evaluated by the reference runner’s CEL evaluator over live state and facts, with the Datalog rules applied). Awhenthat 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 aselect: firstoccasion, 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. - Order — eligible beats by priority descending, then project order.
- Select — the occasion’s
selectcomes from the resolved plugins’occasionsexport (an undeclared occasion isfirst).first: the first eligible beat that is notalsowins, and every eligiblealsobeat follows it (or plays alone when nothing else is eligible); none eligible, and the occasion passes with no story.all: the step’spickis presented; a pick that is not eligible at that moment is an error (exit 1);pick: nonepresents nothing, and so does a step withoutpickwhen nothing is eligible — with an eligible list and nopick, the walk halts with an error (exit 1) naming the offered beats.sequence: every eligible beat, in order. - 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’schoose:— with the step’s ownchoose:over it — decides branches and hubs; an unscripted decision halts incomplete (exit 3). A bundle beat runs the same way, from itsbeatrecord in the lore artifact. An entry beat is presented by the lore-entry rules: its effects apply on the first read only, thenentry.<id>.readandentry.<id>.everReadbecome true. A::endends only the presentation it runs in: the quest advance (6), the step’s other presentations (alsobeats, the rest of asequence), and the occasion’s objective judging (7) still run, and the playthrough goes on with the next step — only anend: truestep ends it. A scene’s::accept{quest="<id>"}printsquest <id> accepted; the quest activates at the advance right after the presentation. A scene or bundle beat counts as visited — forafter:and forvisited('<id>')in any condition — once its presentation finished. A plugin call whose effects read a bridge result takes the nextbridges: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. - Quests — after every presentation, every quest lifecycle advances exactly as
lute runadvances a quest artifact: activation (start, or — for a quest with nostart— an accept from a presented scene’s::accept, anactivate="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 thebydeadline of every open objective (a first true fails it,failed (by)),failbefore completion,<on>handlers, and<reward>grants — adding a scalar amount to the reward kind’scredits:path. A failed quest fails its active children (cascade), and acomplete="any"quest that completes fails its other active children (superseded). A::endin a handler or objective body ends that quest document’s advance, not the step. So a laterwhenoverquest.*, or anafter: completed(…)/active(…), sees real progress. - 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 thequestComplete/questFailedhandlers 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 withtarget=only when the step’stargetequals 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 carriestarget=only when the step’stargetequals it (dsl 0.23.0) — each one’sdone, then itsuntil; and the lifecycles settle again, so a quest can complete (or fail) at exactly that step. Anonobjective 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.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 | Complete — 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. |
1 | Error — 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. |
2 | Usage 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. |
3 | Incomplete — 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 transcript
Section titled “The transcript”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.── startcarries 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 aselect: alloccasion (pick: none (nothing offered)when a step withoutpickfound nothing eligible), and(select: sequence)on aselect: sequenceone —· 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/· dayStartat each midnight stop, a bare── step <n> · <position>for a move that raises nothing there, then theslotoccasion’s own header), or· end (the playthrough ends). A step anend: truestep 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, orbeatfor a bundle beat — and priority, thenalsofor analsobeat andreadfor 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, orwhen: false; a candidate whosewhenevaluated to unknown is marked?withwhen: unknown (<detail>). A step whose occasion no beat answers lists(no candidates). On ajudge: beforeoccasion the quest transitions it judged come first, right under the header. At a roster’s scale (round-5 T3-16), five or morewhen: falsecandidates at one raise print as one line —✗ 8 beats — when: false: a, b, c, … (`lute play --json` lists every candidate)— and--quietleaves out every✗candidate; a?one still prints, since it is why a play halts. → <id>names the winner — one→line per beat on aselect: sequencestep — and+ <id> (also)eachalsobeat that follows it. With no winner the line reads→ (no eligible beat — the occasion passes),→ (no eligible main beat)when onlyalsobeats 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}}as2nd, a path typed against an enum withlabels:as its member’s label (dsl 0.24.0 §1, §4),{{occasion.target}}as the raised member’s castname:(dsl 0.26.0 §5); a line whosewhen=is false asskip @maud "You again." — when: false, and a guarded write that did not apply asskip set run.aff += 1 — when: false— since dsl 0.26.0 every other directive with a falsewhen=the same way:skip ::give{item="potion"} — when: false,skip assert metWren(wren) — when: false, andskip ::use{component="…" …} — when: falsefor a::usethat 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::bgauto-hide) is left out; a plugin directive the reference player cannot run adds(plugin call, not invoked), and one whose bridge result abridges:answer decided adds(bridge answered: passed=true, margin=3)((bridge unanswered: passed, margin)where it halted the walk); a::endas 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 falsepiano✗, and aonceoption already takentable(spent); state writes asset <path> = <value>, a scene’s::acceptasquest <id> accepted—quest <id> accepted (queued: applies after the next newRun)forat="nextRun"— (JSON: an{"kind": "accept", "quest": "<id>"}record inpresented.commands), and an entry’sentry <id> (first read)— orentry <id> (re-read: effects skipped), with each skipped effect marked(skipped: re-read). A bundle beat’sbeatrecord 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)orfailed (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’squests. - 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--jsonthe record carries"effectOf": "<tag>". - Since 0.51 a plugin call or a grant whose declaration has
effects.raisesends 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 playdoes not raise the occasion itself: the script’s next step does, as a host does. In--jsonthepluginrecord carries"raises": [{ "occasion", "target"? }]. --irprints 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’sbeatrecord as abeatline.--irchanges only what is printed:transcriptContains/transcriptLacksalways judge the content lines, and--jsoncarries 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.0quest <id> accepted (engine)for eachaccept:— or, for a quest already active, complete or failed,note: quest <id> is already active — engine accept ignored, which changes nothing. - A
newRunstep printsrun.* state, run-tier facts and once: run reset, then; prev.run.* holds the ended run (<n> values)when the ended run left anyrun.*value to snapshot, then each snapshotted value,prev.run.<path> = <value>(dsl 0.24.0), thenquest <id> -> unset (tier: run; was <status>)for each run-tier quest that had leftunset— with anote:after an accept-driven one that was active with no objective done or failed — thenquest <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 completefor a quest that already settled; the lifecycle’s transitions follow every step kind. - In
--json, every record inpresented.commandscarries itspositionin the document; a line record carriesrole(dialogue,narration,mono,os, orvo),lineId, andvoiceKey— every line has avoiceKey, narration included — plusasandemotionwhere they apply, and a choice or hub record lists the options not offered underineligibleand the takenonceoptions underspent. - The walk ends with
── end: complete (<n> steps)— every repetition counted — or── end: `end: true` at step <n> (<k> later steps skipped)after anendstep, or── end: terminal — `terminal: <condition>` holdswhen the game is over after the last step (dsl 0.27.0 §4), or── halted: <message>when it stops early. Then the--derivationtrees, 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 };Worked example
Section titled “Worked example”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.
$ lute init --template beats towntown/├── 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.mdThe 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:
| Beat | Document | Answers | Conditions | once |
|---|---|---|---|---|
town.welcome | scenes/town/welcome.lute | townVisit | priority 10 | user |
town.morning | scenes/town/morning.lute | townVisit | when: '!@firstDay' — prints Day {{run.day}}. | false |
town.dayEnd | scenes/town/day-end.lute | dayEnd | — | false |
mara.first | scenes/talk/mara-first.lute | talk → npc.mara | priority 10 | user |
mara.idle | scenes/talk/mara-idle.lute | talk → npc.mara | — | false |
tomasOil (entry) | lore/tomas.lute | talk → npc.tomas | priority 10, when="quest.lampOut.state == 'active'"; asserts knows(lamp) | — |
tomasBusy (entry) | lore/tomas.lute | talk → 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 first day
Section titled “The first day”The scaffold’s play script, plays/first-day.play.yaml, plays the day and asserts what it should do:
choose: maraAsk: lampsteps: - 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:
$ 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 —
lampOuthas nostart, so nothing activates before the first step and there is no── startblock. - Step 1 — it is day 1, so
town.morning’s@firstDayguard keeps it out and the welcome plays. - Step 2 —
talkis raised fornpc.mara, a member of thenpckind; both of Mara’s scenes are candidates and the first meeting outranks the fallback. Choosinglampraises 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 writesrun.day, which content may not (owner: engine); nothing is presented and no occasion is raised. - Step 5 —
dayEndpresents the night scene, then judgeslampOut.wait(on="dayEnd"), and the quest completes; itsquestCompletehandler plays. - Step 6 —
town.welcomeis spent for good (once: user), andrun.dayis 2, so the morning plays.
One step decided the other way
Section titled “One step decided the other way”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: lampsteps: - 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 0The walk itself completed; the misses make lute play exit 1.
Coming back
Section titled “Coming back”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.userspends bothonce: userscenes,quests:resumeslampOutas complete, and theuser.bond.maraseed makes Mara’s@trustedline play. - Step 1 — the long-form
newRunseeds the new run: the ended run’srun.dayis snapshotted (prev.run.day = 1), the run-tierlampOutstarts over asunset(so the oil entry’swhenstays false),run.dayresets 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 withrun.dayat 5.
In the test suite
Section titled “In the test suite”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:
$ 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 failedWith 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 playThe 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.