Skip to content

Lore entries

Scenes and quests put story on a time axis: what happens, in what order. A lot of a game’s story lives somewhere else — in objects and places. The torn page in the lab, the inscription on a door, the key whose description changes after the fire, the line an NPC mutters as you walk past. The player finds these in any order, reads them again, and pieces the story together.

A lore document (kind: lore, dsl 0.19.0) declares that text. Lute says what an entry reads, when it is eligible, and what reading it changes. The engine decides where it lives: which item spawns in which room, which panel shows the codex, when an NPC barks.

---
kind: lore
title: Ship's records
entities:
crew: { members: [vesna, toma] }
topic: { members: [project_lumen, true_heading] }
relations:
knows: { args: [crew, topic], tier: run }
state:
run.labBurned: { type: bool, default: false }
---
<entry id="scientistLog1" target="item.torn_note_1" category="note" series="scientistLog" order="1" title="Research log, day 3">
@scientist: Day three. Subject E does not respond to light.
::assert{knows(vesna, project_lumen)}
</entry>
<entry id="scientistLog2" target="item.torn_note_2" category="note" series="scientistLog" order="2" when="entry.scientistLog1.read">
@scientist: Day nine. We stopped writing her name in the logs.
</entry>
<entry id="rustyKey" target="item.rusty_key" category="item">
<match subject="run.labBurned">
<when is="true">
@narrator: A scorched key. Someone's name has melted into the grip.
</when>
<otherwise>
@narrator: A rusty key, stamped "Research wing B2".
</otherwise>
</match>
</entry>

The top level is <entry> declarations and, since dsl 0.23.0, <beat> blocks (see Entries and beats in one file), and nothing else — no ## sections, no <quest>. The document’s title is its frontmatter title:.

AttrMeaning
idrequired; unique across the project
targetthe engine-owned thing it belongs to — item.rusty_key, place.lab-b2, npc.vesna: names (letters, digits, _ or -, not starting with -) joined by .; on an occasion whose target domain lists a kind’s members, one of those members; on an entry beat also kind:<kind>, every member of a kind (dsl 0.26.0 §5)
categorywhat kind of text it is — note, item, place, codex, bark, key-item, … (engine vocabulary, spelled as the engine spells it)
titledisplay title, localized like a quest title
series / ordermulti-part text: order is the position within series (a document-level series: can supply both — see below)
wheneligibility: the entry may be presented only while this holds
on / priority / oncemake the entry a beat that answers an engine occasion; once="run" or once="user" stops it answering again after a read, and on a project with a clock, once="day", once="week" (with the clock’s week:), or once="slot" stops it until the clock’s day, week, or slot changes; once="season:<name>" stops it until a declared season opens again
spentByon an entry beat: the entry keeps answering until this condition holds, then stays spent for its once period (run unless written), even if it turns false again. See Until it is solved
foron an entry beat of an untargeted select: sequence occasion, for="kind:<kind>" (dsl 0.27.0 §3): the entry answers once per member whose when holds, reading it as occasion.target. See Once per member

target and category are checked for shape only, so you can write lore before the engine’s item catalog exists. Several entries may share a target — an NPC’s barks, for example. The one exception is an entry beat whose occasion declares a target domain: its target must then be <prefix>.<member> of that domain. On an occasion declared without any target, an entry beat’s target is metadata (dsl 0.24.0 §6): it still says what the entry is about, and the entry answers every raise of the occasion. Before 0.24.0 that was E-BEAT-ATTR, and for a scene or bundle beat it still is.

An entry beat may also target a whole kind, target="kind:bugRare", on an occasion with a target domain, and read the raised member as occasion.target in its when and its body, as a scene or bundle beat does (see Kind targets).

Authors write a series the way they read it: pages one through seven, top to bottom, in one file. A lore document can say so directly (dsl 0.19.0 §2.1):

  • id: names the file as a bundle — haven.purserLedger, haven.captainsLog. It has the same shape rules as a scene’s id: (E-META-ID), becomes the artifact’s meta.id and the document’s key in project.index.json, and shares one project-wide namespace with scene and quest document ids (E-CONN-EPISODE-ID-DUP). Quest documents may declare one too; without it a document is keyed by its first declared quest or entry id. A lore document that holds <beat>s must declare it, because it prefixes every beat’s id.
  • series: makes every entry in the file one series, ordered by position in the file (1-based). Reordering pages is moving entries; inserting a page renumbers the ones after it.
