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.
Writer’s card
Section titled “Writer’s card”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: sceneid: cafe.countertitle: At the counterpov: fixeron: chapterafter: 'visited("cafe.arrival")'priority: 10when: "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# Titleline.@speaker: textis a line, and@narratornarrates.{mono}is a thought of thepov: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 understate:. → State writes & factson:makes the scene answer an occasion, herechapter: any name works until a plugin declares occasions.after:orders it after another scene,priority:wins a tie, andwhen: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:
file: ../scenes/counter.lute # relative to this test filevisited: [cafe.arrival] # the after: route this walk assumeschoose: { 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.counterchoose: { order: nothing }steps: - occasion: chapter expect: { winner: cafe.arrival } - occasion: chapter expect: { winner: cafe.counter }expect: transcriptContains: ["@mira: We close at ten."]$ 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
Project layout
Section titled “Project layout”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: gameprofiles: game: plugins: { game.occasions: true } # true = active with defaultsidentity: # 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 keysdefaults: # 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 nonechapters: # 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.0entities: 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 bodydefs: 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: 1cast: # 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
Frontmatter keys by kind
Section titled “Frontmatter keys by kind”| Kind | Required | Kind-only keys |
|---|---|---|
kind: scene | id:, or the legacy character + season + episode | id, 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: quest | one or more <quest> in the body | id (optional bundle name), pov, monoSpeakers |
kind: lore | one or more <entry> or <beat> in the body | id (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.
Lines, cast & staging
Section titled “Lines, cast & staging”---kind: sceneid: diner.nightpov: fixermonoSpeakers: [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"}| Piece | Rule |
|---|---|
@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 attrs | code, emotion, variant, action, dialogMotion, as (label override), when (guard). A jump target is a ::label{name} on the line before. A quoted value decodes " ' & < > &#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 modifiers | In 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. |
| sections | All 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 line | 0.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, ::clear | 0.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
Choices, hubs, jumps & endings
Section titled “Choices, hubs, jumps & endings”---kind: sceneid: cafe.counterstate: 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"}| Construct | Rule |
|---|---|
<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
Match & when
Section titled “Match & when”---kind: sceneid: cafe.moodsstate: 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, so1..9and10..leave a gap at 9.5. A maybe-unset subject needsis="unset"or<otherwise>. Arms narrow it: inside<when is="x">the subject is set, and after anis="unset"arm with notest, later arms and<otherwise>read it as set (noE-MAYBE-UNSET). - A beat’s
when:narrows the subject for the whole body (0.24.0): underwhen: "run.verdict != 'undecided'", a<match subject="run.verdict">needs noundecidedarm, and one written there isE-ARM-DEAD. A body that writes the subject keeps the whole domain. test="$ == 'x'"isW-WHEN-TEST-LITERAL, andlute fixrewrites it tois="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(…)">isE-MATCH-RELATION-SUBJECT.- A
<match>with nosubjecthas no subject, so every arm istest=; anis=arm there isE-MATCH-NO-SUBJECT. Addsubject="…", or write the condition astest=(<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 isE-TAG-INLINE-BODY, and a tag wrapped across lines isE-TAG-NOT-ONE-LINE.
State writes & facts
Section titled “State writes & facts”---kind: sceneid: ship.archivestate: 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: sceneid: camp.firestate: run.day: { type: int, default: 1 } run.approval: { type: int, default: 0, per: companion } # run.approval.isolde, run.approval.corvinentities: 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: companiondeclaresrun.approval.<member>for each member. Content names a member. Two places index the family instead: a rulecel()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 (undernot, “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): nosawAt(P, _)at all). Negation binds nothing: each of its variables must be bound by a positive atom of the same body, elseE-DATALOG-UNSAFE.countDistinct('sawAt', ['_', '_'], 0)counts distinct values of one position, wherecount(…)counts tuples. Likecount, it is not allowed in a rule guard.- A rule guard may call
@defs; an undefined def, a wrong argument count, or$isE-RULE-GUARD-DEF. A kind atom in a rule body (companion(P)) tests membership. ::set{… when="…"}writes only when the condition holds (lute playprintsskip set … — when: false). Since 0.26.0::use,::accept,::assert,::retractand plugin passthrough and bridge directives take the samewhen=(::assert{inParty(corvin) when="run.day > 3"}; a guarded::useruns its whole expansion or none of it; play printsskip ::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 pluginlower: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, …) isE-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: sceneid: tower.galleryentities: 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’sRelationEntry.excludescarries the symmetric closure. - Whole-project
check:holds('seenAfter', ['x']) && holds('fell', ['x'])is dead (E-ARM-DEAD/E-BEAT-UNREACHABLE),!holds('fell', ['x'])underholds('seenAfter', ['x'])isW-FACT-GUARANTEED,::assert{fell(elias)}here would beE-FACT-EXCLUSIVE(the other holds on every route), and a rule likefell(P) :- seenAfter(P)that can only break the pair isE-RULE-EXCLUSIVE. - Where both are only possible,
lute playhalts at the write with✗ exclusive: fell(elias) and seenAfter(elias) both hold(exit 1), andlute trace/lute testrefuse there (E-FACT-EXCLUSIVE). - A
reserved:relation may declarechangedOn: [<occasion>…], the occasions on which the engine changes it; it narrows castassume: true(see the schema above).changedOnon a relation that is notreserved, or naming an undeclared occasion, isE-RELATION-DECL.
→ State model · Facts & Datalog
CEL cheat
Section titled “CEL cheat”| Namespace | Resets | Content may write |
|---|---|---|
scene.* | when the scene ends | yes |
run.* | at a new run | yes |
user.* | on a profile wipe | yes |
app.* | on uninstall | no |
quest.<id>.state (always assigned: unset until the quest activates, then active complete failed), quest.<id>.activatedAt, quest.<id>.objectives.<o>.done | engine; a tier="run" quest returns to unset at a new run | no |
quest.<id>.failedBy (unset fail by until subquest cascade superseded), quest.<id>.objectives.<o>.failed (0.24.0) | engine; cleared with a run-tier quest | no (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 slot | no (E-QUEST-RESERVED-WRITE) |
entry.<id>.read | engine; run-tier | no |
entry.<id>.everRead | engine; user-tier: set on the first read, never reset by a new run | no |
a path declared owner: engine | as its namespace | no (E-ENGINE-OWNED-WRITE) |
prev.run.<path> (0.23.0) | engine: snapshot of run.<path> at run end; unset before the first run ends | no (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 ended | yes / no (E-QUEST-RESERVED-WRITE) |
scene.choices.<branch>, scene.visited.<hub>.<choice> | engine | no |
visited('<scene id>'), visited('<doc>.<beat>') | never: the whole save | no |
| Operators | Functions & references |
|---|---|
== != < <= > >= · && || ! · + - * / · % (0.24.0: integers only) · c ? a : b · x in ['a', 'b'] · integer/fractional literals | has(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: sceneid: vesna.gifton: talktarget: npc.vesnawhen: 'user.runs >= 10 && !run.giftRefused'priority: 50once: userafter: '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: loreid: vesna.barksstate: 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: loreid: cafe.talksstate: 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.
| Key | Meaning |
|---|---|
on | The occasion answered. It makes the scene a beat. Any other beat key without on is E-BEAT-ATTR. |
target | Optional 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). |
when | CEL 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. |
priority | Integer, default 0. Higher wins. |
once | Scenes: 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. |
also | 0.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. |
share | 0.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. |
spentBy | 0.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. |
use | 0.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. |
for | 0.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:
id: game.occasionsversion: 0.1.0kind: capabilitydepends: [ { 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: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:` stepevents: - 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 }isE-PLUGIN-PARSEwith a did-you-mean, not a silentselect: first. A flow-map value holding a comma needs quotes. - A directive’s
lower:is optional: without it the directive compiles to the generickind: "plugin"passthrough.{ kind: builtin, name }must name a core hook (actorStage,cameraTransform,clearStage,end,jump,label), elseE-PLUGIN-PARSE. judge: beforejudges the occasion’son=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 isafter.- A
castexport entry takespresent:,emotions:, andassume:, as a schema’s does, and (0.26.0)sharedName: truefor a role name several speakers share (noW-DISPLAY-NAME-DUP). - 0.26.0: a directive attribute may be typed by a project kind,
{ name: item, type: { entity: bagItem } }: a non-member isE-BAD-ENUMwith a did-you-mean, also when it reaches the attribute through a component param.whenis reserved on every passthrough and bridge directive (it is thewhen=guard), so a plugin attribute cannot be namedwhen.
→ 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: sceneid: cafe.openon: slotStartonce: daywhen: "clock.weekday < 5"---
## Open
@mira: {{clock.weekdayLabel}} again. That's slot {{clock.index}} of the story.day(anint) must be anowner: enginepath.slot(an enum, alsoowner: engine) andslots(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,slotswithoutslot, an unknownraiseoccasion (checked once a plugin of the project declares occasions), or araiseoccasion with apayload:isE-CLOCK-DECL.- Read-only, in any condition or interpolation:
clock.index((day-1) * len(slots) + slotIndex, monotone;day-1for a day clock),clock.weekday(the whole numbers0..length-1, sois="0..5"+is="6"is exhaustive andis="7"isE-WHEN-LITERAL-DOMAIN),clock.weekdayLabel(the enum ofweek.labels; both needweek:). A::setof one isE-QUEST-RESERVED-WRITE. once: day/once: sloton scenes and bundle beats,once="day"/"slot"on entries.once: week(0.27.0, needs the clock’sweek:; entriesonce="week") spends it until the next clock week starts, whenclock.weekdayis back atweek.first.lute playmoves it with anadvance: slot | day | <n>step (never backward;advance: daylands on the next day’s first slot) and settles quests.raise:is one occasion or a map:dayEndis raised at each midnight crossed (advance: <n>first walks to the day’s last slot),dayStartat the next day’s first slot, andslot(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..3walks every slot of those days;--occasion-axes dusk=clock.dayevaluatesduskonce 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 raisesdayEnd/dayStart). A step’sexpect: { clock: { weekday, slot, day } }judges where the clock stands, which is how aninclude:d steps file states the time it expects. - 0.27.0:
last: { day: 1, slot: h05 }(ordays: N) ends the clock: an advance past it walks to the last position, raises the lastdayEndonce (noslotraise) and stops; a lateradvance:isE-CLOCK-END(exit 1) until anewRun. Awhenneeding a later position (run.night == 2) is unreachable, andlute calendar --axis clockstops at the end. - 0.28.0:
clock.dayandclock.slotread the day and slot paths by name, and a clock withlast:/days:hasclock.ended, true from the settle of the advance that ends it until anewRun.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 withexpect: { clock: { ended: true } }. Anadvance:that passes slots without raising the slot occasion notes them (note: passed day 1 (Mon) afternoon, night without raising …), andlute calendar --axis clockreadsnot raisedwhere the clock would not raise an occasion. No advance stops where the run starts, so the clock raises neither theslotoccasion nordayStartthere;raiseAtStart: truesays the engine does, and the checker and calendar count it raised there. Without it, a beat only that position takes isW-BEAT-UNRAISED, which names the key. - 0.27.0: an occasion may declare
raisedWhen: "holds('canEnter', [occasion.target])"(pluginoccasions/*.yaml): its beats are judged under the gate (E-BEAT-UNREACHABLEnames it),lute beatsmarks a target whose gate never holds,lute playrefuses 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 declareterminal: "run.fate == 'taken'": every beat is judged under!terminal, and once it holds play ends── end: terminal — …(--json"end": "terminal"); a lateroccasion:/advance:step isE-OCCASION-GATEuntil anewRun. Anoccasion:step may carryengine:writes, applied before the raise. 0.41: anadvance:step’sengine:writes land before the clock moves, so its raises read them; through the runtime a refused raise is the rejectionE-OCCASION-GATE(state unchanged) andgate(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-PERSISTENTis silent,lute playsays the game is over for good instead of offeringnewRun, and the IR carriesterminalPersists: true.persists: trueover run state only isE-META-VALUE.
Shipped alongside, none of which needs a clock: enum labels, integer %, a guarded ::set, and
the :ordinal hint.
---kind: sceneid: diner.paydaystate: 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
Quests
Section titled “Quests”---kind: questid: cafe.questsstate: 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>| Piece | Rule |
|---|---|
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 objective | Visibility 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: questid: keep.questsstate: 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):
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 memberA 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.
Lore entries
Section titled “Lore entries”---kind: loreid: ship.recordsseries: captainsLogentities: 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,userorfalse;day/slot/weekwith a clock,season:<name>with a season),share,spentBy, andfor(on a beat only). Useseriesandorderfor a series that spans files. A document-levelseries:orders its entries by position in the file, and in such a document a per-entryseries=/order=isE-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, isE-GRAMMAR-NOT-ADMITTED. - Effects apply on the first read in a run only. After that,
entry.<id>.readistrueand any document can read it. It is run-tier: a new run resets it.entry.<id>.everReadis its user-tier twin, set on the first read and never reset by a new run. Under a guard that assumesentry.X.read, whole-projectcheckknows the facts X asserts on every path (0.24.0), so a redundantholds(…)there isW-FACT-GUARANTEED;everReadcounts only thetier: user/appones. An entry withoutoncethat::sets or::retracts isW-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 playandlute testaccept<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.
Components, extends & params
Section titled “Components, extends & params”---component: greetparams: 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: scenecharacter: demoseason: 1episode: 2uses: ../base.schema.yamlcomponents: [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: praiseeffects: trueparams: who: speaker delta: intentities: # declared here only so this file checks alone: companion: { members: [isolde, corvin] } # at `::use` the host's schema decidesstate: 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: welcomeparams: 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
Timeline
Section titled “Timeline”---kind: sceneid: storm.beatenums: 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.
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>.
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.
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.
occasions: cutscene: { select: first, target: { prefix: scene, entity: cutsceneId }, raisedBy: declared }# plugins/acme.stage/directives/d.yamldirectives: - name: playScene attrs: - { name: scene, required: true, type: { entity: cutsceneId } } effects: raises: [{ occasion: cutscene, target: "@scene" }]# plugins/acme.stage/rewardkinds/r.yamlrewardKinds: 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: sceneid: lock.retrytitle: 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
Compiled artifact
Section titled “Compiled artifact”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}]}| Family | Kinds |
|---|---|
content | line |
staging | bg, music, sfx, vfx, actor, camera, cg, video, sequence |
state | set, assert, retract |
control | choice, match, hub, jump, end, barrier |
declaration | quest, on, entry, accept, beat |
plugin | plugin |
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.
| Command | Use |
|---|---|
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.yamlfile: ../scenes/counter.lute # required under mocks/, which whole-project `check` validatesstate: { run.tip: 5 } # path: literalfacts: ["knows(vesna, manifest)"] # base facts, on top of the schema's facts: seedschoose: { greet: tip, chat: [coffee, leave] } # branch: choice; hub: its visit orderevents: [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.lutestate: { 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 failsfile: ../scenes/counter.lutechoose: { 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 presentsfile: ../lore/barks.luteentries: [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 idfile: ../lore/talks.lutebeat: miraOrder # or cafe.talks.miraOrderstate: { run.tips: 3 }expect: eligible: trueoptions: 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 1presented: { user: [vesna.gift] } # spent `once: user` / `once: run` beatsquests: { lostCup: active } # unset | active | complete | failed; objectives start undoneentriesRead: { user: [vesnaFirst] } # run: entry.<id>.read + everRead · user: entry.<id>.everReadstate: { user.runs: 10, run.tips: 3, quest.lostCup.objectives.find.done: true } # objective progressfacts: ["knows(vesna, manifest)"]choose: { greet: wave, chat: [coffee, leave] } # hub: visit order; a branch list: one per presentationbridges: { check: [ { passed: true, margin: 3 } ] } # 0.24.0: answers to plugin calls, consumed in ordersteps: - 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 # skippedexpect: # 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 refusesquest.*: a quest’s status is the lifecycle’s, seeded withquests:.newRuntakes the samestate:andfacts:as the new run’s seed. targetbelongs to anoccasionstep, andpickandchooseto anoccasionoradvancestep;labelgoes on any step, andrepeaton any butend. Aninclude:step takes noexpect; itslabel(0.28.0) labels the spliced steps that have none, nested includes too; since 0.27.0 it MAY carryrepeat: n(the file is spliced n times) and its ownchoose:/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 stepexpect:takeswinner(nonewhen the occasion passes),offered(a subset of the eligible beats),notOffered, andpresented(0.23.0: the exact ids presented, in order) on anoccasionstep, and on anadvancestep, wherewinner,offered, andnotOfferedjudge the raise where the clock stops andpresentedlists every beat the step presented,dayStart/dayEndraises first; it takesquests,state,facts,notFacts(0.23.1) andclock(0.26.0:{ weekday, slot, day }, any of them) on any step butend. A miss names the step and both values. A step withtarget:also judges that target’s<objective on target>. A winner orpick:names an entry by its id or (0.26.0)<doc>.<entry>, and aforbeat’s member as<id> for <member>(presented: [cafe.birthday for toma]). 0.28.0: on anadvancesteppresented: { dayStart: [a], slotStart: [b] }judges only the named raises. A step’s ownchoose:key no presentation used gets a note, as aninclude:’s does.- A
newRunstep prints eachprev.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 (anactivate="accept"child whose parent is not active) printsnote: accept of quest c spent — its parent quest p is not active yet …. - An
advance:step moves the clock forward only. Itsengine:writes land where the clock arrives, after anydayEnd/dayStartraised on the way and before the final settle and raise; writing the clock’s own day or slot path there, or anengine:step that movesclock.indexbackward, is a usage error (exit 2). So is aninclude: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 ownbridges: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 traceandlute testread its result as unknown. In a mock or test, a missing field isE-TRACE-MOCK-TYPE, anchored in that file. Ascene.*seed no longer answers a call:lute playrefuses one (exit 2).- A step
expect.options: { <branch or hub>: [ids] }(0.24.0) asserts what that step offered. Aselect: alloccasion with nothing eligible needs nopick:. transcriptContains/transcriptLacksmatch only lines that played, one@speaker: textper line (a bare text fragment matches too). A skipped guarded line, a step header, or staging never counts, inlute playandlute testalike. 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 carryingemotion="shy"(and maybe more), so atranscriptLacksneedle 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) inlute 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 inlute play(else the castname:, else the id); a bridge answer is typed by its result slot or the capability’sresult: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
Common diagnostics
Section titled “Common diagnostics”| Code | What it usually means |
|---|---|
E-KIND-MISSING | The frontmatter has no kind: and no defaults: supplies one. |
E-META-MISSING | A scene has no id: and no character + season + episode. |
E-META-PARSE | The frontmatter is not valid YAML, usually an unquoted value containing : . |
E-META-UNKNOWN-KEY | The key is not legal on this kind, for example after: on a quest (use <quest follows=…>). |
E-CONTENT-OUTSIDE-SECTION | Content comes before the first ## Heading. |
E-INERT-TITLE | The body has a # Title line. Put the title in the frontmatter’s title:. |
E-SECTION-DUP | Two sections of one document share a {#id}. |
E-DOMAIN-UNKNOWN | emotion=, action=, anchor, mood, framing, cgLayout, a :style[…] text style, … is used but its slot has no declared members. |
E-CAMERA-EMPTY | A ::camera has none of focus, framing, move, transition. |
E-MISSING-ATTR | A required attribute is missing: ::actor without character, ::cg / ::video without assetId, ::sequence without name. |
E-MONO-POV / E-MONO-NO-POV | A {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-ESCAPE | An 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-BACKWARD | Two ::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-DUP | Two rewards of one quest share an id. |
E-AUTHOR-CASE | An authored key contains _. Write it in lowerCamelCase. |
E-UNDECLARED / E-UNDECLARED-REF | The state path, or the @def, is not declared, or its schema is not imported. |
E-MAYBE-UNSET | A path is read that has no default and no dominating ::set or has guard. Every prev.run.* read needs one. |
E-CAST-UNKNOWN | A 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-ABSENT | 0.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-ENUM | A 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-WRITE | A ::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-HAS | has(quest.<id>.state) is always true. Compare with 'unset' instead. |
W-TEXT-LOOKS-LIKE-REF | A line’s whole text is @name for a def or param; it ships as that literal. Write {{@name}}. |
E-UNSET-UNCOVERED / E-NONEXHAUSTIVE | A <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-DOMAIN | A <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-LINE | A tag shares its line with its body, or a tag is wrapped across lines. |
E-BRANCH-ALL-GUARDED / E-HUB-NO-EXIT | A menu could be empty, or a hub could never end. |
E-SET-TYPE / E-REF-TYPE / E-ATTR-TYPE | A 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-DYNAMIC | A directive attribute takes a @def that reads state. An attribute value must be a constant; branch with <match>. |
E-INTERP-DEF | A {{@def}} whose body cannot be inlined into one expression (an expansion cycle, or a body that reads $). |
E-ATTR-QUOTE | An attribute value in single quotes, or in curly quotes (“…”, ‘…’) a word processor typed. Use straight "…", and write a " inside it as \". |
E-DEF-DECL | A 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-DONE | An objective has no done, or has both quest= and done=. |
E-QUEST-TIER-MIX | A 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-ADMITTED | The 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-ATTR | A 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-UNKNOWN | A plugin declares occasions and this one is not among them. |
W-BEAT-PRIORITY-TIE | Two 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-USER | A 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-ABSENT | A 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-DEAD | The 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-ACCEPTED | 0.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-EXCLUSIVE | 0.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-STALE | A 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-DONE | 0.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-DECL | A 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-UNREAD | A 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-SHAPE | An 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-CONFLICT | 0.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-TARGET | 0.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-CYCLE | 0.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-DUP | 0.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-SURFACE | 0.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-CONFIG | 0.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-CROSS | 0.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-BODY | 0.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-BRACE | 0.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-MARKERS | 0.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-INTERP | 0.52. An <objective title> holds {{; a title is shown as written. Write plain text. |
E-PLUGIN-RAISES / E-RAISES-TARGET / W-OCCASION-NEVER-RAISED | 0.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-DUP | 0.51. A code contains ~; two localization units share one final lineId. |
W-ENTRY-WRITE-REREAD | 0.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-ARG | A ::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-ATTR | An 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-CLASH | Two 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-IDENT | A 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-NODE | visited('…') or after names no scene (or, since 0.24.0, bundle beat) in the project. |
E-CLOCK-DECL | 0.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-DECL | 0.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-TEMPLATE | 0.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-CHAPTERS | 0.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-STALL | 0.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-ORDER | 0.28.0. On a select: sequence occasion, a chained scene’s own priority: plays it out of the listed order. |
E-ENUM-LABEL-NOT-MEMBER | 0.24.0. An enum’s labels: names something that is not one of its members. |
E-CEL-TYPE | 0.24.0. % has a non-integer operand: `%` takes two integers: `2.5` is not an integer. |
E-RULE-GUARD-DEF | 0.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-NAME | 0.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-UNUSED | 0.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-KEY | A 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-DUP | Two documents share a scene id or a quest id, or a bundle beat’s canonical <doc>.<beat> id equals a scene id. |
E-DUP-VOICEKEY | Lines 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-MISMATCH | The project’s documents resolve two capability snapshots (different profiles or scene-local plugins:), so it cannot compile as one. |
E-TEST-LORE | A *.test.yaml names a lore document without entry:, entries:, or beat:. Name what it presents. |
E-TEST-FILE | A *.test.yaml’s file: names no document. The test fails; the rest of the suite still runs. |
E-TRACE-BEAT | lute 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-CREDIT | A 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-GUARANTEED | A fact guard is always true on every route, so it is redundant. |
W-BEAT-SHADOWED | An earlier beat that is always eligible and never spent wins every time. |
W-ENTRY-REF-UNKNOWN | entry.<id>.read or entry.<id>.everRead names an entry that nothing declares. |
E-LEGACY-CONTENT-SIGIL · W-WHEN-TEST-LITERAL | Old syntax. lute fix rewrites it. |
E-RENAMED-DIRECTIVE · E-RENAMED-ATTR · E-RENAMED-TAG-ATTR | A 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-KEY | A 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-REMOVED | Delete persist= from the choice by hand; into= alone records the run fact. |
Gotchas
Section titled “Gotchas”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: sceneid: clock.cheatstate: 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: sceneid: dock.nightenums: 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: sceneid: chapter.onetitle: 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: sceneid: 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: sceneid: late.shifton: townVisitwhen: "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: sceneid: tab.openstate: 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: sceneid: week.endstate: 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: sceneid: ledger.ruleentities: 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.
Machine-readable CLI output
Section titled “Machine-readable CLI output”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.