Skip to content

Beats

A lot of games do not advance on a clock. The story moves when something happens: a hub visit, entering a room, talking to an NPC, a new day, the start of a run. At that moment the game picks one piece of story whose conditions hold, or none.

Lute calls those moments occasions and the pieces of story that answer them beats (dsl 0.21.0). The engine raises occasions. Lute declares which beats answer each one, when each is eligible, and which eligible beat wins. A scene, a lore entry, or a <beat> block in a lore document (a bundle beat) becomes a beat by naming the occasion it answers. Everything else about the document stays the same.

---
kind: scene
id: achilles.gift
on: talk
target: npc.achilles
when: 'user.runs >= 10 && !run.giftRefused'
priority: 50
once: user
state:
user.runs: { type: int, default: 0 }
run.giftRefused: { type: bool, default: false }
---
## Opening {#opening}
@achilles: You have been at this a while, lad. Take this.

When the engine raises talk for npc.achilles, this scene is a candidate. It is eligible once the player has started ten runs and has not refused the gift this run. It has never been shown before, because once: user spends it for good the first time it plays.

KeyMeaning
onthe occasion this scene answers; it is what makes the scene a beat
targetoptional; the scene is a candidate only when the occasion is raised for this target, a dotted id in the <entry target> shape (npc.achilles, place.lab_b2). When the occasion declares a target domain, it must be <prefix>.<member> of that domain, and one of its listed members: when it lists them. kind:<kind> (dsl 0.26.0 §5) answers every member of a kind instead: see Kind targets
whenoptional CEL condition over run / user / app state, quest.*, entry.<id>.read / entry.<id>.everRead, and fact queries (holds(…), count(…))
priorityoptional integer, default 0; higher wins
oncerun (the default: at most once per run), user (at most once ever), or false (repeatable). On a project with a clock, also day (at most once per clock day) or slot (at most once per clock slot), and week (at most once per clock week, dsl 0.27.0 §5) when the clock declares a week:. On a project with a declared season, season:<name> (at most once per window of that season)
alsooptional true / false, default false (dsl 0.23.0). On a select: first occasion, an also beat is a side remark: it is presented after the winner instead of competing with it. See Side remarks with also
shareoptional; a project-wide key, written beside a spending once (dsl 0.25.0 §2). Every beat with the same key is spent together: see One event, several places
spentByoptional CEL condition that spends the beat instead of a presentation: once it has held the beat stays spent for its once period (run unless written): see Until it is solved: spentBy
foroptional "kind:<kind>", on an untargeted select: sequence occasion, instead of target (dsl 0.27.0 §3). The scene is presented once per member whose when holds, reading the member as occasion.target: see Once per member

The beat keys are scene-only and never come from project defaults:. A beat belongs to one scene.

when is an ordinary CEL slot, checked the way a quest start is: the profile’s CEL surface, definite assignment, and the unset-sentinel rules all apply. The one extra rule is that a when may not read the scene’s own scene.* state. That state does not exist until the scene starts.

after: keeps its meaning. It is the structural prerequisite over visited / completed / active that connectivity analyzes. A beat is eligible only when both after: and when hold, so put route order in after: and state conditions in when.

A scene without on: answers no occasion, so no occasion presents it: your engine starts it itself, by its scene id, the way it started every scene before occasions existed. lute trace and lute run walk such a scene on its own, and lute play never presents it. To chain scenes into a story you can play with lute play, see Connect scenes into a story. when, target, priority, once, also, share, spentBy, or for without on is an error:

---
kind: scene
id: courtyard.rumor
priority: 5
---
## Opening {#opening}
@narrator: A rumor drifts across the courtyard.

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

---
kind: lore
title: Achilles barks
state:
user.runs: { type: int, default: 0 }
---
<entry id="achillesBark1" on="talk" target="npc.achilles" category="bark">
@achilles: Keep your guard up.
</entry>
<entry id="achillesBark3" on="talk" target="npc.achilles" category="bark" priority="10" when="user.runs >= 3">
@achilles: Back again, lad.
</entry>
<entry id="achillesMorning" on="talk" target="npc.achilles" category="bark" priority="20" once="run">
@achilles: Up early again? Mind the stairs.
</entry>
<entry id="achillesFirstMeeting" on="talk" target="npc.achilles" category="bark" priority="30" once="user">
@achilles: So you are the one who keeps coming back.
</entry>

Without once, an entry beat is repeatable. Being read again is normal for an entry: an NPC repeats a bark, a codex page stays open. once (dsl 0.22.0) makes it spendable. run and user spend an entry by its own read flags rather than by a presentation record:

onceNot eligible whileEligible again
absentnever spent—
runentry.<id>.read is set: it was read this runat the next run, which resets read
userentry.<id>.everRead is set: it was read in any runnever
dayit was presented earlier this clock dayon the next day
weekit was presented earlier this clock weekwhen the next clock week starts
slotit was presented earlier in this clock slotin the next slot
season:<name>it was presented since the season last openedwhen the season opens again

The engine sets both flags after an entry’s first read in a run, however it was presented, so an entry the engine looked up by its target spends a run or user once too. See Reading twice for the flags themselves. day and slot (dsl 0.24.0 §1) need a declared clock. They count time rather than reads: the engine remembers where on the clock the entry was last presented, as it does for a scene beat, so a bark with once="slot" answers again in the next slot although entry.<id>.read is still set.

An entry’s ::set and ::retract apply on its first read in a run only (with for=, each member’s first read); a later presentation in the same run shows the text and changes nothing. A repeatable entry beat that writes is therefore a counter that stops at one, and so is one with a once shorter than the run or a spentBy. lute check warns W-ENTRY-WRITE-REREAD at the first such write, naming the remedies for the entry’s shape; an ::assert is exempt, since the fact holds for the rest of the run either way. Give achillesBark1 a ::set{run.nagged += 1}:

./lore/barks.lute:9:3: warning [W-ENTRY-WRITE-REREAD] `<entry id="achillesBark1">` has no `once`, so every raise of `talk` can present it again in a run, but its `::set` applies on the first read in a run only — later reads skip it; to apply it on every presentation, put the body in a `<beat on="talk" target="npc.achilles" once="false">` — a beat's writes apply each time it is presented; for one application per run, write `once="run"` on the entry; to keep the write to the first read on purpose, guard it with `when="!entry.achillesBark1.read"`