---
kind: lore
luteVersion: "0.30.0"
id: haven.purserLedger
title: Purser's ledger
series: purserLedger
uses: ../world.schema.yaml
---
<entry id="purserLedger1" target="item.purser_ledger" category="note" title="Page 1">
@ottavio{code="0010"}: Manifest amended at the third bell. Two pods logged aboard that were never loaded.
::assert{knows(vesna, manifest)}
</entry>
<entry id="purserLedger2" target="item.purser_ledger" category="note" title="Page 2" when="entry.purserLedger1.read">
@ottavio{code="0010"}: If anyone reads this far: the heading on the bridge was never the true one.
::assert{knows(vesna, true_heading)}
</entry>

(From docs/examples/haven/lore/purser-ledger.lute.)

purserLedger1 compiles as order 1 and purserLedger2 as order 2 of series purserLedger. The compiled entry records and project.index.json carry the resolved series / order, so the engine reads every entry the same way whichever form you wrote.

The per-entry series= / order= attributes (the first example on this page) remain the form for a series that spans several files. Inside a document that declares series:, an entry carrying its own series= or order= is E-ENTRY-ATTR — the two forms never mix in one file. Two entries that resolve to the same (series, order) are E-ENTRY-SERIES-ORDER: per document in lute check, across the project in whole-project lute check.

Content lines, <match>, ::set, ::assert, and ::retract. No <branch> or <hub> (reading has no player choice), no <timeline> or other :: directives (the engine owns how an entry is shown), no <on> or <objective>. Anything else is E-GRAMMAR-NOT-ADMITTED. Story with a choice in it belongs in a scene, or in a <beat> in the same file.

One kind of plugin directive is the exception (dsl 0.27.0 §4): a directive whose only behaviour is its declared effects, the state it writes and the facts it asserts or retracts, with no bridge call, result slot, staging layer or lowering. Reading a diary page may cost the player sanity:

<entry id="diary" target="item.diary" category="note">
::fright{amount=3}
@narrator: The diary's last page is wet. Sanity {{run.sanity}}.
</entry>

Such a call is applied like the entry’s own ::set: on the first read only (see Reading twice). Any other directive in an entry is still E-GRAMMAR-NOT-ADMITTED.

Lines are ordinary content lines. The speaker can be @narrator or an in-world author such as @scientist, and each entry gets its own lineId / voiceKey scope, as each quest does.

Reading an entry reveals knowledge with the ordinary ::assert, against relations your schema already declares — the checker applies the usual arity, domain, and tier rules. Scenes and quests react with holds(…):

@eris{when="holds('knows', ['vesna', 'project_lumen'])"}: ...So you read the log.

The first time an entry is presented in a run, its ::set / ::assert / ::retract apply, and so do the effects of an effect-only directive it calls. Afterwards the engine sets entry.<id>.read to true, and later readings show the text only — a <match> may pick a different arm by then, but nothing else changes. On a re-read, lute trace and lute play list the effects they did not apply as skipped.

So an entry that is read again in the same run changes state only the first time. The rule follows the read, not the way the entry was reached, so it covers every entry that can be read more than once in a run:

EntryRead again in a runIts writes apply
a lookup entry (no on=): a codex page, an item descriptioneach time the engine opens iton the first read in the run
an entry beat without once (or once="false")at every raise of its occasionon the first read in the run
once="day", "slot", "week", or "season:<name>"in each new periodon the first read in the run, not once per period
spentBy="…"until its condition has heldon the first read in the run
for="kind:<kind>" without once="run" / "user"per member, at every raise or periodon each member’s first read in the run

An entry with once="run" or once="user" is read at most once per run (per member, with for=), so its writes always apply. For the others, lute check warns W-ENTRY-WRITE-REREAD at the first ::set, ::retract, or effect-only directive that writes, and names what works for that entry’s shape. For an entry greet on morning with for="kind:npc" and no once:

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

