Core directives
A staging directive is a single-line leaf that stages the scene: background, music, sound, actors, camera, event illustrations (CGs), video, cinematic sequences, and effects. Its shape is:
::name{attributes}Directives never nest — anything with children is a logic block instead (<branch>, <timeline>,
…). Directive names and attribute meanings are vocabulary, extensible by plugins without any
grammar change; run lute context <file> to list the directives and attributes your project
accepts.
Core vocabulary
Section titled “Core vocabulary”lute.core declares thirteen directives — ten staging leaves, the walk terminator ::end, and the
jump pair ::jump / ::label — with these canonical attributes:
| Directive | Attributes |
|---|---|
::bg | location, time, assetId — a scene change: characters still on stage are hidden first (below) |
::music | playback (domain musicPlayback), mood (domain mood), volume (domain volume), assetId |
::sfx | sound (what is heard), assetId (the concrete file) |
::actor | character (required), anchor (domain anchor), action (domain action), emotion (domain emotion), costume (domain costume) — entrance, exit, pose, expression, outfit |
::clear | none — every character on stage exits; background and music stay (below) |
::camera | focus (a cast member), framing (domain framing), move (domain cameraMove), transition (domain transition) — at least one |
::cg | assetId (required), display (show|hide, default show), layout (domain cgLayout) — an event illustration; player-web draws it over the actors, under the dialogue window (stage layers) |
::vfx | type (domain vfxType), label, transition |
::video | assetId (required), display (show|hide, default show) |
::sequence | name (required, domain sequence) — a reference to a cinematic the engine owns; wait defaults to true |
::end | reason (optional, free-form) — terminates the walk; control flow, not staging (below) |
::jump | to (required) — jumps to a ::label, forward or (with max, 1–99) backward a bounded number of times; control flow (below) |
::label | name (required) — names a position for ::jump; emits no record (below) |
::bg{location="family_restaurant" time="afternoon" assetId="BG.space.family_restaurant.interior.afternoon"}::music{playback="start" mood="peaceful" assetId="sound-bgm-common-vn-mood-peaceful-0.mp3" volume="quiet"}::actor{character="marina" anchor="center" action="fadeInUp"}::camera{focus="marina" framing="close" duration="0.5"}(From docs/examples/marina-s01ep02.lute.)
Character staging lives on ::actor; music fading out is
::music{playback="fadeOut"}; a character exit is ::actor{character="marina" action="fadeOutDown"}
where fadeOutDown is listed in the action domain’s exits:. All attribute values are strings
in double quotes (a " inside one is written \" or
"; single quotes are E-ATTR-QUOTE), or a bare
@ref to a def that folds to a constant (::camera{focus=@hero}; a def that
reads state is E-ATTR-DEF-DYNAMIC). There are no inline code expressions, which keeps staging
non-Turing-complete.
A bg staging record is a scene change, whether authored as ::bg or produced by a
plugin directive’s lower: { record: bg, fields: ... }. Every character still on stage is
hidden just before it by an injected actor record (provenance.by: "stage-bookkeeping"). A
character who keeps speaking in the new place must enter again with an actor record; a line
from one before that is W-STAGE-ABSENT (below), so re-enter them explicitly:
::bg{location="station" time="night"}::actor{character="marina" action="fadeInUp"}@marina: The last train is gone.::bg{location="street" time="night"}::actor{character="marina" action="fadeInUp"}@marina: We walk, then.Staging vocabulary is the project’s
Section titled “Staging vocabulary is the project’s”The core declares the slots; the project declares the members. Every domain-typed attribute
in the table above reads a closed domain declared in enums: — in the document’s frontmatter, or in
a schema it reaches through uses: (see Content vocabulary). The core ships
no members for any of them, so one project’s camera says framing="closeUp" and another’s says
framing="twoShot", and the checker holds each to its own list:
| Domain | Read by |
|---|---|
anchor | ::actor{anchor} — its default is used when anchor is omitted |
action | ::actor{action} and a line’s action — members in exits: take the character off stage |
emotion | ::actor{emotion} and a line’s emotion |
costume | ::actor{costume} |
framing | ::camera{framing} |
cameraMove | ::camera{move} |
transition | ::camera{transition} |
cgLayout | ::cg{layout} |
musicPlayback | ::music{playback} |
sequence | ::sequence{name} |
mood, volume | ::music{mood}, ::music{volume} |
vfxType | ::vfx{type} |
textStyle | inline style spans in line text |
A value outside its domain is E-BAD-ENUM; a domain the document never declares is
E-DOMAIN-UNKNOWN. A project does not declare new directives this way — it fills core slots:
---kind: sceneid: demo.rooftopenums: anchor: members: [left, center, right] default: center action: members: [fadeInUp, fadeOutDown] exits: [fadeOutDown] emotion: [neutral, angry, shy] costume: [school, festival] framing: [wide, close, closeUp] cameraMove: [pushIn, shake] transition: [cut, dissolve] cgLayout: [full, inset] sequence: [fireworks] musicPlayback: [start, fadeOut] mood: [romantic]---
## The Rooftop {#rooftop}
::bg{location="rooftop" time="night"}::music{playback="start" mood="romantic"}::actor{character="marina" anchor="left" action="fadeInUp" costume="festival"}::camera{focus="marina" framing="close" transition="dissolve" duration="0.5"}@marina: You came.::actor{character="marina" emotion="shy"}::camera{move="shake" duration="0.3"}::sequence{name="fireworks"}::cg{assetId="CUT.rooftop.fireworks" layout="full"}@narrator: The sky opens in colour.::cg{assetId="CUT.rooftop.fireworks" display="hide"}::video{assetId="VID.rooftop.ending"}::music{playback="fadeOut" duration="2"}::actor{character="marina" action="fadeOutDown"}Each directive compiles to one staging record whose kind and fields carry the directive’s and
attributes’ own names (lute compile, trimmed to the staging records):
{"kind": "bg", "family": "staging", "position": "001-0100", "location": "rooftop", "time": "night", "timing": {"wait": true}}{"kind": "music", "family": "staging", "position": "001-0200", "playback": "start", "mood": "romantic"}{"kind": "actor", "family": "staging", "position": "001-0300", "character": "marina", "anchor": "left", "action": "fadeInUp", "costume": "festival"}{"kind": "camera", "family": "staging", "position": "001-0400", "focus": "marina", "framing": "close", "transition": "dissolve", "timing": {"wait": false, "duration": 0.5}}{"kind": "actor", "family": "staging", "position": "001-0600", "character": "marina", "emotion": "shy"}{"kind": "camera", "family": "staging", "position": "001-0700", "move": "shake", "timing": {"wait": false, "duration": 0.3}}{"kind": "sequence", "family": "staging", "position": "001-0800", "name": "fireworks", "timing": {"wait": true}}{"kind": "cg", "family": "staging", "position": "001-0900", "assetId": "CUT.rooftop.fireworks", "display": "show", "layout": "full", "timing": {"wait": false}}{"kind": "cg", "family": "staging", "position": "001-1100", "assetId": "CUT.rooftop.fireworks", "display": "hide", "timing": {"wait": false}}{"kind": "video", "family": "staging", "position": "001-1200", "assetId": "VID.rooftop.ending", "display": "show", "timing": {"wait": true}}{"kind": "music", "family": "staging", "position": "001-1300", "playback": "fadeOut", "timing": {"duration": 2.0}}{"kind": "actor", "family": "staging", "position": "001-1400", "character": "marina", "action": "fadeOutDown", "exit": true}A few rules ride on these records:
::actorwithout a line. An::actorthat only setsemotionorcostumechanges the character’s expression or outfit in place; an authoredemotionupdates the stage state exactly as a line’semotiondoes.exit: trueappears only for anactionlisted inexits:.- Camera members are opaque. The engine decides what
closeorshakelooks like; Lute checks membership and thefocuscast reference, and synthesizes no numeric transform. A::camerawith none offocus,framing,move,transitionisE-CAMERA-EMPTY. The camera is not part of the saved state. displayis always emitted.::cgand::videoresolve an omitteddisplaytoshow, so the record always says which way it goes.- A sequence is a reference.
::sequence{name}names a cinematic the engine plays; Lute does not describe its contents, and it blocks (wait: true) unless writtenwait="false".lute playrecords it without simulating it.
Character references in quoted values
Section titled “Character references in quoted values”A double-quoted attribute value, on a directive, a content line or a tag such as <choice>, decodes
the XML character references " ' & < > and the numeric forms &#NN; /
&#xHH; (0.24.0). Before 0.24.0 the entity text shipped literally.
---kind: sceneid: demo.sign---
## The Sign
::sfx{sound="a "ding" & a hiss"}<branch id="sign"> <choice id="read" text="Read "No Entry" aloud"> @narrator: You read it aloud. </choice> <choice id="fish" text="Order fish & chips, \"to go\""> @narrator: You order. </choice></branch>The artifact carries a "ding" & a hiss and Read "No Entry" aloud. Decoding is a single pass, so
&quot; becomes " rather than ". Any other & stays literal: a bare & as in
fish & chips, a &&, or a reference outside the list such as . The \" escape still
works. Content text after the line’s colon is not an attribute value and is never decoded.
Stage state
Section titled “Stage state”The checker threads a stage state through the document — who is on stage, where, and in what
pose — and lute compile injects its staging records from the same state. A character’s first
line needs no ::actor: a speaker who has never been shown enters implicitly, and nothing warns.
What warns is staging someone the state says has left: after a declared actor exit (an
actor record with exit: true), a bg auto-hide — including one caused by a plugin directive
lowered to bg — or a ::clear, a line by that character — or
another declared exit — before an actor record shows them again is W-STAGE-ABSENT.
Since 0.22.0 the stage state follows paths:
- It forks at every
<branch>or<hub>choice and every<match>arm. Each arm starts from the stage as it was at the fork, so an exit in one arm never warns on a line in its sibling. - It joins where the arms converge, keeping only what holds on every arm. After the convergence a character is on stage only if every arm left them there; one taken off on any arm warns when staged again without a re-show.
- A
::bgauto-hide records the hidden characters as exited, so a later line by one of them warns until an::actorbrings them back, and a scene change no longer forgets an earlier declared exit. (Before 0.22.0 both checked clean.)
---kind: sceneid: demo.platformenums: action: members: [fadeInUp, fadeOutDown] exits: [fadeOutDown] anchor: members: [left, center, right] default: center---
## The Platform
::bg{location="station" time="night"}::actor{character="marina" action="fadeInUp"}@marina: The last train is gone.<branch id="wait"> <choice id="leave" text="Let her go"> ::actor{character="marina" action="fadeOutDown"} @narrator: She walks off without a word. </choice> <choice id="stay" text="Ask her to stay"> @marina: Fine. One more minute. </choice></branch>@marina: So, what now?@marina: Fine. One more minute. is silent — on the stay path she never left. The line after
the branch is not, because the leave path reaches it with her gone:
platform.lute:27:1: warning [W-STAGE-ABSENT] `marina` left the stage on an earlier declared exit (line 20) on a path that reaches here and has not been shown again, so a spoken line here stages someone who is not present. Show them again with an `::actor` (or a plugin directive declared `lower: { record: actor, fields: { character: { fromAttr: character } } }`) before this point, or remove the earlier exitAn ::actor{character="marina" action="fadeInUp"} before that line, or at the end of the leave
choice, puts her on stage on every path and silences it. lute compile stages the artifact over
the same join: after the convergence she is not on stage, so an ::actor there is a fresh entrance
and gets its anchor again.
::clear — emptying the stage
Section titled “::clear — emptying the stage”::clear (dsl 0.24.0 §4) takes every character off the stage at once. It has no attributes — no
timing key and, since 0.49.0, no plugin stamp either (E-UNKNOWN-ATTR; see
Directives that carry no stamp). The
background and the music stay, which is what separates it from a ::bg scene change:
---kind: sceneid: demo.lastTrainenums: action: members: [fadeInUp, fadeOutDown] exits: [fadeOutDown] anchor: members: [left, center, right] default: center musicPlayback: [start, stop] mood: [peaceful]---
## The Last Train
::bg{location="station" time="night"}::music{playback="start" mood="peaceful"}::actor{character="marina" action="fadeInUp"}::actor{character="oskar" action="fadeInUp"}@marina: The last train is gone.@oskar: So it is.::clear@narrator: The platform empties. The music plays on.@marina: Wait for me.Every character the stage state holds exits, including one who is on stage on only some of the
paths that reach the ::clear, as at a ::bg. The directive compiles to one actor exit record
per character, with no record of its own and no new IR kind, so an engine needs nothing new:
{ "kind": "actor", "family": "staging", "position": "001-0900", "character": "marina", "exit": true, "provenance": { "by": "stage-clear", "explanation": "`::clear` takes `marina` off stage" } }{ "kind": "actor", "family": "staging", "position": "001-1000", "character": "oskar", "exit": true, "provenance": { "by": "stage-clear", "explanation": "`::clear` takes `oskar` off stage" } }A cleared character is gone until an ::actor shows them again, so the last line above warns, and
the warning names the ::clear:
platform.lute:25:1: warning [W-STAGE-ABSENT] `marina` was taken off stage by an earlier `::clear` (line 23) on a path that reaches here and has not been shown again, so a spoken line here stages someone who is not present. Show them again with an `::actor` (or a plugin directive declared `lower: { record: actor, fields: { character: { fromAttr: character } } }`) after the `::clear`lute play prints ::clear where it ran, and lute trace records it as an
exit.
Timing & the wait model
Section titled “Timing & the wait model”duration, delay, and wait are reserved staging timing keys that may appear on any
directive but ::clear, ::jump and ::label:
duration— the transform length in seconds (e.g.duration="0.6").delay— an offset in seconds from the directive’s own slot start.wait— blocking control (true/false).
wait="true" holds the script until that effect completes; an absent or false wait is
non-blocking, so the next line proceeds concurrently. The default is per-directive, not global
— for example ::bg, ::video and ::sequence default to wait="true", while most effects default
non-blocking. Concurrency is therefore just consecutive non-wait directives; there is no
<parallel> wrapper.
::camera{move="shake" duration="0.2"} /* no wait -> next line runs concurrently */::camera{focus="elena" framing="closeUp" duration="0.5" wait="true"} /* holds -> the following line waits for the push */In the artifact the resolved values sit in the record’s timing object —
"timing": {"wait": true, "duration": 0.5} — and timing is omitted when none is set.
A timing key may also be written as a reference, ::bg{location="cafe" wait=@n}, and since 0.49.0 the checker types it: wait expects a bool, duration and delay a number. A def of another type is E-REF-TYPE at the reference (@n produces an int but this position expects a bool); before 0.49.0 such a reference checked clean and compiled to the default timing. The same holds in a component body and for a line’s wait=@ref, and a bool or double def checks clean where it fits.
The at key is not a staging timing attribute; it is a timeline-position key valid only on
clips inside a <timeline>.
A content line admits wait (a bool) and no other timing key; delay and duration on a line stay
E-UNKNOWN-ATTR. It compiles to the line record’s timing.wait, and an absent wait leaves
timing off the line. A line always waits for its own advance. wait says whether the staging cues
that follow it, up to the next line, wait for the line: true (or absent) means they wait, false
means they start while the line is presented. The next line still waits for the previous one, and so does a presentation cue, because it clears the dialogue window.
@marina{wait="false"}: The fireworks start as I speak.::cg{assetId="CUT.rooftop.fireworks" display="show"} /* starts while the line is shown */@oskar: Look at that. /* still waits for marina's line */Directives that carry no stamp
Section titled “Directives that carry no stamp”::jump, ::label and ::clear emit no stamped record: ::jump lowers to a jump record with no
stamp, ::label lowers to no record, and ::clear lowers to no record of its own (it only injects
one actor exit per character on stage, and none on an empty stage). A plugin stamp attribute or a
core timing key on one of them is E-UNKNOWN-ATTR, at the attribute, instead of being accepted and
silently dropped:
::jump:`::jump` does not carry `cue`: it compiles to a `jump` record that has no stamp — put the attribute on the line or directive before it::label:`::label` does not carry `cue`: it compiles to no record — put the attribute on the line or directive after it::clear: “it takes no attributes; every character on stage exits with its own exit” now covers stamp attributes too.
::clear does not hand its stamps to the exits it injects, because whether an exit exists depends
on the stage, and an empty stage would drop the value. A stamp belongs on the ::actor{exit} you
write yourself.
::end — terminating the walk
Section titled “::end — terminating the walk”::end stops the walk at its own record. It is exactly equivalent to falling off the end of the
command array, except that it carries a reason:
@narrator: The platform emptied out. Nothing left to catch.::end{reason="missedTheLastTrain"}reason is optional and free-form — "completed", "error", an ending id. Lute assigns it no
meaning; it rides through to the artifact for the host to surface. ::end, ::jump and ::label
are the entries in the table above that are not staging leaves, so none is admitted inside a
<track> clip (E-TIMELINE-CONTENT), which takes
staging leaves and ::set only.
In lute play, an ::end ends only the presentation (or quest handler) it runs
in; the step still settles and the playthrough goes on with the next step. A play script stops
early with a step end: true. ::end never ends the game: a game that can be over says so with a
schema’s terminal:, after which the engine raises
no occasion. The game is over sets the five meanings of “end”
side by side.
Anything after an ::end in the same straight-line body can never run, and the checker says so
once per body, anchored at the first dead node: W-CODE-AFTER-END — unreachable content after
::end (the walk terminates here). It is a warning; promote it with
lute check --deny W-CODE-AFTER-END.
The scope is the immediately enclosing sequence only — one section body, one <choice> body, one
<when> arm, one objective body, one <on> body. An ::end in one choice says nothing about its
siblings, nor about content after the enclosing <branch>, so a per-branch ending is written the
obvious way and the shared tail below it stays live:
<branch id="ledge"> <choice id="jump" text="Jump for it"> @mira: Nothing to it. ::end{reason="fell"} </choice> <choice id="wait" text="Wait for the ladder"> @mira: I can be patient. </choice></branch>
@narrator: The siren faded somewhere east.Why termination is core, not a plugin directive
Section titled “Why termination is core, not a plugin directive”Termination is control flow, and control flow is the one thing plugin vocabulary does not get. A
plugin directive lowers to a record of kind: "plugin", which is opaque to the checker:
reachability analysis cannot know that such a record terminates, so content after it would never be
reported dead and W-CODE-AFTER-END could not exist. Shipping ::end as a new IR command kind is
also the honest version signal — an engine that does not implement the IR minor that introduced it
(0.8.0) must refuse the artifact rather than fall through a record it does not recognise.
::jump and ::label — forward and bounded backward jumps
Section titled “::jump and ::label — forward and bounded backward jumps”::label{name="…"} names a position; ::jump{to="…"} continues the walk there. A jump without
max only goes forward in document order, so the walk stays a DAG; with max (since 0.51) it
may go backward a bounded number of times (see Bounded backward jumps).
To offer choices again until the player leaves, use a <hub>.
Labels share one namespace per document; a section’s
{#id} is identity metadata, not a label, and a ::jump cannot target it. ::jump takes
when=, and a guarded jump is taken only while its condition holds:
---kind: sceneid: demo.jumpstate: run.tip: { type: int, default: 0 }---
## The Counter
@narrator: The diner is quiet.::jump{to="outro" when="run.tip > 0"}@narrator: You leave without a word.::label{name="outro"}@narrator: The bell over the door rings.::label emits no record: the label resolves at compile time, and the jump’s target is the
position of the first record after the label. The guarded jump above compiles to a one-arm
match around a jump:
{"kind": "jump", "family": "control", "position": "001-0300", "target": "001-0700"}A to that names no ::label in the document is E-JUMP-UNDEFINED. A label at or above the jump
is E-JUMP-BACKWARD unless the jump carries max (below); the message names the spelling —
::jump{to="again" max="3"} repeats at most 3 times, and to offer choices again you use a <hub>.
Content after an unguarded ::jump in the same body never runs and is W-CODE-AFTER-JUMP. To jump
to a line, put a ::label immediately before it.
Neither ::jump nor ::label carries a stamp (a plugin stamp or a timing key such as wait is
E-UNKNOWN-ATTR; see Directives that carry no stamp): put the
attribute on the line or directive before the jump, or after the label.
Which bodies a jump may cross
Section titled “Which bodies a jump may cross”Some bodies run as a range of their own, entered only when their moment comes: a <hub> option
body and a hub <return> body, an <on> and an <objective> body, a lore <entry> and a <beat>.
A document’s top level is one range too (every section of a scene together, so a forward jump from
one section to the next is legal). <choice> bodies of a <branch> and <match> arms are laid
out inline and are not ranges. Since 0.52:
- A jump to a label in its own range, or in an enclosing one past only hub option and
<return>bodies, is legal. Out of a hub it leaves the hub: the visit ends, no<return>runs and the menu is not presented again (#216; see Leaving a hub). The compiledjumpcarriesleaves, the number of hub bodies it leaves, and the artifact requires the semantic idlute.core.hubLeave/1. - Any other jump enters a range from outside it — a sibling option’s body, an option body from
before the hub, another
<objective>or<on>body — and isE-JUMP-INTO-BODY. The message names the body and what to do instead:`::jump{to="mid"}` enters the body of `<objective id="p">`, which runs only when that objective completes — jump to a label in the same body (to end a body early, jump to a label at its end). On 0.51 a jump between two objective bodies checked clean and only ended its own body, whatever the label looked like it promised.
Bounded backward jumps — max
Section titled “Bounded backward jumps — max”Since 0.51, a ::jump may target an earlier ::label when it carries max, the number of times
the jump may be taken. After max takes the jump is not taken, and control falls through to the
node after it. max is a literal integer from 1 to 99; lute.core declares it on ::jump as an
int:
::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.::tryLock is a blocking bridge call that writes scene.lock.vault.won (the
flow-and-text example,
scenes/lock.lute). The lock is tried at most three times: the first pass and two repeats. If the
third attempt still fails, the jump is exhausted and @theo speaks. when= and max combine — the
jump is taken while its when holds and takes remain. max is not a stamp carrier: ::jump still
carries no stamp, so max is the only attribute added.
- Counter. Each bounded jump has its own counter, local to one walk (one presentation, one quest
handler or objective segment, one entry or beat body), starting at 0. When control reaches the
jump (and its
whenholds), a counter belowmaxis incremented and control moves to the label; otherwise control falls through. - Reset. A jump’s counter returns to 0 whenever control arrives at its label from outside the
loop: by falling through into the label, by a forward jump, or by a bounded jump that is not
inside this loop. Taking the jump itself, or another bounded jump inside the loop, does not reset
it. So an inner loop gets its full
maxagain on every iteration of the outer loop. - Exhaustion. After
maxtakes the jump is simply not taken; nothing is reported. Amaxthat is never reached, or awhenthat never holds, is not an error. - Shared labels. Two or more bounded jumps may target the same label. Each has its own counter and its own loop.
The loop of a bounded jump is every node from its label to the jump, in document order; the label is the loop’s head. A document’s top-level sections are one range, so a loop may span them (below). To keep every loop well-nested and entered only at its head, the checker enforces these rules:
| Rule | Code |
|---|---|
max is a literal integer from 1 to 99 — not an @ref, not 0, not negative, not above 99; and max on a forward jump means nothing | E-JUMP-MAX |
The label is in the jump’s own body or in a body that encloses it, and every body in between is a <choice> body of a <branch> or an arm body of a <match>. The loop may not leave a <hub> option body or <return> body, an <on> or <objective> body, a quest, an entry or a beat (a loop around a quest’s <on>/<objective> bodies would need a ::label at the quest’s top level, which is E-GRAMMAR-NOT-ADMITTED since 0.52) | E-JUMP-LOOP-SCOPE |
| A forward jump from outside a loop may not land strictly inside it after its head; a loop is entered only at its head | E-JUMP-INTO-LOOP |
| Two loops nest or are disjoint; they may not overlap without nesting (loops with the same head nest) | E-JUMP-LOOP-CROSS |
A jump from inside a loop to a label after it (an exit) and a forward jump from before the loop to
its head are both legal. A bounded jump inside a component body is checked in the component’s own
document, and each expanded instance has its own counters and its own labels: the compiler names a
label per ::use instance, so a component used twice loops, or jumps forward, inside each instance
(#207).
A loop may span sections
Section titled “A loop may span sections”There is no section bound. The top-level ## sections of a document run as one range, so a bounded
jump may loop back to a label in an earlier section: the loop’s region is every node from the head
through the last back edge to it, across the sections in between (several bounded jumps may
loop to one head, in the head’s section and in later ones; when an earlier jump’s max is
exhausted, control reaches the later sections and their jump). Every other rule above applies
unchanged. A “try again” that sits in its own section checks clean:
---kind: sceneid: demo.tossstate: run.x: { type: int, default: 0 }---## Offer
@mira: Want to try the ring toss?<branch id="offer"> <choice id="play" text="Play"> ::jump{to="play"} </choice> <choice id="skip" text="Not now"> ::jump{to="out"} </choice></branch>
## Play {#play}
::label{name="play"}@mira: Toss!
## Retry {#retry}
<branch id="again"> <choice id="retry" text="Try again"> ::jump{to="play" max="3"} </choice> <choice id="stop" text="Stop"> ::jump{to="out"} </choice></branch>
## After {#after}
::label{name="out"}@mira: Let us head back.::end inside a loop stays legal. A loop that leaves a hub option, a <return>, an <on>, an
<objective>, an entry or a beat body is still E-JUMP-LOOP-SCOPE, and the message never names a
section:
hub.lute:16:16: error [E-JUMP-LOOP-SCOPE] `::jump{to="top" max="2"}` loops back to a label outside the `<hub>` option it is in; a loop stays inside one option body — put `::label{name="top"}` inside it, or use the hub itself to repeatA forward jump into the middle of a loop, in a section or not, is E-JUMP-INTO-LOOP. When that jump
is the last node of one <choice> body of a <branch> and no other jump targets its label, the
message ends with the one-option hint: the block belongs inside that option.
entered.lute:11:16: error [E-JUMP-INTO-LOOP] `::jump{to="blockA"}` enters the loop of `::jump{to="top" max="2"}` (line 24) after its head `top`; a loop is entered only at its head — jump to `top` instead — `blockA` is reached only from option `a` of `<branch id="pick">`: move the block into that option's bodyMove the block into the option and the document checks clean:
---kind: sceneid: demo.tossstate: run.x: { type: int, default: 0 }---## One
<branch id="pick"> <choice id="a" text="A"> @mira: A. </choice> <choice id="b" text="B"> @mira: B. </choice></branch>::label{name="top"}@mira: Top.
## Two
::jump{to="top" max="2"}What a section is at runtime. A ## section is presentation and identity: its heading, its
optional {#id} in the artifact’s descriptive sections table, and the first segment of a
record’s build-local position. It is not a runtime range, a resume point, a host entry or a jump
target. The runtime runs a scene’s sections as one range, so a loop across sections compiles to the
same commands as the same document without its headings (apart from positions and the sections
table). Content that a host routes to is a bridge result that the script <match>es, not a section
it enters.
Loops cannot make a walk run forever. Between two takes of bounded jumps control moves only
forward, so it is bounded by the walk’s length; a loop’s counter resets only when its head is
entered from outside, so by induction over the nesting a loop nested inside d bounded jumps runs
at most the product of (max + 1) over those jumps. Every walk ends without player cooperation,
and the checker can still follow every path. The reference runtime does not take the compiler’s
word for it: at load it refuses an artifact with a backward transfer other than a bounded jump,
or with loops that cross (see the runtime contract).
Flow analyses see the loops too. Definite assignment and stage tracking join the states of every
jump that reaches a label, so a write that a jump skips is not assumed at the label: a read after it
is E-MAYBE-UNSET, and a line there whose speaker a skipped ::actor would have shown is
W-STAGE-ABSENT. A loop head is iterated to a fixed point.
Reserved directives
Section titled “Reserved directives”Three ::-directives are built-in rather than staging vocabulary: ::set writes declared state (see
State model), ::use expands a reusable content component (see
Components & extends), and ::accept takes up a quest (below).
Content additionally uses
::assert / ::retract to mutate facts (see Facts & Datalog) — in
scenes as well as quests. docs/examples/haven/scenes/cryobank.lute is kind: scene and carries
four ::assert directives inside <choice> bodies; lute check on it reports
ok … (0 warning(s)).
::accept — taking up a quest
Section titled “::accept — taking up a quest”::accept{quest="<id>"} (dsl 0.21.0) declares that the player accepts an accept-driven quest —
one with no start predicate — at this point in a scene. It is the scene-side form of the engine’s
“accept quest” action and of lute trace --accepted-quest, and it usually sits in the choice where the
player agrees:
<branch id="request"> <choice id="accept" text="I'll keep it calm"> ::accept{quest="calmTheShed"} @vesna: Thank you. </choice> <choice id="decline" text="Not now"> @vesna: Another time, then. </choice></branch>Like ::set and ::assert, it is built in, not plugin vocabulary: it lowers to its own IR record,
{"kind": "accept", "family": "declaration", "position": …, "quest": "calmTheShed"}, and the engine activates the quest if it is
still unset and ignores the record otherwise. The target is checked twice: a missing or
malformed quest is E-ACCEPT-TARGET in lute check, and whole-project lute check reports
E-ACCEPT-TARGET when the id names no quest in the project or names a quest that has a start
predicate (such a quest activates itself; accepting it means nothing), and, since 0.24.0, a child
quest that activates with its parent (the message names the parent). See
Quests & scenes.
Two 0.24.0 additions sit on the same directive. A child quest declared activate="accept" waits
for an ::accept and activates only while its parent is active. An accept that arrives while
the parent is not active is spent without effect, and the toolchain says so (lute play prints
note: accept of quest c spent — its parent quest p is not active yet …). ::accept{quest="…" at="nextRun"}
queues the acceptance until just after the next newRun reset, so a run-tier quest taken at a hub
between runs survives that reset. nextRun is the only value at takes, and any other is
E-ACCEPT-TARGET. Both are described in Quests & scenes.
Guarding a directive: when=
Section titled “Guarding a directive: when=”::set{… when="<condition>"} has always been skipped when its condition is false. Since dsl
0.26.0 §4, ::use, ::accept, ::assert, ::retract, and plugin passthrough and bridge
directives take the same when=, with the same meaning:
---kind: sceneid: dock.recruiterstate: run.pitchHeard: { type: bool, default: false }entities: item: { members: [nugget] }relations: hasItem: { args: [item], tier: run }---
## The Dock
@recruiter: Think it over. The pay is good.::assert{hasItem(nugget) when="!run.pitchHeard"}::set{run.pitchHeard = true}::accept{quest="recruiterJob" when="holds('hasItem', ['nugget'])"}@narrator{when="holds('hasItem', ['nugget'])"}: The nugget is heavy in your pocket.A guarded directive compiles to a one-arm match around it: when the condition is false, it is
skipped and the walk goes on. A guarded ::use
runs its whole expansion or none of it, and its argument reads are judged under its guard. A plugin
directive is guarded the same way, ::give{item="nugget" when="!run.pitchHeard"}, which is how a
repeatable beat hands out a one-time gift.
For the checker a guarded directive is never a definite effect: a guarded ::assert is not a fact
that holds on every route, a guarded ::set is not a definite write, and a guarded ::accept is
not a certain acceptance. That is why the last line above is not W-FACT-GUARANTEED. lute trace,
lute test and lute play apply the same writes, facts and accepts, and lute play prints each
skip where it happened, as it does a skipped ::set:
skip ::give{item="potion"} — when: false skip ::use{component="trainerBattle" who="lassMina" …} — when: falseA directive that lowers to a builtin record runs where it stands and refuses when=
(E-UNKNOWN-ATTR): every staging directive in the table above, ::end,
::label, and a plugin directive with a declarative lower: record.
Put such a directive in a <match> instead. A clip inside a
<track> refuses it too (E-TIMELINE-CONTENT), since a
conditional clip is logic:
./scenes/dock.lute:14:28: error [E-UNKNOWN-ATTR] `::bg` cannot take `when=`: it lowers to a builtin record and runs where it stands — put it in a `<match>`; `when=` guards `::use`, `::accept`, `::assert`, `::retract`, `::set` and plugin passthrough directivesA plugin that declares an attribute named when collides with the guard, and a use of that
attribute is E-UNKNOWN-ATTR asking to rename it in the plugin.