Skip to content

Cheatsheet

Every construct on one page, as snippets you can copy. Each lute block below is compile-checked in CI against the real toolchain, and a link after each section goes to the full page.

What you need to write a story and play it, on one screen. The rest of the page starts at Project layout and covers every construct in full.

---
kind: scene
id: cafe.counter
title: At the counter
pov: fixer
on: chapter
after: 'visited("cafe.arrival")'
priority: 10
when: "run.tips == 0"
state:
run.tips: { type: int, default: 0 }
run.mood: { type: { enum: [calm, tense] }, default: calm }
---
## Counter {#counter}
@narrator: The espresso machine hisses.
@mira: The usual?
@fixer{mono}: She remembered.
@mira{os}: One moment!
<branch id="order" prompt="What will you have?">
<choice id="tea" text="Tea, and a tip">
::set{run.tips += 5}
@mira: Thank you!
</choice>
<choice id="nothing" text="Nothing tonight">
::set{run.mood = 'tense'}
@mira: Suit yourself.
</choice>
</branch>
<match subject="run.mood">
<when is="calm">
@mira: Stay as long as you like.
</when>
<when is="tense">
@mira: We close at ten.
</when>
</match>
  • title: in the frontmatter is the document’s title; the body has no # Title line.
  • @speaker: text is a line, and @narrator narrates. {mono} is a thought of the pov: speaker, {os} off-screen, {vo} voiceover. Content sits under a ## Heading, optionally ## Heading {#id}. → Lines, cast & staging
  • <branch> is a menu of <choice text="…">s. → Choices, hubs, jumps & endings
  • <match subject> picks the first <when is> arm that fits. → Match & when
  • ::set{…} writes state declared under state:. → State writes & facts
  • on: makes the scene answer an occasion, here chapter: any name works until a plugin declares occasions. after: orders it after another scene, priority: wins a tie, and when: gates it on state. → Beats · Connect scenes into a story

A test walks one scene; a play raises the occasion through the whole project, once per step:

tests/counter.test.yaml
file: ../scenes/counter.lute # relative to this test file
visited: [cafe.arrival] # the after: route this walk assumes
choose: { order: tea }
expect:
state: { run.tips: 5 }
transcriptContains: ["@mira: Stay as long as you like."]
end: complete
# plays/story.play.yaml: cafe.arrival answers `chapter` first, then cafe.counter
choose: { order: nothing }
steps:
- occasion: chapter
expect: { winner: cafe.arrival }
- occasion: chapter
expect: { winner: cafe.counter }
expect:
transcriptContains: ["@mira: We close at ten."]
Terminal window
$ lute check --project . # every document, and the project as a whole
$ lute test --project . # every *.test.yaml, and every play with an expect:
$ lute play --project . --script plays/story.play.yaml # the transcript of one playthrough

→ Tests and plays · Playing a story

my-game/
├── lute.project.yaml profiles, plugins, identity, defaults, chapters
├── world.schema.yaml run/user/app state, enums, defs, facts, rules
├── plugins/game.occasions/ optional: plugin.yaml + occasions/*.yaml + events/*.yaml
├── scenes/*.lute kind: scene
├── quests/*.lute kind: quest
├── lore/*.lute kind: lore
├── components/*.component.lute component: <name>
├── tests/*.test.yaml lute test
└── plays/*.play.yaml lute play; lute test runs those with an expect:

lute init --template beats <DIR> scaffolds this layout: an occasions plugin, beats with id:, a quest, entry beats, a play script with engine: steps and expect:, and scenario tests, all passing lute check --project, test, and play as generated.

lute.project.yaml:

pluginsDir: plugins/
defaultProfile: game
profiles:
game:
plugins: { game.occasions: true } # true = active with defaults
identity: # both values below are the defaults
lineId: "{prefix}.{speaker}_{code}"
voiceKey: "{prefix}.{speaker}-{code}" # the 0.21 default was {speaker}-{code}: pin it to keep old keys
defaults: # frontmatter every document inherits
luteVersion: "0.30.0"
uses: # resolved against THIS file's directory
- world.schema.yaml
- schema/areas/*.schema.yaml # 0.26.0: a glob, expanded in path order; matching nothing is fine
questTier: run # 0.26.0: the tier= of every <quest> that writes none
chapters: # 0.28.0: chains of scenes by id, in play order
- on: chapter # one chain per occasion: every listed scene answers it
scenes: [prologue, counter, accusation]

chapters: (0.28.0; it replaces 0.27.0’s single chain) is a list of chains, one per occasion. A chain writes on:, after: 'visited("<previous id>")' and a descending priority: (30, 20, 10, …) into every scene it lists; a key the scene writes itself wins. An id no scene declares, an unknown on:, a scene listed twice, two chains on one occasion, or a malformed chain is E-CHAPTERS (that chain is not applied; the others are). It is unrelated to an occasion’s select: sequence (every eligible beat of one raise plays, in selection order): on such an occasion the chain derives only on: and priority:, so the listed scenes play in one raise, in list order. On a targeted occasion every listed scene declares its own target:. See Connect scenes.

defaults: accepts only kind, character, season, episode, pov, monoSpeakers, luteVersion, contentLang, uses, extends, components, extra, and (0.26.0) questTier: run | user (anything else is E-DEFAULTS-KEY). A document that writes a key at all replaces the default for that key entirely, with no merging (uses: [] means “no imports”). A default that is illegal on a document’s kind is skipped for that document. Since 0.26.0 a uses entry may be a glob (*, **): a glob that matches nothing (an empty or missing directory) imports nothing; a literal path must exist.

components: in a document resolves against that document’s directory; defaults: components: resolves against the manifest’s directory, as defaults: uses: does. So a scene in scenes/ writes components: [../components/greet.component.lute], while the manifest writes defaults: { components: [components/greet.component.lute] } for the same file.

The default voiceKey carries {prefix} since 0.22.0, so voice keys are unique across the project. A project that recorded audio against the 0.21 keys pins identity: { voiceKey: "{speaker}-{code}" }, and then gets E-DUP-VOICEKEY wherever lines with different text land on one key.

world.schema.yaml is plain YAML with no --- fence. Scenes reach it with uses::

state: # scalar only: int | double | bool | string | enum
run.pressure: { type: int, default: 0 }
run.day: { type: int, default: 1, owner: engine } # content reads it; ::set is E-ENGINE-OWNED-WRITE
run.slot: { type: { enum: [morning, afternoon, night] }, default: morning, owner: engine }
run.mood: { type: { enum: [calm, tense] }, default: calm }
run.rival: { type: { enum: [kai, lee] } } # no default: maybe-unset until set
run.trust: { type: int, default: { _: 0, vesna: 2 }, per: crew } # 0.24.0: run.trust.vesna (2), run.trust.toma (0)
run.today: { type: { domain: weekday }, default: mon } # {{run.today}} renders "Monday"
user.runs: { type: int, default: 0 }
app.rating: { type: { enum: [teen, adult] }, default: teen }
enums: # content vocabulary: you declare every member
emotion: [neutral, happy, worried]
action: { members: [fadeInUp, fadeOutDown], exits: [fadeOutDown] }
anchor: { members: [left, center, right], default: center }
weekday: { members: [mon, tue], labels: { mon: Monday, tue: Tuesday } } # labels: 0.24.0
entities:
crew: { members: [vesna, toma] }
watch: { subsetOf: crew, members: [vesna] } # 0.24.0 sub-kind: legal wherever a kind is
topic: { members: [manifest, heading] }
relations:
awake: { args: [crew], tier: run }
knows: { args: [crew, topic], tier: run }
can_halt: { args: [crew], derive: true }
onWatch: { args: [crew], derive: true }
asleep: { args: [crew], tier: run, excludes: [awake] } # 0.25.0: never both on the same args (symmetric)
hurt: { args: [crew], tier: run, reserved: true, changedOn: [dusk] } # 0.25.0: the engine writes it only on `dusk`
facts:
- "awake(vesna)"
rules:
- "can_halt(C) :- awake(C), knows(C, manifest)"
- "onWatch(C) :- awake(C), count(awake(_)) >= 2" # 0.26.0: count / countDistinct in a rule body
defs:
calm: "run.pressure < 2" # shorthand: the body alone, type inferred (bool)
veteran: "user.runs >= 10"
vesnaKnows: "holds('knows', ['vesna', 'manifest'])"
pace: "run.pressure > 2 ? 0.4 : 0.8" # inferred double
holdTime: "1.5" # a constant: the only kind of def an attribute takes
atLeast: { type: bool, params: { n: int }, cel: "user.runs >= n" } # params need type:
clock: # optional (0.24.0), one per project: see Clock below
day: run.day # owner: engine
slot: run.slot # optional, with slots: (owner: engine); omit both for a clock of whole days
slots: [morning, afternoon, night] # exactly the slot enum's members, in order
raise: { slot: slotStart, dayEnd: dusk } # optional: one occasion (= slot:), or any of slot / dayStart / dayEnd
raiseAtStart: true # optional (0.28.0): the engine raises slot / dayStart where the run starts
week: { length: 7, first: 0, labels: [Mon, Tue, Wed, Thu, Fri, Sat, Sun] } # optional
last: { day: 1, slot: night } # optional (0.27.0): where the clock ends; or days: 1
cast: # optional (0.23.0): once declared, any other speaker is E-CAST-UNKNOWN
vesna: { name: Vesna, present: "holds('awake', ['vesna'])", emotions: [neutral, worried] } # 0.24.0 keys
toma: { name: Toma }
mira: { name: Mira }
guard1: { name: Night Guard, sharedName: true } # 0.26.0: a role name several speakers share
guard2: { name: Night Guard, sharedName: true }

Def type inference: comparisons, && || !, holds, and has give bool; count and arithmetic give int or double according to their operands, and integer/fractional literals give int/double; a bare path read gives that path’s type. A body the checker cannot type (for example "@other") needs the long form { type: …, cel: … }. Otherwise it is E-DEF-DECL.

The vocabulary slots are domains the project declares in enums:: emotion, action, anchor, mood, volume, musicPlayback, vfxType, and for staging costume (::actor), framing, cameraMove and transition (::camera), cgLayout (::cg), sequence (::sequence), and textStyle (inline text styles). Using one that nothing declares is E-DOMAIN-UNKNOWN, and a value outside a declared one is E-BAD-ENUM. action must list exits: (the members that take an actor off stage) and anchor must name a default: (E-ENUM-MISSING-SEMANTICS).

cast: (0.23.0) names the speakers, in a schema document or in a plugin’s cast export (cast/*.yaml, the same cast: map). Once any cast is declared, a speaker outside it (other than @narrator) is E-CAST-UNKNOWN with a did-you-mean, in scenes, quests, entries, and bundle beats, and since 0.24.0 so is an ::actor{character} or ::camera{focus} outside it. With no cast declared, any speaker id is accepted. A scene’s frontmatter cannot declare cast: (E-META-UNKNOWN-KEY). lute context lists the cast. A cast entry may also carry present: (a condition) and emotions: (0.24.0): a line by that speaker whose guards do not imply present is W-CAST-ABSENT, and an emotion= outside the speaker’s emotions: is E-BAD-ENUM. A {vo} line is exempt (the speaker may be outside the scene’s time); an {os} line is checked, since {os} means in the scene but off screen. With assume: true on the entry, a negated holds of an engine-reserved: relation in present: reads as true: present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])" then needs only the inParty guard. Since 0.25.0, when that relation declares changedOn: [battleEnd], assume stops covering it in a unit presented on battleEnd and in every unit after one in the scenario graph (after: / after= / [start] edges): those lines warn again until guarded.

Also 0.24.0: subsetOf: declares a sub-kind; an argument of kind watch is also a crew. Since 0.26.0 a sub-kind’s members are members of its parent without being listed there (restating one is allowed), a member listed twice in one kind or enum is E-ENTITY-KIND-SHAPE, and a schema may extend a kind another import declares with add: (crew: { add: [ilsabet] }; exactly one import declares the kind, and a member added by two files is E-ENTITY-KIND-SHAPE naming both). A rule body may compare count(R(…)) or countDistinct(R(…), V…) with >=, >, <=, <, ==, != over a relation its head does not feed (else E-RULE-AGGREGATE-CYCLE). Frontmatter state: declarations of one path in several documents must agree on type, default, per and owner (E-STATE-DECL-CONFLICT); to share a path, declare it once in a schema. Two cast entries with the same name: are W-DISPLAY-NAME-DUP unless both are marked sharedName: true. See Many authors, one project. per: <kind> declares one path per member of a closed kind (run.trust.vesna); an open or unknown kind is E-STATE-DECL. A map default: gives members their own values, with _ for the rest; an unknown member key, a member left with no value and no _, or a map default without per: is E-STATE-DECL. labels: on a long-form enum is what {{path}} renders for a path typed { domain: … }; a label for a non-member is E-ENUM-LABEL-NOT-MEMBER. An entity kind takes labels: the same way (0.27.0, room: { members: [chapel], labels: { chapel: the chapel } }); a label for a non-member is E-ENTITY-KIND-SHAPE. In text a kind’s label wins over a cast name: ({{occasion.target}}, a typed path, a typed component argument); a speaker head keeps the cast name. The clock: block is under Clock. seasons: (0.27.0) names recurring windows, harvest: { live: "@harvestLive" }: each opening (live false→true) moves season.<name>.* into prev.season.<name>.* and resets it to its defaults, clears once: season:<name> spends and resets tier="season:<name>" quests; a malformed or undeclared season is E-SEASON-DECL.

→ State schemas · Imports · Content vocabulary · Facts & Datalog · Clock

KindRequiredKind-only keys
kind: sceneid:, or the legacy character + season + episodeid, character, season, episode, episodeId, pov, monoSpeakers, after, beat keys on / target / when / priority / once / also / share (0.25.0) / spentBy / for (0.27.0)
kind: questone or more <quest> in the bodyid (optional bundle name), pov, monoSpeakers
kind: loreone or more <entry> or <beat> in the bodyid (required when it holds a <beat>), series, pov, monoSpeakers
component (no kind:)component: <name>component, params, effects (0.24.0), beat (0.27.0: a beat template’s header, used as <beat use="<name>" id="…" param="…">)

Every root kind also accepts title, luteVersion, contentLang, profile, plugins, uses, extends, components, state, defs, enums, entities, relations, facts, rules, codesLocked, mode, and extra (free descriptive data). A key outside this set is E-META-UNKNOWN-KEY, and a key written with _ is E-AUTHOR-CASE naming its lowerCamelCase spelling. title: is the only document title: a # Title line in the body is E-INERT-TITLE. A quest’s graph edge is the follows= attribute on <quest>. It is not a frontmatter key.

A plugin frontmatter key typed entity, domain, enumFromOption, providerRef or assetKind is checked (0.49.0) with the same type-specific validation as an attribute of that type: providerRef gives E-UNKNOWN-ID (and possibly W-CATALOG-STALE), asset kinds give E-ASSET-*, and an entity or enum typo is E-BAD-ENUM with a did-you-mean, for example (`mirra` is not a member of entity kind `person` (attribute `lead` of `frontmatter`) — did you mean `mira`?). Since 0.53 the same check reaches every such value nested in a record field, a list item or a map key or value, at any depth, and the message names the field path (field `partner` of `talk` of `frontmatter`). A value that cannot be represented, an unsupported map key type or a shape that does not match stays E-FRONTMATTER-SCHEMA.

→ Frontmatter & profiles

---
kind: scene
id: diner.night
pov: fixer
monoSpeakers: [mira]
enums:
emotion: [neutral, happy]
action: { members: [fadeInUp, fadeOutDown], exits: [fadeOutDown] }
anchor: { members: [left, center, right], default: center }
costume: [apron]
mood: [peaceful]
volume: [quiet, normal]
musicPlayback: [start, fadeOut]
vfxType: [whiteOut]
framing: [closeUp, wide]
cameraMove: [pushIn, shake]
cgLayout: [full, inset]
sequence: [dinerOpening]
textStyle: [emphasis]
state:
run.affection: { type: int, default: 0 }
---
## Counter {#counter}
::bg{location="diner" time="night"}
::music{playback="start" mood="peaceful" volume="quiet"}
::sequence{name="dinerOpening"}
::actor{character="mira" anchor="center" action="fadeInUp" costume="apron"}
::camera{focus="mira" framing="closeUp" move="pushIn" duration="0.5" wait="true"}
@narrator: The diner hums.
@mira{code="0010" emotion="happy"}: You're back, {{userName}}! Warmth: {{run.affection}}.
@fixer: I am.
@fixer{mono}: She remembered.
@mira{mono}: He came back.
@fixer{mono when="run.affection > 2"}: She remembered my order, too.
@mira: :emphasis[Wait.]:pause{s=0.5} :speed[Take your time.]{rate=0.8}
// a `//` line comment: the whole line is ignored
@mira{os}: Hold on!
@mira{as="???"}: ...who's there?
::sfx{sound="door bell"}
::vfx{type="whiteOut"}
::cg{assetId="cg.diner.window" layout="inset"}
::cg{assetId="cg.diner.window" display="hide"}
::clear
::music{playback="fadeOut"}
::end{reason="closing"}
PieceRule
@speaker{attrs}: text@narrator is narration; any other speaker is dialogue. The text runs to the end of the line: {{…}} interpolates and :name[…]{…} marks it up (inline modifiers, below). With a declared cast:, the speaker must be in it (E-CAST-UNKNOWN); a cast present: the line’s guards do not imply is W-CAST-ABSENT, and emotion= outside the speaker’s emotions: is E-BAD-ENUM (0.24.0). Every line, narration and {mono} included, compiles a lineId and a voiceKey.
line attrscode, emotion, variant, action, dialogMotion, as (label override), when (guard). A jump target is a ::label{name} on the line before. A quoted value decodes &quot; &apos; &amp; &lt; &gt; &#NN; &#xHH; (0.24.0); any other & stays literal, and \" still works.
delivery flags{mono} thought, {os} off-screen, {vo} voiceover; the IR roles are dialogue, narration, mono, os, vo. At most one per line, and never on @narrator. A flag combines with attributes: {mono when="…"}. A {mono} speaker is the point of view (pov:, else the project’s defaults.pov) or listed in monoSpeakers:: otherwise E-MONO-POV, or E-MONO-NO-POV when no point of view resolves. A component’s {mono} lines are judged at each ::use with the caller’s pov:.
inline modifiersIn line text only. :pause{s=0.5} pauses (seconds, s required); :speed[text]{rate=1.25} delivers the span at a rate; :emphasis[text] applies a textStyle member, which takes no attributes. Spans nest (the innermost rate wins). \: \[ \] \{ \} \\ are literal; a malformed or unclosed modifier is E-TEXT-MODIFIER, an unknown escape E-TEXT-ESCAPE. The IR text is the plain text (Wait. Take your time.); a modified line adds segments. A translation must keep the source’s modifiers (E-L10N-MODIFIERS).
{{…}}{{userName}}, a declared state path, or {{@def}} (the artifact carries the def’s body, and lute run / lute play evaluate it). Reading a maybe-unset path is E-MAYBE-UNSET. A line whose whole text is @name ships that literal text (W-TEXT-LOOKS-LIKE-REF); write {{@name}}. 0.24.0: {{run.visits:ordinal}} renders 1st, 2nd, … (an int or double only), and a path typed against an enum with labels: renders the label. 0.25.0: {{run.day:ordinalWord}} renders first … twentieth (the engine localizes it; lute play falls back to 21st digits above twenty). 0.27.0: {{run.lamps:plural(lamp|lamps)}} renders the first form for 1 and the second otherwise (# in a form is the number: plural(# lamp|# lamps)), and a value of an entity kind with labels: ({{occasion.target}}, a { domain: <kind> } path) renders its label; a cast name: wins. 0.28.0: :cardinalWord spells one … twenty; in a plural form #word / #Word is the number as a word (plural(One wagon|#Word wagons) → Eleven wagons); plural forms are bare text split on | (quoted or , forms are E-PLURAL-FORM); :capitalize, :start and :indefinite format text, and a kind label may declare { text, start, indefinite } forms for them (absent: capitalized text, a/an + text). Any other hint is E-CEL-PROFILE.
sectionsAll content sits under a ## Heading (E-CONTENT-OUTSIDE-SECTION before the first). ## Heading {#id} gives the section a stable id, unique in the document (E-SECTION-DUP); it is identity metadata, never a jump target.
directives::bg{location time assetId}, ::music{playback mood volume assetId}, ::sfx{sound assetId}, ::vfx{type label transition}, ::actor{character anchor action emotion costume} (entrance, pose, exit: an action in exits: takes the actor off stage), ::camera{focus framing move transition} (at least one, else E-CAMERA-EMPTY), ::cg{assetId display layout} and ::video{assetId display} (display is show or hide, default show), ::sequence{name} (names a cinematic the engine owns; wait defaults to true), ::end, and ::clear (0.24.0: everyone on stage exits; background and music stay). Timing keys: duration, delay (seconds), wait="true" (blocks). With a cast declared, ::actor{character} and ::camera{focus} must be in it (E-CAST-UNKNOWN).
comments// … to the end of the line, on a line of its own or after a directive (::set{run.n += 1} // why), and /* … */. After a <tag> it is E-TAG-INLINE-BODY, and inside a content line’s text // is literal.
wait on a line0.49.0. @mira{wait="false"}: … is admitted (a bool) and compiles to the line’s timing.wait; delay and duration on a line stay E-UNKNOWN-ATTR. A line always waits for its own advance; wait says whether the staging cues after it, up to the next line, wait for the line: true or absent they wait, false they start while the line is presented. The next line still waits, and so does a presentation cue (it clears the dialogue window).
::jump, ::label, ::clear0.49.0. They carry no stamp: a plugin stamp attribute or a core timing key on them is E-UNKNOWN-ATTR (`::jump` does not carry `cue`: it compiles to a `jump` record that has no stamp). Put the attribute on the line or directive before a jump, after a label, or on the ::actor{exit} you write — ::clear does not hand stamps to the exits it injects.