An ::assert is exempt: its fact holds for the rest of the run either way.

  • The write should happen every time. Put the body in a <beat> with the same on, target, for, and once: a beat’s writes apply on each presentation. Mind the defaults: an <entry> without once is repeatable, but a <beat> without once is spent for the run, so a beat that repeats writes once="false" (<beat on="talk" target="npc.tomas" once="false">, <beat on="morning" for="kind:npc" once="false">).
  • Once per run is what you meant. Write once="run" on the entry.
  • The write belongs to the first read on purpose. Guard it with when="!entry.<id>.read", on the ::set or on a <match subject="entry.<id>.read"> arm is="false". The guard says so to the reader and silences the warning.

entry.<id>.read is a reserved bool any document can read: gate the next page of a series (when="entry.scientistLog1.read"), branch a scene on it, or complete a quest objective. Content never writes it. Whole-project lute check warns W-ENTRY-REF-UNKNOWN when no document declares the id.

The flag is run-tier: a new run resets it, and the next first read applies the effects again. So when="!entry.<id>.read" makes an entry play once per run, not once ever.

Its user-tier twin, entry.<id>.everRead (dsl 0.22.0), answers “has the player ever read this?”. The engine sets it whenever it sets entry.<id>.read, so it turns true on the first read ever, and a new run never resets it. It is reserved the same way: readable wherever entry.<id>.read is, never written by content, and resolved by W-ENTRY-REF-UNKNOWN like its twin. Together the two flags tell a later run from the first one:

<entry id="tomaDejaVu" target="npc.toma" category="bark" when="entry.scientistLog1.everRead && !entry.scientistLog1.read">
@toma: You have that look again. As if you already know what the log says.
</entry>

tomaDejaVu is eligible only in a run after the one where the player first read the log, and only until they find it again. On its own, when="!entry.<id>.everRead" makes an entry play once ever.

FlagTierSetReset
entry.<id>.readrunafter the entry’s first presentation in a runat every new run
entry.<id>.everReaduserafter the entry’s first presentation evernever

An entry beat can also spend itself on these flags directly: once="run" stops it answering its occasion while read is set, and once="user" once everRead is. once="day" and once="slot" (dsl 0.24.0 §1, only with a declared clock) spend it by time instead: it does not answer again until the clock’s day, or slot, changes, whatever its flags say. See Beats.

A guard that assumes an entry was read also knows what that entry asserted (dsl 0.24.0 §6). Under entry.X.read (or entry.X.read == true), whole-project lute check adds the facts X’s body asserts on every route, and that nothing retracts, to the facts it knows hold, the Must set of the fact analysis. A holds(…) of one of them inside that guard is then redundant, W-FACT-GUARANTEED, and its negation can never hold (E-ARM-DEAD on a line). Save the first document on this page as lore/ship-records.lute and give scientistLog2 a second line:

<entry id="scientistLog2" target="item.torn_note_2" category="note" series="scientistLog" order="2" when="entry.scientistLog1.read">
@scientist: Day nine. We stopped writing her name in the logs.
@scientist{when="holds('knows', ['vesna', 'project_lumen'])"}: You know what Lumen was. Now you know who.
</entry>
./lore/ship-records.lute:20:20: warning [W-FACT-GUARANTEED] guard `holds('knows', ['vesna', 'project_lumen'])` is redundant: `knows(vesna, project_lumen)` is asserted on every route to here (./lore/ship-records.lute:15)

The entry’s when already guarantees the log was read this run, so the line’s guard is always true. entry.X.everRead promises less. It adds only the asserted facts of tier: user and tier: app relations, the ones a new run keeps. knows above is tier: run, so under everRead alone, as in tomaDejaVu, its holds(…) stays an open question.

An interview, a short NPC moment, the remark someone makes when you pick up the log: these are scene beats, not entries, because the player plays them rather than reads them. They are also a few lines each. Rather than one scene file per moment, a lore document can hold them as <beat> blocks beside its entries (dsl 0.23.0):

---
kind: lore
id: shipRecords
title: Ship's records
---
<entry id="bridgeLog" target="item.bridge_log" category="note" title="Bridge log">
@narrator: The heading was changed eleven years ago.
</entry>
<beat id="tomaAtTheLog" on="examine" target="item.bridge_log" title="Toma at the log" when="entry.bridgeLog.read">
@toma: You found it too.
<branch id="heading">
<choice id="tell" text="Tell her what it says">
@toma: Then we are not going home.
</choice>
<choice id="hide" text="Say it is nothing">
@toma: Your hands say otherwise.
</choice>
</branch>
</beat>

