Skip to content

Components & extends

Lute has three reuse mechanisms, each for a different thing: defs reuse typed CEL values, uses/extends reuse schema, and components reuse content. This page covers content components and schema extends: composition.

A component is a named, parameterized block of lines and staging that is expanded inline wherever it is invoked. It lives in its own component file — a .lute document whose frontmatter declares component: <name> and, optionally, params: (typed exactly like a def param, plus, since 0.24.0, the speaker type). The body is a presentational template, unless the file declares effects: true.

---
component: greet
params:
who: string
uses: ../base.schema.yaml
---
## A Familiar Face
::actor{character=@who action="fadeInUp"}
@narrator: A familiar face steps into the light.

(From docs/examples/components/greet.component.lute.)

A parameter is referenced as @<param> in ref and attribute positions, and inside content text via {{@param}} interpolation. @who binds to the invocation argument at expansion time — it is legal in the character position because that attribute is string-typed.

An {{…}} interpolation in a component line renders an int, double, bool, or enum param (§7.6) and, since dsl 0.23.0, a string param whose argument is a literal. So who: string above could also appear as {{@who}} in a line. See Sentences in string params.

The uses: line is the component’s own content vocabulary import — since 0.9.0 action="fadeInUp" resolves against a declared action domain, and a component file has to reach one to check on its own. Through ::use it is the importing document’s vocabulary that applies (see the known limitation), so both sides declare it.

A scene imports components via a components: frontmatter key (canonicalized, cycle-checked, and diamond-deduped like uses: — see Imports), then invokes one with the reserved built-in directive ::use:

---
kind: scene
character: demo
season: 1
episode: 2
uses: ../base.schema.yaml
components: [greet.component.lute]
---
## Greeting by Component
::use{component="greet" who="marina"}
@narrator: And the scene carries on.

(From docs/examples/components/scene.lute.)

::use expands the named component’s body inline, binding each @param to the matching named arg; argument count and type are checked (E-COMPONENT-ARG), and naming a component from no imported file is E-COMPONENT-UNDECLARED. A literal argument is substituted into the expansion, so {{@weather}} in a component line compiles to Outside: grey. for weather="grey".

The names component, when, at, and instance are reserved by ::use itself. They MUST NOT be component parameter names: at is the timeline clip offset and instance is the use identity. A declaration reports one E-TEMPLATE at that params: entry, naming the complete reserved list and suggesting a rename; uses of the component do not repeat that parameter diagnostic. Beat-template header keys keep their existing reservation rule.

An argument may also be a def of the calling document — ::use{component="greet" tier=@mood}. The component still reads no state itself; the caller’s def is where the state read lives, and it is checked where it lands:

  • for an enum param, every value the def body can produce must be a member, else E-COMPONENT-ARG (@mood = "run.n > 2 ? 'warm' : 'hot'" against { enum: [cold, warm] } fails on hot);
  • in a <match subject="@tier">, it dispatches at run time like any subject;
  • in content text, {{@n}} stays a placeholder naming the caller’s def, and the engine evaluates it (an int, double, bool, or enum param; a string param that a line interpolates takes only a literal, below);
  • in a directive attribute, it must fold to a constant, and a state-dependent def is E-ATTR-DEF-DYNAMIC.

A component line may interpolate a string param (dsl 0.23.0). A literal ::use argument is substituted into the line when the component expands, like any other literal argument, so each call site ships its own finished sentence:

---
component: cheers
params:
to: string
---
## A Toast
@mira: To {{@to}}!
@oskar: To {{@to}}.

A scene with id: harbor.wake imports it and uses it twice:

::use{component="cheers" to="the harbor"}
@oskar: Again?
::use{component="cheers" to="absent friends"}

Each expansion’s lines carry their own text under their own component-scoped lineId:

lineIdText
harbor.wake.cheers#1.mira_0010To the harbor!
harbor.wake.cheers#1.oskar_0010To the harbor.
harbor.wake.cheers#2.mira_0010To absent friends!
harbor.wake.cheers#2.oskar_0010To absent friends.

A translator or a voice actor therefore gets To absent friends! as a line of its own, not a template with a hole in it. The argument has to be a literal. A string @def of the caller is only known when the engine evaluates it, and a {{…}} interpolation renders only int, double, bool, and enum values at run time, so ::use{component="cheers" to=@place} is E-REF-TYPE at the argument. The check follows the param through nested components, so a component that passes its own @to on to cheers needs a literal from its caller too.