The defaults differ: an <entry> without once is repeatable, a <beat> without once is spent for the run, so the beat that repeats a write says once="false". See Reading twice for every shape the rule covers.

once takes run, user, day, week, slot, or season:<name>; once="false" is the same as leaving the attribute out, a repeatable entry. Any other value is E-BEAT-ATTR, and so are day, week, and slot in a project without a clock (week also needs the clock’s week:), and once= or priority= without on=: a repetition policy belongs to a beat. A season that no schema declares is E-SEASON-DECL. Before 0.22.0 the same effects were spelled as conditions, when="!entry.<id>.read" for once per run and a user.* flag the entry set for once ever. Those still work, but once says it directly. An entry that should answer until something is done takes spentBy= (with once= saying how long it then stays spent), and an entry on a select: sequence occasion may be read once per member with for=. An entry never rides along another beat, so also on an <entry> is E-BEAT-ATTR too. An entry beat takes share= beside its once= (dsl 0.25.0 §2): reading it spends every beat of its key.

On an occasion declared without a target, an entry’s target= is metadata (dsl 0.24.0 §6). It says what the entry is about, as it does on an entry the engine looks up, and the entry answers every raise of the occasion:

---
kind: lore
title: Shop window
---
<entry id="compassNote" on="shopVisit" target="item.compass" category="item">
@narrator: A brass compass sits in the window, its needle trembling.
</entry>

lute play, lute calendar, lute beats, and the beat warnings treat such an entry as untargeted. Before 0.24.0 this was E-BEAT-ATTR. A scene’s or bundle beat’s target on an untargeted occasion still is, because for them the target restricts which raises they answer.

An interview, a round of talk at the bar, a day’s worth of short NPC moments: many beats are a few lines and a choice each, and a file per beat scatters them across the project. A lore document can keep them together (dsl 0.23.0). A <beat> block is a scene beat written inside a lore document, beside its entries, and its body is a scene body: lines, branches, hubs, <match>, and directives.

---
kind: lore
id: interviews
title: Station interviews
state:
run.porterTrust: { type: int, default: 0 }
---
<entry id="porterNote" target="item.porter_note" category="note">
@narrator: A note in the porter's hand: "Lamps out at ten."
</entry>
<beat id="porter" on="talk" target="npc.porter" title="The porter" priority="5">
@porter: You again.
<branch id="night">
<choice id="ask" text="Ask about the night">
@porter: I saw nothing. Nothing I'll say twice.
::set{run.porterTrust += 1}
</choice>
<choice id="leave" text="Leave him be">
@porter: Good.
</choice>
</branch>
</beat>
<beat id="porterAgain" on="talk" target="npc.porter" title="The porter, again" after="visited('interviews.porter')" when="run.porterTrust >= 1">
@porter: Fine. The lamps went out before ten.
</beat>

A <beat> takes the scene beat keys as attributes:

AttributeMeaning
idrequired; a name (letters, digits, _ or -, not starting with -), unique in the document
onrequired; the occasion the beat answers
target, when, priority, once, also, share, spentBy, foras on a scene beat. once="false" makes the beat repeatable, and also may be written bare
afteroptional (dsl 0.25.0 §3); a scene after: formula, with the same meaning: an eligibility conjunct and an edge of the scenario graph
titleoptional; the label an engine shows for the beat in a select: all menu, localized like an entry title

A bundle beat is a scene beat that lives in a different file:

  • Its canonical id is <document id>.<beat id>, interviews.porter above, so the document must declare an id:. That id is the beat’s row in project.index.json and its key in the presentation record. It shares the project-wide namespace of document ids, so a scene whose id: is also interviews.porter is E-CONN-EPISODE-ID-DUP.
  • once defaults to run and is spent by presentation, as a scene’s is. Read flags do not spend it, because it is not an entry. With a clock, once="day", once="week", and once="slot" work as on a scene, and so does once="season:<name>" with a declared season.
  • Presenting it marks the canonical id visited, so visited('interviews.porter') reads it in any condition. Since dsl 0.24.0 §2 it is a legal after: predecessor: a scene’s after: "visited('interviews.porter')", or a quest’s, names it, and connectivity routes through it like a scene. (Before 0.24.0 that was E-CONN-UNKNOWN-NODE.)
  • Since dsl 0.25.0 §3 it may declare an after="…" of its own, as porterAgain does. The formula has a scene after:’s grammar and meaning: the beat is eligible only once it holds (lute play and lute calendar list it — after: prerequisite not satisfied, and lute trace --beat notes not eligible: `after` prerequisite not satisfied on the beat’s head without enforcing it), and lute scenario draws its edges. A malformed formula is E-CONN-PROFILE, and a node no document declares is E-CONN-UNKNOWN-NODE at whole-project lute check. A visited() conjunct in a beat’s when still gates it, but draws no edge, so lute scenario lists such a beat as unanchored and names the after= to write. Put route order in after= and state in when, as on a scene.
  • It is checked like a scene beat. E-OCCASION-UNKNOWN, E-BEAT-UNREACHABLE, and the whole-project lute check warnings below name it by its canonical id.
  • Its lines are addressed under the canonical id. The porter’s first line is interviews.porter.porter_0010, and the title’s titleLineId is interviews.porter.title.

Shape faults are E-BEAT-ATTR: a missing, malformed, or duplicate id, a document with beats but no id:, a missing on, or a bad priority, once, also, share, or target. Any other attribute is E-UNKNOWN-ATTR, and a <beat> in a scene document is not admitted. Entries and beats may interleave in any order. Lore entries covers the entry side of the file.

A roster of heroes, each with three bond stories, repeats one header shape dozens of times. A beat template writes it once (dsl 0.27.0 §6). It is a component whose frontmatter also declares a beat: header:

component: bondStory
params: { who: { type: string }, need: { type: int, default: 0 }, prev: { type: string, default: "" } }
beat:
on: bond
once: user
when: "user.bond >= @need"
after: "@prev" # left out when empty

A bundle beat names it with use= and passes the params as attributes:

<beat use="bondStory" id="first" who="aria">
@aria: Thank you for staying.
</beat>
<beat use="bondStory" id="second" who="aria" need="2" prev="bonds.first">
@aria: You came back.
</beat>
  • Every header key the <beat> does not write itself comes from the template, with each @param replaced by the argument’s text (or the param’s default:). A key that comes out empty is left out, and an after: that comes out a bare id means visited("<id>"). The header keys are the <beat> attributes: on, target, for, title, priority, once, share, after, when, spentBy, and also (also: true, unquoted). A {{@who}} in title: renders the argument, a speaker’s cast name.
  • In when: and spentBy: a member of a per: family is read the way a component body reads it: user.bond[@who] becomes user.bond.aria for who="aria". The dot form user.bond.@who also works in a header, with a W-TEMPLATE-DOT-PARAM hint toward the brackets.
  • An attribute written on the <beat> wins: <beat use="bondStory" … once="run">. It replaces the template’s key whole, so when="@isNight" on a use drops the template’s own when, and W-TEMPLATE-OVERRIDE says so, naming the dropped condition and any argument only it read. lute beats shows the use’s own guard with the note (replaces template `bondStory`'s `when: …`) (replacesTemplateWhen in --json). To keep both, write both in the use’s when=. To let a use add a condition, give the template a param for it and conjoin it in the header: only: { type: string, default: "" } and when: "!holds('defeated', [@who]) && (@only)". A use writes only="@isNight"; a use that leaves it empty (or true) derives just !holds('defeated', ['<who>']), with no && (true) left behind.
  • A header value with no @param in it is the same for every use, so a fault in it (an unknown occasion, a bad once:) is reported once, at the header key in the component, and the uses derive nothing for that key.
  • The template’s own body plays first, then the beat’s. A ::body line at the top level of the template body places the beat’s body there instead, so a template can frame it. A template’s body needs no ## heading: like a bundle beat’s, it is written straight after the frontmatter.
  • The other attributes are the template’s params, checked like ::use arguments (E-COMPONENT-ARG). A header key that reads a missing or refused argument is not derived, so one bad argument is one report. A param named like a header key (title, once, id, …) could never be passed, because the attribute sets the beat’s own key; it is E-TEMPLATE where it is declared. Rename it (title → heading).
  • A template with no body and a self-closing use make a one-line beat: <beat use="trainer" id="r3Joey" who="joey"/>.

A template use is an ordinary beat to every tool: lute beats lists the derived header, and play, trace and the compiled artifact present it as written out. Misuse is E-TEMPLATE: a use= that names no imported component (with a did-you-mean), a component without beat:, a malformed header, or ::body anywhere but the top level of a template.

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

  1. The candidates are the beats with on: O whose target is absent or equal to T, and (dsl 0.26.0) the kind beats whose kind has T’s member. Scene, entry, and bundle beats on the same occasion compete in one list.
  2. A candidate is eligible when its after: (a bundle beat’s after=) and when hold and its once is not spent: a scene’s or bundle beat’s by its presentation record, an entry’s by its read flags (or, for once="day" / "week" / "slot" / "season:<name>", by when it was last presented). A beat with a share key is spent when any beat of its key is, and a beat with spentBy once its condition has held.
  3. Eligible beats are ordered by priority, descending, then project order: document path, then declaration order within the document. project.index.json lists every beat in that order under beats. At equal priority a kind beat comes after the other candidates, so the beat that names T itself outranks it.
  4. The occasion’s select decides what is presented. For select: first the engine presents the first eligible beat that is not also (the winner), then every eligible also beat. For select: sequence it presents every eligible beat, in order. For select: all it offers the whole ordered list and the player picks one, or closes the list without picking. Closing it presents nothing and spends nothing. An engine labels that menu with each beat’s title (a scene’s title:, an entry’s or bundle beat’s title=), which project.index.json carries on every beat row (dsl 0.23.0).
  5. If no beat is eligible, the occasion passes with no story and the engine does its default.

Take the four barks above and a player on their fifth run. The first time they ever talk to Achilles, the engine presents achillesFirstMeeting (priority 30), and once="user" spends it for good. The next talk that run presents achillesMorning (priority 20). After that it presents achillesBark3 (priority 10) for the rest of the run, and achillesBark1 answers only while user.runs is below 3. Every later run opens with achillesMorning again, because a new run resets its entry.achillesMorning.read.

Sometimes one event can happen in different places. Sol is warm with you once a day: on the radio in the morning, on the roof at night, whichever comes first. Two beats with once="day" each spend only themselves, so the player would get both. Beats that stand for one event may share one spend (dsl 0.25.0 §2):

<beat id="solRadio" on="talk" target="npc.sol" once="day" share="solWarm" when="run.slot == 'morning'">
@sol: The radio says clear skies. Stay a while.
</beat>
<beat id="solRoof" on="talk" target="npc.sol" once="day" share="solWarm" when="run.slot == 'night'">
@sol: Up here you can see the whole bay.
</beat>
<beat id="solNod" on="talk" target="npc.sol" once="false" priority="-1">
@sol: Hey.
</beat>

Beats with the same share key, anywhere in the project, are spent together: once one of them is presented (an entry: read), every beat of the key is spent for the once period, exactly as if each had been presented. Scene beats (share: in frontmatter), entry beats (share=), and bundle beats (share=) may share one key. In the morning the radio beat plays, and the roof beat is spent for the rest of the day. lute play says why:

── step 3 · talk → npc.sol ──────────────
✓ talks.solNod [beat, priority -1]
✗ talks.solRadio [beat, priority 0] — once: day — already presented today
✗ talks.solRoof [beat, priority 0] — once: day — `share: solWarm` already spent today by talks.solRadio
→ talks.solNod

A key is a name (letters, digits, _ or -, not starting with -). It needs a spending once written beside it: share with no once, or with once: false, is E-BEAT-ATTR, since a repeatable beat has nothing to spend. Every beat of one key must declare the same once, because the key is spent for one period. Add a beat solDock to a lore document harbor with once="user" share="solWarm", and whole-project lute check reports the mismatch on the radio and roof beats:

./lore/talks.lute:7:66: error [E-BEAT-ATTR] beat `talks.solRadio` shares `solWarm` with beat `harbor.solDock`, but declares `once: day` where beat `harbor.solDock` declares `once: user`; the beats of one `share` key are spent together for one period, so every one of them declares the same `once`

The key replaces the old workaround of stamping the day into state and guarding every beat on it (::set{run.solWarmDay = clock.index}). lute beats shows the key in its once column (day, share solWarm), lute calendar spends the key together, and W-BEAT-PRIORITY-TIE and cast presence read the whole key’s spent flags. The key reaches the IR as share on the beat’s record and on its project.index.json row, absent when unauthored.

A puzzle the player may fail and retry is not spent when it is shown. It is spent when it is solved. Before 0.27.0 that took a repeatable beat and a guard on the result:

<beat id="valves" on="townVisit" once="false" when="!holds('solved', ['valves'])">
@narrator: Three valves, one pressure gauge. The needle waits.
</beat>

spentBy says it directly. It is a CEL condition, checked like when, and it spends the beat instead of a presentation: the beat stays eligible, presentation after presentation, until the condition holds, and from then on it is spent:

<beat id="valves" on="townVisit" spentBy="holds('solved', ['valves'])">
@narrator: Three valves, one pressure gauge. The needle waits.
</beat>

A scene writes it in frontmatter (spentBy: "holds('solved', ['valves'])"), an entry and a bundle beat as an attribute (spentBy="…"). The condition may read anything a when may. It is a latch: once it has held — in the settled world after any settle of the playthrough (every presentation, engine write, clock move and new run), or when the beat is judged — the beat is spent, and a condition that turns false again does not bring it back. The latch looks only once a settle is done, so a start="true" quest is already active when it first reads it, never the unset it had a moment before. For a kind beat or a for= beat the condition is judged per member, occasion.target bound, and spends that member.

once beside spentBy sets how long the beat stays spent: run unless written (spendable again in the next run), user (for good), day / slot / week (until the clock’s period moves on), or season:<name> (until the season opens again). A weekly puzzle that resets writes once="week". once="false" would keep nothing spent, so beside spentBy it is E-BEAT-ATTR: a beat that should answer only while something is false writes when="!(…)". A share key spends beats by presentation, so it cannot sit beside spentBy either. A spentBy that already holds at the start of play (every state path at its default, only the seed facts, each quest unset until its start holds) is W-BEAT-SPENT-AT-START: the beat would be spent before it could play, and the message names the when: "!(…)" rewrite.

A latch is not a condition judged afresh at each raise, and the two differ when the condition can turn false again after it has held. Whole-project lute check warns W-SPENT-BY-REVERSIBLE on a spentBy beat with no once written whose condition reads a fact some ::retract / ::assert or a directive’s declared effect can undo, a season’s state, facts or quest (each reset when the season opens again), or a quest a rearm returns to unset. The message names the rewrite: once: false with when: "!(…)" for a beat that should come back whenever the condition is false again, once: season:<name> for a condition over one season, or once: run to keep the latch on purpose. Writing once states the period, so it silences the warning.

lute play, lute calendar, and lute test’s eligible: report a spent beat as ineligible with the reason spentBy: `run.solved` holds while the condition holds and spentBy: `run.solved` held — spent this run once it has held (a kind beat’s reason names the member: … held for cod — spent this run). The condition reaches the IR as spentBy: {cel, expr, authored?} on the scene meta.beat, the beat record, or the entry record, and as its raw text on the beat’s project.index.json row; the beat’s once there is how long it stays spent (run when unwritten on a scene or bundle beat, absent on an entry).

select: first presents one beat per occasion, and that is usually right: a single main beat keeps “what happens when I talk to Maud” easy to explain. Two additions in dsl 0.23.0 cover the moments where one beat is not enough.

An occasion declared select: sequence presents every eligible beat, one after another, in selection order:

occasions:
evening: { select: sequence }

Take a supper scene on evening with priority: 5 and once: false that ends with ::set{ run.day = run.day + 1 }, and a letter scene on evening with when: 'run.day >= 2'. The first evening presents supper, then the letter: once a beat of the raise has played, each later beat is judged again just before its turn, so the letter sees the day supper moved to 2. The second evening presents supper alone, since the letter’s default once: run is spent. A beat whose when an earlier beat of the raise made false is skipped the same way. A sequence has nothing to pick, so a lute play step that writes pick: on a sequence occasion is a usage error.

On a select: first occasion, a scene or bundle beat with also: true does not compete for the win. When it is eligible, it is presented after the winner, in addition to it. It is still presented when no main beat is eligible at all.

---
kind: scene
id: town.cat
on: townVisit
also: true
when: 'run.day >= 2'
once: false
state:
run.day: { type: int, default: 1 }
---
## Opening {#opening}
@narrator: The stray cat is back on the fountain's rim, watching you.

The winner is the first eligible beat that is not also, whatever the also beat’s priority. Several eligible also beats follow the winner in selection order, and each spends its own once. The cat above says once: false, so from day two it follows whichever beat wins every hub visit.

also only means something where a single beat wins. On a select: all or select: sequence occasion every eligible beat is already offered or presented, so also: true there is E-BEAT-ATTR:

./scenes/supper-aside.lute:6:7: error [E-BEAT-ATTR] `also: true` applies only to a `select: first` occasion; `evening` is `select: sequence`, which already presents or offers every eligible beat — remove `also:`

So is an also value other than true or false. W-BEAT-SHADOWED and W-BEAT-PRIORITY-TIE ignore also beats: an also beat never shadows another beat, is never shadowed, and never ties.

An also beat is judged once, when the occasion is raised; a sequence beat is judged again at its turn, as above. Each presented beat spends its own once, and quests settle after each one, so a quest deadline is judged between two beats of one occasion. The <on event> handlers the raise answers, a questComplete included, run after all of its beats.

The project’s terminal: is judged when an occasion is raised, so the raise that makes it hold plays out: the beat whose write ends the game finishes, and the rest of its sequence and its handlers follow. That is where an epilogue goes, after the write or in the questComplete handler of the quest terminal: reads. See The game is over for the other things “end” means.

Occasions are engine vocabulary. A plugin declares them with an occasions export:

occasions:
townVisit: { select: first }
talk: { select: first, target: { prefix: npc, entity: person } }
examine: { select: first, target: true }
inbox: { select: all }
evening: { select: sequence }

Until some resolved plugin declares occasions, occasion names are shape-only: any name is accepted, so you can write beats before the engine’s plugin exists. Once any plugin declares them, a beat naming an undeclared occasion is E-OCCASION-UNKNOWN, and a scene’s or bundle beat’s target on an untargeted occasion (one declared with neither target: true nor a domain) is E-BEAT-ATTR. An entry’s target there is metadata (dsl 0.24.0 §6). The declaration format, select, and target are covered on Playing a story.

An occasion can also judge quest objectives: <objective on="runEnd" …> evaluates its done only when runEnd is raised while its quest is active, and its occasion is checked against the same vocabulary. Since dsl 0.23.0 an objective can also name a target. <objective on="talk" target="npc.maud" …> is judged only when talk is raised for npc.maud, and its target is checked exactly like a beat’s (E-BEAT-ATTR): a dotted id, only with on, only on an occasion that takes a target, and inside the occasion’s domain. Without target, an objective is judged whenever its occasion is raised, whatever the target. See Quests & scenes.

A raise answers quests after its beats. Once the occasion’s beats have been presented (or none was eligible, or the player closed a select: all list without picking), the raise does two more things, in this order:

  1. When a plugin also declares a world event of the same name under events:, raising the occasion fires it: every active quest’s <on event> handler for that name runs, once. The event carries no target.
  2. The occasion judges the on= objectives of every active quest, so an objective can read what a handler just wrote. Then the quests settle.

An occasion that only objectives or same-named handlers reference is still raised: it presents nothing and does just these two steps. lute play, lute run, and lute trace answer a raise in the same order. So when a plugin declares dayEnd as both an occasion and a world event, an active quest’s <on event="dayEnd"> handler runs every time the engine raises dayEnd, with no separate event to fire.

target: true keeps its 0.21.0 meaning: the occasion is raised for something, and a beat’s target is any dotted id. So a typo such as npc.achilels names a target the engine never raises, and the beat silently never plays.

A target domain { prefix, entity } (dsl 0.22.0) also says which things. Every target of the occasion is <prefix>.<member>, where <member> is a member of the entity kind entity: the same entities: vocabulary your facts use, read from the document’s schema (its uses:, or the project’s defaults:). With the talk declaration above and

entities:
person: { members: [maud, oskar] }

target: npc.oskar answers talk, while target: npc.osker is E-BEAT-ATTR with a did-you-mean:

./scenes/oskar.lute:5:9: error [E-BEAT-ATTR] target `npc.osker` is outside occasion `talk`'s domain `npc.<person>` (`npc.maud`, `npc.oskar`) — did you mean `npc.oskar`?

So is place.oskar, which has the wrong prefix. For an open: kind, whose members the engine populates, any <prefix>.<id> passes. A domain naming a kind the document’s schema does not declare is E-BEAT-ATTR on every target of that occasion. Scene, entry, and bundle beat targets are checked alike, and so are objective targets. lute play refuses a step target outside the domain with the same did-you-mean.

A domain can also narrow its kind with members:, when the engine raises the occasion for only some members:

occasions:
bossDefeated: { select: first, target: { prefix: boss, entity: foe, members: [gatekeeper, warden] } }

With foe: { members: [gatekeeper, warden, cinderhound] } under entities:, the occasion’s targets are boss.gatekeeper and boss.warden only. target: boss.cinderhound is E-BEAT-ATTR although cinderhound is a foe, and the did-you-mean runs over the listed members:

./scenes/hound.lute:5:9: error [E-BEAT-ATTR] target `boss.cinderhound` is outside occasion `bossDefeated`'s member list (`boss.gatekeeper`, `boss.warden`), a subset of entity kind `foe`

Every listed member must belong to the kind. One the kind does not declare makes every target of the occasion E-BEAT-ATTR, well-spelled ones included, and the message names the stray member: occasion `bossFled` lists `wardne` in its target `members:`, but `wardne` is not a member of entity kind `foe` — did you mean `warden`?. An open: kind’s members are not known to the checker, so there the list is taken as written. An empty list, or a member listed twice, fails the plugin load (see Manifests). The subset narrows every consumer of the domain alike: beat, entry, and objective targets, a lute play step’s target:, lute new scene --target, and lute calendar --target.

A beat may answer every member of a kind with target="kind:<kind>" (dsl 0.26.0 §5). A bug-catching contest scores a dozen species in a few tiers. Rather than one caught beat per species, the project declares the tiers as sub-kinds of species, the entity kind of the occasion’s { prefix: mon, entity: species } domain:

entities:
species: { members: [inchlet, cocoonix, stingle, hornbeetle, bladebug] }
bugRare: { subsetOf: species, members: [hornbeetle, bladebug] }
bugCommon: { subsetOf: species, members: [inchlet, cocoonix, stingle] }

and writes one beat per tier, in a lore document contest:

<beat id="catchRare" on="caught" target="kind:bugRare" once="false" when="run.contest == 'entered'">
::set{run.contestScore = 3}
@narrator: A {{occasion.target}}! The nets all around you go still.
</beat>
<beat id="catchCommon" on="caught" target="kind:bugCommon" once="false" when="run.contest == 'entered'">
::set{run.contestScore = 1 when="run.contestScore < 1"}
@narrator: A {{occasion.target}}. It counts.
</beat>
<beat id="firstInchlet" on="caught" target="mon.inchlet" once="user">
@narrator: Your very first inchlet. The judges smile.
</beat>
  • The kind. kind:<kind> names a closed kind (one with members:) inside the occasion’s domain: the domain’s own kind or one of its sub-kinds. The occasion must be declared by a plugin with a { prefix, entity } domain. Anything else is E-BEAT-ATTR, with a did-you-mean for a misspelled kind. The beat is a candidate whenever the occasion is raised for <prefix>.<member> of one of its members. Scene, entry, and bundle beats take a kind target alike, and it counts as a read of the kind, so the kind draws no W-DOMAIN-UNREAD.
  • occasion.target. In the beat’s when, its guards, and its text, occasion.target is the member the occasion was raised for (inchlet for mon.inchlet), typed by the kind. It is a value you compare (occasion.target == 'inchlet') or match on: a <match subject="occasion.target"> checks its arms against the kind’s members, and needs no unset arm, because the engine always assigns it. Since dsl 0.27.0 it also stands in for a member name in a fact query and a per: family: see Asking about the member. The value lasts for that presentation only. Reading occasion.target in a beat or entry that neither targets a kind nor is presented once per member is E-UNDECLARED.
  • Display. {{occasion.target}} compiles to the placeholder {"kind": "occasionTarget", "entityKind": "bugCommon"}, so an engine can show the member’s display name. lute play renders the kind’s label for the member, else its cast name: when the member is a cast id, and the id otherwise (A inchlet. It counts.).
  • Ranking. At equal priority the beat that names the member outranks the kind beat, and the two never draw W-BEAT-PRIORITY-TIE. W-BEAT-SHADOWED judges a kind beat member by member.

lute play shows the member beat winning its first raise and the kind beat answering after it:

── step 1 · caught → mon.inchlet ──────────────
✓ contest.firstInchlet [beat, priority 0]
✓ contest.catchCommon [beat, priority 0]
→ contest.firstInchlet
@narrator: Your very first inchlet. The judges smile.
── step 2 · caught → mon.inchlet ──────────────
✓ contest.catchCommon [beat, priority 0]
✗ contest.firstInchlet [beat, priority 0] — once: user — already presented
→ contest.catchCommon
set run.contestScore = 1
@narrator: A inchlet. It counts.

Each shape fault names what is wrong:

./lore/contest.lute:24:42: error [E-BEAT-ATTR] `target="kind:bugRar"`: `bugRar` is not a declared entity kind — did you mean `kind:bugRare`?
./lore/contest.lute:28:40: error [E-BEAT-ATTR] `target="kind:bugRare"`: `hornbeetle`, `bladebug` are outside occasion `talk`'s domain `npc.<person>`; a kind target names `person` or one of its sub-kinds
./lore/contest.lute:33:23: error [E-UNDECLARED] `occasion.target` is readable only in a beat or entry that targets a kind (`target="kind:<kind>"`) or runs once for each member of one (`for="kind:<kind>"`), where it is the member the beat answers

lute play and lute calendar offer a kind beat for every member’s raise. lute beats lists it in each member’s ladder that names a beat of its own, and in a kind:<kind> ladder for the rest (caught @ kind:bugCommon); its --target accepts any member. A lute test of a kind beat names the member in state: { occasion.target: inchlet }. The compiled beat carries targetKind: { kind, prefix, members } on its scene meta.beat, its entry or beat record, and its project.index.json row.

occasion.target stands in for a member name where a member is expected (dsl 0.27.0 §3): as a fact query’s argument, holds('logged', [occasion.target]), and as a per: family’s index, run.caught[occasion.target]. The checker instantiates the condition for every member of the kind, and the engine substitutes the member it raised:

<beat id="rareAgain" on="caught" target="kind:bugRare" once="false" priority="5" when="holds('logged', [occasion.target]) && run.caught[occasion.target] >= 2">
@narrator: Another {{occasion.target}} for the book.
</beat>

Because each member is checked, a fault names the member it is about: a member outside the relation’s argument kind is E-FACT-DOMAIN, and a family declared per: a kind the member is not in is E-UNDECLARED for that member’s path.

A lute test names the member and mocks what is asked about it:

file: ../lore/contest.lute
beat: rareAgain
state: { occasion.target: hornbeetle, run.caught.hornbeetle: 2 }
facts: [logged(hornbeetle)]
expect:
transcriptContains: ["Another hornbeetle for the book."]

Without the member, lute trace stops and says which mock decides it: supply --state occasion.target=<hornbeetle|bladebug> as a mock.

When each member needs its own conditions throughout, a beat per member (target="mon.hornbeetle") says it more plainly, and it outranks the kind beat at equal priority.

The same beat can write what it asks about. Where a member is expected in a write, occasion.target stands for the member the beat runs for:

  • a per: family index in a ::set: ::set{run.caught[occasion.target] += 1};
  • a fact argument in an ::assert or ::retract: ::assert{logged(occasion.target)};
  • a directive attribute typed by an entity kind or domain: ::haul{bug=occasion.target} (the directive’s declared effects then name the member too);
  • a component argument whose param is a speaker or is typed by a kind: ::use{component="reaction" who=occasion.target}. The member speaks the component’s @@who: lines, and a kind-typed param renders its label.
<beat id="rareAgain" on="caught" target="kind:bugRare" once="false" when="!holds('logged', [occasion.target])">
@narrator: A {{occasion.target}} for the book.
::assert{logged(occasion.target)}
::set{run.caught[occasion.target] += 1}
</beat>

Each write is checked once per member, as a condition is: a member the family is not declared per:, or that is outside the relation’s or parameter’s kind, is reported naming that member. lute play writes only the member the beat ran for (set run.caught.hornbeetle = 1). A write through occasion.target in a beat or entry that neither targets a kind nor runs for each member of one is E-UNDECLARED: there is no member to write.

In the compiled artifact the write keeps occasion.target (a set record’s path run.caught[occasion.target], an assert argument, a plugin field); the engine substitutes the member it bound, exactly as it does for a read. A component argument is expanded per member instead: one match arm on occasion.target for each member.

Some occasions are raised for no one in particular: a morning, a daily reset. A beat on such an occasion may still be about each member of a kind in turn. With for="kind:<kind>" (dsl 0.27.0 §3), a beat on an untargeted select: sequence occasion is presented once per member whose when holds, in the kind’s member order (its own members, then its sub-kinds’ in declaration order), with occasion.target bound to that member:

occasions:
morning: { select: sequence }
<beat id="birthday" on="morning" for="kind:person" once="false" when="holds('birthdayToday', [occasion.target])">
@narrator: It is {{occasion.target}}'s birthday.
</beat>

On a morning where birthdayToday(maud) and birthdayToday(oskar) hold, the beat is presented twice, for maud and then for oskar, among the occasion’s other eligible beats in the usual order. Everything Kind targets says about occasion.target holds here too: a fact query and a per: family take it, and {{occasion.target}} renders the member’s label.

  • Eligibility is decided per member. once is spent per member: a once: run beat that was presented for maud this morning can still be presented for oskar tomorrow, and not for maud again this run. The same holds for user, day, slot, week, and season:<name>.

  • A spentBy condition is judged per member too, with occasion.target bound, so it spends one member at a time. A beat that greets each person until they are befriended:

    <beat id="hello" on="morning" for="kind:person" spentBy="holds('befriended', [occasion.target])">
    @narrator: {{occasion.target}} nods at you, warily.
    </beat>

    Once befriended(maud) has held, the beat is spent for maud for the run and still greets everyone else.

  • A scene writes the same key in its frontmatter, for: "kind:person".

  • for needs an occasion declared select: sequence and raised without a target, and a closed kind (one with members:). It is not written beside target; an occasion raised for a member takes target="kind:<kind>" instead. Each of these is E-BEAT-ATTR, with a did-you-mean for a misspelled kind.

lute play lists one candidate per member (✓ lore.birthday for maud), and its --json candidates carry the member as for. A lute test of the beat names one member in state: { occasion.target: maud }. The compiled beat carries forKind: { kind, members } on its scene meta.beat, its entry or beat record, and its project.index.json row.

An occasion may hand typed values to the beats that answer it (dsl 0.27.0 §3). A gacha pull is raised for the hero drawn, and says how many copies came with it:

occasions:
summon: { select: first, target: { prefix: hero, entity: hero }, payload: { copies: int } }

A beat on summon reads each field as occasion.payload.<field>, typed as declared, in its when, its guards, its ::set values, and its text:

<beat id="pull" on="summon" target="kind:hero" once="false" when="occasion.payload.copies >= 1">
@narrator: {{occasion.payload.copies}} copies of {{occasion.target}}.
::set{run.shards += occasion.payload.copies}
</beat>

The engine binds the payload for one raise; it is gone once that raise’s beats have been presented. Reading a field in a beat of an occasion that does not declare it is E-UNDECLARED, and so is a read outside any beat. A lute play step gives the payload beside the occasion (payload: { copies: 2 }), and a field the occasion does not declare is a usage error. A step that leaves a declared field out is a usage error too, before anything plays: step 1: occasion `summon` declares payload field `copies`, which is missing from this raise — add `payload: { copies: <int> }` to the step lute trace and lute test mock it like any state, state: { occasion.payload.copies: 2 }, and halt incomplete on a line that reads a field the mock leaves out, instead of printing the placeholder. An occasion with a payload cannot be the clock’s raise: (E-CLOCK-DECL): the clock has no values to give it.

CodeWhen
E-BEAT-ATTRa malformed beat key or attribute: on not a name, target not a dotted id or outside its occasion’s target domain, a kind: target that names no closed kind within the occasion’s domain, a for that is not kind:<kind> of a closed kind, sits beside target, or is on an occasion raised for a target or not select: sequence, priority not an integer, a scene’s or bundle beat’s once outside run / user / false or an entry’s once outside run / user / false (each also day / slot, but only in a project with a clock, and week only with its week:; dsl 0.27.0 §5), once="false" or a share key written beside spentBy, also not a bool, on an entry, or on a select: all / sequence occasion, a share that is not an identifier or has no spending once beside it, beats of one share key with different onces (whole-project lute check), beat keys without on, a scene’s or bundle beat’s target on an untargeted occasion, or a bundle beat shape fault (its id, a duplicate id, a lore document with beats but no id:)
E-OCCASION-UNKNOWNon names an occasion no resolved plugin declares (only once some plugin declares occasions)
E-BEAT-UNREACHABLEa scene or bundle beat’s when provably never holds; see How a when is decided. lute check decides what one file settles, and whole-project lute check also decides fact queries through the fact envelope. An entry beat’s dead when stays E-ENTRY-UNREACHABLE.
W-BEAT-SHADOWEDwhole-project lute check only: a select: first beat that can never win, because an earlier-ordered beat on the same occasion and target is always eligible (no after:, and a when that is absent or always true) and never spent (an entry without once, or a scene or bundle beat with once: false). On an occasion whose target domain is closed, an untargeted beat is also reported when, at every <prefix>.<member>, such a beat for that member wins; the message names the shadower per target. A kind beat is judged member by member the same way. also beats neither shadow nor are shadowed.
W-BEAT-PRIORITY-TIEwhole-project lute check only: two beats on one select: first occasion, either untargeted or for the same target, with equal priority and whens that are not provably exclusive. When both are eligible, project order picks the winner, so renaming or moving a file changes it. Give one a different priority, or make the conditions exclusive: conditions that cannot hold together, such as run.slot == 'morning' against run.slot == 'night', run.day > 5 against run.day < 3, or holds(P) against !holds(P). A beat’s once counts as part of its condition: an entry’s once="user" as !entry.<id>.everRead, its once="run" as !entry.<id>.read, a scene’s or bundle beat’s once: user as !visited('<id>'). So a bark with once="user" and one whose when reads that bark’s everRead do not tie. A holds(…) of a derived atom whose rules are all ground, cel()-only schedules counts as those cel() guards, so beats on two different schedule slots do not tie either. Since dsl 0.26.0 §8 negations are normalized first (De Morgan, !(run.money < 100) as run.money >= 100, so it is exclusive with run.money < 100) and each condition’s alternatives are compared; the fact analysis’s must set at either beat’s when is read; a fact that only a beat’s own unplayed once: run / user presentation asserts counts as absent while that beat is eligible; and the message says why the two are not exclusive: a flag that outlives once: run, a fact asserted elsewhere or tier: user, or the paths each when reads (beat `tie.first` reads `run.metMira` and beat `tie.again` reads `run.money`: no path both constrain). A member beat and a kind beat never tie: the member beat ranks first. A shadowed beat reports W-BEAT-SHADOWED instead, and an also beat never ties.
W-BEAT-ONCE-RUN-USERwhole-project lute check only: a scene or bundle beat whose once is left at its default run and whose when reads only user-tier state: user.*, entry.<id>.everRead, holds / count of a tier: user relation, or quest.<id>.* of a user-tier quest, and no run-tier path, run-tier fact, or visited(). Once that condition holds it holds in every run, so the beat plays again each run. Use once: user for a beat heard once ever, or gate it on run-tier state. If replaying every run is the point, write once: run: an authored once: run, like an entry’s once="run", says so and silences the warning. prev.run.* is run history, not user state, so a when reading it never warns.
W-ENTRY-WRITE-REREADan entry that can be read again in a run (a lookup entry, an entry beat without once, a once shorter than the run, a spentBy entry, a for= entry without once="run" / "user") whose body has a ::set, a ::retract, or an effect-only directive that writes: its writes apply on its first read in a run only. ::assert is exempt, and so is a write guarded by !entry.<id>.read. See Reading twice.

The checker proves what it can about a condition without running the game, and dsl 0.23.0 made it sharper. Inside one &&, it collects what each operand says about each path: equalities, disequalities, and numeric ranges. A path is a state path, a component param, $ in a <match>, or a relational call such as holds(P), visited(id), or count(P), compared by its text. When the constraints on one path cannot all hold, the whole conjunction is false. @defs are expanded first. Each of these beats is E-BEAT-UNREACHABLE in a plain lute check:

whenWhy it never holds
run.slot == 'morning' && run.slot == 'night'one path, two values
run.day > 5 && run.day < 3an empty range
run.flag && !run.flaga condition and its negation
holds('met', ['maud']) && !holds('met', ['maud'])the same query, both ways
run.slot == 'afternon'a value outside the path’s enum

A condition hidden behind a def is found the same way:

---
kind: scene
id: square.festival
on: townVisit
when: "@festivalNight && run.day == 2"
state:
run.day: { type: int, default: 1 }
defs:
festivalNight: { type: bool, cel: "run.day == 5" }
---
## Opening {#opening}
@narrator: Lanterns everywhere.

An || works the other way: when its cases cover every value a path can take, it is always true. run.day < 3 || run.day >= 3 holds on every day, so a repeatable beat with that when shadows every later beat on its occasion (W-BEAT-SHADOWED).

unset counts as a value. Reading an ordering or a bare boolean on an unset path is an error, not false, so a contradiction on a path with no default is decided only once the same conjunction proves the path set: has(run.mood) && run.mood > 5 && run.mood < 3 is false. (Without the has guard, a when reading that path is E-MAYBE-UNSET anyway.) The reasoning stays within && and ||. It does not look inside a Datalog rule’s cel(…) guard, so a when that contradicts the scalar premise of a derived fact is not caught.

The same decider serves every condition slot. A dead <when test> or gated line is E-ARM-DEAD, a dead entry when is E-ENTRY-UNREACHABLE, a dead objective done is E-OBJECTIVE-UNSATISFIABLE, and W-BEAT-PRIORITY-TIE asks it whether two whens can hold together. A project that checked clean on 0.22 may report a contradiction it used to miss. The beat never played before either, but now the checker says so.

A fact-gated beat is often dead only because the scene that asserts its fact is not written yet. lute check --project <DIR> --wip (dsl 0.23.0) reports E-BEAT-UNREACHABLE, E-ENTRY-UNREACHABLE, and E-OBJECTIVE-UNSATISFIABLE as warnings when the condition is dead only because some relation has no producer at all: no seed, no ::assert anywhere, no rule, and not reserved:. The note on the warning says why. A relation that has producers but can never match stays an error. So does what follows downstream: a quest that is dead only because its content is unwritten still counts for connectivity, so an E-CONN-UNREACHABLE it causes stays an error. See whole-project lute check.

  • lute play --project <DIR> --script <play.yaml> raises a scripted sequence of occasions through a whole project. It prints every candidate with its verdict, presents the winner and any also beats (or every eligible beat of a select: sequence occasion), and advances quest lifecycles after each one. A step on a select: all occasion names the player’s choice with pick:, or closes the list with pick: none, and a step’s expect: { presented: [ids] } asserts the presented beats in order. Bundle beats play like scene beats. See Playing a story. Since dsl 0.26.0 §8 lute play and lute test also accept an entry by <document id>.<entry id> (talks.miraBark) wherever they take an entry id: pick:, entriesRead:, a step’s winner / offered / notOffered / presented, a test’s entry: / entries: and expect.eligible keys, and lute trace --entry.

  • lute beats --project <DIR> prints each occasion’s (and each target’s) beat ladder in selection order, with priority, once (and its share key), also, after:, when, title, and the whole-project lute check verdicts above, even for a project that does not check clean yet. Since dsl 0.26.0 §8 it also marks a fallback that an earlier, never-spent beat always beats, because the fallback’s when implies the earlier beat’s, as covered by <id> (--json: coveredBy). It is informational, not a warning:

    townVisit — select: first
    # priority beat kind once verdict after when
    1 5 gate.open bundle no - - run.badges >= 1
    2 0 gate.shutFallback bundle no covered by gate.open - run.badges >= 3

    lute calendar --project <DIR> --axis … evaluates eligibility over a grid of state values, such as every day and time slot, and shows what each cell presents: the winner, or a sequence’s whole list. lute scenario --project <DIR> knowledge traces each fact-gated beat to the facts it queries and whatever produces them. See Overviews.

  • lute trace <lore.lute> --beat <id> previews one bundle beat, and lute run <artifact> --beat <id> runs one from a compiled lore artifact. An id that names no bundle beat is E-TRACE-BEAT in trace and a usage error in run. See Tracing.

  • Whole-project lute compile (no document operand, -o <DIR>) writes beats into project.index.json: one row per scene, entry, or bundle beat (kind bundle), carrying the expanded when and the title (dsl 0.23.0). Each compiled scene beat carries meta.beat, each entry beat carries on / priority / once on its entry record, and each bundle beat is a beat record in its lore artifact, followed by its body.

  • lute new scene <name> --occasion <OCCASION> [--target <TARGET>] writes a scene beat, checking the occasion and target against the project first; a targeted occasion needs --target.

  • lute context lists the project’s declared occasions, with their target domains, beside its other vocabulary.

The engine contract (candidates, eligibility, spending, presentation) is in docs/runtime/beats-and-occasions.md; the normative specs are 0.21.0.md, 0.22.0.md for entry once and target domains, 0.23.0.md for select: sequence, also, beat bundles, the sharper decider, and --wip, 0.24.0.md for once: day / once: slot, entry targets as metadata, and bundle beats as after: predecessors, 0.25.0.md for share and bundle beat after=, 0.26.0.md for kind targets, W-ENTRY-WRITE-REREAD, the sharper W-BEAT-PRIORITY-TIE, and covered by, and the draft 0.27.0.md for once: week, once: season:<name>, and spentBy.

lute beats adds evidence to each verdict in JSON. Proven checker decisions remain proven; conservative ladder guidance is heuristic, and clock-window findings are bounded with scope declared clock windows; no path search. The bounded W-OBJECTIVE-STRANDED and W-SLOT-CONTENTION messages describe only that scope, not a universal impossibility.

Since 0.41.0 W-OBJECTIVE-STRANDED names that evidence level in its own message and is always a warning. It skips a quest that re-arms (tier="run", tier="season:<name>", or a rearm=), which retries the objective, and an objective a beat of another document can complete.

lute beats --json is a report, not an artifact stream. Consume its top-level command, schemaVersion: "0.43.0.beats", and ok fields, then the beat verdicts and their evidence (proven, heuristic, or bounded). Do not infer eligibility from human formatting. The command identity and all report versions are discoverable with LUTE_TELEMETRY=0 target/debug/lute schema beats.