The two kinds of block share a file but keep their own rules:

<entry><beat>
Idits own id, unique across the project<document id>.<beat id>: shipRecords.tomaAtTheLog
Bodycontent lines, <match>, ::set / ::assert / ::retract, effect-only plugin directivesa scene body: lines, branches, hubs, <match>, directives
Reachedlooked up by the engine, or as an entry beatonly as a beat answering its on occasion, once its when and (dsl 0.25.0) its after= hold
Effectson the first read in a run (per member with for=; see Reading twice)on every presentation, as a scene’s
Spentnever, without once: repeatable by default. With once=, by entry.<id>.read / everRead (by the clock for "day" / "week" / "slot", by the season for "season:<name>"), or by spentBy=by presentation, or by spentBy=; once defaults to run
Shared spend (dsl 0.25.0)share= beside once=: reading it spends every beat of the keyshare= beside once: presenting it spends every beat of the key
Read by conditionsentry.<id>.read, entry.<id>.everReadvisited('<document id>.<beat id>')

Entries and beats may come in any order. In the compiled lore artifact each one heads its own position unit, in source order — the first segment of position, 001, 002, … — holding an entry or beat record, then its body, which runs to the next entry or beat record. Beat bodies get lineIds under the canonical id (shipRecords.tomaAtTheLog.toma_0010). The Beats page covers the <beat> attributes, the canonical id, and how a bundle beat is checked and selected.

  • lute trace <doc> --entry <id> --mock m.yaml previews one entry against mocked state; seed entry.<id>.read: true, or entriesRead: { run: [<id>] }, to preview a re-read. A run: id sets both entry.<id>.read and entry.<id>.everRead (read this run is read ever); entriesRead: { user: [<id>] } sets entry.<id>.everRead alone. Either is legal for any entry the document declares. An entry whose once="run" or once="user" those flags spend is ineligible in trace and test, as it is in lute play. lute run <artifact> --entry <id> does the same over a compiled lore artifact.

  • lute trace <doc> --beat <id> --mock m.yaml previews one <beat> by its local or canonical id, with the mock’s choose: picking its branches; lute run <artifact> --beat <id> does the same over a compiled lore artifact. The beat’s when and after= are shown, not enforced. An id that names no beat is E-TRACE-BEAT in trace and a usage error in run; both list the document’s beats. See Tracing.

  • lute test tests a lore document too (dsl 0.22.0). The test names the entries to present, with entry: <id> or entries: [ids]. They are presented in order with the read flags set between them, so a repeated id is a re-read. With the first document on this page saved as lore/ship-records.lute:

    file: ../lore/ship-records.lute
    entries: [scientistLog1, scientistLog2]
    expect:
    transcriptContains: ["Day nine. We stopped writing her name in the logs."]

    A lore test that names neither is E-TEST-LORE, and the message lists the document’s entry ids. Since lore is testable, lute test --coverage lists an untested lore document with the other untested documents.

    Since dsl 0.26.0 §8 a test (entry:, entries:, expect.eligible keys), a play script (pick:, entriesRead:, a step’s winner / offered / notOffered / presented), and lute trace --entry also accept <document id>.<entry id> as an alias for an entry id: entry: shipRecords.bridgeLog names bridgeLog of the document on this page whose id: is shipRecords. A test of an entry or beat that targets a kind names the raised member with state: { occasion.target: <member> }.

  • lute lore --project <DIR> prints the world-narrative map: entries by target and by series, with each entry’s and beat’s when under it, and which facts entries reveal versus scenes and quests. It reads beat bodies too. When the project’s rules derive a relation, a Derived section lists every conclusion the rules can reach from what the project asserts, the rule instance behind it, the evidence it rests on (which entry or scene asserted each premise), and the entries and beats it gates (derived in --json). See the CLI reference.

  • lute new lore <name> scaffolds a lore document.

The engine contract — eligibility, presentation, first-read effects, the two read flags — is in docs/runtime/lore-entries.md; the normative specs are 0.19.0.md, 0.22.0.md for everRead and lore tests, 0.23.0.md for beats in a lore document, 0.24.0.md §6 for entry targets as metadata and what a read proves, and the draft 0.26.0.md for kind targets, W-ENTRY-WRITE-REREAD, and <document id>.<entry id>.