State schemas
The run / user / app tiers are game/season-global: one persisted value cannot carry per-scene types. So they live in a single schema document, the source of truth every scene imports with uses:. Scenes then declare only their own scene.* locals. Since 0.2.2 a declaration file is plain .yaml (no ---/Lute envelope) — a pure declaration, not a scene.
Schema shape
Section titled “Schema shape”A schema declares state: (scalar tiers), defs: (named typed-CEL macros), enums: (declared
member lists), and — when the relational layer is used — entities: / relations: / facts: /
rules:. It may also declare the project’s cast: (dsl 0.23.0), since 0.24.0 one clock:, and
since 0.27.0 its seasons: and its terminal: condition.
state: run.choseHelp: { type: bool, default: false } run.day: { type: int, default: 1, owner: engine } user.level: { type: int, default: 1 }defs: helped: { type: bool, cel: "run.choseHelp" }Each <path> segment is a name (letters, digits, _ or -, not starting with -); a condition writes one that is not an identifier quoted, run.visits["lab-b2"]. A declaration is { type, default?, owner?, per? }. type: { domain: <name> } takes its members from a named enum or from an entity kind, the way to type an engine-kept position over a map kind — see Paths typed by a named enum. A default is materialized into the tier’s initial state at schema load and re-materialized whenever the engine fires that tier’s reset — so a defaulted path is always assigned, and the checker and engine read the one snapshot. owner: engine (0.22.0) marks a path content may read but never ::set (E-ENGINE-OWNED-WRITE); engine is the only value it takes — see owner: engine. per: <kind> (0.24.0) declares one path per member of a closed entity kind — see One path per entity.
An enums: block does double duty. Its domains are argument types for the
relational layer, and since language 0.9.0 they are also how a project
declares the content vocabulary — emotion, action, anchor, costume, mood, volume,
musicPlayback, vfxType, framing, cameraMove, transition, cgLayout, sequence, and
textStyle. The compiler ships no members for those slots, so a schema reached through uses: is the
route a multi-document project should prefer for declaring them; action and anchor additionally
carry required member semantics. That is a distinct concern from the scalar tiers this page is about
— see Content vocabulary.
Keys added in 0.24.0
Section titled “Keys added in 0.24.0”A schema for a party game with a day clock uses most of them at once:
state: run.day: { type: int, default: 1, owner: engine } run.slot: { type: { domain: slot }, default: dawn, owner: engine } run.approval: { type: int, default: 0, per: companion }enums: slot: members: [dawn, noon, dusk] labels: { dawn: Dawn, noon: Midday, dusk: Dusk } emotion: [calm, fierce, tired]entities: person: { members: [isolde, corvin, hollis] } companion: { subsetOf: person, members: [isolde, corvin] }relations: inParty: { args: [companion], tier: run }clock: day: run.day slot: run.slot slots: [dawn, noon, dusk]cast: isolde: { name: Isolde, present: "holds('inParty', ['isolde'])", emotions: [calm, fierce] } corvin: { name: "Corvin Hale", present: "holds('inParty', ['corvin'])" } hollis: { name: Hollis }clock:names theowner: enginepath that holds the day and, for a clock with slots, the path that holds the slot and the slots in order (a clock may also count whole days only). A schema declares at most one, and a malformed clock isE-CLOCK-DECL. It adds the read-onlyclock.*paths andonce: day/once: slotbeats, and since 0.27.0once: weekwhen it declares aweek:. See The clock.labels:on a long-form enum gives members display text:{{run.slot}}rendersDawn, while conditions still comparerun.slot == 'dawn'. A label for a non-member isE-ENUM-LABEL-NOT-MEMBER, and a non-string label isE-META-VALUE.subsetOf:on an entity kind declares a sub-kind whose members all belong to the parent. See Sub-kinds.per:on a state path declaresrun.approval.isoldeandrun.approval.corvin. Itsdefault:may be one value for every member or a map with per-member values and a_fallback,{ _: 0, isolde: 2 }. See One path per entity.present:,emotions:andassume:on a cast entry say when the character is with the player, whichemotion=values their lines may use, and whether presence may take the engine’sreserved:facts as absent. In a schema, apresent:that does not parse isE-CEL-PARSE, one outside the CEL profile isE-CEL-PROFILE, and anemotions:member outside the schema’semotionenum isE-BAD-ENUM, each reported at the entry’s key. See The cast.
Entity kinds have one spelling: use kind: { members: [a, b] } for a closed
kind or kind: { open: true } for an open kind. The YAML list shorthand
kind: [a, b] is not accepted; E-ENTITY-KIND-SHAPE shows the exact
{ members: [a, b] } rewrite using the members you wrote.
Seasons
Section titled “Seasons”A live game runs events that come and go: a harvest festival every autumn, a starfall week the studio switches on when it likes. Each window starts fresh and may look back at how the last one ended. A schema declares such windows as seasons (dsl 0.27.0 §5), each with the condition that says when it is live:
state: user.month: { type: int, default: 1, owner: engine } user.starfallOn: { type: bool, default: false, owner: engine } season.harvest.bushels: { type: int, default: 0 } season.starfall.wishes: { type: int, default: 0 }defs: harvestLive: { type: bool, cel: "user.month >= 9 && user.month <= 10" } starfallLive: { type: bool, cel: "user.starfallOn" }seasons: harvest: { live: "@harvestLive" } starfall: { live: "@starfallLive" }Each season is a named state tier:
season.<name>.<field>paths are declared understate:likerun.*anduser.*, with defaults, and content reads and writes them (::set{season.harvest.bushels += 1}).prev.season.<name>.<field>is a read-only mirror: the values the season’s previous window ended with. It is unset until the season has opened a second time, so guard a read withhas, as forprev.run.*. Writing it isE-QUEST-RESERVED-WRITE, as forprev.run.*.once: season:<name>on a scene beat (once="season:<name>"on an entry or bundle beat) spends the beat until the season opens again. See Beats.<quest tier="season:<name>">returns the quest tounsetwhen the season opens again. See Quests that come back.
A season opens when its live condition turns from false to true. At that moment the engine
copies the current season.<name>.* values into prev.season.<name>.*, resets season.<name>.*
to their declared defaults, clears the season’s once spends, and returns its season-tier quests to
unset. Nothing resets when it closes. Seasons are independent and overlap freely: harvest and
starfall may both be live at once. lute play prints each flip, the opening with what it reset
and the values the last window ended with:
season harvest opens — season.harvest.* reset to defaults; last window: prev.season.harvest.tokens = 2season harvest closes@narrator{when="has(prev.season.harvest.bushels)"}: Last harvest you brought in {{prev.season.harvest.bushels}} bushels.::set{season.harvest.bushels += 1}The compiled artifact and project.index.json carry the declarations as
seasons: [{ name, live: {cel, expr, authored?} }], with the live condition after @def expansion, so an
engine opens and resets each season from the same source the checker read.
Everything wrong with a season is E-SEASON-DECL: a seasons: entry that is not a map, has a
missing or empty live, an unknown key, or a bad name; two schemas that declare one season
differently; and a season.<name>.* path, a once: season:<name>, or a tier="season:<name>"
that names an undeclared season.
The end of the game: terminal:
Section titled “The end of the game: terminal:”Some games end: the ward takes the player, or the run’s fate is sealed. A schema may say when with
terminal:, a CEL condition. Once it holds the game is over, and the engine raises no
occasion, a title screen or a menu between runs included, unless the occasion declares
outsideRun: true:
state: run.fate: { type: { domain: fate }, default: open } run.sanity: { type: int, default: 10 }enums: fate: [open, escaped, taken]terminal: "run.fate == 'taken' || run.sanity <= 0"Only a schema document declares terminal:, never lute.project.yaml. defaults.uses already
makes one schema project-wide, so a manifest key would be a second place to look for the same
thing. Several schemas may each declare one; the project’s condition is their ||, and the game
is over when any of them holds. The frontmatter JSON schema (schemas/lute.schema.json) accepts
the key in both forms: the condition string, or the long form { when, persists } below.
The checker reads the condition like any other:
- Its text is checked as a condition slot. An error in it is prefixed
`terminal: …`:, and an error in an imported schema’sterminal:is reported at that schema’s line. - A value that is not a string or a
{ when, persists }mapping, or names no condition, isE-META-VALUE:terminal:must name when the game is over. - Every beat is judged under
!terminal, since no occasion is raised once it holds. A beat whosewhenis provably false while the game is not yet over, one that needsrun.fate == 'taken', isE-BEAT-UNREACHABLE. The same seam gates occasions one at a time, through an occasion’sraisedWhen.
lute play follows the engine. The step after which terminal: holds gets a
note that the game is over, and a playthrough that ends there reports end: terminal
("end": "terminal" at the root of --json). A later occasion: or advance: step is
E-OCCASION-GATE (exit 1). A newRun: true step starts a new run, and play goes on only when the
new run makes the condition false: a terminal: over run state is reset by it, one that reads what
a new run keeps (user.*, visited(…)) is not, and whole-project lute check warns W-TERMINAL-PERSISTENT
unless the long form says persists: true. With persists: true the notes and the refusal say the
game is over for good and never suggest newRun.
The condition is judged when an occasion is raised, so the raise that makes it hold plays out: the
beat whose write ends the game finishes, the rest of a select: sequence raise plays, and the
questComplete / questFailed handlers the raise settles run last. An epilogue goes after that
write, or in the <on event="questComplete"> handler of the quest terminal: reads. ::end in
content never ends the game; see The game is over for every
meaning of “end”.
The compiled artifact and project.index.json carry the condition at the top level as
terminal: { cel, expr, authored? }, after @def expansion and joined with || when several schemas
declare one, so an engine stops raising occasions from the same source the checker read. Beside
it, terminalPersists: true marks an ending that persists (omitted otherwise): an engine that
offers a new run after the game is over does not offer one then.
An ending that outlives runs: persists: true
Section titled “An ending that outlives runs: persists: true”A new run normally reopens the game: a terminal: over run state is reset by it. An ending meant
to outlive every run, such as a roguelike’s permanent ending, reads state a new run keeps, and says
so with the long form:
state: user.crowned: { type: bool, default: false }terminal: { when: "user.crowned", persists: true }when is the condition, exactly as the short form writes it; persists is true or false
(default false, the same as the short form). The long form takes no other key: a slip such as
persist: is E-META-VALUE and names the key it meant. persists: true states the design, so
W-TERMINAL-PERSISTENT stays silent, and lute play says the game is over for good instead of
offering a new run. On a condition that reads only state a new run forgets (run.*, the clock,
run-tier quests and relations) persists: true is E-META-VALUE: that ending cannot outlive the
run. With several schemas, the project’s ending persists only when every declaration says so.
Defs nothing uses
Section titled “Defs nothing uses”Whole-project lute check reports W-DEF-UNUSED (dsl 0.24.0 §7) for a declared @def that no content, no other def, and no rule guard references. It is reported once per project, at the declaration: the schema file’s line, or the document’s own defs: key. Play scripts and tests are not uses. The relational counterpart is W-RELATION-UNREAD (see Relations nothing reads).
A schema file’s def that only the host reads — a game’s Program.def, say — is not dead: mark it host: true and the warning stays silent, as the message says (0.41.1). A document’s own frontmatter def cannot take host: (E-DEF-DECL), so its warning only says to use the def or drop it.
./world.schema.yaml:5:3: warning [W-DEF-UNUSED] def `stale` is declared but no `@stale` reference uses it anywhere in the project (content, other defs, or rule guards); use it, drop it, or mark it `host: true` if the host reads it (for example, `Program.def`)Composition: uses and extends
Section titled “Composition: uses and extends”Imports form a DAG: cycles are a static error (the diagnostic prints the chain), schemas are loaded and checked before any scene, duplicate defs names across imports are an error (no silent shadow), and two paths to one file resolve to one identity.
uses: unions peer schemas — a name declared by two peers is a conflict. extends: names one or more base schemas this document refines: a base is a lower-precedence layer, so if the extending document (or its peers) redeclares a base name, the extending declaration overrides it — no duplicate error.
state: run.blessed: { type: bool, default: false }# child.schema.yaml — refines the baseextends: base.schema.yamlstate: run.blessed: { type: bool, default: true } # default-only overridePrecedence runs low → high: a document’s extends bases (recursively) < its uses peers < its own inline state:/defs:. Because persisted state must keep a stable type, an override that changes a path’s declared type is E-EXTENDS-STATE-TYPE; a default-only refinement of the same type is allowed silently. extends edges reuse the same cycle / missing-file / parse diagnostics (E-USES-{CYCLE,NOT-FOUND,PARSE}) as uses:.
A per: family follows the same rules as the explicit paths it expands to, in a schema document as well as inline (since 0.49.0). Within one document an explicit member beats its family: run.rep.corvin: { default: 4 } stays 4 beside run.rep: { default: 0, per: comp }. Across extends depth the closer declaration wins, so a child’s run.rep: { default: 3, per: comp } refines every member of the base’s family, explicit ones included, and a change of type is E-EXTENDS-STATE-TYPE. Across uses peers a redeclaration is an error: a peer family over members another peer declares is E-USES-DUP-STATE, one per member (state path `run.rep.corvin` is declared by two imports). Before 0.49.0 a schema-level per: over another schema’s kind dropped the refinement, the duplicate and the retype without a diagnostic.
Kinds assembled across files: add:
Section titled “Kinds assembled across files: add:”When several authors share one project, each area wants to add its own people and places to a kind the lead owns. Since dsl 0.26.0 §2.3 a schema may add members to a kind that exactly one of the document’s imports declares:
# schema/roster.schema.yaml (the lead's)entities: person: { members: [professorOak, mom] } trainer: { subsetOf: person, members: [rival] }# schema/areas/south.schema.yaml (one area's)entities: person: { add: [grannyWren, oldSalt] } trainer: { add: [youngsterTodd, lassMina] }A document importing both sees person with every member, and the added trainers are persons too
(a sub-kind’s members belong to its parent). Each of
these is E-ENTITY-KIND-SHAPE, naming both files:
- an
add:for a kind no import declares, with a did-you-mean (persnsuggestsperson); - an
add:onto anopen:kind, whose members the engine registers; add:besidemembers:oropen:in one declaration;- a member listed twice: by two
add:lists, re-added over the declaration, or twice in one kind’s or enum’s own list (dsl 0.26.0 §2.2), reported at the second one’s line with both lines.
schema/areas/south.schema.yaml:3:35: error [E-ENTITY-KIND-SHAPE] entity kind `trainer` lists `lassMina` twice — in the `add:` of `schema/areas/east.schema.yaml` (line 7) and in the `add:` of `schema/areas/south.schema.yaml` (line 3); list each member onceDeclarations across documents
Section titled “Declarations across documents”A path declared in two documents’ frontmatter state: is one runtime value, so since dsl 0.26.0
§2.1 whole-project lute check compares the declarations and reports E-STATE-DECL-CONFLICT when their
type, default, per or owner differ (see Declaration).
Schema errors that every importer would repeat — sub-kind and add: E-ENTITY-KIND-SHAPE,
E-USES-DUP-STATE, E-USES-DUP-DEF, E-USES-DUP-RELATION, a peer E-KIND-NAME-CLASH, and
E-DEF-DECL for an imported def — are reported by whole-project lute check once, as a project-wide line at
the schema line ending (imported by N documents) (dsl 0.26.0 §2.7), rather than at every importing
document; the importers stay ok.
The state schema is game content — separate from the engine capability manifest (engine vocabulary), which has its own owner and change cadence.