→ Dialogue & cast · Core directives

---
kind: scene
id: cafe.counter
state:
scene.warmth: { type: int, default: 0 }
run.metMira: { type: bool, default: false }
run.tip: { type: int, default: 0 }
defs:
warm: "scene.warmth >= 2"
---
## Counter
<branch id="greet" prompt="Mira looks up.">
<choice id="wave" text="Wave" into="run.metMira">
@mira: Hi!
::set{scene.warmth += 2}
</choice>
<choice id="tip" text="Leave a tip" into="run.tip" value="5">
@mira: Thanks!
</choice>
<choice id="flirt" text="Flirt" when="@warm">
@mira: Oh, stop.
</choice>
</branch>
<hub id="chat" prompt="Anything else?">
<choice id="coffee" text="Ask about the coffee" once>
@mira: House blend.
</choice>
<choice id="cup" text="Ask about the missing cup">
::accept{quest="lostCup"}
@mira: Find it and your next one is free.
</choice>
<choice id="leave" text="Leave" exit>
@mira: Bye.
</choice>
</hub>
::jump{to="outro" when="run.tip > 0"}
@mira: No tip, huh.
::label{name="outro"}
@narrator: The door closes behind you.
::end{reason="leftCafe"}
ConstructRule
<branch id>A menu. The pick is recorded in scene.choices.<id>, which clears when the scene ends. At least one choice must be unguarded (E-BRANCH-ALL-GUARDED). Optional prompt= and timeout="N".
<choice id text>text= is what the player sees. when= guard. into="run.x" writes true, or value= for numeric (int/double) and enum paths, so later scenes can read it. The IR’s choice and hub records name the path a pick lands in as selectionKey (scene.choices.greet).
<hub id>Re-presents eligible choices until an exit. once removes a choice after one take. It needs an unguarded exit, or every choice once (E-HUB-NO-EXIT). Each pick sets scene.visited.<hub>.<choice>. Optional prompt= (0.23.0) is the question shown with the options; an empty one is E-BRANCH-PROMPT.
::jump{to when max}A jump to a ::label{name} in the same document (labels share one document-wide namespace; a repeated name is E-LABEL-DUP). It goes forward. Since 0.51, max="N" (a literal integer from 1 to 99) lets it go back to an earlier label at most N times, then control falls through (Bounded jumps); since 0.53 the label may sit in an earlier top-level ## section. A backward jump without max is E-JUMP-BACKWARD; max on a forward jump is E-JUMP-MAX. A section heading or {#id} is not a target (E-JUMP-UNDEFINED). Without when, and without max, the content after it is dead (W-CODE-AFTER-JUMP). ::label emits no record and takes no when=. Since 0.26.0 lute trace and lute test follow a taken jump to its label (the trace shows <jump -> outro>), as lute play does.
::end{reason}Ends the scene. In lute play it ends only the presentation (or quest handler) it runs in; the playthrough goes on. Content after it in the same body is W-CODE-AFTER-END.
::accept{quest}Takes up an accept-driven quest (no start, or a child with activate="accept"). at="nextRun" (0.24.0) queues it until right after the next newRun reset; any other at is E-ACCEPT-TARGET. Takes when= (0.26.0). See Quests.

→ Choices & hubs · Branch, match & when

---
kind: scene
id: cafe.moods
state:
run.tips: { type: int, default: 0 }
run.mood: { type: { enum: [calm, tense, joyful] }, default: calm }
run.rival: { type: { enum: [kai, lee] } }
---
## Moods
<match subject="run.mood">
<when is="calm">
@mira: Quiet day.
</when>
<when is="tense|joyful">
@mira: Busy day.
</when>
</match>
<match subject="run.tips">
<when is="10..">
@mira: My best customer.
</when>
<when is="1..9">
@mira: Thanks, as always.
</when>
<otherwise>
@mira: Hmm.
</otherwise>
</match>
<match subject="run.rival">
<when is="kai">
@mira: Kai was here earlier.
</when>
<when test="$ == 'lee' && run.tips > 3">
@mira: Lee asked about you.
</when>
<when is="unset">
@mira: Nobody asked about you.
</when>
<otherwise>
@mira: Lee was here.
</otherwise>
</match>
@mira{when="run.mood != 'calm'"}: Take a breath.
  • is= takes enum members, true/false, numbers, inclusive ranges (3.., ..0, 1..9), unset, and | alternation. test= is a CEL guard with the subject bound to $. Writing both means pattern AND guard.
  • Arms run top to bottom and the first match wins. A match must be exhaustive: an enum or bool with every member covered needs no <otherwise>. Numbers are real, so 1..9 and 10.. leave a gap at 9.5. A maybe-unset subject needs is="unset" or <otherwise>. Arms narrow it: inside <when is="x"> the subject is set, and after an is="unset" arm with no test, later arms and <otherwise> read it as set (no E-MAYBE-UNSET).
  • A beat’s when: narrows the subject for the whole body (0.24.0): under when: "run.verdict != 'undecided'", a <match subject="run.verdict"> needs no undecided arm, and one written there is E-ARM-DEAD. A body that writes the subject keeps the whole domain.
  • test="$ == 'x'" is W-WHEN-TEST-LITERAL, and lute fix rewrites it to is="x".
  • @who{when="G"}: … is sugar for a one-arm match. For a fact query (holds(…)) this line form is the only form, because <match subject="holds(…)"> is E-MATCH-RELATION-SUBJECT.
  • A <match> with no subject has no subject, so every arm is test=; an is= arm there is E-MATCH-NO-SUBJECT. Add subject="…", or write the condition as test= (<when is="run.lamps >= 3"> is <when test="run.lamps >= 3">).
  • Every tag sits alone on one physical line. <when …>text</when> on one line is E-TAG-INLINE-BODY, and a tag wrapped across lines is E-TAG-NOT-ONE-LINE.

→ Branch, match & when

---
kind: scene
id: ship.archive
state:
run.trust: { type: int, default: 0 }
run.seen: { type: bool }
entities:
crew: { members: [vesna, toma] }
topic: { members: [manifest, heading] }
relations:
knows: { args: [crew, topic], tier: run }
---
## Archive
::set{run.seen = true} /* the first write of a no-default path must be `=` */
::set{run.trust += 1} /* also -= and *= */
@vesna{when="has(prev.run.trust) && prev.run.trust >= 3"}: You trusted me last time.
@vesna{when="run.seen"}: You found the archive.
::assert{knows(vesna, manifest)}
@vesna{when="holds('knows', ['vesna', 'manifest'])"}: I read the manifest.
@vesna{when="count('knows', ['_', 'manifest']) >= 2"}: So we both know.
::retract{knows(vesna, _)}

_ is a wildcard in queries and in ::retract. app.* is read-only (E-APP-READONLY), and so is a path declared owner: engine (E-ENGINE-OWNED-WRITE): the engine writes it, and lute play writes it with an engine: step. A relation with derive: true or reserved: cannot be asserted from content. A relation’s key: [0] makes its first argument functional, so a new assert replaces the old fact.

prev.run.<path> (0.23.0) reads the value run.<path> had when the previous run ended, for every declared run.* path, with the same type. It is read-only (E-QUEST-RESERVED-WRITE) and unset until a first run ends, so every read needs has(prev.run.x) or an unset arm (E-MAYBE-UNSET). Declaring a prev.* path yourself is E-STATE-NAMESPACE. lute play snapshots it at newRun; a play script’s state: or a mock’s state: may seed it.

Parties (0.24.0): sub-kinds, entity-indexed state, and richer rules.

---
kind: scene
id: camp.fire
state:
run.day: { type: int, default: 1 }
run.approval: { type: int, default: 0, per: companion } # run.approval.isolde, run.approval.corvin
entities:
person: { members: [isolde, corvin, hollis] }
companion: { subsetOf: person, members: [isolde, corvin] } # every member is a person
place: { members: [camp, ridge] }
relations:
inParty: { args: [companion], tier: run }
sawAt: { args: [person, place], tier: run }
loyal: { args: [companion], derive: true }
witness: { args: [person], derive: true }
unseen: { args: [companion], derive: true }
defs:
evenDay: "run.day % 2 == 0"
rules:
- "loyal(P) :- inParty(P), cel(\"run.approval[P] >= 3\")" # [P]: rule guards only
- "witness(W) :- sawAt(W, _), cel(\"@evenDay\")" # `_` in a body; a @def in a guard
- "unseen(P) :- companion(P), not sawAt(P, _)" # not: no such fact holds
---
## Camp
::assert{inParty(isolde)}
::set{run.approval.isolde += 2}
::set{run.approval.corvin += 1 when="run.day > 3"}
@isolde{when="holds('loyal', ['isolde'])"}: I'm with you.
@hollis{when="countDistinct('sawAt', ['_', '_'], 0) >= 2"}: Two of us saw it.
@hollis{when="holds('witness', ['hollis'])"}: I was there.
@hollis{when="holds('unseen', ['corvin'])"}: Nobody saw Corvin.
  • per: companion declares run.approval.<member> for each member. Content names a member. Two places index the family instead: a rule cel() guard, with a variable a positive atom binds (run.approval[P]; such a rule compiles to one instance per member), and a component’s ::set, with a param (run.approval[@who], checked at each ::use: see Components).
  • _ in a rule body is a fresh anonymous variable (under not, “no such tuple at all”). It is still an error in a rule head (E-DATALOG-PARSE) and in a comparison.
  • not r(…) in a rule body holds when no such fact does (unseen(P): no sawAt(P, _) at all). Negation binds nothing: each of its variables must be bound by a positive atom of the same body, else E-DATALOG-UNSAFE.
  • countDistinct('sawAt', ['_', '_'], 0) counts distinct values of one position, where count(…) counts tuples. Like count, it is not allowed in a rule guard.
  • A rule guard may call @defs; an undefined def, a wrong argument count, or $ is E-RULE-GUARD-DEF. A kind atom in a rule body (companion(P)) tests membership.
  • ::set{… when="…"} writes only when the condition holds (lute play prints skip set … — when: false). Since 0.26.0 ::use, ::accept, ::assert, ::retract and plugin passthrough and bridge directives take the same when= (::assert{inParty(corvin) when="run.day > 3"}; a guarded ::use runs its whole expansion or none of it; play prints skip ::give{item="potion"} — when: false). The checker never counts a guarded one as a definite write, fact or accept. Directives that lower to a built-in record (every core staging directive such as ::bg, ::sfx, ::actor, ::clear, and ::end, ::label, a plugin lower: record) refuse it (E-UNKNOWN-ATTR: put them in a <match>), as does a <track> clip (E-TIMELINE-CONTENT). A relation named like a CEL call (has, holds, count, now, …) is E-RESERVED-NAME (every reserved name: Reserved names).

Exclusive relations (0.25.0): excludes: names the relations one can never hold together with on the same arguments.

---
kind: scene
id: tower.gallery
entities:
person: { members: [elias, maren] }
relations:
seen: { args: [person], tier: run }
seenAfter: { args: [person], derive: true, excludes: [fell] } # symmetric: fell excludes seenAfter
fell: { args: [person], tier: run }
rules:
- "seenAfter(P) :- seen(P)"
---
## Gallery
::assert{seen(elias)}
@maren{when="holds('seenAfter', ['elias'])"}: He was on the stairs after the storm.
  • Partners must be declared relations with the same argument kinds, and not the relation itself (E-RELATION-DECL). The IR’s RelationEntry.excludes carries the symmetric closure.
  • Whole-project check: holds('seenAfter', ['x']) && holds('fell', ['x']) is dead (E-ARM-DEAD / E-BEAT-UNREACHABLE), !holds('fell', ['x']) under holds('seenAfter', ['x']) is W-FACT-GUARANTEED, ::assert{fell(elias)} here would be E-FACT-EXCLUSIVE (the other holds on every route), and a rule like fell(P) :- seenAfter(P) that can only break the pair is E-RULE-EXCLUSIVE.
  • Where both are only possible, lute play halts at the write with ✗ exclusive: fell(elias) and seenAfter(elias) both hold (exit 1), and lute trace / lute test refuse there (E-FACT-EXCLUSIVE).
  • A reserved: relation may declare changedOn: [<occasion>…], the occasions on which the engine changes it; it narrows cast assume: true (see the schema above). changedOn on a relation that is not reserved, or naming an undeclared occasion, is E-RELATION-DECL.