A literal argument may itself hold {{…}} interpolations (dsl 0.26.0 §3.1). They keep their placeholder records after expansion, exactly as in a line written in the host: ::use{component="cheers" to="you, {{userName}}"} compiles to the text To you, {{userName}}! with "placeholders": [{"kind": "reserved", "token": "userName"}], so the engine renders the player’s name. Before 0.26.0 the placeholder record was dropped and the braces shipped as literal text.

To pass a literal {{ instead, escape it as in a line, written with the attribute string’s own backslash escape: to="you, \\{{userName}}" is the value you, \{{userName}}, which the line reads as a literal {{ (lute 0.47.1), so the text shows To you, {{userName}}! and has no placeholder. The escape is judged on the decoded value — an odd run of backslashes before {{ makes it literal — so to="\\\\{{userName}}" passes one backslash and keeps the placeholder.

A single backslash (p="\{{") is E-STRING-ESCAPE at the argument, since a ::use argument does not take the label grammar, and the message names \\{{. Since 0.52 an argument that ends in a plain {{ right before a marker in the component line (p="{{" spliced into S {{@p}}{{@q}} E with q="{{run.a}}") is E-INTERP-BRACE at the ::use (#221): the two would fuse into the escaped opener \{{, and the marker would be lost from the text:

./scenes/s.lute:9:1: error [E-INTERP-BRACE] a `::use` argument ending in a plain `{{` merges with the `{{run.a}}` spliced after it, and the marker is lost: pass a literal `{{` in an argument as `\\{{` (lute 0.47.1)

An argument ending in an odd run of { before a marker is E-INTERP-BRACE at the ::use too, with the line-text message (Literal {{).

A component body is presentational: lines, staging directives, and @param refs only. It may not read scene/run state, may not write it unless the file declares effects: true, and may not contain logic blocks (E-COMPONENT-BODY) — pass values in through params instead (a caller’s def is a legal argument, above). A def is no exception: @narrator{when="@late"} in a body is E-COMPONENT-STATE at @late, once, in the component’s own check, and the message names the param that carries it in, late: { type: bool, default: "@late" }. Declaring it changes nothing in the body, and each ::use defaults it to the host’s @late. One notable exception: a <match> that dispatches on the component’s own param is admitted, because dispatch on a param is a pure read of an invocation argument, not of ambient state:

---
component: reaction
params:
tier: { enum: [cold, warm, fond] }
uses: ../base.schema.yaml
---
## The Tiered Greeting
<match subject="@tier">
<when is="fond">
@marina{emotion="delighted"}: You remembered!
</when>
<when is="warm">
@marina{emotion="content"}: Not bad at all, Mr. Fixer.
</when>
<when is="cold">
@marina{emotion="neutral"}: ...Shall we begin?
</when>
</match>

(From docs/examples/components/reaction.component.lute.) The three arms cover the declared enum and a param is never unset, so no <otherwise> is needed.

The second exception (dsl 0.26.0 §3.3) is the result of a plugin directive in the body itself: a component that calls ::battle{… resultKey="fight"} may read scene.battle.fight.won. See A component’s own directive results.

Components that write state: effects: true

Section titled “Components that write state: effects: true”

Some reusable content has a consequence: a companion’s nod that also raises their approval, a search that asserts what was found. A component file that declares effects: true (dsl 0.24.0 §4) may ::set, ::assert and ::retract, use a plugin directive that writes state, and ::use another effects component:

---
component: nod
effects: true
params:
who: speaker
delta: int
state:
run.approval: { type: int, default: 0, per: companion }
entities:
companion: { members: [isolde, corvin] }
---
## A Nod
@narrator: {{@who}} nods.
<match subject="@who">
<when is="isolde">
::set{run.approval.isolde += @delta}
</when>
<when is="corvin">
::set{run.approval.corvin += @delta}
</when>
<otherwise>
@narrator: Nobody else minds.
</otherwise>
</match>

(The state: and entities: above let the file check on its own. In a project the component uses: the shared schema, and the declaration that counts is the host’s.)

The writes belong to the host. Each one is checked at every ::use against the host document’s schema — an undeclared path, a ::set of the wrong type, an owner: engine path, a relation’s vocabulary and domain, permissions — and the error is reported at the ::use. A host whose schema declares no run.approval gets:

./scenes/market.lute:12:1: error [E-UNDECLARED] `::set` target `run.approval.isolde` is not declared in the `state:` schema

The writes compile into the host’s commands where the ::use sits, and a <match> on a param keeps only the arm the argument selects. In a scene camp.fire whose cast names corvin “Corvin Hale”, ::use{component="nod" who="corvin" delta="2"} compiles to:

{"kind": "line", "family": "content", "position": "001-0100", "role": "narration", "speaker": "narrator", "text": "Corvin Hale nods.", "lineId": "camp.fire.nod#1.narrator_0010", "voiceKey": "camp.fire.nod#1.narrator-0010", "source": {"component": "nod"}}
{"kind": "set", "family": "state", "position": "001-0200", "path": "run.approval.corvin", "op": "+=", "value": {"cel": "2", "expr": {"int": 2}}, "source": {"component": "nod"}}

A fact atom in the body may take params as arguments, ::assert{gifted(@who, @item)} or the same in a ::retract. Each ::use binds the params to its arguments, and the host checks the bound atom there, like any atom it wrote itself. With item: string and an item kind of [locket, map], ::use{component="gift" who="isolde" item="locket"} compiles to an assert of gifted(isolde, locket), and item="lockt" is E-FACT-DOMAIN at the ::use. Whole-project lute check’s fact analyses see the bound atom too, so a later holds('gifted', ['isolde', 'locket']) is a guaranteed guard, and a query no ::use can produce is dead (E-ARM-DEAD). A fact’s arguments are ground, so the argument must be a constant: an entity or enum member id, true or false. A def argument, item=@best, is E-COMPONENT-ARG, and the message names the atom. Outside a component body a @param in a fact is E-FACT-DOMAIN.

Writing is the only thing effects: true unlocks. A guard or match subject in the body still may not read state (E-COMPONENT-STATE), apart from the results of its own plugin directives (below). A presentational component that ::uses an effects component is E-COMPONENT-BODY, and the message asks for effects: true on it as well. Without effects: true, a write is E-COMPONENT-BODY, as before.

A param typed speaker (dsl 0.24.0 §4) takes a cast id, as who does in nod above:

  • {{@who}} in a line renders the member’s display name from the cast, or the id when the member has none. So who="corvin" renders Corvin Hale nods.
  • The line attribute as=@who renders the display name too, and so does {{@who}} inside a quoted line attribute (as="{{@who}}, again" compiles to "as": "Corvin Hale, again") (dsl 0.26.0 §3.1). Before 0.26.0 as=@who shipped the id and the braces in an attribute string stayed literal.
  • Other attributes (::actor{character=@who}, a plugin directive’s foe=@who) and <match subject="@who"> see the id. The match ranges over the declared cast plus narrator, so arms named after cast members check against it: a typo arm is E-WHEN-LITERAL-DOMAIN, and a match with neither every member nor <otherwise> is E-NONEXHAUSTIVE (narrator counts as a member).
  • An argument outside the cast is E-CAST-UNKNOWN at the ::use, with a did-you-mean (who="corvn" suggests corvin). Without a declared cast any identifier is accepted.
  • The argument must be a literal. A def argument, who=@lead, is E-COMPONENT-ARG, because the name is chosen when the component expands, not at run time.
  • A component may pass its own speaker on, ::use{component="nod" who=@who}: the id the outer ::use gives is the inner one’s too, and the host judges it where it binds it (cast membership, and the member of a per: family the inner body writes).
  • A mono line in the body is judged at each ::use, in the caller’s point of view: the speaker it resolves to must be the host’s effective pov or listed in its monoSpeakers (see Frontmatter & profiles). Each ::use is checked on its own, so the same component may pass in one host and fail in another.

A component body may speak as the member a speaker param names (dsl 0.26.0 §3.2). @@who: is a content line whose speaker is the argument of who at each ::use:

---
component: greeter
params:
who: speaker
line: string
times: { type: int, default: 1 }
---
## Greeter
@@who: {{@line}}
@narrator{as=@who}: ({{@times}} times today.)

::use{component="greeter" who="mira" line="Morning."} in a scene harbor.gate compiles the first line with "speaker": "mira" and "lineId": "harbor.gate.greeter#1.mira_0010": the member’s own portrait, voice and line identity, as if the host had written @mira: Morning.. The member’s cast checks are judged at the ::use: an emotion= outside its emotions: is E-BAD-ENUM, a line its present: does not cover is W-CAST-ABSENT (once per member and guard), and a line after the member left the stage is W-STAGE-ABSENT.

A mono line follows the same rule. @@who{mono}: Not again. in a component aside passes at ::use{component="aside" who="fixer"} in a host whose effective POV is fixer, and ::use{component="aside" who="corvin"} in the same host is E-MONO-POV unless the host lists corvin in monoSpeakers:. The error is reported at that ::use, names the component file and line, and carries the component line as a related location.

@@x for a param that is not a speaker param, for a name that is no param, or in a document that is no component is E-COMPONENT-ARG:

./components/challenge.component.lute:11:1: error [E-COMPONENT-ARG] `@@taunt:` speaks as the cast member a `speaker` param names, and `taunt` is not a `speaker` param — declare `taunt: speaker`
./scenes/pier.lute:9:1: error [E-COMPONENT-ARG] `@@mira:` speaks as a component's `speaker` param `mira`, and this document is no component — write the cast id (`@mira:`)

A param may declare a default: (dsl 0.26.0 §3.3) in the long form name: { type: <type>, default: <value> }, as times does in greeter above. A ::use that omits the argument takes the default, and the default is judged at the ::use like the argument it stands for. It is a literal or a @def the host resolves; quote a def, since YAML does not start a plain value with @:

params:
kind: { type: { enum: [rival, champion] }, default: rival }
won: { type: bool, default: "@wonFight" }

A params: entry that is neither name: <type> nor the long form is E-COMPONENT-PARSE, and the message spells both shapes.

A plugin directive that declares result slots, such as a bridge ::battle that writes scene.battle.<resultKey>.*, may sit in a component body, and the body may read the slots it declares (dsl 0.26.0 §3.3). This is the trainer battle of a monster-collecting game, where every route trainer is one ::use:

---
component: trainerBattle
effects: true
params:
who: speaker
intro: string
win: string
lose: string
prize: { type: int, default: 100 }
---
## Trainer battle
@@who: {{@intro}}
::battle{foe=@who resultKey="fight"}
<match subject="scene.battle.fight.won">
<when is="true">
@@who: {{@win}}
::assert{defeated(@who)}
::set{run.money += @prize}
@narrator: {{userName}} got {{@prize}} coins for winning!
</when>
<otherwise>
@@who: {{@lose}}
</otherwise>
</match>

scene.battle.fight.won is not ambient state: it is the slot this body’s own ::battle declares, so a <match>, a guard or a {{scene.battle.fight.turns}} interpolation may read it. Any other scene.* or run.* read is still E-COMPONENT-STATE.

Each ::use also declares those slots in its host, bound to the use’s arguments (dsl 0.26.0 §3.1). The host’s check, its compiled state table (scene.battle.fight.won, scene.battle.fight.turns), trace mocks and lute play all see them, and the host declares nothing of its own. lute play types the bridge’s answer by that slot, or else by the capability’s result: shape, and refuses an answer it cannot type instead of storing it as a string. Before 0.26.0 a host had to declare the slot in its schema, and the component took the outcome as a param (won=@wonFight) instead of reading it.

::use takes when="<condition>" like ::set (dsl 0.26.0 §4). The whole expansion runs, or none of it: the lines, the ::battle and the writes alike. The use’s argument reads are judged under its guard. A dungeon floor skips a trainer who is already beaten in one line:

::use{component="trainerBattle" who="lassMina" intro="You look strong, {{userName}}." win="Oh no!" lose="Hehe." prize="200" when="!holds('defeated', ['lassMina'])"}

lute play prints a skipped use with its arguments, skip ::use{component="trainerBattle" who="lassMina" …} — when: false, and trace and test skip it the same way.

A plugin may type a directive attribute by a project entity kind, ::give{item} by { entity: bagItem } (see Manifests). The check reaches through components (dsl 0.26.0 §2.5). When a body passes a param whole to such an attribute (::give{item=@item}, through nested ::uses as well), or the param is itself typed { entity: bagItem } or { domain: … }, each ::use argument, or the param’s default:, is checked against the kind, with a did-you-mean:

./scenes/route2.lute:9:30: error [E-BAD-ENUM] `superPotoin` is not a member of entity kind `bagItem` (attribute `item` of `::give` in component `gift`) — did you mean `superPotion`?

lute refs --project <DIR> --attr give.item lists such a value at the ::use line that binds it, marked (via component `gift`).

Such a param also renders as text (dsl 0.27.0 §7): {{@hall}} over hall: { domain: room } shows the kind’s labels: entry for the argument (the chapel for chapel), a cast member’s name: winning for a cast id, and the id itself when the kind has no label for it. The argument must be a literal, as for a string param.

A component whose frontmatter also declares a beat: header (on, when, once, after, …, its values free to use @param) is a beat template (dsl 0.27.0 §6): a bundle beat names it with <beat use="<component>" id="…" …>, passes the params as attributes, and gets the header with each @param replaced. Its body needs no ## heading, as a bundle beat’s does not. See Beat templates for the header rules, ::body, and the E-TEMPLATE misuses.

Each ::use expansion is its own identity scope. A line expanded from a component is addressed {prefix}.{component}#{instance}.{speaker}_{code}, where instance is the authored instance="…" token, not a count of sibling uses. Missing keys are warning-bearing fallbacks until lute tag writes them. A voiced line’s default voiceKey takes the same scope (see Frontmatter & profiles for the identity: templates).

---
component: toast
---
## A Toast
@mira{code="0010"}: To the harbor.

Take a component with one tagged and one untagged line:

---
component: toast
---
## A Toast
@mira{code="0010"}: To the harbor.
@oskar: To the harbor.

A scene with id: harbor.dinner imports it (components: [toast.component.lute]) and uses it twice, between lines of its own:

@mira{code="0010"}: Sit, everyone.
::use{component="toast"}
@oskar: Again?
::use{component="toast"}
@mira: Last one.

Every line gets its own lineId:

LinelineId
@mira{code="0010"}: Sit, everyone.harbor.dinner.mira_0010
first toastharbor.dinner.toast#1.mira_0010, harbor.dinner.toast#1.oskar_0010
@oskar: Again?harbor.dinner.oskar_0010
second toastharbor.dinner.toast#2.mira_0010, harbor.dinner.toast#2.oskar_0010
@mira: Last one.harbor.dinner.mira_0020
  • Each expansion back-fills its own untagged codes. The component’s @oskar is 0010 at every use, however many host lines precede it, and the host’s own untagged lines keep the codes lute tag writes for them (@oskar: Again? → 0010, @mira: Last one. → 0020).
  • Codes no longer collide across the boundary. Two uses of a tagged component, or a component line sharing a code with a host line (both @mira lines above are 0010), compile clean with distinct ids. Before 0.22.0 both were E-DUP-LINE-CODE.
  • A nested ::use adds a segment. It counts within its enclosing expansion: a feast component that uses toast, itself used twice, gives …feast#1.toast#1.mira_0010 and …feast#2.toast#1.mira_0010.
  • lute loc export emits the same ids.

Migrating from 0.21: every line a component contributes has a new lineId and voiceKey, so re-export the localization and voice manifests of each document that ::uses a component.

Where uses: unions peer schemas (a name declared by two peers is an error), extends: names one or more base schemas that a document refines. A base is a lower-precedence layer.

base.schema.yaml
state:
run.blessed: { type: bool, default: false }
defs:
wealthy: { type: bool, cel: "run.blessed" }
child.schema.yaml
extends: base.schema.yaml
state:
run.blessed: { type: bool, default: true } # overrides the base default

(From docs/examples/child.schema.yaml and base.schema.yaml.)

Precedence, low → high: a document’s extends bases (recursively) < its uses peers < its own inline state:/defs:. When the extending layer redeclares a base name, it overrides it — no duplicate error. A defs entry is replaced wholesale. A state entry is overridden too, but because persisted state must keep a stable type, an override that changes the declared type is E-EXTENDS-STATE-TYPE; a default-only refinement (same type) is allowed silently. extends edges share the same DAG discipline as uses: — cycles, missing files, and parse errors reuse the E-USES-* diagnostics.