Skip to content

Dialogue & cast

Content is the spoken and narrated text of a scene. Every content line has the same shape:

@speaker{attributes}: the text they say

The speaker selects the line’s kind:

  • the reserved narrator → narration (speakerless);
  • any other speaker id → dialogue, carrying that speaker.

Frontmatter pov names the scene’s point-of-view character, falling back to the project’s defaults.pov. It does not change how that speaker’s ordinary lines compile: the protagonist’s lines are dialogue, and the artifact carries neither pov nor a player role. It does decide who may speak {mono}. An engine that renders its protagonist differently (no portrait, the player’s chosen name as the label) keys that on the speaker id; in line text, write {{userName}} for the player’s name.

There is no separate thought or prose node — role is derived from the speaker plus its delivery (below).

@narrator: Venny's again. The chain restaurant that has never offended anyone.
@marina{code="0010" emotion="delighted" variant="1"}: Mr. Fixer! You came back!
@fixer{code="0010"}: I did.

(From docs/examples/showcase/episode01.lute.)

Content lives in sections. A section starts at a ## heading and runs to the next one; the heading text is for the author and tools. A document has no body title — its title is frontmatter title:, and a # heading in the body is E-INERT-TITLE.

A heading may end in a stable id, {#id}: a letter followed by up to 63 letters, digits, - or _, unique among the document’s sections (E-SECTION-DUP otherwise). The id is not part of the heading text:

---
kind: scene
id: diner.night
title: Night Shift
---
## The Counter {#counter}
@narrator: The diner hums.
## Closing Time
@narrator: The lights go out one by one.

The artifact lists the sections in document order, numbered from 1:

"sections": [
{"section": 1, "heading": "The Counter", "id": "counter"},
{"section": 2, "heading": "Closing Time"}
]

A section’s number is also the first segment of every record position in it (001-0100 is the first record of section 1). Positions and numbers follow source order, so they change when sections move; tools that need a section to keep its identity across edits read its {#id}, and report a section without one as having no stable identity. A section id is identity only: it is not a jump target — ::jump goes to a ::label.

Attributes in {…} are content metadata: code (a stable per-line id), emotion, variant, action, dialogMotion, and as (a one-off speaker-label override). Their domains are project vocabulary, not grammar — run lute context <file> to list the legal emotion/variant values for your project. None is required; a missing code is back-filled deterministically at compile time and can be persisted with lute tag.

Every line compiles with a lineId and a voiceKey — narration and {mono} lines included:

{"kind": "line", "family": "content", "position": "001-0100", "role": "narration", "speaker": "narrator", "text": "The diner hums.", "lineId": "diner.night.narrator_0010", "voiceKey": "diner.night.narrator-0010"}

A voice key is a join key for recordings, not a claim that a recording exists; a project that does not voice its narration simply has no asset under those keys. Two lines in a project that share a voiceKey must say the same text (compared after inline modifiers are stripped), or whole-project lute check reports E-DUP-VOICEKEY.

action= is the one line attribute with a stage consequence, and it is a small one: it sets the speaker’s pose for that line and marks them dirty, so the next plain line from the same speaker gets a posReset injected ahead of it (provenance.by: "auto-pose-reset"). This is a delivery detail, not staging. A character’s entrance and exit are any staging record lowered to actor (including ::actor); an exit: true field ends that character’s presence. ::clear (0.24.0) ends everyone’s (see Core directives; lute context lists mayExitCharacter among actor’s semantics for the core directive). Writing a member of the action domain’s exits: list on a content line is W-EXIT-INERT: the pose is honoured, the character stays on stage, and the artifact gets no exit record. Once a character has left — by a declared actor exit, a bg staging record (including a plugin directive lowered to bg), or a ::clear — a line from them before an actor record stages them again is W-STAGE-ABSENT, judged along every path through choices and <match> arms (see Stage state).

@marina{as="???"}: ...who's there?

as overrides only the shown label for that one line. When absent, a line renders its speaker id as the label, and the engine maps that id to a display name: the name the project’s cast gives it. as= is the only way to set a label from the source. A richer display-name capability — the character/cast proposal, with costumes and name-reveal — is a draft, and no such plugin ships yet.

A project can declare its cast (dsl 0.23.0): the speaker ids its lines may use, each with an optional display name. A schema document declares it under cast:, and the documents that import the schema are checked against it:

cast.schema.yaml
cast:
mira: { name: Mira }
oskar: { name: "Oskar Lind" }
vesna: {}

A plugin can ship one as well, with a cast export of cast/*.yaml files in the same shape (see Manifests), so an engine pack declares the characters its portraits and voices exist for.

Once a cast is declared, every speaker must be in it. Scene lines, quest bodies, lore entries, and bundle beats are all checked. A speaker outside the cast is E-CAST-UNKNOWN, with a did-you-mean for a near miss such as @oskr. narrator is always a speaker. The pov speaker is not special: a scene whose pov is fixer still needs fixer in the cast.

./scenes/arrival.lute:12:2: error [E-CAST-UNKNOWN] speaker `fixer` is not in the declared cast

Without a declared cast, speakers are checked for shape only, as before 0.23.0, so a project can write dialogue before it settles its characters. The cast belongs to the project, not to one scene: a cast: key in a scene’s frontmatter is E-META-UNKNOWN-KEY.

lute context <file> lists the cast with its display names (pass --project <DIR> to include a plugin’s cast), and the language server offers the cast when it completes a speaker after @.

Two speakers shown under the same name look like one person in the dialogue box. When several authors add characters to one project, that happens by accident: two areas each write a Hiker Gus. Whole-project lute check reports it as the advisory W-DISPLAY-NAME-DUP (dsl 0.26.0 §2.8), and lute lint reports it too. It compares two cast entries with exactly the same name:, a cast name equal to a component’s ::use{… name="…"} display string, and two such strings for different speakers (who=):

./scenes/a.lute:11:1: warning [W-DISPLAY-NAME-DUP] display name `Hiker Gus` is shown for 2 different speakers: `gus` (./scenes/a.lute:10), `gus2` (./scenes/a.lute:11) — the dialogue box cannot tell them apart; rename one

Some names are shared on purpose: a role several speakers play, such as a villain team’s rank and file. Mark such an entry sharedName: true, and it is not counted:

cast:
gus: { name: Hiker Gus }
gus2: { name: Hiker Gus Jr. }
grunt1: { name: Eclipse Grunt, sharedName: true }
grunt2: { name: Eclipse Grunt, sharedName: true }

A plugin’s cast/*.yaml entry takes the same key. A merge gate that runs lute check --project . --deny-warnings passes again once the intended role names are marked, and lute lint --deny W-DISPLAY-NAME-DUP promotes the code on its own. See Multi-author projects.

With a cast declared, the character a staging directive names must be in it, like a speaker (dsl 0.24.0 §4). ::actor{character} and ::camera{focus} outside the cast are E-CAST-UNKNOWN, with the same did-you-mean. Timeline clips and the bodies of <match> arms and choices are checked as well:

./scenes/ridge.lute:11:20: error [E-CAST-UNKNOWN] `::actor{character}` `corvn` is not in the declared cast — did you mean `corvin`?
./scenes/ridge.lute:12:17: error [E-CAST-UNKNOWN] `::camera{focus}` `isold` is not in the declared cast — did you mean `isolde`?

A cast entry may list the emotions a character shows, emotions: [calm, fierce] (dsl 0.24.0 §4). Each member must also be a member of the project’s emotion enum. On a line by that speaker, or on a directive whose literal character= names them, an emotion= outside the list is E-BAD-ENUM, even when the emotion enum has the value:

./scenes/ridge.lute:14:18: error [E-BAD-ENUM] `sad` is not one of `isolde`'s emotions (expected one of: calm, fierce) — the cast declares `emotions:` for `isolde`

A value the emotion enum itself rejects keeps its one existing error. A speaker without emotions: may use any member of the enum.

In a party game, a companion is not always there to speak. A cast entry may say when they are, as a condition, present: "<condition>" (dsl 0.24.0 §4). The condition is ordinary state or facts, so party membership needs no new mechanism:

world.schema.yaml
entities:
person: { members: [isolde, corvin] }
relations:
inParty: { args: [person], tier: run }
cast:
isolde: { name: Isolde, present: "holds('inParty', ['isolde'])", emotions: [calm, fierce] }
corvin: { name: "Corvin Hale" }

A plugin’s cast/*.yaml entries take the same two keys (see Manifests).

The checker then asks, for every line by isolde, whether the guards around the line imply her present: condition. When they do not, the line is W-CAST-ABSENT:

@corvin: Where is Isolde?
@isolde: Right here.
@isolde{when="holds('inParty', ['isolde'])"}: Right behind you.
<branch id="ask">
<choice id="call" text="Call her over" when="holds('inParty', ['isolde'])">
@isolde{emotion="calm"}: I came.
::retract{ inParty(isolde) }
@isolde: And now I'm leaving.
</choice>
<choice id="wait" text="Wait">
@corvin: We wait, then.
</choice>
</branch>
::assert{ inParty(isolde) }
@isolde: Back again.

@isolde: Right here. has no guard, so it warns. The next line and I came. are guarded by the condition itself. And now I'm leaving. warns again, because the ::retract between the choice guard and the line falsifies what the guard said. The message names the condition and the guard that would satisfy it:

./scenes/camp.lute:12:2: warning [W-CAST-ABSENT] `isolde` may not be here: the cast declares `present: "holds('inParty', ['isolde'])"` for `isolde`, and the guards around this line do not imply it. Guard the line — `@isolde{when="holds('inParty', ['isolde'])"}` — or move it under a guard that implies it

The guards that count are the line’s own when=, every enclosing <choice when> (in a branch or a hub), <match> arms (the arm’s is= or test, and the fact that no earlier arm matched), <on when>, <objective done>, and the scene beat’s when:, an entry’s when= or a bundle beat’s when=. @defs in them are expanded. Some guards are implied by where the line sits:

  • a lore entry’s body assumes its own entry.<id>.read / everRead;
  • a quest <on> handler assumes the quest’s state at that event, and the conjuncts of its start that stay true once true (entry.<id>.everRead, visited(…));
  • a quest’s questComplete handler also assumes the quest’s completion (dsl 0.27.0): the quest completed when one of its required objectives did, so one required objective’s done holds. A handler of a quest whose required objectives all need @withToby may give Tobias a line without repeating the guard. An ::assert, ::retract or ::set in an objective body or an earlier questComplete handler of the quest can cancel this assumption, like any other guard;
  • under whole-project lute check, a beat assumes that every always-eligible once beat ranked above it for the same occasion has already been spent.

A holds(A) guard over a derived relation also implies the bodies of A’s rules: with inParty(P) :- recruited(P), not departed(P), a line guarded by holds('inParty', ['isolde']) satisfies present: "!holds('departed', ['isolde'])". A relation whose rules are ground once bound, such as a cel()-only schedule, reads as the disjunction of those rule bodies.

A write cancels a guard only when it can falsify it on that path. An ::assert or ::retract voids a guard atom of the same relation with unifiable arguments, or one derived through a rule that has the written relation as a premise, and only when it moves the atom the wrong way. Asserting inParty(corvin) keeps a holds('inParty', ['isolde']) guard, and so does asserting a positive premise of it. A ::set voids a guard that reads the path, including through such a rule. Each && conjunct of a guard is judged on its own, and a write in one <choice> or <match> arm does not reach its siblings.

A {vo} line is exempt, since a voice-over is not the speaker being there: they may speak from outside the scene’s time, as a memory or a narration over it. An {os} line is still checked (dsl 0.25.0 D-E): {os} means the speaker is in the scene, heard but out of frame, so they must be present. There is no flag yet for a remote speaker, such as a voice over the radio or the phone.

Facts are the one place where lute check <FILE> and whole-project lute check differ. Whole-project lute check also counts the facts that hold on every route to the line: asserted earlier on every path, or seeded and never retracted. So the last line above, @isolde: Back again. after ::assert{ inParty(isolde) }, is clean there. A single-file lute check <FILE> cannot see those facts, so it warns on that line too and adds a note saying that whole-project lute check does see them. Like any warning, --deny W-CAST-ABSENT makes it an error.

Presence often has a second half that only the engine writes, such as “and she has not fallen”: present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])", where fell is a reserved: true relation. The checker does not know when the engine writes fell, so without help only a guard at each line or beat satisfies that half, and every line by isolde warns. A cast entry that adds assume: true (dsl 0.24.0 §4) reads every negated holds of a reserved: relation in present: as true, whether it is negated directly or through a rule such as inParty(P) :- recruited(P), not fell(P). With it, the lines above warn exactly as they did with the plain holds('inParty', ['isolde']) condition. The price is that a line spoken after the engine event needs its own guard, since the checker no longer asks for one, unless the relation names the events it changes on (changedOn, dsl 0.25.0):

cast:
isolde: { name: Isolde, present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])", assume: true }

present:, emotions: and assume: are checker inputs. None of them reaches the compiled artifact.

assume: true is right until the engine event that the reserved relation stands for: after a battle, fell(isolde) may well hold, and a line there is exactly where the check matters. The reserved relation can say on which occasions the engine changes it (dsl 0.25.0 §6):

entities:
companion: { members: [isolde] }
relations:
inParty: { args: [companion], tier: run }
fell: { args: [companion], tier: run, reserved: true, changedOn: [battleEnd] }
cast:
isolde: { name: Isolde, present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])", assume: true }

With changedOn, assume: true no longer reads !holds('fell', ['…']) as true in a unit presented on one of those occasions: a scene, entry or bundle beat answering on: battleEnd, or a quest <on event> handler or on= objective body judged there. Under whole-project lute check the same holds for every after-descendant of such a unit in the scenario graph, over its after: / after= / [start] edges. Occasions have no static order of their own, so the graph is what says “after the battle”. Take a camp scene that asserts inParty(isolde), an aftermath scene on battleEnd with after: 'visited("camp")', and a road scene with after: 'visited("aftermath")':

@isolde{when="holds('inParty', ['isolde'])"}: We should keep moving.
@isolde{os}: Wait for me!
@isolde{vo}: I remember that road.

On the road, the first two lines warn again, each with a note saying why assume: true did not cover them. The {vo} line stays exempt:

./scenes/road.lute:11:2: warning [W-CAST-ABSENT] `isolde` may not be here: the cast declares `present: "holds('inParty', ['isolde']) && !holds('fell', ['isolde'])"` for `isolde`, and the guards around this line do not imply it. Guard the line — `@isolde{when="holds('inParty', ['isolde']) && !holds('fell', ['isolde'])"}` — or move it under a guard that implies it; `assume: true` does not cover `fell`: this line follows an occasion its `changedOn:` names

Guard those lines with the whole condition, @isolde{when="holds('inParty', ['isolde']) && !holds('fell', ['isolde'])"}, and the warnings go. Lines in the camp scene, which comes before the battle in the graph, stay covered by assume: true. Without changedOn, the 0.24 behaviour is unchanged: assume: true covers every line. changedOn on a relation that is not reserved: true is E-RELATION-DECL, and so is an occasion no plugin declares, with a did-you-mean (`changedOn: batleEnd` is not a declared occasion — did you mean `battleEnd`?). In a project whose plugins declare no occasions, the names are not checked.

Presence is about who is with the player. Who is on stage is the stage state (see Stage state). An ::actor brings a character on, and a declared exit or a ::bg takes them off. Since 0.24.0 the leaf ::clear takes everyone off at once, leaving the background and music in place (see Core directives). A line from a cleared character before an ::actor shows them again is W-STAGE-ABSENT, and the warning names the ::clear.

A delivery flag is a bare word in the braces (no =value) that changes how a line is delivered:

  • {mono} — interior monologue / thought (not spoken aloud in-scene).
  • {os} — off-screen: the speaker is heard but not currently staged or visible.
  • {vo} — voice-over: narration-style delivery layered over the scene.
@fixer{mono}: An android, then. Which would, on reflection, explain the ramen.

The three are mutually exclusive — at most one per line (E-DELIVERY-CONFLICT on two) — and none is allowed on @narrator (E-DELIVERY-NARRATOR).

Roles derive from speaker + delivery, and the artifact’s role uses the same words: narrator → narration; {mono} → mono; {os} → os; {vo} → vo; any character otherwise → dialogue.

A {mono} line is the point-of-view character’s inner voice. The effective POV is the document’s pov:, else the project’s defaults.pov. A {mono} line is legal when its speaker is the effective POV or is listed in monoSpeakers: — a list of speaker ids in the document’s frontmatter, or in the project’s defaults: for every document (the document’s own list wins). Use it for a scene that shares the inner voice, such as a second protagonist:

---
kind: scene
id: diner.night
pov: fixer
monoSpeakers: [mira]
---
## The Counter
@fixer{mono}: Another night, another coffee.
@mira{mono}: He looks tired.
@mira{os}: Order up!
@fixer{vo}: I remember that night.

Without monoSpeakers: [mira], @mira{mono} is E-MONO-POV; when no POV resolves at all — no pov: and no defaults.pov — a {mono} line by a speaker outside monoSpeakers is E-MONO-NO-POV:

diner.lute:10:7: error [E-MONO-POV] `@mira{mono}` is an interior monologue, but `mira` is not this document's point of view (`fixer`) and not in `monoSpeakers:` (which lists none) — only the POV character and the speakers `monoSpeakers:` lists may speak `mono`; add `mira` to `monoSpeakers:`, or write the line as dialogue, `{os}` or `{vo}`

A component’s {mono} lines are checked at each ::use, against the calling document’s POV and monoSpeakers; the error is reported at the ::use and names the component’s line.

Content Text (and the label-grammar values below) may embed {{…}} interpolations that read game state at render time:

@narrator: Good to see you, {{userName}}.
@marina{code="0010" emotion="delighted" variant="1"}: You came back! Warmth so far: {{run.affection}}.

(From docs/examples/showcase/hub-demo.lute.)

{{userName}} is the always-available reserved token. Any other bare name is a host token a plugin declares; otherwise an interpolation must name a declared state path; an interpolation is a read for definite-assignment analysis, so a maybe-unset path interpolated without a guard is E-MAYBE-UNSET. Apart from interpolations and inline modifiers, the text after the second colon is opaque to end of line — parentheses, <, //, #, single braces and anything else are literal, never parsed. The checker warns on the shapes that are markup in Ink or Yarn, since the player would see them: a single-brace {run.oil} or {run.oil > 0: …} (W-TEXT-SINGLE-BRACE; interpolation is {{run.oil}}, conditional text is a guarded line), and a // note or trailing # tag (W-TEXT-COMMENT-LIKE; a comment is // on a line of its own). See Coming from Ink or Yarn.

A path typed against an enum whose members carry display labels: renders the label, not the member id: Today is {{run.weekday}}. reads Today is Sunday. (see Paths typed by a named enum).

Three kinds of quoted attribute value take the label grammar: a <choice>’s text, a <branch prompt> or <hub prompt>, and a plugin directive attribute declared type: text. Each holds {{…}} interpolations with their format hints and no inline modifiers. Since 0.52 a prompt is checked as an option label is: an undeclared path is E-CEL-PROFILE, a maybe-unset one E-MAYBE-UNSET, and the runtime renders the prompt’s markers onto the menu with the labels’ (see Choices & hubs). \{{ writes a literal {{ in all three (0.52, #211), as in line text: text="Say \{{open}}" shows Say {{open}} and has no placeholder. In a quoted value that does not take the label grammar — a ::bg location, a when, a ::use argument — \{{ is E-STRING-ESCAPE:

br.lute:17:16: error [E-STRING-ESCAPE] `\{{` (a literal `{{`) is defined only in line text and in the values that take the label grammar — `<choice text>`, `prompt` and a plugin `text` attribute; `location` of `::bg` is not one

A ::use argument passes a literal {{ as \\{{ (Components). An <objective title> is plain text, not label grammar: a quest UI shows it as written, so a {{ in it is E-OBJECTIVE-TITLE-INTERP (Quests & scenes).

A line may name a value only the engine knows, such as the player’s name, with a bare identifier: {{playerName}}. Since 0.50 a plugin declares such names in its hostTokens export, and lute.core declares userName: { type: string }. With hostTokens: { playerName: { type: string } }:

@narrator: Welcome, {{playerName}}.

compiles to a reserved placeholder, { "kind": "reserved", "token": "playerName", "format"? }. A token is a bare identifier (an ASCII letter or _, then letters, digits or _). {{player-name}} and {{2nd}} are not tokens: they stay state-path-shaped and keep their E-CEL-PROFILE. A state path always has a tier root and a ., so no spelling that was a state path changes meaning; the names that are state roots or CEL literals cannot be declared as tokens (see Reserved names).

  • Declared. The token’s type decides which format hints it takes. A string or enum token takes the text hints ({{platform:capitalize}} is clean), and a number hint on it is E-REF-TYPE. An int token takes the number hints ({{rank:ordinal}} is clean), and a text hint on it ({{rank:capitalize}}) is E-REF-TYPE. {{userName}} behaves exactly as before.
  • Undeclared. A bare name no plugin declares is E-CEL-PROFILE. The message names “a declared host token” and lists the declared tokens: interpolation `playerName` is not a valid `{{…}}` form — only a state path, …, a def `@ref` / `@ref(args)`, or a declared host token (`userName`) are permitted; declare `playerName` in a plugin's `hostTokens:` export if the engine substitutes it …. The rest of the message is unchanged.
  • Escape. \{{name}} is unchanged: it is never a placeholder.
  • Never a state read. A host token takes no part in definite-assignment, dependency or quest-reference analysis, and counts as one word for lint.
  • At runtime. The runtime leaves every host-token marker verbatim in the rendered text, and the host or presenter substitutes it. This holds for every token, not only userName (see the runtime contract). The editor’s hover reads host token `playerName` — `string`, declared by `acme.stage`; the engine substitutes it at render time.

A numeric value can be rendered as an English ordinal with a format hint after a colon (dsl 0.24.0 §4):

---
kind: scene
id: tower.gate
state:
user.climbs: { type: int, default: 1 }
---
## The Gate
@narrator: Welcome back, {{userName}}. This is your {{user.climbs:ordinal}} climb.

lute run, lute play, lute trace and lute test render 1st 2nd 3rd 4th … 11th 12th 13th … 21st 22nd … 101st 111th. A value that has no ordinal, a fraction or a negative number, renders unchanged (2.5, -3). In the artifact the line’s placeholder carries "format": "ordinal", and the engine does the rendering. A placeholder without a hint has no format key, so artifacts that use none are unchanged.

:ordinalWord (dsl 0.25.0 §8) renders the ordinal as a word, for text that reads better spelled out:

---
kind: scene
id: road.camp
state:
run.day: { type: int, default: 1 }
---
## Camp
@narrator: The {{run.day:ordinalWord}} night on the road.

The IR placeholder carries "format": "ordinalWord", and the engine localizes the word. lute run, lute play, lute trace and lute test render the English words first second … twentieth for 1–20, and fall back to the :ordinal digits outside that range (0th, 21st). The checker admits :ordinalWord wherever it admits :ordinal.

Number words and plurals: {{x:cardinalWord}}, {{x:plural(one|other)}}

Section titled “Number words and plurals: {{x:cardinalWord}}, {{x:plural(one|other)}}”

:cardinalWord spells a number one two … twenty and falls back to its digits outside that range (0, 21); the checker admits it wherever it admits :ordinal. :plural(singular|plural) picks the first form when the number is 1 and the second otherwise. In a form, # is the number, #word the number as a cardinal word and #Word that word capitalized, for a sentence that starts with it:

---
kind: scene
id: road.wagons
state:
run.wagons: { type: int, default: 11 }
---
## Camp
@narrator: {{run.wagons:plural(One wagon waits|#Word wagons wait)}} at the ford, {{run.wagons:cardinalWord}} in all.

That line reads Eleven wagons wait at the ford, eleven in all. Plural forms are bare text separated by |: a quoted form (plural("One wagon"|"# wagons")) would print its quotes and a , separator (plural(wagon,wagons)) is one form, so both are E-PLURAL-FORM, and the message shows the rewrite.

Text hints and label forms: {{x:capitalize}}, {{x:start}}, {{x:indefinite}}

Section titled “Text hints and label forms: {{x:capitalize}}, {{x:start}}, {{x:indefinite}}”

Three hints format text — a string, an enum or kind label, a cast name, {{occasion.target}}, a string or enum host token such as {{userName}}. :capitalize upper-cases the first letter. :start and :indefinite read the forms a kind’s labels: entry may declare beside its text:

entities:
spot:
members: [cut, den]
labels:
cut: { text: "the smugglers' cut", start: "The smugglers' cut", indefinite: "a smugglers' cut" }
den: ashwraith den

{{occasion.target:start}} is quiet. reads The smugglers' cut is quiet. for cut. When a form is absent the hint falls back: :start capitalizes the text (Ashwraith den), and :indefinite puts an before a text whose first letter is a vowel and a before any other (an ashwraith den). Declare indefinite: for the words that rule gets wrong (an hour, a unicorn). A label entry is a string or { text, start, indefinite } with text: required; any other key is E-ENTITY-KIND-SHAPE. The IR carries the hint as the placeholder’s format and the forms as labelForms on the kind’s entities[] entry and on a state path typed by the kind, so an engine can localize them. A component param spliced as a literal takes the fallbacks.

The hints are ordinal, ordinalWord, cardinalWord and plural for an int or double (or an int host token), and capitalize, start and indefinite for text. Any other — {{run.floor:roman}}, a misspelt {{run.day:ordinalword}} — is E-CEL-PROFILE. A numeric hint on a string, enum or bool path or def, or on a string or enum host token such as {{userName}}, is E-REF-TYPE, and so is a text hint on an int, double, or bool, or on an int host token.

A plugin directive can carry text that a translator must see, such as a banner title. Its manifest declares the attribute with type: text. A text attribute is admitted only on a directive that compiles to a plugin record (no lower:, no bridge:), and it takes no default:. Each text attribute is one translatable unit, the way a choice label is:

::banner{code="0010" title="Welcome to the hall, {{userName}}" style="card"}
  • Value grammar. A text value is a quoted string in the same grammar as a <choice> label: {{…}} interpolations of state paths, @defs and host tokens with their modifiers. \{{ writes a literal {{ (since 0.52, #211; see Labels and prompts). Inline text modifiers are not parsed in it. Each interpolation is checked as a label’s is. An @ref value (title=@greeting) is E-ATTR-TYPE: a @def value has no translation of its own.
  • code=. A directive with at least one text attribute takes code="…" as a content line does: a string, trimmed. On any other directive code stays E-UNKNOWN-ATTR. A directive that declares its own attribute named code beside a text attribute is refused when the plugin loads.
  • lineId. The id of a text attribute is {prefix}~{tag}~{code}~{attr}, for example intro~banner~0010~title. It does not go through the lineId template, and position is never identity. ~ is the separator, so a code containing ~ is E-LINE-CODE-RESERVED and an identity template whose literal text contains ~ is E-IDENTITY-TEMPLATE. As a backstop, whole-project lute check reports two units with the same final lineId — a line, option, prompt or directive text — as E-LINE-ID-DUP, since a lineId keys one translation. Two ::banner calls in one scope sharing a code are E-DUP-LINE-CODE.
  • Back-fill. An untagged directive gets the next code in its scope, counted per directive tag, as a line does; in a component the codes are counted over the whole component source. lute tag writes code="…" into every untagged directive that has a text attribute. A component is tagged under the snapshots of the documents that use it, and when those users resolve its text directives differently it is E-COMPONENT-TEXT-DIRECTIVE. With identity.requireStable, such a directive without code is W-LINE-CODE-UNTAGGED.
  • Artifact. The record’s fields.<attr> keeps the source-language string with its {{…}} markers as authored, so a host that ignores localization reads what it read before. The lineId and the placeholders ride in localized.<attr>, and code itself is written to neither fields nor stamps. A directive without text attributes compiles exactly as in 0.50.

The strings are exported, translated and merged with lute loc export, lute loc import and lute compile --locale-bundle: run lute tag first, because a directive’s lineId can be reproduced from the source only when its code is authored.

A line’s text may mark up part of itself for presentation — a styled word, a pause, a slowed phrase. A modifier is a colon, a lowerCamel name, then a bracketed span, a braced attribute list, or both:

  • span — :name[text] wraps text;
  • leaf — :name{attrs} marks a point in the text;
  • span with attributes — :name[text]{attrs}.

Modifiers are legal only in the text after a content line’s second colon — never in headings, attributes, tag text or a <choice>’s text. The core has two:

ModifierFormMeaning
:pause{s=0.5}leafa pause of s seconds (required, non-negative)
:speed[text]{rate=1.25}spanthe wrapped text is delivered at rate (required, positive)

Every other name is a text style: a member of the project’s textStyle domain, declared in enums: like any other staging vocabulary. A style is a span with no attributes; what emphasis or whisper looks like is the engine’s business.

---
kind: scene
id: diner.night
pov: fixer
enums:
textStyle: [emphasis, whisper]
state:
run.tips: { type: int, default: 3 }
---
## The Counter
@mira: You look :emphasis[tired].:pause{s=0.5} Coffee?
@fixer: :speed[Just... one more cup.]{rate=0.75}
@mira: :whisper[I counted :emphasis[{{run.tips}}] tips tonight.]
@fixer: Write \:emphasis[this] to show the markup itself.
@fixer: The sign says 10:30 :) and nothing else.

In the artifact, text is always the plain text: modifier markup removed, escapes decoded (except a literal {{, see below), {{…}} kept. A line that uses a modifier also gets segments — text runs carrying the active styles (outermost first) and rate, and {pause} leaves; a line without one has no segments:

{"kind": "line", "family": "content", "position": "001-0100", "role": "dialogue", "speaker": "mira", "text": "You look tired. Coffee?", "lineId": "diner.night.mira_0010", "voiceKey": "diner.night.mira-0010", "segments": [{"text": "You look "}, {"text": "tired", "styles": ["emphasis"]}, {"text": "."}, {"pause": 0.5}, {"text": " Coffee?"}]}
{"kind": "line", "family": "content", "position": "001-0200", "role": "dialogue", "speaker": "fixer", "text": "Just... one more cup.", "lineId": "diner.night.fixer_0010", "voiceKey": "diner.night.fixer-0010", "segments": [{"text": "Just... one more cup.", "rate": 0.75}]}
{"kind": "line", "family": "content", "position": "001-0300", "role": "dialogue", "speaker": "mira", "text": "I counted {{run.tips}} tips tonight.", "lineId": "diner.night.mira_0020", "voiceKey": "diner.night.mira-0020", "placeholders": [{"kind": "path", "path": "run.tips"}], "segments": [{"text": "I counted ", "styles": ["whisper"]}, {"text": "{{run.tips}}", "styles": ["whisper", "emphasis"]}, {"text": " tips tonight.", "styles": ["whisper"]}]}
{"kind": "line", "family": "content", "position": "001-0400", "role": "dialogue", "speaker": "fixer", "text": "Write :emphasis[this] to show the markup itself.", "lineId": "diner.night.fixer_0020", "voiceKey": "diner.night.fixer-0020"}

The rules, line by line above:

  • Nesting. Spans nest; an interpolation may sit inside a span and stays in text and placeholders. Nested styles stack (["whisper", "emphasis"]); a nested :speed uses the innermost rate. Adjacent runs with the same styles and rate are merged.
  • Escapes. \: is a literal colon that never starts a modifier. Inside a modifier, \[, \], \{, \} and \\ write the bracket, brace or backslash; any other escape there is E-TEXT-ESCAPE.
  • Literal {{. \{{ writes a literal {{: an odd run of backslashes directly before {{ escapes it, an even run leaves a marker (\\{{run.a}} is a backslash, then the marker). In the artifact (lute 0.47.1) a literal {{ stays written \{{ in text, segment runs and translations, and a literal backslash directly before a {{ is doubled, so Lit \{{run.a}} and {{ run.a }}. keeps that text, has one placeholder, and renders Lit {{run.a}} and 3. when run.a is 3. A translation may write \{{ too.
  • A marker that opens on a literal brace (0.52, #139; 0.53, #229). The artifact has no spelling for a literal { directly before a {{…}} marker: compiled text would open the marker one brace early. So in line text a marker whose preceding literal text ends in an odd run of { is E-INTERP-BRACE, whichever escapes or modifiers spell the run ({\{{{{run.a}}, x{:pause{s=0.5}{{run.a}}). An even run is a run of literal {{ pairs and stays legal: \{{{{run.a}} renders {{3. Since 0.53 a marker whose expression itself begins with { ({{{run.a}}, {{{{run.a}}) reads as the marker after n literal braces and is E-INTERP-BRACE too, in line text and in every label-grammar value (a choice or hub label, a prompt, a plugin text attribute), where 0.52 reported E-CEL-PROFILE. The message names the run length n and spells the two ways out for exactly that many braces. A space after the {{ ({{ {run.a}}) is a marker you opened, so it stays E-CEL-PROFILE. A translation with such a marker is not merged (see Translations keep the source’s markers), and a component line whose spliced argument ends the run reports at the ::use, as does an argument ending in a plain {{ (#221, see Components).
  • Plain colons stay text. A colon starts a modifier only when a name and then [ or { follow it, so 10:30 and :) need no escape.
sign.lute:14:20: error [E-INTERP-BRACE] a literal `{` directly before `{{run.a}}` cannot be told apart from the marker in compiled text (lute 0.47.1 §3): put a character between them (`{ {{run.a}}`), or show the brace from a string state path (`{{run.lbrace}}{{run.a}}`, with `run.lbrace` declared `{ type: string, default: "{" }`)

That is the message for Your score: {{{run.a}} (n = 1). For {\{{{{run.a}}, three braces, the message says “literal {{{” and spells \{{{ {{run.a}} and three {{run.lbrace}}:

sign.lute:14:24: error [E-INTERP-BRACE] a literal `{{{` directly before `{{run.a}}` cannot be told apart from the marker in compiled text (lute 0.47.1 §3): put a character between them (`\{{{ {{run.a}}`), or show each brace from a string state path (`{{run.lbrace}}{{run.lbrace}}{{run.lbrace}}{{run.a}}`, with `run.lbrace` declared `{ type: string, default: "{" }`)

A malformed modifier is E-TEXT-MODIFIER: an unterminated span, a name that is neither core nor a textStyle member, attributes on a style, or a positional value (:pause{0.5} — write s=0.5):

diner.lute:11:33: error [E-TEXT-MODIFIER] inline modifier span is missing a closing `]`
diner.lute:12:10: error [E-TEXT-MODIFIER] `:shout` is neither a core modifier (`pause`, `speed`) nor a member of `textStyle` (it has `emphasis`)
diner.lute:13:26: error [E-TEXT-MODIFIER] text style `:emphasis` takes no attributes, got `size` — only the core `pause`/`speed` carry attributes

Voice keys compare the plain text, so restyling a line never collides with its recording. lute loc export exports the source markup; a translation must carry the same modifiers — same names, forms and attributes, in any order — or it is not merged (E-L10N-MODIFIERS). A translated locale always gets its plain text in texts, and a modified line also gets localeSegments for that locale.

Each export row also carries a sourceHash — "sha256:<hex>" of the line’s plain text (markup and escapes removed, as text compiles), of an option’s text, or of a prompt. Keep it in the translated file: lute loc import stores it per lineId and locale in the bundle (schemaVersion: 2, sourceHashes), and lute compile --locale-bundle reports W-L10N-STALE when a line was edited after the export its translation came from — re-export and update that translation. The stale translation is still merged until you do.

Since 0.53 a translation also shows its source’s {{…}} markers, as it shows its modifiers: each source marker as often as the source does, in any order. A marker is compared by its interior without the spaces at its edges, so {{ run.a }} is {{run.a}}, but a space inside it or another format hint makes another marker ({{run.a: ordinal}} is not {{run.a:ordinal}}). The rule covers a line, an option label, a prompt and a directive text attribute. A marker that opens on a literal brace, or one that is not the source’s, is E-L10N-MARKERS: the translation is not merged, and lute compile --locale-bundle fails. A line translation whose {{ has no }} fails the compile as any inline markup that does not parse, E-L10N-MODIFIERS (wrapping E-INTERP-UNTERMINATED). lute loc import has only the translated rows, so it refuses a marker that does not parse — that unterminated {{ in a line included, there as E-L10N-MARKERS — and writes no bundle. Without this the runtime would show a marker it cannot match to the source verbatim. Given Score {{run.a}} now. as the source and 점수 {{run.zzz}} as the ko text:

s.lute:1:1: error [E-L10N-MARKERS] `ko` text for `s.narrator_0010` is not merged: its markers differ from the source's (missing `{{run.a}}`; extra `{{run.zzz}}`): a translation shows each `{{…}}` marker of its source as often as the source does, in any order