→ State model · Facts & Datalog

NamespaceResetsContent may write
scene.*when the scene endsyes
run.*at a new runyes
user.*on a profile wipeyes
app.*on uninstallno
quest.<id>.state (always assigned: unset until the quest activates, then active complete failed), quest.<id>.activatedAt, quest.<id>.objectives.<o>.doneengine; a tier="run" quest returns to unset at a new runno
quest.<id>.failedBy (unset fail by until subquest cascade superseded), quest.<id>.objectives.<o>.failed (0.24.0)engine; cleared with a run-tier questno (E-QUEST-RESERVED-WRITE)
clock.index, clock.weekday, clock.weekdayLabel (0.24.0, with a declared clock; weekday* with a week:)engine: derived from the clock’s day and slotno (E-QUEST-RESERVED-WRITE)
entry.<id>.readengine; run-tierno
entry.<id>.everReadengine; user-tier: set on the first read, never reset by a new runno
a path declared owner: engineas its namespaceno (E-ENGINE-OWNED-WRITE)
prev.run.<path> (0.23.0)engine: snapshot of run.<path> at run end; unset before the first run endsno (E-QUEST-RESERVED-WRITE)
season.<name>.<field> / prev.season.<name>.<field> (0.27.0)the season opening: reset to defaults / snapshot of the window that endedyes / no (E-QUEST-RESERVED-WRITE)
scene.choices.<branch>, scene.visited.<hub>.<choice>engineno
visited('<scene id>'), visited('<doc>.<beat>')never: the whole saveno
OperatorsFunctions & references
== != < <= > >= · && || ! · + - * / · % (0.24.0: integers only) · c ? a : b · x in ['a', 'b'] · integer/fractional literalshas(p) / 'k' in a.b (assigned?) · holds('rel', ['a', '_']) · count('rel', ['_']) >= n · countDistinct('rel', ['_', '_'], 0) (0.24.0) · validAt('rel', ['a'], quest.q.activatedAt) · visited('scene.id') · @def / @def(args) · $ (inside <match> only)

Not available: size, matches, map/filter/exists/all (E-CEL-PROFILE), in a guard or in a def body. % takes two int operands: a non-int operand or a fractional literal is E-CEL-TYPE. An unset value is not the string 'unset' (E-UNSET-LITERAL); test it with !has(p) or is="unset". The exception is quest.<id>.state, where unset is a real member: write quest.q.state == 'unset', because has(quest.q.state) is always true (W-QUEST-STATE-HAS). Since 0.26.0 a string a guard compares with an enum path, occasion.target or a quest’s state / failedBy (==, !=, or an in [...] element) must be one of its members: run.rank == 'silvr' is E-WHEN-LITERAL-DOMAIN, with a did-you-mean, as is="silvr" is.

0.49.0 presence of a per: member: 'look-around' in user.stepRank is a presence guard of user.stepRank.look-around, the same as has() of an identifier path (and !('look-around' in user.stepRank) for the negation); use it for a member that is not an identifier. has(user.stepRank["look-around"]) stays E-CEL-PARSE, with the hint to write '<m>' in <family>. Every message that names a presence test spells it has(p) / !has(p) for identifier paths and '<m>' in <family> / !('<m>' in <family>) for such a member; a path with any other non-identifier segment names only the after: / default: remedies.

0.30.0: every name you write is a name — letters, digits, _ or -, not starting with - — and a dotted id joins names with .: scene, beat, entry, quest, objective, branch, hub, choice and label names, document id segments, share keys, seasons, relations, enum and entity kinds and their members, the occasions and events a plugin declares, targets and categories. lamp-out, zero-coke-001 and 001 are names; a ., a space or a quote is an error at the name, under that slot’s code, listing the allowed characters. A name read bare, like a JavaScript variable — a def, a def’s param, a component or template param (@lampLit) — is an identifier (a letter or _, then letters, digits or _); lamp-lit there is an error naming lampLit.

A name that is an identifier may be written bare in a condition; any name may be written quoted, the way JavaScript reaches a key: quest["zero-coke-001"].state, run.visits["lab-b2"], holds('at', ["lab-b2"]). The two spellings are the same name. quest.zero-coke-001.state reads as a subtraction, so it is E-PATH-IDENT, naming the bracket spelling.

Where CEL goes: <match subject>, <when test>, when= on a line or choice, ::set right-hand sides and when=, when= on any other directive that takes it (0.26.0), ::jump when, beat when:, entry when=, quest start / fail, objective done / by / until / visibleWhen, <on when>, <reward when>, and a cast entry’s present:. In a directive attribute, a def reference is bare: write ::camera{framing="closeUp" duration=@holdTime}, not duration="@holdTime". It is compiled as the constant the def folds to, so a def that reads state, such as pace above, is E-ATTR-DEF-DYNAMIC: branch with <match> and write a literal in each arm.

→ CEL expressions · Definitions & params

Beats: scenes and entries that answer occasions

Section titled “Beats: scenes and entries that answer occasions”

A beat is a scene, lore entry, or bundle beat that the engine can pick when an occasion is raised.

---
kind: scene
id: vesna.gift
on: talk
target: npc.vesna
when: 'user.runs >= 10 && !run.giftRefused'
priority: 50
once: user
after: 'visited("cafe.counter")'
state:
user.runs: { type: int, default: 0 }
run.giftRefused: { type: bool, default: false }
---
## Gift
@vesna: You have been at this a while. Take this.
---
kind: lore
id: vesna.barks
state:
user.runs: { type: int, default: 0 }
---
<entry id="vesnaBark" on="talk" target="npc.vesna" category="bark">
@vesna: Keep your head down.
</entry>
<entry id="vesnaBack" on="talk" target="npc.vesna" category="bark" priority="10" when="user.runs >= 3">
@vesna: Back again?
</entry>
<entry id="vesnaFirst" on="talk" target="npc.vesna" category="bark" priority="20" once="user">
@vesna: So you are the new one.
</entry>

A lore document can also hold <beat> blocks (0.23.0), bundle beats, beside its entries:

---
kind: lore
id: cafe.talks
state:
run.tips: { type: int, default: 0 }
---
<beat id="miraOrder" on="talk" target="npc.mira" title="Order" priority="10" when="run.tips >= 3">
@mira: The usual?
<hub id="order" prompt="What will it be?">
<choice id="usual" text="The usual" exit>
@mira: Coming up.
</choice>
</hub>
</beat>
<beat id="miraHum" on="talk" target="npc.mira" once="false" also>
@narrator: Mira hums while she works.
</beat>
<beat id="thanksCounter" on="talk" target="npc.mira" after="visited('cafe.talks.miraOrder')" once="user" share="miraThanks">
@mira: Thanks for the tips, by the way.
</beat>
<beat id="thanksDoor" on="leave" once="user" share="miraThanks">
@mira: Thanks for the tips! See you.
</beat>

A bundle beat takes id, on, target, title, when, priority, once, also, and since 0.25.0 after and share, and its body is a scene body (lines, branches, hubs, match, directives). The document needs id:, the beat id is a name, and the beat’s canonical id is <document id>.<beat id> (cafe.talks.miraOrder): the id that lute play, presented:, visited('cafe.talks.miraOrder'), and lute trace --beat use. It behaves like a scene beat: once defaults to run, and presentation spends it. Since 0.24.0 a scene or quest may name it as a predecessor (after: visited('cafe.talks.miraOrder')), and since 0.25.0 its own after="…" is a scene after:: an eligibility conjunct and a scenario edge. A visited() in a beat’s when gates but draws no edge, so lute scenario lists that beat as unanchored with the after= to write. title labels it in a select: all menu. A canonical id equal to a scene id is E-CONN-EPISODE-ID-DUP.

KeyMeaning
onThe occasion answered. It makes the scene a beat. Any other beat key without on is E-BEAT-ATTR.
targetOptional dotted id (npc.vesna). The beat is a candidate only when the occasion is raised for that target. When the occasion declares a target domain, the target must be <prefix>.<member> of it. On an occasion with no target:, an entry’s target= is metadata (0.24.0): the entry answers every raise. A scene’s or bundle beat’s there is still E-BEAT-ATTR. 0.26.0: target="kind:<kind>" answers every member of a closed kind inside the occasion’s domain (below).
whenCEL over run / user / app, quest.*, entry.*.read / entry.*.everRead, facts, and visited(). It cannot read scene.*. To compare strings, put the YAML value in double quotes so CEL keeps its single quotes: when: "run.slot == 'night' && user.runs >= 3". Single quotes on both levels is E-META-PARSE.
priorityInteger, default 0. Higher wins.
onceScenes: run (the default), user (once ever), or false (repeatable). Entries: once="run" (until a new run resets entry.<id>.read) or once="user" (spent once entry.<id>.everRead is set); without it, or with once="false", an entry repeats. With a declared clock (0.24.0), once: day / once: slot (entries once="day" / "slot") spends it until the day or slot changes; without a clock that is E-BEAT-ATTR. 0.27.0: week (with the clock’s week:) and season:<name>. An entry’s writes apply on its first read in a run only (per member with for=), so an entry that can be read again in a run and writes — a lookup entry, no once, a once shorter than the run, spentBy, for — is W-ENTRY-WRITE-REREAD (::assert is exempt): put a write meant to repeat in a <beat once="false">, whose once defaults to run, not to repeatable.
also0.23.0, scenes (also: true) and bundle beats (also) on a select: first occasion: presented after the winner, or alone when no main beat is eligible, and never replaces it. On an entry, or on a select: all / sequence occasion, it is E-BEAT-ATTR. W-BEAT-SHADOWED and W-BEAT-PRIORITY-TIE ignore also beats.
share0.25.0, scenes (share:), entries and bundle beats (share=): a project-wide key for one event told in several places. Presenting (an entry: reading) any beat of the key spends every beat of it for the once period (lute play: once: user — `share: miraThanks` already spent … by cafe.talks.thanksCounter). It needs a written once other than false, and every beat of a key declares the same once; otherwise E-BEAT-ATTR. lute beats shows user, share miraThanks.
spentBy0.27.0, scenes (spentBy:), entries and bundle beats (spentBy=): a condition that spends the beat instead of a presentation (spentBy: "holds('solved', ['valves'])"). 0.28.0: it latches — once it has held the beat stays spent for its once period (run unless written; once: week for a weekly reset), even if it turns false again; lute play reports spentBy: `run.solved` held — spent this run. once: false or share beside it is E-BEAT-ATTR; one that holds at the start is W-BEAT-SPENT-AT-START; with no once written, one that can turn false again (a retracted fact, season state, a rearmed quest) is W-SPENT-BY-REVERSIBLE, naming once: false + when: "!(…)", once: season:<name>, or once: run to keep the latch.
use0.27.0, bundle beats (use="bondStory"): a beat template — a component whose beat: header supplies every key the <beat> does not write (@params replaced by the arguments; an empty value is left out; a bare-id after is visited("<id>")). Its body runs first (a top-level ::body places the beat’s own body instead); the other attributes are its params (E-COMPONENT-ARG). <beat use="trainer" id="r3Joey" who="joey"/> is a one-line beat. Misuse is E-TEMPLATE.
for0.27.0, scenes (for: "kind:crew"), entries and bundle beats (for="kind:crew") on an untargeted select: sequence occasion: presented once per member whose when holds, in member order, reading the member as occasion.target (lute play: ✓ cafe.birthday for toma). 0.28.0: once (and spentBy) is spent per member. Beside target, or on a targeted or non-sequence occasion, it is E-BEAT-ATTR.

Selection: the candidates are the beats whose on matches and whose target is absent or equal to the raised target. A candidate is eligible when its after: and when hold and its once is unspent. Eligible beats are ordered by priority, then document path, then declaration order. select: first presents the top beat that is not also, then the eligible also beats; select: sequence (0.23.0) presents every eligible beat in that order (a routine, then the day’s event); select: all lets the player pick. Eligibility is decided when the occasion is raised; 0.28.0: in a select: sequence raise each later beat is judged again at its turn, once an earlier beat of the raise has played. Quests settle after each presentation, and the raise’s handlers run last. Two select: first beats with equal priority and the same (or no) target, whose whens are not provably exclusive, leave the winner to file order (W-BEAT-PRIORITY-TIE, from whole-project check; since 0.26.0 it normalizes negations and says why the two whens are not exclusive). lute beats lists a fallback that an earlier, never-spent beat whose when it implies always beats as covered by <id> (0.26.0).

A kind target (0.26.0) answers every member of a kind with one beat. The raised member is occasion.target, typed by the kind, in its when, guards and text; a beat that names the member outranks it at equal priority (no tie), and reading occasion.target in a beat without a kind target (or for) is E-UNDECLARED. Since 0.27.0 it is also a fact argument and a per: index, checked for every member: holds('regular', [occasion.target]), user.bond[occasion.target] >= 2. lute play renders {{occasion.target}} as the kind’s label for the member, else its cast name:, else the id, and a test sets the member with state: { occasion.target: toma }:

<beat id="anyRegular" on="talk" target="kind:crew" once="false">
@narrator: {{occasion.target}} waves from the counter.
</beat>
<entry id="regularNod" on="talk" target="kind:crew" when="occasion.target != 'toma'">
@narrator: A nod from the regulars' table.
</entry>

The kind must be closed and inside the occasion’s { prefix, entity } domain, and the occasion must be declared by a plugin; otherwise E-BEAT-ATTR (with a did-you-mean). A plugin declares the occasions, any world events, and optionally reward kinds and the cast:

plugins/game.occasions/plugin.yaml
id: game.occasions
version: 0.1.0
kind: capability
depends: [ { id: lute.core, range: "^0.0.1" } ]
exports:
occasions: occasions/
events: events/
rewardKinds: rewardkinds/ # rewardKinds: see Quests
cast: cast/ # cast: { <id>: { name, present?, emotions? } }, like a schema's cast:
plugins/game.occasions/occasions/game.yaml
occasions:
townVisit: { select: first }
talk: { select: first, target: { prefix: npc, entity: crew } } # or `target: true`: any dotted id
greet: { select: first, target: { prefix: npc, entity: crew, members: [mira, vesna] } } # only these members (0.23.1)
runEnd: { select: first, judge: before } # 0.24.0: judge on="runEnd" objectives before the beats
evening: { select: sequence } # every eligible beat, in selection order (0.23.0)
inbox: { select: all, description: Letters waiting at the fountain }
gift: { select: first, target: { prefix: npc, entity: crew }, payload: { hearts: int } } # 0.27.0: beats read occasion.payload.hearts; a play step gives payload: { hearts: 2 }
# plugins/game.occasions/events/game.yaml: for <on event> and a play's `event:` step
events:
- name: combatEnd
- name: talk # same name as an occasion: <on event="talk" target="npc.mira">

With no occasion-declaring plugin, any name is accepted. Once a plugin declares occasions, an unknown one is E-OCCASION-UNKNOWN, and target on an occasion that declares no target is E-BEAT-ATTR. A target domain { prefix, entity } admits <prefix>.<member> for each member of that entities: kind (any member of an open: kind), so target: npc.vesan is E-BEAT-ATTR with a did-you-mean.

Plugin manifests (0.24.0):

  • Every export file rejects a key it does not know: { selct: all } is E-PLUGIN-PARSE with a did-you-mean, not a silent select: first. A flow-map value holding a comma needs quotes.
  • A directive’s lower: is optional: without it the directive compiles to the generic kind: "plugin" passthrough. { kind: builtin, name } must name a core hook (actorStage, cameraTransform, clearStage, end, jump, label), else E-PLUGIN-PARSE.
  • judge: before judges the occasion’s on= objectives and settles the quests before its beats are chosen, so an epilogue on it reads how the quests ended. Only the judging moves: the <on> handler bodies the raise answers still run after the beats. The default is after.
  • A cast export entry takes present:, emotions:, and assume:, as a schema’s does, and (0.26.0) sharedName: true for a role name several speakers share (no W-DISPLAY-NAME-DUP).
  • 0.26.0: a directive attribute may be typed by a project kind, { name: item, type: { entity: bagItem } }: a non-member is E-BAD-ENUM with a did-you-mean, also when it reaches the attribute through a component param. when is reserved on every passthrough and bridge directive (it is the when= guard), so a plugin attribute cannot be named when.

→ Beats · Playing a story · Plugin manifests

A schema’s clock: (0.24.0; see the world.schema.yaml above) gives the engine-owned day and slot an order. This beat reads it, so it checks only inside a project with that schema:

---
kind: scene
id: cafe.open
on: slotStart
once: day
when: "clock.weekday < 5"
---
## Open
@mira: {{clock.weekdayLabel}} again. That's slot {{clock.index}} of the story.
  • day (an int) must be an owner: engine path. slot (an enum, also owner: engine) and slots (its members in order) are optional, but only together; without them the clock counts whole days. A malformed or second clock, a content-owned path, slots without slot, an unknown raise occasion (checked once a plugin of the project declares occasions), or a raise occasion with a payload: is E-CLOCK-DECL.
  • Read-only, in any condition or interpolation: clock.index ((day-1) * len(slots) + slotIndex, monotone; day-1 for a day clock), clock.weekday (the whole numbers 0..length-1, so is="0..5" + is="6" is exhaustive and is="7" is E-WHEN-LITERAL-DOMAIN), clock.weekdayLabel (the enum of week.labels; both need week:). A ::set of one is E-QUEST-RESERVED-WRITE.
  • once: day / once: slot on scenes and bundle beats, once="day" / "slot" on entries.
  • once: week (0.27.0, needs the clock’s week:; entries once="week") spends it until the next clock week starts, when clock.weekday is back at week.first.
  • lute play moves it with an advance: slot | day | <n> step (never backward; advance: day lands on the next day’s first slot) and settles quests. raise: is one occasion or a map: dayEnd is raised at each midnight crossed (advance: <n> first walks to the day’s last slot), dayStart at the next day’s first slot, and slot (the bare form) once, where the clock stops; each prints as its own header (── step 1 · day 2 (Tue) morning · dawn). lute calendar --axis clock=1..3 walks every slot of those days; --occasion-axes dusk=clock.day evaluates dusk once a day.
  • 0.26.0: advance: { to: night } / advance: { to: { weekday: Fri, slot: morning } } moves to the next such position after the current one (forward only, never zero steps; one advance: the slot occasion is raised once, where it stops, and each midnight crossed raises dayEnd / dayStart). A step’s expect: { clock: { weekday, slot, day } } judges where the clock stands, which is how an include:d steps file states the time it expects.
  • 0.27.0: last: { day: 1, slot: h05 } (or days: N) ends the clock: an advance past it walks to the last position, raises the last dayEnd once (no slot raise) and stops; a later advance: is E-CLOCK-END (exit 1) until a newRun. A when needing a later position (run.night == 2) is unreachable, and lute calendar --axis clock stops at the end.
  • 0.28.0: clock.day and clock.slot read the day and slot paths by name, and a clock with last: / days: has clock.ended, true from the settle of the advance that ends it until a newRun. by="clock.ended" fails an objective at that moment (by="clock.index >= N" naming the last position fails one slot earlier); terminal: "clock.ended" ends the game there; a play step checks it with expect: { clock: { ended: true } }. An advance: that passes slots without raising the slot occasion notes them (note: passed day 1 (Mon) afternoon, night without raising …), and lute calendar --axis clock reads not raised where the clock would not raise an occasion. No advance stops where the run starts, so the clock raises neither the slot occasion nor dayStart there; raiseAtStart: true says the engine does, and the checker and calendar count it raised there. Without it, a beat only that position takes is W-BEAT-UNRAISED, which names the key.
  • 0.27.0: an occasion may declare raisedWhen: "holds('canEnter', [occasion.target])" (plugin occasions/*.yaml): its beats are judged under the gate (E-BEAT-UNREACHABLE names it), lute beats marks a target whose gate never holds, lute play refuses a step raising it while the gate is false (E-OCCASION-GATE, exit 1), and a clock raise it gates is skipped with a note. A schema (only) may declare terminal: "run.fate == 'taken'": every beat is judged under !terminal, and once it holds play ends ── end: terminal — … (--json "end": "terminal"); a later occasion: / advance: step is E-OCCASION-GATE until a newRun. An occasion: step may carry engine: writes, applied before the raise. 0.41: an advance: step’s engine: writes land before the clock moves, so its raises read them; through the runtime a refused raise is the rejection E-OCCASION-GATE (state unchanged) and gate(occasion, target?) asks first.
  • 0.29.0: an ending that outlives runs on purpose (a roguelike’s permanent ending) writes the long form terminal: { when: "user.crowned", persists: true }: W-TERMINAL-PERSISTENT is silent, lute play says the game is over for good instead of offering newRun, and the IR carries terminalPersists: true. persists: true over run state only is E-META-VALUE.

Shipped alongside, none of which needs a clock: enum labels, integer %, a guarded ::set, and the :ordinal hint.

---
kind: scene
id: diner.payday
state:
run.day: { type: int, default: 1 }
run.visits: { type: int, default: 1 }
run.tab: { type: int, default: 0 }
run.today: { type: { domain: weekday }, default: fri }
enums:
weekday: { members: [mon, fri], labels: { mon: Monday, fri: Friday } }
---
## Payday
@mira: Happy {{run.today}}! Your {{run.visits:ordinal}} visit.
::set{run.tab = 0 when="run.day % 7 == 0"}
@mira{when="run.day % 7 == 0"}: Tab's cleared.

lute trace --state run.visits=3 prints Happy Friday! Your 3rd visit.

→ Clock · Playing a story

---
kind: quest
id: cafe.quests
state:
run.tips: { type: int, default: 0 }
run.fired: { type: bool, default: false }
run.found: { type: bool, default: false }
run.day: { type: int, default: 1 }
user.xp: { type: int, default: 0 }
---
<quest id="regular" title="Become a regular" start="true" fail="run.fired" follows="visited('cafe.counter')">
<reward kind="BADGE" amount="1"/>
<objective id="tip" title="Tip three times" done="run.tips >= 3">
@narrator: Mira starts your order when you walk in.
</objective>
<objective id="calm" title="End the night calm" on="runEnd" done="run.tips > 0"/>
<objective id="chat" title="Chat with Mira" done="visited('cafe.counter')" optional/>
<objective id="help" title="Help out back" quest="sideJob"/>
<on event="questComplete">
::set{user.xp += 50}
@narrator: You are a regular now.
</on>
<on event="questFailed">
@narrator: You are no longer welcome.
</on>
</quest>
<quest id="sideJob" title="Help out back">
<reward kind="GOLD" amount="10..20"/>
<objective id="dishes" title="Do the dishes" done="run.tips >= 1"/>
</quest>
<quest id="lostCup" title="Find the lost cup" tier="run">
<objective id="find" title="Find it by day 3" done="run.found" by="run.day > 3"/>
<objective id="tell" title="Tell Mira" on="talk" target="npc.mira" done="run.found"/>
</quest>
PieceRule
start=Activates the quest (unset → active) when it holds. With no start the quest is accept-driven: it stays unset until a scene runs ::accept{quest="…"}, or a mock accepts it. A play script seeds a save’s status with quests:. One that no ::accept names is W-QUEST-NEVER-ACCEPTED (0.24.0, whole-project check); since 0.25.0 an accepts: mock no longer counts. Since 0.26.0 a test’s accepts: resolves quests project-wide, and a play accepts one mid-play with an engine: { accept: [id] } step.
accept="external"0.25.0. The engine accepts the quest outside any document (a quest board, a menu): it silences W-QUEST-NEVER-ACCEPTED. Beside start it is E-ATTR-TYPE; on a child that activates with its parent it is E-ACCEPT-TARGET (add activate="accept"). IR: QuestCmd.accept. Since 0.53 a host-granted quest with a condition is accept="external" follows="…": the host applies the condition, Lute does not (an in-script accept activates regardless of follows).
fail=active → failed. It wins over completion when both hold.
follows=Graph metadata for the scene graph: visited / completed / active with && / ||. It never gates activation; start does. Scenes write their gating prerequisite as after: in frontmatter. Before 0.28.0 it was spelled after= (now E-UNKNOWN-ATTR). Without it a quest is anchored by its ::accepts, its parent ([subquest], 0.25.0) and its start conjuncts reading visited(…) / entry.X.everRead / quest.Y.state == … ([start], 0.25.0).
tier="run"A new run returns the quest to unset and undoes its objectives. The default tier="user" keeps its status across runs; since 0.26.0 the manifest’s defaults: questTier: run changes that default for every quest that writes no tier=. A subquest’s tier must equal its parent’s (E-QUEST-TIER-MIX).
rearm=0.27.0. Each time the condition turns false→true the quest returns to unset (objectives, failedBy and deadlines cleared) and can be started or accepted again: <quest id="harvestMissions" start="@harvestLive" rearm="@harvestLive">. tier="season:<name>" instead resets it whenever that season opens.
<objective done>done is required (E-OBJECTIVE-MISSING-DONE). The quest completes when every non-optional objective is done. Completion is monotonic, and the body plays once.
on="runEnd"done is judged only when that occasion is raised while the quest is active. Raising it first runs the handlers of a same-named declared world event (<on event="runEnd">), then judges the objectives.
by=A deadline, and since 0.24.0 a moment: judged at every settle, on= or not. The first time it holds while the objective is not done, the objective fails for good; a failed required objective fails its quest (failed rewards, questFailed). done wins a tie in the same settle, and in the step that raises an on= objective’s occasion done is judged first. An on= objective whose done implies its by fails unless both come true in that step: W-DEADLINE-BEFORE-DONE suggests until=.
until= (0.24.0)The place-bound deadline (0.23.1’s by on an on= objective): judged only when the objective’s occasion (and target) is raised, after done. Without on= it is E-BEAT-ATTR.
on="talk" target="npc.mira" (0.23.0)Judged only when the occasion is raised for that target, checked like a beat target (E-BEAT-ATTR). Tooling raises it as talk@npc.mira; a lute play step with target: judges it.
visibleWhen= on an objectiveVisibility only: it hides the objective and never gates done (put the condition in done= for that). Before 0.28.0 it was spelled when=.
quest="child"A subquest: done when the child completes, and the parent fails when a required child fails. It cannot be combined with done= (E-OBJECTIVE-QUEST-DONE). A child with no start activates with its parent, unless it declares activate="accept" (0.24.0): then it waits for an ::accept while the parent is active. ::accept of a child that activates with its parent is E-ACCEPT-TARGET.
complete="any" (0.24.0)The quest completes when any one required objective is done; its other still-active children fail with failedBy superseded. One failed alternative leaves it open. Default complete="all".
quest.<id>.failedBy (0.24.0)Why it failed: unset until then, then fail, by, until, subquest (a required subquest failed), cascade, or superseded. quest.<id>.objectives.<o>.failed is true once a by/until failed that objective. lute play prints quest X -> failed (by).
<reward id kind amount target when outcome/>Data for the engine, which pays it. Content cannot read a reward, so do not also ::set the same currency in an <on> handler: it would be paid twice. amount is an integer or a range N..M. outcome="failed" grants on failure (before 0.28.0: on=). An optional id names the reward for engines and source maps; it is unique among the quest’s rewards, its objectives’ included (E-REWARD-DUP). lute run, lute play, lute trace, and lute test print the grant; when the kind declares credits: (below) they also add a scalar amount to that path, and a ::set of that path in the quest’s <on> or objective bodies is W-REWARD-DOUBLE-CREDIT.
<on event>questActive, questComplete, questFailed, or a plugin world event. An optional when= guards it. With target= (0.24.0) it runs only when the same-named occasion is raised for that target (never for a plain event: step). A questFailed handler on a quest that can never fail (no fail, no required objective with a by= deadline, no required subquest objective that can fail, no parent quest) is W-QUEST-HANDLER-DEAD. An ::accept in a handler is applied by lute trace / lute test as by lute play (0.26.0).

Quest documents have no #/## headings, <hub>, or <timeline>, and since 0.52 nothing but <on>, <objective> and <reward> sits directly inside <quest> (E-GRAMMAR-NOT-ADMITTED). An <objective title> is plain text: a {{ in it is E-OBJECTIVE-TITLE-INTERP. Other documents read a quest through <match subject="quest.regular.state"> or when="quest.regular.state == 'complete'". The state is always assigned, so quest.regular.state == 'unset' means “not taken up yet”.

Quest structure (0.24.0): alternatives, subquests taken up in dialogue, and the two deadlines.

---
kind: quest
id: keep.quests
state:
run.day: { type: int, default: 1 }
run.gold: { type: int, default: 0 }
run.freed: { type: bool, default: false }
run.lamp: { type: bool, default: false }
---
<quest id="rescue" title="Free the prisoner" start="true" complete="any">
<objective id="force" title="Storm the keep" quest="storm"/>
<objective id="guile" title="Bribe the guard" quest="bribe"/>
<on event="questComplete">
@narrator{when="quest.storm.failedBy == 'superseded'"}: No blood was spilled.
</on>
</quest>
<quest id="storm" title="Storm the keep" activate="accept">
<objective id="breach" title="Breach the gate" done="run.freed" by="run.day > 3"/>
</quest>
<quest id="bribe" title="Bribe the guard" activate="accept">
<objective id="pay" title="Pay him at his post" on="talk" target="npc.guard" done="run.gold >= 10" until="run.day > 2"/>
</quest>
<quest id="lamp" title="Light the lamp">
<objective id="light" title="Light it" done="run.lamp" by="run.day > 2"/>
<on event="questFailed">
@narrator{when="quest.lamp.objectives.light.failed"}: The night came first.
</on>
</quest>

::accept{quest="storm"} in a scene takes up a child. ::accept{quest="lamp" at="nextRun"} queues the acceptance until right after the next newRun reset, so a run-tier quest taken at a hub between runs survives it. An accept-driven quest without follows= is anchored at every scene, bundle beat, and quest body that ::accepts it (lute scenario draws an accept edge).

A reward kind can name the state path its grants pay into (0.23.0), and a contract for its target= (0.26.0):

plugins/game.occasions/rewardkinds/game.yaml
rewardKinds:
GOLD: { credits: user.gold } # a grant adds its amount to user.gold (a range is the engine's roll)
BADGE: {}
ITEM: { target: { entity: bagItem, required: true } } # 0.26.0: target= must be a bagItem member

A target: { entity: <kind> } or { provider: <name> } contract validates each reward’s target= (did-you-mean for an entity; a stale provider snapshot only warns), and required: true rejects a reward with none: E-REWARD-TARGET (0.26.0). A contract naming both fails the plugin load.

Since 0.50 a reward kind can also declare effects (asserts, retracts, accepts) that apply at the grant; see Scoped stamps, host tokens, reward effects & project levels.

→ Quests & scenes

---
kind: lore
id: ship.records
series: captainsLog
entities:
crew: { members: [vesna] }
topic: { members: [heading] }
relations:
knows: { args: [crew, topic], tier: run }
state:
run.fire: { type: bool, default: false }
---
<entry id="log1" target="item.captains_log" category="note" title="Day 1">
@captain: We changed heading at midnight.
::assert{knows(vesna, heading)}
</entry>
<entry id="log2" target="item.captains_log" category="note" title="Day 2" when="entry.log1.read">
<match subject="run.fire">
<when is="true">
@narrator: The page is scorched.
</when>
<otherwise>
@captain: Nobody noticed.
</otherwise>
</match>
</entry>
  • <entry> attributes: id (required, unique in the project), target, category, title, when, on, priority, once (run, user or false; day / slot / week with a clock, season:<name> with a season), share, spentBy, and for (on a beat only). Use series and order for a series that spans files. A document-level series: orders its entries by position in the file, and in such a document a per-entry series=/order= is E-ENTRY-ATTR.
  • An entry body may hold content lines, <match>, ::set, ::assert, ::retract, and (0.27.0) a plugin directive whose only behaviour is its declared effects (::pickup{item=…}). Anything else, including <branch>, other directives, and headings, is E-GRAMMAR-NOT-ADMITTED.
  • Effects apply on the first read in a run only. After that, entry.<id>.read is true and any document can read it. It is run-tier: a new run resets it. entry.<id>.everRead is its user-tier twin, set on the first read and never reset by a new run. Under a guard that assumes entry.X.read, whole-project check knows the facts X asserts on every path (0.24.0), so a redundant holds(…) there is W-FACT-GUARANTEED; everRead counts only the tier: user / app ones. An entry without once that ::sets or ::retracts is W-ENTRY-WRITE-REREAD (0.26.0): the write does not repeat when the entry does. Use a <beat once="false"> for a write that should.
  • 0.26.0: lute play and lute test accept <document id>.<entry id> (ship.records.log1) wherever they take an entry id, and an entry may target a whole kind (target="kind:crew", see Beats).
  • A lore document may also hold <beat> blocks with a scene body (0.23.0); see Beats.

→ Lore entries

---
component: greet
params:
who: string
tier: { enum: [cold, warm] }
---
## Greet
@narrator: {{@who}} walks in.
<match subject="@tier">
<when is="warm">
@narrator: A warm welcome.
</when>
<when is="cold">
@narrator: A curt nod.
</when>
</match>

A component file is name.component.lute. Its body holds lines, staging, @param refs, and a <match> on a param, with no state reads or writes of its own. A {{@param}} renders an int, double, bool, or enum param, and since 0.23.0 a string one: a literal ::use argument is substituted at expansion, so each call site ships its own sentence under its own lineId. Binding an interpolated string param to a @def is E-REF-TYPE. The importing scene lists it in components: and expands it with ::use. An argument may be the caller’s @def (tier=@mood): an enum param requires every value the def can produce to be a member (E-COMPONENT-ARG), and a state-dependent def that the component puts into a directive attribute is E-ATTR-DEF-DYNAMIC:

---
kind: scene
character: demo
season: 1
episode: 2
uses: ../base.schema.yaml
components: [greet.component.lute]
---
## Greeting by Component
::use{component="greet" who="marina"}
@narrator: And the scene carries on.

A line a component expands to is addressed {prefix}.{component}#{n}.{speaker}_{code}, where n counts the host’s ::uses of that component (1-based, in document order), so two uses never share a lineId and a component line’s code does not depend on the host lines around it.

A component that declares effects: true (0.24.0) may ::set, ::assert, and ::retract, with params in fact atoms (::assert{gifted(@who, @item)}; each ::use binds them, and a non-constant argument is E-COMPONENT-ARG; a @param in a fact outside a component is E-FACT-DOMAIN). Each write is checked at every ::use against the host’s schema and compiles where the ::use sits. A param also picks the member of a per: family, run.approval[@who]: each ::use writes the member its argument names, and an argument that is not a member of the family’s kind is E-COMPONENT-ARG. A speaker param takes a cast id (E-CAST-UNKNOWN outside a declared cast): {{@who}} renders the cast name, while <match subject="@who"> and directive attributes (::battle{foe=@who}) see the id. Since 0.26.0 a line’s as=@who label shows the cast name too, as {{@who}} does.

---
component: praise
effects: true
params:
who: speaker
delta: int
entities: # declared here only so this file checks alone:
companion: { members: [isolde, corvin] } # at `::use` the host's schema decides
state:
run.approval: { type: int, default: 0, per: companion }
---
## Praise
@narrator: {{@who}} approves.
::set{run.approval[@who] += @delta}

Guards and match subjects in the body still cannot read state (E-COMPONENT-STATE), and without effects: true a write is E-COMPONENT-BODY. The exception (0.26.0) is the component’s own directive results: after its own ::battle{… resultKey="fight"} a body may read scene.battle.fight.won, and each ::use declares those result slots in the host.

Since 0.26.0 a component reaches parity with direct text: @@who: … speaks as the cast member a speaker param names (portrait, voice, emotions: and present: checks at each ::use; @@x for anything but a speaker param is E-COMPONENT-ARG), a param may declare default: (a literal or a @def) that an omitted argument takes, {{…}} inside a string argument is interpolated, and an attribute such as as=@who shows the cast name. ::use{… when="…"} runs the whole expansion or none of it.

---
component: welcome
params:
who: speaker
greeting: { type: string, default: "Welcome back." }
mood: { type: { enum: [cold, warm] }, default: warm }
---
## Welcome
@@who: {{@greeting}}
<match subject="@mood">
<when is="warm">
@narrator: {{@who}} smiles.
</when>
<otherwise>
@@who: Hm.
</otherwise>
</match>

::use{component="welcome" who="mira"} says “Welcome back.” as Mira; ::use{component="welcome" who="mira" greeting="You again, {{userName}}?" mood="cold"} overrides both defaults. A param typed by a kind ({ type: { entity: bagItem } }) is checked at each ::use.

A schema can refine another with extends: base.schema.yaml. The base is the lower layer, so redeclaring a name overrides it; changing a state path’s type is E-EXTENDS-STATE-TYPE. uses: joins peer schemas, where a name declared twice is an error.

→ Components & extends · Definitions & params

---
kind: scene
id: storm.beat
enums:
framing: [closeUp, wide]
cameraMove: [pushIn, shake]
---
## Storm
<timeline duration="1.2">
<track subject="camera">
::camera{focus="mira" framing="closeUp" duration="0.6"}
::camera{move="shake" duration="0.3" at="0.7"}
</track>
<track channel="sfx">
::sfx{sound="thunder" at="0.5"}
</track>
</timeline>
<timeline duration="1.0">
</timeline>
@narrator: After the pause.

A track holds staging directives and ::set only. Its key (subject=, channel=, or subject= + property=) must be unique. at= is absolute on the timeline’s own clock, in seconds. An empty timeline is a timed pause. In the IR each clip carries timing: { duration, delay, at, timeline } (timeline is the timeline’s ordinal, at its seconds), and a barrier record joins the timeline at its duration.

→ Timeline & property tracks

Scoped stamps, host tokens, reward effects & project levels (0.50)

Section titled “Scoped stamps, host tokens, reward effects & project levels (0.50)”

A plugin stampAttrs entry can scope itself with on: (surfaces: line, branch, hub, choice, or a directive tag). required: true is allowed only with on:. Without on: it is cross-cutting: every stamp-carrying directive and content line, never <branch>/<hub>/<choice>.

plugins/acme.stage/stampattrs/columns.yaml
stampAttrs:
- { name: voiceAssetId, type: { providerRef: voice }, required: true, on: [line] }
- { name: assetName, type: string, on: [bg, actor] }
- { name: menuType, type: { enum: [plain, timed, minigame] }, on: [branch, hub] }
- { name: optionKey, type: string, required: true, on: [choice] }
- { name: cue, type: string } # cross-cutting, as in 0.49
<branch id="route" prompt="Which way?" menuType="plain">
<choice id="goLeft" text="Left" optionKey="L1">
@mira{voiceAssetId="VO.mira.0010"}: Left it is.
</choice>
</branch>

A plugin declares a host token with a hostTokens export (hosttokens/*.yaml); {{playerName}} then compiles to a reserved placeholder that the host substitutes at render time (lute.core declares userName). An undeclared bare {{name}} is E-CEL-PROFILE.

plugins/acme.stage/hosttokens/tokens.yaml
hostTokens:
playerName: { type: string }
rank: { type: int } # {{rank:ordinal}} ok, {{rank:capitalize}} is E-REF-TYPE
platform: { type: { enum: [ios, android, web] } }

A reward kind can apply facts and accepts at the grant (@target is the reward’s target=; writes is not admitted, credits: is the state write). Per reward: grant + credits, then retracts, asserts, accepts:

rewardKinds:
BADGE: { target: { entity: person, required: true }, effects: { asserts: ["met(@target)"] } }
REVOKE: { target: { entity: person, required: true }, effects: { retracts: ["met(@target)"] } }
OFFER_QUEST: { target: { required: true }, effects: { accepts: ["@target"] } }

lute.project.yaml sets the level of a checker W-* code (off, info, warn, error), optionally only under paths globs. --deny CODE beats off; errors are never demoted.

diagnostics:
W-DISPLAY-NAME-DUP: error
W-STAGE-ABSENT: { level: off, paths: ["scenes/imported/**"] }

A node lint (lute.lint.yaml custom: or a plugin lints: export) runs once per node; guard every index:

custom:
- id: beat-label-prefix
target: node
when: 'node.tag == "label" && scene.kind == "beat" && "name" in node.attrs && !node.attrs["name"].startsWith("b_")'
level: error
message: "a beat's labels start with b_"

→ Plugin manifests · CLI · Linting

Bounded jumps, declared raises, directive text & pair lints (0.51)

Section titled “Bounded jumps, declared raises, directive text & pair lints (0.51)”

A ::jump goes back to an earlier ::label when it carries max, the most times it may be taken. After max takes, control falls through. max is a literal integer from 1 to 99 (E-JUMP-MAX otherwise, and on a forward jump). A loop stays inside one body (E-JUMP-LOOP-SCOPE), is entered only at its head label (E-JUMP-INTO-LOOP), and nests rather than overlaps (E-JUMP-LOOP-CROSS). A loop that must re-offer a menu until an exit choice is a <hub>.

::label{name="attempt"}
::tryLock{door="vault"}
<match subject="scene.lock.vault.won">
<when is="false">
@mira: Again!
::jump{to="attempt" max="2"}
</when>
<when is="true">
@mira: It clicks open.
</when>
</match>
@theo: That's enough for today.

A directive or reward kind can declare the occasions the host raises after the call or the grant. An occasion marked raisedBy: declared is raised only through a declared raises; lute scenario draws a [raises] edge from the raiser to every scene or beat that answers it, and W-OCCASION-NEVER-RAISED warns when nothing raises it. A declared raise is possible, never guaranteed: it adds no fact, write or Must claim.

plugins/acme.stage/occasions/o.yaml
occasions:
cutscene: { select: first, target: { prefix: scene, entity: cutsceneId }, raisedBy: declared }
# plugins/acme.stage/directives/d.yaml
directives:
- name: playScene
attrs:
- { name: scene, required: true, type: { entity: cutsceneId } }
effects:
raises: [{ occasion: cutscene, target: "@scene" }]
# plugins/acme.stage/rewardkinds/r.yaml
rewardKinds:
CUTSCENE: { target: { entity: cutsceneId, required: true }, effects: { raises: [{ occasion: cutscene, target: "@target" }] } }

The target of a raise is @<attr> on a directive, @target on a reward kind, or a literal (scene.chapter2). E-PLUGIN-RAISES (assembly) and E-RAISES-TARGET (project) check it.

An attribute typed text is localizable player-visible text. A directive that has one takes code=, and its translation key is {prefix}~{tag}~{code}~{attr}; lute tag writes the missing codes, and lute loc export / import / report handle the text as kind: "directive" rows.

directives:
- name: banner
attrs:
- { name: title, required: true, type: text }
- { name: subtitle, type: text }
::banner{code="0010" title="Welcome to the hall, {{userName}}"}

A pair lint finds an opener that is never closed, a close with no opener, an opener inside an open pair, and rows not allowed inside a pair. scope: body (default) pairs siblings of one body; scope: section pairs rows in document order at any depth, reset at each section; since 0.53 scope: document is one slot for the whole document, every section included.

custom:
- id: group-balanced
target: pair
open: 'node.tag == "group" && "mode" in node.attrs && node.attrs["mode"] == "begin"'
close: 'node.tag == "group" && "mode" in node.attrs && node.attrs["mode"] == "end"'
inside: 'node.kind == "line" || node.tag in ["actor", "sfx"]'
scope: body
level: error
message: "::group begin/end"

list, map and record attribute types stay refused (structured authoring is not supported yet): give each field its own attribute, use a text attribute for player-visible text, or write a list of options as <choice> elements.

→ Directives · Plugin manifests · Linting · Porting a content set

Literal braces, hub leaves, menu text & reward attributes (0.52)

Section titled “Literal braces, hub leaves, menu text & reward attributes (0.52)”

\{{ writes a literal {{ in line text and, since 0.52, in every value that takes the label grammar: a <choice text>, a prompt and a plugin text attribute. Elsewhere in a quoted value it is E-STRING-ESCAPE, and a ::use argument passes \\{{. A literal { right before a marker ({\{{{{run.a}}) is E-INTERP-BRACE: write { {{run.a}} instead.

A forward ::jump from a hub option or <return> to a label after the hub leaves the hub: no <return>, no menu again. A jump into a body that runs on its own (another option, an <on> or <objective> body, an entry, a beat) is E-JUMP-INTO-BODY.

<hub id="desk">
<choice id="take" text="Take it">
@narrator: Taking it.
::jump{to="after"}
</choice>
<choice id="leave" text="Leave" exit>
@narrator: Leaving.
</choice>
</hub>
::label{name="after"}
@narrator: After the hub.

A prompt’s {{…}} is checked like a label’s, and the runtime renders labels and prompts onto the awaitChoice menu (text, texts, prompt, promptTexts): a host shows those. Each hub option is judged from the stage every earlier pick can leave, so a character one option sends off is W-STAGE-ABSENT in another.

A reward kind’s attrs: are written on <reward> and read by its effects as @<attr>; the grant record carries them in reward.attrs.

rewardKinds:
BADGE:
target: { entity: person, required: true }
attrs: [ { name: rarity, type: { enum: [common, rare, gold] }, default: common } ]
effects: { asserts: ["badge(@target, @rarity)"] }
<reward kind="BADGE" target="mira" rarity="gold"/>

→ Choices & hubs · Dialogue & cast · Quests & scenes · Plugin manifests

Loops across sections, host-granted quests, nested frontmatter references & scope: document (0.53)

Section titled “Loops across sections, host-granted quests, nested frontmatter references & scope: document (0.53)”

The top-level ## sections of a document are one loop scope, as they are one range at runtime (a section is presentation and identity, not an entry or a resume point). A bounded ::jump{to max} may go back to a ::label in an earlier section; every other loop rule holds (entered only at its head, no overlap, no loop out of a hub option, <return>, <on>, <objective>, entry or beat body). E-JUMP-LOOP-SCOPE no longer names a section.

---
kind: scene
id: lock.retry
title: Retry
---
## Shot 1. The door {#door}
::label{name="attempt"}
@narrator: Mira tries the lock.
## Shot 2. The result {#result}
@narrator: It sticks.
::jump{to="attempt" max="2"}
@narrator: That's enough for today.

A quest the host grants once a condition holds is accept="external" follows="…": the host applies the condition (Lute carries it to the host and analysis reads it as host policy, heuristic evidence). A reward or ::accept activates a quest whatever its follows, and accept="external" with start stays E-ATTR-TYPE. Quests that wait on each other through start and in-script accepts, with no source outside the cycle and no host route, are E-QUEST-START-CYCLE (also for accept-driven quests); let the host grant one with accept="external".

<quest id="letter" accept="external" follows="completed('intro')">
<objective id="read" title="Read the letter"/>
</quest>

A reference inside a plugin frontmatter record, list or map value is checked like a top-level one and the message names the field path. A pair lint takes scope: document (one slot for the whole document). lute fix applies every migration of every older release and keeps a directive attribute the profile declares as a stamp.

→ Directives · Quests & scenes · Frontmatter & profiles · Linting · What lute fix runs

lute compile writes the execution IR. The envelope carries kind, lute, irVersion, capabilitySnapshot (the hash of the resolved plugin set), sorted requiredSemantics, meta, sections ({ section, heading, id? }, one per ##), and commands. Every command has a kind, a family, and a position; timing, provenance, and source appear only when they carry something. Four records of the staging snippet as compiled:

{"kind": "bg", "family": "staging", "position": "001-0100", "location": "diner", "time": "night", "timing": {"wait": true}}
{"kind": "actor", "family": "staging", "position": "001-0500", "character": "mira", "emotion": "happy", "preload": true, "provenance": {"by": "entry-emotion-lookahead", "explanation": "pre-loading `mira`'s first emotion `happy` seen ahead of the entrance"}}
{"kind": "camera", "family": "staging", "position": "001-0600", "focus": "mira", "framing": "closeUp", "move": "pushIn", "timing": {"wait": true, "duration": 0.5}}
{"kind": "line", "family": "content", "position": "001-1700", "role": "dialogue", "speaker": "mira", "text": "Wait. Take your time.", "lineId": "diner.night.mira_0030", "voiceKey": "diner.night.mira-0030", "segments": [{"text": "Wait.", "styles": ["emphasis"]}, {"pause": 0.5}, {"text": " "}, {"text": "Take your time.", "rate": 0.8}]}
FamilyKinds
contentline
stagingbg, music, sfx, vfx, actor, camera, cg, video, sequence
stateset, assert, retract
controlchoice, match, hub, jump, end, barrier
declarationquest, on, entry, accept, beat
pluginplugin

position is the section’s place in document order, then the command’s order in it. It is regenerated on every compile and is never identity: the stable joins are lineId and voiceKey (every line has both), a section’s id, and a reward’s id. lute play --json lists each presented command by its kind and position.

→ Runtime contract

CommandUse
lute check <file>… [--project <DIR>]Check one or more documents. Without --project each document applies the nearest lute.project.yaml above it and says so on stderr. Relational guard decisions (dead guards over relations, W-WIP) need the whole-project mode below. A directory operand is a usage error naming --project.
lute check --project <DIR> [--wip] [--deny-warnings] (no file operand)Check every document, plus connectivity, quest ids, ::accept targets, occasions, and fact guards. It also compiles every clean document, so compile-stage errors (E-DUP-VOICEKEY, E-CAPABILITY-MISMATCH) fail here. Project advisories such as W-BEAT-PRIORITY-TIE and W-QUEST-HANDLER-DEAD come from here too. --wip (0.23.0) reports E-BEAT-UNREACHABLE, E-ENTRY-UNREACHABLE, and E-OBJECTIVE-UNSATISFIABLE as warnings when the guard is dead only because a relation has no producer yet (no seed, assert, rule, or reserved; since 0.26.0 also a relation only a component ::assert with an unbound @param writes); a relation that has producers but never matches stays an error.
lute fix <path>…Mechanical migrations in place, over every path given: ::auto → ::actor, ::cut → ::cg, ::next → ::jump, ::mark{id} → ::label{name}, a line’s id="x" → ::label{name="x"} on the line before, music action= → playback=, cg / video action= → display=, choice label= → text=, <match on=> → <match subject=>, plus the older :line sigil, as= → into=, and test="$ == …" → is=. A directory covers every .lute and project/schema YAML file under it, recursively, in sorted order. What has no lossless rewrite (camera numbers, cg full, music track, sfx name, a missing pov:) stays an error for you to migrate by hand. Since 0.53 it applies every migration of every older release, not only the version raise (What lute fix runs); each changed file’s line names its rules, and a directive attribute the profile declares as a stamp is kept (printed on stderr). Run lute fix --check first and review the diff.
lute tag [<path>]… [--renumber [--commit]] [--check]Back-fill a stable code on every line, of one file or every .lute file under a directory. --renumber is the dangerous mode: it previews (writes nothing) what a renumber would change, and --renumber --commit writes it. --check writes nothing and exits 1 when the selected mode would change a file.
lute compile <file> -o out.json · lute compile --project <DIR> -o <DIR> (no document) · lute compile --stream <file>Build artifacts. The whole-project form also writes project.index.json, including beats. Without --project, one file compiles against the nearest lute.project.yaml above it (0.27.0), as check does. --locale-bundle <file> merges a translation bundle. --stream appends body text from stdin to a checked template (NDJSON, no envelope).
lute trace <file> [--mock m.yaml] [--state P=V] [--fact "r(a)"] [--choice id=c[,c]] [--event e] [--accepted-quest q] [--occasion o[@target]] [--entry id | --beat id] [--no-derive] [--expand]Preview the source against mocks, with the project’s seed facts and rules applied; without --project, the project is the nearest lute.project.yaml above the file (0.27.0), as for check. Exit 3 means a guard was unknown. --occasion talk@npc.mira raises an occasion for a target (0.23.0). --beat presents one bundle beat by local or canonical id (E-TRACE-BEAT when it names none); --entry takes <doc>.<entry> too (0.26.0). A @def prints as written (<match @weekday>); --expand (0.24.0) prints its expansion. --accepted-quest also takes an activate="accept" child. Since 0.26.0 a taken ::jump is followed to its label.
lute run <artifact> [--mock m.yaml] [--occasion o[@target]] [--entry id | --beat id] [--conditions-output <file>]Run a compiled artifact the way an engine would. A lore artifact needs exactly one of --entry and --beat (a bundle beat’s canonical id, or its bare id when unambiguous). The mock’s bridges: answers plugin calls (0.24.0).
lute play --project <DIR> --script p.play.yaml [--json] [--ir] [--quiet] [--derivation <ATOM>] [--no-derive] [--conditions-output <file>]Raise occasions through the whole project, advancing quests. A missed expect: exits 1. Staging prints as authored; --ir prints the lowered records instead, injected ones marked. --derivation (repeatable) prints, after the play, the derivation tree of a ground atom, or the failing premises of every rule that could conclude it; since 0.24.0 each asserted leaf names its source (asserted by scene `cafe.open`, step 1).
lute test [<path>]… [--project <DIR>] [--coverage] [--no-derive]Run every *.test.yaml, and every *.play.yaml that carries an expect:, under the project directory, or only the test or play files (or directories) given. Without --project, the project is the nearest lute.project.yaml above the current directory; each script resolves against its nearest project at or below it. An incomplete walk fails, and so does a test whose file: is missing (E-TEST-FILE), without stopping the suite. --coverage lists the documents no test traced and no play presented, of --project or of the nearest lute.project.yaml; since 0.24.0 a document an advance: step’s raise presented counts, and the header counts both: coverage over N traced path(s) and M play(s) (…):. Since 0.26.0 it loads each project once and runs the tests, then the plays, in parallel (RAYON_NUM_THREADS is respected), reporting in the usual order. Since 0.27.0 the unit is the beat: each bundle beat and entry is listed on its own (lore/endings/ren.lute: ember), a play’s picks count in the branch/hub rows, the header reads (plays count toward what they presented and the choices they picked, not match arms), and a last section lists the beats no play presented (--json: untested, notPresentedByPlay, each {file, id, kind}).
lute scenario --project <DIR> [reach [--node <NODE>] | envelope --node <NODE> | knowledge [--node <NODE>]] [--facts] [--format text|dot | --json]The after: graph, reachability, and guaranteed state and facts. A node is a scene id, quest:<id>, or a bundle beat’s canonical id (bare or beat:<doc>.<beat>; drawn as an edgeless entry node). knowledge (0.23.0) traces every fact-guarded beat, entry, and objective to the relations it queries and each relation to its producers through the rules: asserting documents, seed facts, the engine (reserved), or no producer, and names what can defeat a negated premise. Its --node also takes an entry id or <quest>.<objective>. Since 0.24.0 it covers every guard slot, reads a kind atom as membership (suitor(sol) — entity kind `suitor`; sol is a member), and names what a rule’s cel() premise reads; since 0.26.0 it traces a rule’s count(…) premise to the producers of the facts it counts. --facts (0.26.0) also draws fact-producer edges (scene(mid.gameCorner) -> scene(east.ashTowerLens) [hasItem(spectralLens)]; --json: factEdges, dot: dotted). reach --endings[=<OCCASION>] (0.27.0) lists every ending — the beats answering the occasion, or bare, every beat whose content can run ::end — with its after: verdict, its when verdict (never holds / never wins), what a satisfiable when reads and who writes it (nothing writes it when nothing does), and N ending(s): A reachable, B unreachable, C unknown.
lute beats --project <DIR> [--occasion <OCCASION>] [--target <TARGET>] [--json] [--expand]0.23.0. Each occasion’s (and target’s) beat ladder in selection order, with priority, once (including bundle beats’ day / slot), also, after:, when (a @def as written; --expand expands it), title, and the whole-project check verdicts (unreachable, shadowed, tied, once-run-user). The project need not check clean. Since 0.26.0 a fallback that an earlier, never-spent beat whose when it implies always beats shows covered by <id> (--json: coveredBy), a kind-target beat gets a kind:<kind> ladder, and --target takes any member.
lute refs --project <DIR> --attr <DIRECTIVE>.<ATTR> | --reward <KIND> [--json]0.26.0. Every value of a directive attribute (give.item) or every reward target of a kind, with the documents and lines using it; a value passed through a component is listed at its ::use (via component <name>), a reward without a target as (no target). Who gives what, before a merge.
lute calendar --project <DIR> [--axis run.day=1..7] [--axis quest.q.state=unset,active] [--axis 'holds('awake', ['toma'])=true,false'] [--axis "visited('cafe.counter')=true,false"] [--axis run.aff.*=6,7] [--axis 'run.aff[run.route]=6,7'] [--axis clock=1..3] [--occasion <O>] [--occasion-axes <O>=<axis>[=<value>],…] [--target <T>] [--relation <relation>] [--script p.play.yaml [--until <step | label>]] [--where <cel>] [--format text|csv | --json]0.23.0. For every cell of the axes’ product (first axis slowest), play’s own eligibility per occasion: the winner or the presented list, +N shadowed eligible beats, ? for an undecided cell, then the beats never eligible in any cell and (0.24.0) those eligible somewhere but never presented. It starts from the script’s save with its steps replayed (up to --until), or the declared defaults. --where drops cells where the condition does not hold. A targeted occasion gets one column per target its beats name; --target mon.inchlet names one instead (--occasion-axes takes axes, not targets). 0.24.0: clock[=d1..d2] expands to day × slot in clock order; a visited() axis puts an id in or out of the save; --occasion-axes dusk=clock.day (or --occasion-axes dusk=run.day,run.slot=night, any varied path) evaluates that occasion once per value of that axis, blank elsewhere; --relation at prints who is where per cell. 0.27.0: --axis run.aff.*=6,7 sets every member of a per: family, --axis 'run.aff[run.route]=6,7' only the member the run.route axis names in each cell (the others keep their seed or default); a bare --axis run.aff is a usage error naming both forms.
lute lore --project <DIR>Entries and beats by target and series, and which facts they reveal.
lute context <file> [--project <DIR>] [--node <kind:key> | --at <file>:<line>:<column>] [--script <file>]… [--all-scripts] [--json]Everything legal to write here: directives (built-ins included) with their attributes, vocabulary, delivery flags, text modifiers, state (marking owner: engine), defs, relations with their tier and reserved, occasions with target domains, the cast, component signatures, and every scene, quest, and entry id. --json carries the capabilitySnapshot hash and textModifiers (the core pause / speed forms). Without --project, of the nearest lute.project.yaml above the file (0.27.0).
lute lint [<path>] [--config lute.lint.yaml] [--deny <CODE>]Advisory editorial lints (L-*), configured per project. The linear-VN metrics skip beats, components, quests, and lore. Since 0.26.0 it also reports W-DISPLAY-NAME-DUP (--deny W-DISPLAY-NAME-DUP accepts the code). Since 0.50 a custom: or plugin rule can use target: node, and the CEL fragment has startsWith/endsWith/contains.
lute doctor [--project <DIR>] [--strict]Toolchain and project setup: versions, active plugins, occasions with the number of beats answering each, play scripts and tests, whether the lute-lsp on PATH is this version, whether (0.24.0) it is the build beside the running lute (lute-lsp beside lute), and whether a running lute-lsp is stale (restart the editor). --strict (0.26.0) exits 1 when any check fails (✗). Since 0.26.0 lute-lsp itself notices that its binary was replaced and publishes one lute-lsp-stale “restart” diagnostic instead of an older build’s results.
lute new scene|quest|lore|schema <name> [--project <DIR>] · lute init [<DIR>] [--template minimal|investigation|beats]Scaffold a document or a project. New documents get an id: and omit what defaults: supplies. The file is named after the id, which keeps the case you type. lute new scene <name> --occasion <OCCASION> [--target <prefix>.<member>] writes a beat, checking both against the project; a targeted occasion needs --target.

Trace mock (--mock), which also supplies a test’s mock keys:

# mocks/counter.yaml: lute trace scenes/counter.lute --project . --mock mocks/counter.yaml
file: ../scenes/counter.lute # required under mocks/, which whole-project `check` validates
state: { run.tip: 5 } # path: literal
facts: ["knows(vesna, manifest)"] # base facts, on top of the schema's facts: seeds
choose: { greet: tip, chat: [coffee, leave] } # branch: choice; hub: its visit order
events: [combatEnd] # world events, for <on event>
# bridges: { check: [ { passed: true, margin: 3 } ] } # 0.24.0: plugin-call answers by tag, in call order
# derive: false # the 0.21 model: no seeds, no rules
# a save's history, for the paths the document reads:
# visited: [cafe.counter] # scenes already played; unlisted = not visited
# quests: { regular: complete } # quest.<id>.state
# entriesRead: { run: [log1], user: [vesnaFirst] } # entry.<id>.read / entry.<id>.everRead
# quest walks also take:
# accepts: [lostCup] # accept-driven quests to take up (0.26.0: resolved project-wide)
# occasions: [runEnd, talk@npc.mira] # raised after the walk settles; <occasion>@<target> for a target
# a kind-target beat reads its member from state: { occasion.target: vesna } (0.26.0)

tests/regular.test.yaml (file: is relative to the test file):

file: ../quests/cafe.lute
state: { run.tips: 3 }
visited: [cafe.counter]
occasions: [runEnd]
expect:
quests: { regular: complete, lostCup: unset } # unset | active | complete | failed
state: { user.xp: 50 }
facts: ["regular(mira)"] # hold at the end, after derivation
notFacts: ["banned(mira)"]
transcriptContains: ["@narrator: You are a regular now."] # lines that played, as `@speaker: text`
transcriptLacks: ["You are no longer welcome."]
end: complete # complete | terminal | incomplete | error; without it, incomplete fails
tests/counter.test.yaml
file: ../scenes/counter.lute
choose: { greet: wave, chat: [coffee, cup, leave] }
expect:
options: { greet: [wave, tip], chat: [coffee, cup, leave] } # exact sets: flirt's `when` failed
accepts: [lostCup] # 0.24.0: the quests this walk's ::accept took, as a set
# tests/barks.test.yaml: a lore test names the entries it presents
file: ../lore/barks.lute
entries: [vesnaFirst, vesnaBack] # in order, read flags set between; or `entry: <id>`
state: { user.runs: 3 }
expect:
transcriptContains: ["So you are the new one.", "Back again?"]
eligible: { vesnaFirst: true, vesnaBark: true } # `when` verdicts; an unpresented key is judged alone
# tests/order.test.yaml: a bundle beat, by its bare or canonical id
file: ../lore/talks.lute
beat: miraOrder # or cafe.talks.miraOrder
state: { run.tips: 3 }
expect:
eligible: true

options: is the exact option set a branch or hub showed, across its presentations (for a hub, every choice eligible at some visit, since 0.24.0). A lore test without entry:, entries:, or beat: is E-TEST-LORE; lute trace <file> --entry <id> previews one entry. An entry may be named <doc>.<entry> (0.26.0). A test takes the mock’s bridges: too.

Since 0.26.0 a test that walks an entry, bundle beat or scene beat whose when (or after:, or a spent once: user) is false under its mocks fails, naming the false premise and the mock it needs, unless it asserts eligible:; with eligible: asserted, the ineligible body is not walked:

FAIL tests/north/gull.test.yaml (tests/north/../../lore/north/gull.lute)
eligible northGullFog: not eligible under these mocks (its `when` is false) — the engine would never present it, so the walk proves nothing about play; fix the mocks, or assert `expect: { eligible: { northGullFog: false } }` (the body is then not walked)

Since 0.27.0 the other misses name their premise too. An asserted eligible: { ember: true } that misses reads eligible ember: expected true, got false — its `after="visited('ren.confession')"` is false — mock `visited: [ren.confession]` . A --choice (or a test’s choose:) that lands on an option whose guard is false names the guard, each read it is false over, and the mock that changes it: its guard `holds('found', ['receipt'])` decided false: `found(receipt)` does not hold (mock `--fact "found(receipt)"`) — spelled facts: ["found(receipt)"] in a test, and as the YAML key in a play.

plays/first.play.yaml. Its top-level keys are state, facts, choose, derive, bridges (0.24.0), the save seeds visited, presented, quests, and entriesRead, expect, and steps. Each step is one occasion, engine, event, newRun, advance, include, or end:

visited: [cafe.counter] # save seeds, applied before step 1
presented: { user: [vesna.gift] } # spent `once: user` / `once: run` beats
quests: { lostCup: active } # unset | active | complete | failed; objectives start undone
entriesRead: { user: [vesnaFirst] } # run: entry.<id>.read + everRead · user: entry.<id>.everRead
state: { user.runs: 10, run.tips: 3, quest.lostCup.objectives.find.done: true } # objective progress
facts: ["knows(vesna, manifest)"]
choose: { greet: wave, chat: [coffee, leave] } # hub: visit order; a branch list: one per presentation
bridges: { check: [ { passed: true, margin: 3 } ] } # 0.24.0: answers to plugin calls, consumed in order
steps:
- occasion: townVisit
label: arrival # printed in the step header
choose: { greet: tip } # this step only, replacing that key
expect: { options: { greet: [wave, tip] } } # 0.24.0: the options this step offered, as a set
- occasion: talk
target: npc.vesna # <prefix>.<member> of the occasion's target domain
expect: { winner: vesnaBack, offered: [vesnaBack, vesnaBark], notOffered: [vesna.gift] }
- occasion: talk
target: npc.mira
expect: { presented: [cafe.talks.miraOrder, cafe.talks.miraHum] } # the winner, then its `also` beats
- occasion: inbox
pick: megNote # required on a non-empty `select: all`, refused on `select: first` and `sequence`
- occasion: inbox
pick: none # pass: nothing presented or spent
- occasion: evening # select: sequence: every eligible beat, in selection order
- occasion: runEnd # a same-named world event's <on event> first, then <objective on="runEnd">
expect: { quests: { lostCup: active }, state: { run.tips: 3 } } # judged right after this step settles
- event: combatEnd # a world event: active quests' <on event> run
- engine: # writes what the engine owns; presents nothing
state: { run.day: { add: 1 } } # a literal, or { add: n }; quest.* is refused
facts: ["awake(toma)"] # any declared base relation, reserved ones included
retract: ["awake(vesna)"]
- advance: day # 0.24.0, with a clock: slot | day | <n>; settles, raises the clock's raise: occasions
engine: { facts: ["awake(toma)"] } # optional: writes of the same moment, applied where the clock arrives
- advance: { to: night } # 0.26.0: the next night (or { to: { weekday: Fri, slot: morning } }), forward only
expect: { clock: { slot: night } } # 0.26.0: where the clock stands (weekday, slot, day)
- engine: { accept: [lostCup] } # 0.26.0: the engine accepts an accept-driven quest (quest lostCup accepted (engine))
- include: common.steps.yaml # 0.24.0: splice another script's steps here (path relative to this file)
- include: day.steps.yaml # 0.27.0: repeat / choose / bridges on an include script its spliced steps
label: a day # 0.28.0: labels every spliced step without its own
repeat: 3
choose: { firstLook: ren }
bridges: { exam: [ { grade: pass } ] }
- newRun: { facts: ["knows(vesna, manifest)"] } # or `true`; resets run.*, run facts, once: run, tier="run" quests
- occasion: townVisit
repeat: 2
- end: true # ends the playthrough (exit 0); later steps print as skipped
- occasion: townVisit # skipped
expect: # judged at the end; a miss exits 1
end: complete
quests: { regular: complete, lostCup: unset }
state: { user.xp: 50, run.day: 1 }
facts: ["can_halt(vesna)"] # after derivation
notFacts: ["awake(toma)"]
transcriptContains: ["@narrator: You are a regular now."]
transcriptLacks: ["You are no longer welcome."]
  • An engine: step writes declared state, facts, and retracts, checked against their types before anything plays, then settles the quest lifecycle, so a write can complete a quest on the spot. It refuses quest.*: a quest’s status is the lifecycle’s, seeded with quests:. newRun takes the same state: and facts: as the new run’s seed.
  • target belongs to an occasion step, and pick and choose to an occasion or advance step; label goes on any step, and repeat on any but end. An include: step takes no expect; its label (0.28.0) labels the spliced steps that have none, nested includes too; since 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 and are dropped when the segment ends. The script’s own list for an overridden key does not move while the segment plays. A step expect: takes winner (none when the occasion passes), offered (a subset of the eligible beats), notOffered, and presented (0.23.0: the exact ids presented, in order) on an occasion step, and on an advance step, where winner, offered, and notOffered judge the raise where the clock stops and presented lists every beat the step presented, dayStart / dayEnd raises first; it takes quests, state, facts, notFacts (0.23.1) and clock (0.26.0: { weekday, slot, day }, any of them) on any step but end. A miss names the step and both values. A step with target: also judges that target’s <objective on target>. A winner or pick: names an entry by its id or (0.26.0) <doc>.<entry>, and a for beat’s member as <id> for <member> (presented: [cafe.birthday for toma]). 0.28.0: on an advance step presented: { dayStart: [a], slotStart: [b] } judges only the named raises. A step’s own choose: key no presentation used gets a note, as an include:’s does.
  • A newRun step prints each prev.run.* value it snapshotted (--json: prevRun) and each run-tier quest it reset, with its previous status; a quest queued by ::accept{at="nextRun"} activates right after it. An accept that cannot take (an activate="accept" child whose parent is not active) prints note: accept of quest c spent — its parent quest p is not active yet ….
  • An advance: step moves the clock forward only. Its engine: writes land where the clock arrives, after any dayEnd / dayStart raised on the way and before the final settle and raise; writing the clock’s own day or slot path there, or an engine: step that moves clock.index backward, is a usage error (exit 2). So is an include: cycle.
  • bridges: (0.24.0): each answer gives exactly the result fields the call reads. The top-level list is consumed in call order across the play; a step’s own bridges: is consumed first by that step’s calls, and answers it leaves over fail the step. An unknown tag, a stray or missing field, or a misfit value is a usage error at load. An unanswered call halts the play at the call (exit 3); lute trace and lute test read its result as unknown. In a mock or test, a missing field is E-TRACE-MOCK-TYPE, anchored in that file. A scene.* seed no longer answers a call: lute play refuses one (exit 2).
  • A step expect.options: { <branch or hub>: [ids] } (0.24.0) asserts what that step offered. A select: all occasion with nothing eligible needs no pick:.
  • transcriptContains / transcriptLacks match only lines that played, one @speaker: text per line (a bare text fragment matches too). A skipped guarded line, a step header, or staging never counts, in lute play and lute test alike. A needle with no attribute block matches the line whatever its attributes. Since 0.27.0 a needle’s block is judged (0.26.0 dropped it): "@mara{emotion=\"shy\"}: Any luck?" matches only a line carrying emotion="shy" (and maybe more), so a transcriptLacks needle with a block holds when the words are said another way — write it bare to mean “never said”. A block key a line never shows (emotoin=, when=) or a value outside its domain is refused before anything plays, with a did-you-mean: a usage error (exit 2) in lute play, E-TEST-NEEDLE (the test fails) in a scenario test. A miss names the nearest presented line.
  • A kind-target beat (0.26.0) renders {{occasion.target}} as the kind’s label for the member in lute play (else the cast name:, else the id); a bridge answer is typed by its result slot or the capability’s result: shape, and an untyped answer is refused rather than stored as a string.
  • lute play --project . --script plays/first.play.yaml --derivation "can_halt(vesna)" shows which rule concluded the atom and where each premise came from.

→ CLI reference · Tracing · Playing a story

CodeWhat it usually means
E-KIND-MISSINGThe frontmatter has no kind: and no defaults: supplies one.
E-META-MISSINGA scene has no id: and no character + season + episode.
E-META-PARSEThe frontmatter is not valid YAML, usually an unquoted value containing : .
E-META-UNKNOWN-KEYThe key is not legal on this kind, for example after: on a quest (use <quest follows=…>).
E-CONTENT-OUTSIDE-SECTIONContent comes before the first ## Heading.
E-INERT-TITLEThe body has a # Title line. Put the title in the frontmatter’s title:.
E-SECTION-DUPTwo sections of one document share a {#id}.
E-DOMAIN-UNKNOWNemotion=, action=, anchor, mood, framing, cgLayout, a :style[…] text style, … is used but its slot has no declared members.
E-CAMERA-EMPTYA ::camera has none of focus, framing, move, transition.
E-MISSING-ATTRA required attribute is missing: ::actor without character, ::cg / ::video without assetId, ::sequence without name.
E-MONO-POV / E-MONO-NO-POVA {mono} line’s speaker is neither the point of view nor in monoSpeakers:, or no pov: resolves at all (the document’s, else the project’s defaults.pov).
E-TEXT-MODIFIER / E-TEXT-ESCAPEAn inline modifier is malformed: unclosed, a positional value (:pause{0.5} for :pause{s=0.5}), attributes on a text style; or an unknown \ escape.
E-LABEL-DUP / E-JUMP-UNDEFINED / E-JUMP-BACKWARDTwo ::labels share a name; a ::jump names no label in the document (a section is not one); a ::jump points backward without max (since 0.51, max="N" allows N takes).
E-REWARD-DUPTwo rewards of one quest share an id.
E-AUTHOR-CASEAn authored key contains _. Write it in lowerCamelCase.
E-UNDECLARED / E-UNDECLARED-REFThe state path, or the @def, is not declared, or its schema is not imported.
E-MAYBE-UNSETA path is read that has no default and no dominating ::set or has guard. Every prev.run.* read needs one.
E-CAST-UNKNOWNA cast is declared (a schema’s cast: or a plugin cast export) and this speaker is not in it, or (0.24.0) an ::actor{character}, a ::camera{focus}, or a speaker component argument names someone outside it. The message suggests the nearest id.
W-CAST-ABSENT0.24.0. The speaker’s cast entry declares present: and the guards around the line do not imply it (a {vo} line is exempt). Guard the line (@corvin{when="holds('inParty', ['corvin'])"}) or move it under one; only a write that can falsify a guard cancels it. A single-file check cannot see facts asserted on every route; whole-project check can.
E-BAD-ENUMA value outside its enum; since 0.24.0 also an emotion= outside the speaker’s cast emotions: (happy is not one of isolde’s emotions), and since 0.26.0 a value outside the kind of an attribute typed { entity: K } (with a did-you-mean, also through a component param).
E-ENGINE-OWNED-WRITEA ::set writes a path declared owner: engine. Content only reads it; lute play writes it with an engine: step, and trace and test with a mock’s state:.
W-QUEST-STATE-HAShas(quest.<id>.state) is always true. Compare with 'unset' instead.
W-TEXT-LOOKS-LIKE-REFA line’s whole text is @name for a def or param; it ships as that literal. Write {{@name}}.
E-UNSET-UNCOVERED / E-NONEXHAUSTIVEA <match> misses unset, an enum member, or a numeric gap (the message names what is uncovered). Add arms or <otherwise>. Since 0.24.0 a beat’s when: narrows the domain first, so an arm it rules out need not be written.
E-WHEN-LITERAL-DOMAINA <when is> literal, or since 0.26.0 a string a guard compares with == / != / in [...], is not a member of the subject’s domain (an enum, the kind occasion.target is typed by, a quest’s state / failedBy). The message lists the members and names the nearest one.
E-TAG-INLINE-BODY / E-TAG-NOT-ONE-LINEA tag shares its line with its body, or a tag is wrapped across lines.
E-BRANCH-ALL-GUARDED / E-HUB-NO-EXITA menu could be empty, or a hub could never end.
E-SET-TYPE / E-REF-TYPE / E-ATTR-TYPEA value has the wrong type for its slot, often a quoted "@def" in a directive, or a @def bound to a component string param the component interpolates.
E-ATTR-DEF-DYNAMICA directive attribute takes a @def that reads state. An attribute value must be a constant; branch with <match>.
E-INTERP-DEFA {{@def}} whose body cannot be inlined into one expression (an expansion cycle, or a body that reads $).
E-ATTR-QUOTEAn attribute value in single quotes, or in curly quotes (“…”, ‘…’) a word processor typed. Use straight "…", and write a " inside it as \".
E-DEF-DECLA def is malformed: its type cannot be inferred, it has params: without type:, or it has an unknown key.
E-OBJECTIVE-MISSING-DONE / E-OBJECTIVE-QUEST-DONEAn objective has no done, or has both quest= and done=.
E-QUEST-TIER-MIXA subquest’s tier differs from its parent’s. Give both the same tier: a mixed tree locks for good once a new run resets only one side.
E-GRAMMAR-NOT-ADMITTEDThe construct is not allowed in this kind, for example <branch> in an entry or a heading in a quest. Since 0.52 also anything but <on>, <objective> and <reward> directly inside <quest> (a quest runs only those bodies): move it into the body whose moment it belongs to.
E-BEAT-ATTRA beat key is malformed or has no on, when reads scene.*, a scene’s or bundle beat’s target is on an untargeted occasion or any target is outside its target domain (with a did-you-mean), an entry’s once is not run, user, day, week, slot, or season:<name>, once: day / week / slot has no clock to count by (week also no week:), spentBy sits beside once: false or share (0.28.0), or also is not a bool, is on an entry, or is on a select: all / sequence occasion. So is a <beat> with no id, a - in its id, or no document id:, an objective target= that is malformed or has no on, and an until= without on=. 0.26.0: a target="kind:K" whose kind is not a closed kind inside the occasion’s domain, or on an occasion no plugin declares with a domain.
E-OCCASION-UNKNOWNA plugin declares occasions and this one is not among them.
W-BEAT-PRIORITY-TIETwo beats on one select: first occasion, with the same (or no) target and equal priority, whose whens are not provably exclusive: file order picks the winner. Since 0.26.0 negations are normalized first (!(x < 1) is x >= 1), and the message says why the two are not exclusive (a flag that outlives once: run, a fact asserted elsewhere, the paths each reads). A kind-target beat never ties with the beat naming the member.
W-BEAT-ONCE-RUN-USERA beat left at the default once: run whose when reads only user-tier state, so it replays every run. Write once: run if that is intended, or once: user.
W-QUEST-HANDLER-DEAD<on event="questFailed"> on a quest that can never fail: no fail, no required objective with a by= deadline, no required subquest that can fail, no parent quest.
W-STAGE-ABSENTA line stages a character who has exited, or was auto-hidden by a ::bg scene change, on some path to it. Each choice and <match> arm is followed separately, so an exit in one arm does not warn in its sibling; after the arms rejoin, a character is on stage only if every arm left them there.
E-BEAT-UNREACHABLE / E-ARM-DEADThe condition can never hold. Whole-project check also decides fact queries. Since 0.23.0 contradictions inside one && (run.n > 5 && run.n < 3) count too. Under check --project --wip, a guard dead only for a relation nothing produces yet is the warning W-WIP, which names the code.
E-ACCEPT-TARGET::accept names a quest that does not exist or that has a start, or (0.24.0) a child that activates with its parent (declare activate="accept" on it), or has an at other than "nextRun".
W-QUEST-NEVER-ACCEPTED0.24.0, whole-project check. An accept-driven quest (no start, or an activate="accept" child) that no ::accept names: it never activates. Since 0.25.0 a mock’s accepts: no longer counts (the message names the mock); declare accept="external" for a quest the engine accepts.
E-FACT-EXCLUSIVE / E-RULE-EXCLUSIVE0.25.0. An ::assert makes two excludes: relations hold together on every route (or, in lute trace / lute test, on the walked one), or a rule can only derive one where the other holds.
W-LUTE-VERSION-STALEA document’s luteVersion stamp differs from the toolchain’s. 0.25.0: when it is inherited from the manifest’s defaults: luteVersion, whole-project check reports it once, at that line of lute.project.yaml, instead of once per document (… — every document inherits it (N documents)).
W-DEADLINE-BEFORE-DONE0.24.0. An on= objective’s by= holds whenever its done does, so by (judged at every settle) fails it before the occasion ever judges done. Write the condition as until=.
E-STATE-DECLA state declaration is malformed; since 0.24.0 also a per: over an open or unknown kind, or a map default: naming a non-member, leaving a member with no value and no _ (`default:` gives no value for `guild`), or on a path without per:.
W-DOMAIN-UNREADA declared enum or kind that nothing reads. Since 0.24.0 a per: index, a subsetOf: parent, and a kind atom in a rule body or holds(…) count as reads, and the warning sits at the declaration’s own line. Since 0.53 a plugin frontmatter key typed by it (at any depth) reads it too.
E-ENTITY-KIND-SHAPEAn entity kind is malformed. Since 0.26.0 also: a member listed twice in one kind or enum, or added by two files (both lines named); an add: for a kind no import declares (did-you-mean), onto an open: kind, or beside members: / open:. A sub-kind’s members no longer need restating in the parent. Reported once, at the schema line.
E-STATE-DECL-CONFLICT0.26.0, whole-project check. Two documents declare one state path with a different type, default, per or owner (scene.* exempt; an extends: refinement is not a conflict). Declare a shared path once, in a schema both import.
E-REWARD-TARGET0.26.0. A reward’s target= breaks its kind’s target: contract: not a member of the { entity: K } kind, unknown to the { provider: … }, or missing where required: true.
E-RULE-AGGREGATE-CYCLE0.26.0. A rule’s count(…) / countDistinct(…) reads a relation that depends on the rule’s own head. Count only relations the head does not feed.
W-DISPLAY-NAME-DUP0.26.0, whole-project check and lute lint. Two speakers are shown with the same display name: two cast name:s, a cast name and a ::use{… name="…"} string, or two such strings. Rename one, or mark an intended role name sharedName: true on the cast entries.
E-PLUGIN-STAMP-SURFACE0.50. A stampAttrs on: names something that is not line, branch, hub, choice or a stampable directive of the resolved profile (a typo, jump/label/clear, or a directive of an inactive plugin). Fix the name, or depends: on the plugin that declares the directive.
E-DIAGNOSTICS-CONFIG0.50. A diagnostics: entry in lute.project.yaml is malformed or illegal: an E-* or L-* code, an unknown code, a bad level, or bad paths. Only W-* codes can be re-levelled.
E-JUMP-MAX / E-JUMP-LOOP-SCOPE / E-JUMP-INTO-LOOP / E-JUMP-LOOP-CROSS0.51. max is not a literal integer from 1 to 99 or sits on a forward jump; a loop leaves its body (0.53: a ## section is not a body, loops span sections); a forward jump enters a loop after its head (0.53: when the jump is the last node of a <choice> body and no other jump targets that label, the message suggests moving the block into the option); two loops overlap without nesting.
E-JUMP-INTO-BODY0.52. A ::jump enters a body that runs as its own range (a hub option or <return>, an <on> or <objective>, an entry or a beat) from outside it. A jump out of hub bodies to a label after the hub is legal and leaves the hub.
E-INTERP-BRACE0.52. A literal { (an odd run) sits right before a {{…}} marker in line text, or a ::use argument ending in a plain {{ is spliced before one. Put a character between them, or pass \\{{. 0.53. Also a marker whose expression itself opens on { ({{{run.a}}, any label-grammar value too), where 0.52 gave E-CEL-PROFILE; the message names the run length n.
E-L10N-MARKERS0.53. A translation’s {{…}} markers do not parse, or are not its source’s (each as often, any order). The translation is not merged, compile --locale-bundle fails, and loc import refuses a marker that does not parse.
E-OBJECTIVE-TITLE-INTERP0.52. An <objective title> holds {{; a title is shown as written. Write plain text.
E-PLUGIN-RAISES / E-RAISES-TARGET / W-OCCASION-NEVER-RAISED0.51. A raises entry names an unknown occasion or a target of the wrong shape; a resolved raise target is outside the domain or answered by no scene or beat; a raisedBy: declared occasion has an answerer that nothing raises.
E-LINE-CODE-RESERVED / E-LINE-ID-DUP0.51. A code contains ~; two localization units share one final lineId.
W-ENTRY-WRITE-REREAD0.26.0. An entry that can be read again in a run (a lookup entry, no once, a once shorter than the run, spentBy, for) ::sets, ::retracts or calls an effect-only directive that writes: its writes apply on the first read in a run only. Use <beat … once="false"> for a repeating write, once="run", or a when="!entry.<id>.read" guard. ::assert is exempt.
E-COMPONENT-ARGA ::use argument does not fit its param, or names none. Since 0.26.0 also @@x: for a param that is not a speaker param, or outside a component.
E-UNKNOWN-ATTRAn attribute the directive does not declare. Since 0.26.0 also when= on a directive that lowers to a built-in record (the core staging directives such as ::bg and ::sfx, ::end, ::label, a plugin lower: record), and a plugin attribute named when.
E-ENTITY-KIND-CLASHTwo kinds list the same member. If they are two id spaces whose ids only spell the same, declare a shared root kind listing both spaces’ members and make each a sub-kind of it ({first}: { subsetOf: <root>, members: […] }); every reference still checks against its own kind.
E-PATH-IDENTA name segment that is not an identifier written after a .. For an entity or enum member that is an id from another system (vendor:reply), declare a provider and type the attribute { providerRef: <provider> }.
E-CONN-UNKNOWN-NODEvisited('…') or after names no scene (or, since 0.24.0, bundle beat) in the project.
E-CLOCK-DECL0.24.0. The schema’s clock: is malformed: day / slot undeclared, mistyped, or not owner: engine (`day: run.day` must be declared `owner: engine`), slots not the slot enum’s members or given without slot, an unknown raise occasion (once a plugin declares occasions) or one with a payload: (0.28.0), or a second clock.
E-SEASON-DECL0.27.0. A seasons: entry is malformed (not a map, no or empty live, unknown key, bad name), two schemas declare one season differently, or a season.<name>.* path, once: season:<name> or tier="season:<name>" names an undeclared season. A write to prev.season.* is E-QUEST-RESERVED-WRITE, as for prev.run.*.
E-TEMPLATE0.27.0. A beat template is misused: <beat use> names no imported component (did-you-mean) or one without a beat: header, the header has an unknown key (id included) or a @name that is no param, a plain-text header key gets an expression argument, or ::body sits anywhere but the top level of a template.
E-CHAPTERS0.28.0. lute.project.yaml’s chapters: is not a list of { on, scenes } chains, lists a scene twice or puts two chains on one occasion; or (whole-project check) a chain answers an occasion no plugin declares, lists an id no scene declares (did-you-mean), a scene whose own on: answers another occasion, or — on a targeted occasion — a scene without target:. Each scene of an applied chain otherwise gets on:, a descending priority: and (except on a select: sequence occasion) after: visited("<previous>") it does not write itself. A top-level sequence: key is E-REMOVED-PROJECT-KEY.
W-CHAPTER-STALL0.28.0. A chained scene’s own when: reads state that may never make it true, and the next scene’s after: waits on it, so the chapters may stop there. A when: over the clock alone is reported only when its window closes (no later raise of the occasion meets it).
W-CHAPTER-ORDER0.28.0. On a select: sequence occasion, a chained scene’s own priority: plays it out of the listed order.
E-ENUM-LABEL-NOT-MEMBER0.24.0. An enum’s labels: names something that is not one of its members.
E-CEL-TYPE0.24.0. % has a non-integer operand: `%` takes two integers: `2.5` is not an integer.
E-RULE-GUARD-DEF0.24.0. A rule’s cel() guard calls a @def that does not exist or with the wrong number of arguments, or uses $.
E-RESERVED-NAME0.28.0. A declared name is one Lute keeps for itself: a relation named like a CEL call, keyword or rule word (has, holds, visited, not, …), a state root naming an entity member or def (clock), unset/true/false/null naming a member or id, none naming an id, narrator in cast:. The message names a replacement (visited → wasAt); every name is on Reserved names.
W-RELATION-UNREAD / W-DEF-UNUSED0.24.0, whole-project check, at the declaration. A relation is asserted, seeded, or derived but no condition, rule body, or def reads it; or no content, def, or rule guard references a @def. Play scripts and tests are not reads.
E-PLUGIN-PARSE / E-PLUGIN-KEYA plugin export does not parse, or (E-PLUGIN-KEY) holds a key it does not take (has no key `selct` — did you mean `select`?, at its line; 0.28.0 also checks plugin.yaml). E-PLUGIN-PARSE also covers a lower: { kind: builtin } naming no core hook; the follow-up E-PLUGIN-MISSING-ACTIVE says the plugin failed to load.
E-CONN-EPISODE-ID-DUP / E-QUEST-ID-DUPTwo documents share a scene id or a quest id, or a bundle beat’s canonical <doc>.<beat> id equals a scene id.
E-DUP-VOICEKEYLines with different text compile to one voiceKey, typically under a pinned {speaker}-{code} template. Every line counts, narration and {mono} included, and the text compared is the plain text with modifiers stripped. Use the default {prefix}.{speaker}-{code}, or give the lines distinct code=s.
E-CAPABILITY-MISMATCHThe project’s documents resolve two capability snapshots (different profiles or scene-local plugins:), so it cannot compile as one.
E-TEST-LOREA *.test.yaml names a lore document without entry:, entries:, or beat:. Name what it presents.
E-TEST-FILEA *.test.yaml’s file: names no document. The test fails; the rest of the suite still runs.
E-TRACE-BEATlute trace --beat <id> names no bundle beat of the document (or the document is not lore). lute run --beat refuses the same with exit 2.
W-REWARD-DOUBLE-CREDITA quest’s <on> or objective body ::sets the path its reward kind already credits:, so the player is paid twice. Drop the ::set or the reward.
W-FACT-GUARANTEEDA fact guard is always true on every route, so it is redundant.
W-BEAT-SHADOWEDAn earlier beat that is always eligible and never spent wins every time.
W-ENTRY-REF-UNKNOWNentry.<id>.read or entry.<id>.everRead names an entry that nothing declares.
E-LEGACY-CONTENT-SIGIL · W-WHEN-TEST-LITERALOld syntax. lute fix rewrites it.
E-RENAMED-DIRECTIVE · E-RENAMED-ATTR · E-RENAMED-TAG-ATTRA spelling from before 0.37; the message names the current one, and lute fix rewrites it.
E-REMOVED-ATTR · E-CAMERA-REMOVED · E-CG-LAYOUT · E-REMOVED-TAG · E-REMOVED-PROJECT-KEYA form removed in 0.37 with no lossless rewrite. The message names the replacement (a declared framing / cameraMove / cgLayout member, an assetId); migrate it by hand.
E-PERSIST-REMOVEDDelete persist= from the choice by hand; into= alone records the run fact.

A quest with no start never activates by itself. It is accept-driven: it stays unset until a scene runs ::accept{quest="id"}, the engine accepts an accept="external" quest, or a mock or test lists it in accepts: (a play script can seed its status with quests:). ::accept on a quest that has a start is E-ACCEPT-TARGET. The exception is a child named by quest=, which activates with its parent unless it declares activate="accept"; accepting a child that activates with its parent is E-ACCEPT-TARGET too.

once means different things on scenes and entries. A scene beat defaults to once: run: it plays at most once per run unless you write once: false. An entry without once repeats. once="run" spends an entry until a new run resets entry.<id>.read, and once="user" spends it for good (entry.<id>.everRead). A beat left at the default once: run whose when reads only user-tier state replays every run (W-BEAT-ONCE-RUN-USER); you probably meant once: user, and an authored once: run says the replay is intended.

after: is structural and when: carries state. after: reads only visited, completed, and active with && and ||. It feeds the scene graph. A beat is eligible only when both hold. On a <quest>, follows= is graph metadata only and does not delay activation. To gate a quest on a scene, put the condition in start: start="visited('cafe.counter')".

::end ends only its presentation in lute play, not the playthrough. The step still settles and the play goes on with the next step. To stop a play early, add a step - end: true: later steps print as skipped and the play exits 0. Only a schema’s terminal: ends the game (no occasion is raised after it, but an outsideRun: true one); a clock’s last: ends the clock (clock.ended); a play asserts which it was with expect: { end: terminal }. An epilogue goes after the write that makes terminal: hold, or in the questComplete handler of the quest it reads.

visited() covers the whole save. A new run does not clear it. A single-file check never decides it, whole-project check validates the id, trace and test need a visited: list, and a play script that starts from a save lists them in top-level visited:.

Engine-owned state comes from the harness, not from content. A ::set of an owner: engine path is an error. lute play writes it with an engine: step, and trace and test with a mock’s state::

---
kind: scene
id: clock.cheat
state:
run.day: { type: int, default: 1, owner: engine }
---
## Night
::set{run.day += 1}
@narrator: The day ends.

A quest’s status is not an engine write either: an engine: step refuses quest.*. Seed a save’s quest status with the script’s top-level quests:. With a clock (0.24.0), move time with an advance: step; an engine: step that moves clock.index backward is a usage error.

A ::bg scene change takes everyone off stage. A character it auto-hid is recorded as exited, so a later line by them is W-STAGE-ABSENT until an ::actor shows them again. ::clear (0.24.0) exits everyone the same way but keeps the background, and the warning then names the ::clear. With a ::bg:

---
kind: scene
id: dock.night
enums:
action: { members: [fadeInUp, fadeOutDown], exits: [fadeOutDown] }
anchor: { members: [left, center, right], default: center }
---
## Dock
::actor{character="mira" action="fadeInUp"}
@mira: Over here.
::bg{location="street" time="night"}
::actor{character="mira" action="fadeInUp"}
@mira: Keep walking.

A def’s type comes from its body. calm: "run.pressure < 2" is a bool. A body that is only another def ("@calm") cannot be inferred, and a def with params: needs type:.

Quote YAML values that contain : or start with !, @, {, [, *, or &. title: Chapter 1: Start is a parse error, and an unquoted when: !run.x is a YAML tag, not a negation:

---
kind: scene
id: chapter.one
title: Chapter 1: Start
---
## Start
@narrator: Once upon a time.

**scene.choices.<branch> is set once the branch picks (0.28.0), so a match after a branch without timeout needs no unset arm. A timeout can end the branch without a pick; then give the match an is="unset" arm or an <otherwise>:

---
kind: scene
id: choice.readback
---
## Ask
<branch id="ask" timeout="10" default="no">
<choice id="yes" text="Yes">
@mira: Great.
</choice>
<choice id="no" text="No">
@mira: Oh.
</choice>
</branch>
<match subject="scene.choices.ask">
<when is="yes">
@mira: You said yes.
</when>
<when is="no">
@mira: You said no.
</when>
</match>

Every single-file command finds the nearest project (0.27.0). Without --project, lute check, trace, compile (and compile --stream), and context apply the nearest lute.project.yaml above the file and say so on stderr (lute: note: using project <dir> (nearest lute.project.yaml); pass --project to choose another). Before 0.27.0 only check did, and the others resolved without anything hoisted by defaults: or declared by a plugin. lute test resolves its test documents and plays against --project or the nearest lute.project.yaml; whole-project check, play, and scenario load the project from --project (or the nearest lute.project.yaml above the current directory), and fail with a usage error when there is none. With no manifest above the file, resolution is core-only.

trace, test, and play derive: mock the premises, not the conclusion. They load the schema’s facts: seeds and apply its Datalog rules over the mocked and asserted facts, as lute run does. A mocked derived atom still counts, as one more seed. An unmocked base fact is false. A rule guard over state the trace cannot decide leaves its conclusion unknown and halts the walk (exit 3); in lute test that fails the test unless it declares expect: { end: incomplete }. derive: false (a mock, test, or play-script key) or --no-derive restores the 0.21 explicit world: no seeds, and an unmocked derived atom is unknown.

Directive attributes take bare refs to constant defs. Write ::camera{framing="closeUp" duration=@holdTime}. The quoted duration="@holdTime" is the literal string @holdTime (E-ATTR-TYPE on a numeric attribute). A def that reads state is E-ATTR-DEF-DYNAMIC.

Attribute values use double quotes. text='"Hi."' is E-ATTR-QUOTE. Write text="\"Hi.\""; the choice text is "Hi.". Curly quotes pasted from a word processor (text=“Hi”) are E-ATTR-QUOTE too: retype them as straight ".

Numbers are real numbers in <match>. is="1..9" followed by is="10.." does not cover 9.5. Add an <otherwise>, or use open ranges that meet.

Since 0.23.0 the checker proves more conditions false. It reasons per path across an &&: run.n > 5 && run.n < 3, run.slot == 'a' && run.slot == 'b', and x && !x never hold, and an || whose cases cover a path’s domain always does. A project that checked clean on 0.22 may now report E-BEAT-UNREACHABLE, E-ENTRY-UNREACHABLE, E-ARM-DEAD, E-OBJECTIVE-UNSATISFIABLE, or W-BEAT-PRIORITY-TIE. The contradiction is real: fix the condition. unset counts as a value, so on a path with no default run.m > 5 && run.m < 3 stays undecided, while has(run.m) && run.m > 5 && run.m < 3 is false.

---
kind: scene
id: late.shift
on: townVisit
when: "run.day > 5 && run.day < 3"
state:
run.day: { type: int, default: 1 }
---
## Late
@mira: You're early.

lute trace follows a taken ::jump (0.26.0). Before 0.26 the trace reported the jump and ended there, and a test could pass end: complete on a walk that never reached the content after the ::label. Now trace and test continue at the label (<jump -> outro>), as lute play and lute run do, and end: complete means the walk reached the end of the body.

A test that walks an ineligible beat fails (0.26.0). Trace still presents an entry or beat whose when is false, but lute test fails such a test unless it asserts eligible:, and names the premise that is false (the when, an after: and the visited: / quests: mock it needs, or a spent once: user). Fix the mocks; assert eligible: false only when that is the point.

Share a state path through a schema, not two frontmatters. Two documents that each declare run.tide in their own state: must agree on its type, default, per and owner (E-STATE-DECL-CONFLICT, 0.26.0). Declare it once in a schema both import. On a project with several writers, see Many authors, one project.

by= is a moment; until= is a place (0.24.0). A by= deadline is judged at every settle, so a player who never goes back to the objective’s occasion still misses it. 0.23.1 judged an on= objective’s by only at that occasion’s raise. For that place-bound deadline, write until= (which needs on=). An on= objective written for the old rule, whose done implies its by (done="run.v == 'fell'" by="run.v != 'undecided'") now fails unless both come true in the step that raises its occasion: W-DEADLINE-BEFORE-DONE flags it and suggests the same condition as until=.

::set{… when="…"} is not a definite assignment. The write may not happen, so a later read of a path with no default is still maybe-unset:

---
kind: scene
id: tab.open
state:
run.day: { type: int, default: 1 }
run.tab: { type: int }
---
## Tab
::set{run.tab = 0 when="run.day > 1"}
@mira: Your tab is {{run.tab}}.

% needs integers. Both operands must be integers: a fractional literal or a non-int operand is E-CEL-TYPE. At run time a fractional value or a zero divisor reads unknown.

---
kind: scene
id: week.end
state:
run.hours: { type: int, default: 0 }
---
## Payday
@mira{when="run.hours % 7.5 == 0"}: Payday.

_ goes in a rule body, never its head. In the body it is a fresh variable. A _ in the head is E-DATALOG-PARSE, because every head argument must be a bound variable or a constant:

---
kind: scene
id: ledger.rule
entities:
person: { members: [ada, hollis] }
relations:
owes: { args: [person, person], tier: run }
debtor: { args: [person, person], derive: true }
rules:
- "debtor(D, _) :- owes(D, _)"
---
## Ledger
@narrator{when="holds('debtor', ['ada', 'hollis'])"}: Ada owes.

transcriptContains sees only lines that played. Since 0.24.0, lute play and lute test both match the presented content lines, one @speaker: text per line. A guarded line that was skipped (skip @x "…" — when: false), a step header, a candidate, and staging never match, so a transcriptContains that passed on a skipped line now fails, as it should.

An entry’s target= on an untargeted occasion is metadata. The entry answers every raise of that occasion, whatever target it names. To make it target-bound, give the occasion a target: domain. A scene’s or bundle beat’s target there is still E-BEAT-ATTR.

An unanswered bridge halts; nothing is defaulted. A plugin call whose result a later guard reads needs a bridges: answer. Without one, lute play halts at the call (exit 3) before any default arm, and lute trace / lute test read the result as unknown and stop incomplete, with a hint that is a valid answer: bridges: { check: [ { passed: <bool>, margin: <int> } ] }. It used to read the state-shape default. A 0.23.1 test or mock that seeded the result slot (state: { scene.check.guards.passed: true }) no longer decides the guard: replace the seed with bridges: { check: [ { passed: true, margin: 3 } ] }.

A typo in a plugin export is an error now. { selct: all } used to load as select: first; since 0.24.0 every export file rejects unknown keys (E-PLUGIN-PARSE with a did-you-mean), and so does a lower: { kind: builtin } that names no core hook. Omit lower: for the generic passthrough.

W-CAST-ABSENT from a single-file check can be a false alarm. lute check sees only the guards around the line. ::assert{inParty(isolde)} earlier on every path, or a seed nothing retracts, makes @isolde: … clean under whole-project check, which counts the facts that hold on every route. A single-file check cannot, and its message says so.

Agents should consume the JSON report envelope rather than scrape human output. For every JSON-capable report, stdout is one object with command, schemaVersion, and ok; ok is true exactly when the process exits 0. For example, LUTE_TELEMETRY=0 target/debug/lute schema check returns schemaVersion: "0.48.0.schema" and describes the --json check report (0.48.0.check). lute schema itself is always JSON and lists arguments, options, outputs, and inputs (the FILE request of lute schema patch; empty for every other command).

In JSON mode, usage, I/O, and malformed-input failures are also one object: error.code is E-CLI-USAGE, E-CLI-IO, or E-CLI-INPUT; error.path, error.argument, and error.hint are included when applicable. The stable payload fields are documented in the CLI reference.

Telemetry is local and opt-out: set LUTE_TELEMETRY=0 (or DO_NOT_TRACK=1) for deterministic examples and CI. It never changes command output.