Dialogue
@yagejs-addons/dialogue gives you branching conversations: point it at a
script and you get a working typewriter box (or speech bubble) with choices,
inline markup, and per-glyph effects. The runner is headless and the whole look
is swappable, so you can re-theme it or replace any single piece — text, choices,
avatar, frame — with your own.
Install
Section titled “Install”npm install @yagejs-addons/dialogueThe addon declares the engine packages as peer dependencies, so it reuses your single engine install rather than duplicating it. Install the engine yourself:
npm install @yagejs/core @yagejs/input @yagejs/renderer@yagejs/coreand@yagejs/inputare required peers — the headless runner and theDialogueControlleruse them.@yagejs/rendereris the optional peer — only the./presenterssubpath needs it (and it bringspixi.jstransitively). Consuming only the headless runner? Skip them.
Quick start
Section titled “Quick start”The default presenters are zero-asset: Graphics objects for the box frame
plus canvas SplitText/Text for the typewriter, with native bold/italic and
per-glyph effects. defaultDialogueTheme() gives you a working look with no bundled
files.
-
Optionally declare layer order on your scene. Built-in presenters create missing layers on mount, even when used independently. Screen-space frame, avatar, and text layers use orders 1100, 1105, and 1110, including custom theme names by default. Set the theme’s
layerFrameOrderandlayerTextOrderto request other orders. Standalone chrome and choice configs accept the same fields; screen text and avatar configs acceptlayerOrder. A portrait can request 1095 to appear behind a frame at 1100. The exported array lets you declare those layers on the scene too.import { Scene } from "@yagejs/core";import { DIALOGUE_LAYERS } from "@yagejs-addons/dialogue/presenters";class TalkScene extends Scene {readonly name = "talk";readonly layers = [...DIALOGUE_LAYERS];// ...}An existing host layer keeps its order. A different requested order produces one development warning per scene tree, layer name, and requested order. Bubble presenters create a missing configured
worldLayerin world space at order 0. Declare that layer yourself to choose a different world order. -
Give the conversation an entity of its own. Spread a factory bundle into
DialogueControllerand override any one piece. A small component on the same entity removes it when the conversation ends, so the scene’sonEnteronly spawns it.import { Component, Entity, Scene } from "@yagejs/core";import {DialogueController,DialogueEndedEvent,type DialogueScript,} from "@yagejs-addons/dialogue";import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";import { script } from "./script.js"; // the script from "Writing a script" below/** Removes its entity when the conversation reaches an `end` step. */class CloseOnEnd extends Component {onAdd(): void {this.listen(this.entity, DialogueEndedEvent, () => this.entity.destroy());}}/** A box conversation: plays one script, then removes itself. */class Conversation extends Entity {setup(params: { script: DialogueScript }): void {const dlg = this.add(new DialogueController({ ...createBoxDialogue() }));this.add(new CloseOnEnd());dlg.play(params.script);}}class TalkScene extends Scene {readonly name = "talk";onEnter() {this.spawn(Conversation, { script });}} -
Author a script (plain data — see below) and pass it to
play().
Four entry points
Section titled “Four entry points”The package is split so the headless path never pulls a renderer — and so authors who don’t use YAML or Yarn Spinner never bundle those parsers:
// Headless + input only — no pixi, no yaml, fully unit-testable.import { DialogueController, parseExpr, loadCompact } from "@yagejs-addons/dialogue";
// Pixi presentation — Graphics chrome + canvas text, themes, factories.import { defaultDialogueTheme, createBoxDialogue } from "@yagejs-addons/dialogue/presenters";
// YAML-literal authoring — the only entry that pulls `yaml`.import { loadYaml } from "@yagejs-addons/dialogue/yaml";
// Yarn Spinner authoring — .yarn files and a .yarnproject.import { loadYarn } from "@yagejs-addons/dialogue/yarn";| Entry | Imports | Contains |
|---|---|---|
. | @yagejs/core, @yagejs/input | runner, session, types, markup, i18n, canonical (JSON) format, the parseExpr string→expression parser, the parseCompact / loadCompact compact DSL, events, DialogueController, input bindings |
./presenters | + @yagejs/renderer (brings pixi) | chrome, text views, composites, avatars, factories, defaultDialogueTheme(), textured nine-slice variants, radial (experimental) |
./yaml | + yaml | loadYaml — kept off the root so you only bundle the YAML parser if you author in YAML |
./yarn | nothing extra | loadYarn, DialogueYarnError — the Yarn Spinner compiler |
Writing a script
Section titled “Writing a script”Author scripts with defineScript() — an identity helper (no runtime cost)
that captures the script’s declared variable types so play() hands back a typed
handle. A plain DialogueScript literal still works and is validated identically
at runtime; the typing is the only thing you give up. Both are JSON-able, so you
can also load a script from disk.
import { defineScript } from "@yagejs-addons/dialogue";
export const script = defineScript({ id: "intro", start: "greeting", declare: { rude: false, timesTalked: 0, gold: 0 }, // variable defaults (seed-if-absent) speakers: { gwen: { name: "Gwen", color: 0xffd866 }, }, nodes: { greeting: { id: "greeting", steps: [ { kind: "say", speaker: "gwen", text: "Hello, [b]traveler[/b]. You carry {gold} gold." }, { kind: "choice", text: "What do you say?", options: [ { text: "Greet her back.", target: "friendly" }, { text: "Walk off.", once: true, commands: [{ type: "set", var: "rude", value: true }], }, ], }, ], }, friendly: { id: "friendly", steps: [{ kind: "say", text: "Safe travels." }, { kind: "end" }], }, },});A speaker’s id is its key in the speakers map — say/choice steps
reference it (speaker: "gwen"), and presenters anchor actors by it. You never
repeat the key inside the entry.
Variables and storage
Section titled “Variables and storage”Variables live in one shared namespace — conditions, {token} interpolation,
and choice gates all read from it. The namespace is backed by a
VariableStorage (Yarn’s get / set / has shape) installed once on the
controller and persisted across plays — which is what makes cycling-NPC
counters and quest flags survive. (A choice’s once flag is per-conversation
state, not stored — a fresh play() starts it clear.) play(script) is
content-only.
-
declareholds variable defaults. On eachplay()a default seeds the storage only if it’s absent (seed-if-absent) — a game-linked value always wins, the addon never overwrites it. Reset by re-initializing explicitly. -
The default storage is a
MemoryVariableStorage(zero config). To bridge game state, usecells(two-way{ get, set }or read-only() => value) andcomposeto layer:import { cells, compose, MemoryVariableStorage } from "@yagejs-addons/dialogue";const player = { gold: 100 };const storage = compose(cells({ gold: { get: () => player.gold, set: (v) => (player.gold = Number(v)) } }),new MemoryVariableStorage(), // catch-all for dialogue-locals + seeded defaults); -
To back the namespace with a plain record you already own — a state object, a save blob — use
createRecordStorage. It mutates the record in place, so the host reads the same object:import { createRecordStorage } from "@yagejs-addons/dialogue";// record typed Record<string, string | number | boolean> — no null memberconst game: { dialogueVars: Record<string, string | number | boolean> } = { dialogueVars: {} };const storage = createRecordStorage(game.dialogueVars);
Keeping variables in the game store
Section titled “Keeping variables in the game store”createStoreStorage puts the dialogue namespace inside a
reactive store leaf. The variables then ride
the store’s save/load, and a dialogue write that changes a value notifies the
leaf — so a useStore HUD, autoPersist, and the compound store all react to a
set in a script the same way they react to any other game write:
import { createStore } from "@yagejs/core";import { createStoreStorage, type VarValue } from "@yagejs-addons/dialogue";
const game = createStore((s) => ({ flags: s.record<Record<string, VarValue>>({ default: () => ({}) }),}));
const storage = createStoreStorage(game.flags);Pass the leaf, not game.flags.get(). A leaf hands out a fresh snapshot
object and replaces its own on every set, hydrate, and reset, so a storage
holding one of those snapshots would keep writing to an object the leaf has
already discarded — the writes vanish, and nothing subscribed to the store ever
hears about them. createStoreStorage holds the leaf and reads through
game.flags.get() on every access, so a save-load or a host-side write between
two dialogue writes is picked up rather than lost. That is also why
createRecordStorage is for a plain object you own, not for a store leaf.
A map leaf (s.map<string, VarValue>()) works the same way. The leaf has to be
open-ended either way — dialogue names are open-ended, and unsetting a variable
on a fixed-shape record leaf would remove a key its own type declares as
present. Passing one is a compile error.
When the runtime writes null
Section titled “When the runtime writes null”VariableStorage.set can receive null: a literal null in a set directive
(set x = null), and reading an absent variable (set a = undeclared coerces
the missing read to null). null means unset. The storages that bridge to
game state honor that — createRecordStorage deletes the key so the backing
record stays typed Record<string, string | number | boolean> with no null
member, and createStoreStorage drops the name from the leaf so a declare
default can seed it again on the next play(). MemoryVariableStorage keeps the
literal null: it is the scratch store for dialogue-locals, where a nulled name
is still a name the script declared. A hand-written storage over game state
should drop or delete on null the same way.
{gold} (and any condition on gold) resolves when the line is presented, so
an earlier command’s effect shows up on a later line. Variable writes do not
refresh text already on screen. A locale change re-resolves ordinary variable
tokens but keeps computed text values. A choice menu’s conditions do not
live-refresh while it is open.
Conditions and set are expressions
Section titled “Conditions and set are expressions”A Condition and a set value are expression trees, so gold - 50 and
has_item("key") and not rude are plain data. Nodes are literal | varRef | call | unary | binary | group; operators follow Yarn (== != > < >= <= with word
forms, and/or/xor/not, + - * / %). In a string, or binds loosest, then
xor, and, the comparisons, + -, and * / %. The flat { var, op, value }
comparison still works as the degenerate one-level tree.
import type { Command, Condition } from "@yagejs-addons/dialogue";
// "set gold = gold - 50" — needs a writable `gold` cell:const spend: Command = { type: "set", var: "gold", value: { kind: "binary", op: "-", left: { kind: "varRef", name: "gold" }, right: { kind: "literal", value: 50 } } };
// a choice condition that calls an argument-read function:const hasKey: Condition = { kind: "call", fn: "has_item", args: [{ kind: "literal", value: "rusty-key" }] };Write them as strings
Section titled “Write them as strings”Typing those trees by hand is tedious, so a Condition or set value can be a
string — parseExpr parses it into the exact same IR when the script loads
(every loader, JSON included), so the runtime never re-parses. The two snippets
above are simply:
import type { Command, CommandStep, Condition } from "@yagejs-addons/dialogue";
const spend: Command = { type: "set", var: "gold", value: "gold - 50" };const hasKey: Condition = "has_item('rusty-key')";const buy: CommandStep = { kind: "command", commands: [], condition: "gold >= 50 and not rude", target: "buy" };- Identifiers are
[A-Za-z_$]then[A-Za-z0-9_.$], so$goldandquest.stageread as one name (Yarn-forward) but-is a minus operator —hp-1ishpminus1, and a hyphenated id like'rusty-key'goes in a quoted string. - A bare name is a variable read (
"greeted"→ truthy check, the same as before); a quoted string is literal text. So asetvalue of"gold"reads thegoldvariable, while"'gold'"is the five-character string. - These words are reserved and can’t be used bare in a string — quote them,
or use the
{ var, op, value }form /defineScript:and or not xor is eq neq gt lt gte lte true false null.
You can call parseExpr(src) directly (it throws DialogueExprError with
line/col on a bad string), but you rarely need to — the loaders run it for you.
Built-in functions
Section titled “Built-in functions”These work in any condition, set value, or text expression without installing
anything. They are Yarn Spinner’s standard library:
| Function | Result |
|---|---|
random() | a number from 0 up to (not including) 1 |
random_range(min, max) / random_range_float(min, max) | a whole number from min to max / any number from min up to max |
dice(sides) | a whole number from 1 to sides |
round(n), round_places(n, places) | rounded; a half goes to the even neighbour (2.5 → 2) |
floor(n), ceil(n), int(n), decimal(n) | rounded down / up / toward zero; the fractional part |
inc(n), dec(n) | the next whole number above / below |
min(a, b), max(a, b) | the smaller / larger |
string(v), number(v), bool(v) | converted |
format_invariant(n), format(pattern, v) | text: format("{0:F1} kg", 2.25) → "2.3 kg" |
A function you install with the same name replaces the built-in, and a variable may share a built-in’s name. The random ones draw from the scene’s seeded random service, so a run with a fixed seed makes the same picks.
controller.preview(nodeId) returns plain text along the node’s path until a
choice, a select, or the end. It uses a snapshot of the current variables and
runs no commands. Each preview uses a separate random generator starting at the
scene’s seed, so it cannot consume gameplay randomness. Random text and branches
in a preview are illustrative; they may differ from later playback. Custom
functions must be side-effect-free, as during playback. Variables omitted from
storage.entries() remain readable in expressions through storage.get().
Computed text
Section titled “Computed text”A line can show a value computed when it appears. Put a {name} token in the text
and the expression under expressions:
import type { SayStep } from "@yagejs-addons/dialogue";
const line: SayStep = { kind: "say", text: "That leaves {left} gold.", expressions: { left: "gold - 50" } };say steps, choice prompts, and choice options (text and disabledReason) take
expressions. A token without an entry reads the variable of that name. The text
keeps only the token, so a translation writes {left} too.
Computed values stay fixed while that line or choice is on screen, including when its language changes. A choice label and its disabled reason share the same values. Revisiting a step evaluates its expressions again.
Author the whole script in YAML
Section titled “Author the whole script in YAML”For a data-file workflow, loadYaml (from the @yagejs-addons/dialogue/yaml
subpath) reads a YAML document whose shape mirrors the JSON DialogueScript and
runs it through the same loader — same string-expression pre-walk, same
validation, same frozen result. It lives behind a subpath so games that author in
JSON/TypeScript never bundle the YAML parser.
import { loadYaml } from "@yagejs-addons/dialogue/yaml";
const script = loadYaml(`id: shopstart: greetdeclare: { gold: 0 }nodes: greet: id: greet steps: - kind: say text: "You have {gold} gold." - kind: choice options: - { text: "Buy the sword (50g)", target: buy, condition: "gold >= 50" } - { text: "Leave", target: bye } buy: id: buy steps: - { kind: command, commands: [ { type: set, var: gold, value: "gold - 50" } ] } - { kind: say, text: "A fine blade." } - { kind: end } bye: { id: bye, steps: [ { kind: end } ] }`);The root of the document must be a mapping (the script object); a list, a scalar,
or an empty document is rejected with a clear DialogueScriptError.
Author with the compact DSL
Section titled “Author with the compact DSL”For hand-writing a lot of branching dialogue, the JSON/YAML shape gets noisy. The
compact DSL is a line-oriented format aimed at that: one statement per line, a
sigil at the front telling the parser what the line is. loadCompact(text)
compiles it to the same validated, frozen script. (parseCompact(text) stops at
the unfrozen DialogueScript if you want to inspect or post-process it first.)
import { loadCompact } from "@yagejs-addons/dialogue";
const script = loadCompact(`# shop@ mira Mira Brightwater #ffcc00@ guard Guard
:: startmira: Welcome to my [b]shop[/b], traveler!mira happy: You've got coin to spend, I hope.set gold = 100? Buy a healing potion if: gold >= 50 -> buy #once? Ask about the [i]rumors[/i] -> rumors #side:right? Just browsing -> done
:: buyset gold = gold - 50mira: A fine choice. Here you are.do give-item id=healing-potion count=1-> done
:: rumorsguard: Keep your voice down.The tavern falls silent for a moment.-> done
:: donemira: Safe travels!end`);Reading it line by line:
# shopsets the script id. The start node is the first::node (start).@ mira Mira Brightwater #ffcc00declares a speaker: an opaque id, a display name (spaces allowed), and an optional#rrggbb/#rgbnameplate colour.@lines may sit anywhere — before or after the lines that use them.:: startopens a node; every step line until the next::belongs to it.mira: …is a spoken line — but only becausemirais a declared speaker. A second header token is the avatar expression, somira happy: …setsexpression: "happy". A line with no declared-speaker prefix is a narrator line, colons and all —The tavern falls silent for a moment.and evenWarning: do not enter.stay intact as narration.? …is one choice option; consecutive?lines merge into a single choice. Its attributes follow the text as non-bracket sigils, in order:if: cond, then-> node(ortarget=node), then#once/#disabled/#key:valuemetadata. They’re stripped before the visible text, so[..]stays reserved for inline markup —[b]…[/b]and any effect span like[glitch]…[/glitch]render in the choice, while a malformed built-in tag the parser can’t act on (e.g.[color=notacolor]) is a load error instead of vanishing.set gold = 100writes a variable. A bare number /true/false/nullstays a literal; anything else is parsed as an expression, soset gold = gold - 50stores the subtraction the runtime evaluates later.do give-item id=healing-potion count=1fires a host command: atype, thenkey=valuedata and#flagbooleans. Quote a value with spaces (msg="two words"). A data key can’t be namedtype— that’s the command’s own dispatch key, so atype=collision is a load error rather than silently overwriting it.-> donejumps unconditionally;-> rich if: gold > 100jumps only when the condition holds, otherwise it falls through to the next step (the “else” path).endstops the conversation.declare gold = 0— in the preamble or inside a node — sets a script-level variable default (a literal value, seeded only if the storage doesn’t already hold it). Its value is always a literal, never a parsed expression.
For avatars, you usually need no extra code or grammar: write #portrait:key
(and optionally #side:right) on a line and wire a meta-driven avatar presenter —
the in-box and bubble presenters read those per-line meta keys directly. A
speaker-level SpeakerDef.avatar (with an expressions map) is only needed for
the variant-swapping presenters, and is set in code on the parsed script.
A line can also carry trailing hints: view= / voice= / speed= / auto= set the
say-line fields, and trailing #key:value (or a bare #flag) becomes meta — except
#line:id, which makes the say line’s or choice option’s text a { key: id, fallback }
message (Yarn’s localization tag), so a string table can translate it. The set / do / end keywords are
lowercase and matched by their full shape, so ordinary prose that happens to start with
Set, Do, or End stays narration.
Validation
Section titled “Validation”The script is checked in two stages, both throwing hard errors so typos die early rather than silently mis-branching:
- Load-time (
loadScript/loadYaml, environment-free): collects the names read/written, functions called, and command types fired, and type-checks what’s statically knowable — a numeric or arithmetic operator (in the atomic form or inside a parsed expression) with a wrong-type literal operand or a declared-non-number variable operand, and a literalsetvalue against the target’s declared type. ThrowsDialogueScriptError. Undeclared references aren’t rejected here — the storage/functions may supply them, which is only known at play time.defineScriptonly captures types;play()loads and validates its result before starting the conversation. - Play-time (when you call
play): every read name must be provided (a declared default or a value in the storage), every called function installed, every command type handled, nosettarget that’s a function, no declared-default/storage type clash. ThrowsDialoguePlayError.
Step kinds
Section titled “Step kinds”| Kind | Purpose |
|---|---|
say | A line spoken by an optional speaker. Supports markup, expressions, speed, autoAdvance (seconds), expression, commands, view, meta, voice. |
choice | A branch with options (each: text, expressions?, target?, condition?, once?, presentation?, disabledReason?, commands?, meta?) and an optional prompt text. |
command | Fire commands without showing anything; optionally a conditional goto via condition + target. |
goto | Jump to another node. Inside a detour the detour stays pending, unless leaveDetours: true. |
detour | Run another node, then carry on after this step once it finishes. Detours nest. |
return | Leave the node early: back to the detour that entered it, or the end of the conversation. |
select | Let the runtime pick one of several options (see below). |
end | Ends the conversation, detours included. |
A node finishes when it runs off its last step or reaches return. Inside a
detour that returns to the caller; otherwise the conversation ends.
A goto or detour target can also be an expression, such as
{ kind: "varRef", name: "next" }. It is evaluated when the step runs. If the
value isn’t the id of a node in the script, the conversation ends and the error
is reported through onError.
A select step picks one branch for you — a random bark, or the storylet that
fits best. Each option has a target, and optionally a condition, a priority,
and a counter variable. Of the options whose condition holds, the runner keeps
the ones picked the fewest times (read from their counter, which it adds 1 to on
each pick), then the highest priority, and picks one of the rest at random. So a
group of barks cycles through every line before it repeats one. With nothing
available, the conversation goes on to the next step.
import type { SelectStep } from "@yagejs-addons/dialogue";
const bark: SelectStep = { kind: "select", options: [ { target: "bark_weather", counter: "seen_weather" }, { target: "bark_gossip", counter: "seen_gossip" }, { target: "bark_hero", condition: "saved_town", priority: 1, counter: "seen_hero" },] };Pick the starting node per conversation with play(script, { start: "shop" }).
One script can then hold every conversation in a scene. An unknown node throws a
DialoguePlayError and leaves any running conversation alone.
A condition is a variable name (truthy check), an atomic comparison
{ var, op, value } (op is one of == != > >= < <= truthy falsy), a full
expression tree (see above), or — in TypeScript only — a (vars) => boolean
predicate (it receives a materialized snapshot of the readable variables).
Disabled choices
Section titled “Disabled choices”By default an option whose condition is false is filtered out of the menu.
Set presentation: "disabled" to keep it on screen instead — greyed-out and
non-selectable, the Disco-Elysium “[Strength 8] Force the door” pattern that lets
the player see a gate before they can pass it. disabledReason adds a short
explanation shown beside the row (resolved like the option text, so it can be a
translatable message and {token}s interpolate).
import type { ChoiceStep } from "@yagejs-addons/dialogue";
const gate: ChoiceStep = { kind: "choice", options: [ { text: "Unlock the gate", target: "open", condition: { kind: "call", fn: "has_item", args: [{ kind: "literal", value: "rusty-key" }] }, presentation: "disabled", // shown greyed-out when you lack the key disabledReason: "needs the rusty key", }, { text: "Walk away", target: "leave" }, // always enabled — keeps the step pickable ],};Rules to keep in mind:
- A spent
onceoption is always hidden —presentationonly governs condition failures, never a consumed one-shot. - A step is skipped if it has zero enabled options (the same fall-through as when every option is hidden), so a disabled row never soft-locks a conversation — always leave at least one enabled option.
- Selection starts on the first enabled row; arrow-key navigation and pointer hover skip disabled rows, and confirming or clicking one is refused. The default list and bubble presenters grey the row and append the reason in parentheses; the experimental radial wheel greys it without a reason.
Yarn Spinner
Section titled “Yarn Spinner”Write your dialogue in Yarn Spinner — .yarn files,
usually with a .yarnproject — and edit it with the Yarn Spinner extension for
VS Code or any other Yarn editor. loadYarn compiles the files into one script
that plays through the same DialogueController, presenters, and events as any
other script.
-
Load the folder. With Vite,
import.meta.globreads every file as text:/// <reference types="vite/client" />import { loadYarn } from "@yagejs-addons/dialogue/yarn";const files = import.meta.glob("./dialogue/**/*.{yarn,yarnproject,csv}", {query: "?raw",import: "default",eager: true,});const story = loadYarn(files);You can also pass one file’s text:
loadYarn(source). -
Play a node. Each
play()picks its starting node; withoutstartit begins atStart, else the first node:import { DialogueController } from "@yagejs-addons/dialogue";const controller = entity.get(DialogueController); // the controller on your dialogue entitycontroller.play(story, { start: "Shopkeeper" }); -
Handle your commands the same way as for any script, and bridge game state with the storage. Yarn variable names keep their
$:import { DialogueController, cells, compose, MemoryVariableStorage } from "@yagejs-addons/dialogue";import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";const player = { gold: 0 };declare const inventory: { has(id: string): boolean; add(id: string, count: number): void }; // your game's inventorynew DialogueController({...createBoxDialogue(),storage: compose(cells({ $gold: { get: () => player.gold, set: (v) => (player.gold = Number(v)) } }),new MemoryVariableStorage(),),functions: { has_item: (id) => inventory.has(String(id)) },commands: {// <<give_item sword 2>>give_item: (cmd) => inventory.add(String(cmd.args?.[0]), Number(cmd.args?.[1] ?? 1)),},});
A character name before the : on a line becomes the speaker, and each
character gets a speaker with that name. A DialogueActor for a speech bubble
binds to the same name (new DialogueActor({ speaker: "Mae" })). The speakers option adds
nameplate colours, avatars, or a translatable name:
loadYarn(files, { speakers: { Mae: { color: 0xffcc00 } } });loadYarn throws a DialogueYarnError naming the file and line of a mistake.
Like every script, a function or command with no handler fails when play() is
called, naming it. To stop the Yarn editor from warning about your own commands
and functions, list them in the project’s definitions file.
What you can write
Section titled “What you can write”The Yarn language works as it does in Yarn Spinner:
- Lines with characters,
{$expressions}, markup,<<if …>>and<<once>>conditions, and hashtags. - Options (
->) with bodies,<<if …>>conditions, and<<once>>. An option whose condition fails is hidden; add#disabledto show it greyed out instead. When no option is available, the conversation carries on after the group. AName:before an option’s text is left out of its label and kept in the option’smeta.character. - Flow:
<<if>>/<<elseif>>/<<else>>,<<once>>blocks,<<jump Node>>,<<jump {$node}>>,<<detour Node>>,<<return>>,<<stop>>. - Variables:
<<declare>>(withastypes),<<set … to …>>and+=/-=/*=//=/%=, smart variables, and enums. A variable you use without declaring gets a default from how you use it (0,"", orfalse). When the script doesn’t show its type (it is only shown in text, say),loadYarnasks you to declare it. - Functions:
visited(),visited_count(), and the built-in functions. A node withtracking: nevercan’t be read withvisited(). - Storylets: line groups (
=>) and node groups (when:headers), picked the way Yarn Spinner picks them: the least-seen content first, then the most specific condition, then at random.has_any_content()works on a node group. - Comments:
//starts a comment anywhere in a line, as in Yarn Spinner. Write\/\/to show the slashes, as inhttps:\/\/yarnspinner.dev. - Commands:
<<wait 2>>waits two seconds with no handler; every other command reaches yourcommandshandler with its words incmd.args(numbers andtrue/falseas values,{expressions}evaluated,"quoted text"as text). As in Yarn Spinner, a handler that returns a promise holds the dialogue until the promise settles, so<<walk_to Mae door>>can play a whole sequence before the next line. A handler that returns nothing finishes at once.
Some hashtags on a line set how it plays: #view:bubble, #voice:clip_id,
#expression:happy (the speaker’s face when the line starts), #speed:0.5, and
#auto:2 (auto-advance after 2 seconds). Other hashtags land in
the line’s meta (#portrait:mae_happy → meta.portrait), where the
presenters read them. The line just
before a group of options also gets meta.lastline.
Markup follows Yarn Spinner’s runtime: [pause=500/] waits 500 milliseconds (a
decimal such as [pause=0.5/] is seconds), a self-closing marker swallows one
space after it (set trimwhitespace=false to keep it), and a span’s extra
properties are dropped ([wave size=2] is [wave]).
Visit counts, <<once>> flags, and storylet view counts are variables in your
storage under Yarn Spinner’s own names ($Yarn.Internal.Visiting.Shop, …), so
they persist between conversations and save with the rest of your storage.
<<call>>, <<local>>, and #shadow: lines are not supported.
Projects and localisation
Section titled “Projects and localisation”With a .yarnproject among the files, its sourceFiles and excludeFiles
patterns choose the .yarn files, relative to the project file. Without one,
every .yarn file you pass is compiled. The script is named after the project
file.
Tag a line #line:some_id to make it translatable. loadYarn collects the tagged
lines into story.catalogs: the base language from your .yarn files, and each
language whose localisation entry in the project points at a strings table
(it.csv, in the format Yarn Spinner’s tools write). Include the CSV files in
the glob. Give the catalogs to your localization — with @yagejs-addons/i18n,
the controller then follows its locale, and switching language updates the line
on screen:
import { createLocalization, LocalizationPlugin } from "@yagejs-addons/i18n";
const localization = await createLocalization({ locale: "en", fallbackLocale: story.baseLanguage, catalogs: story.catalogs,});engine.use(new LocalizationPlugin(localization));A translated line drops its Name: prefix when its source line has a
character, because the speaker shows the name. As in Yarn Spinner, the prefix is
the text up to the first :. Write \: for a colon in a translation that has no
prefix.
Character names stay as written; to translate one, give that speaker a name
message through the speakers option. Lines without a #line: tag show as
written in every language.
Inline markup
Section titled “Inline markup”Markup is a small BBCode-ish syntax that nests, inherits down the stack, and
survives translation (translators keep the tags). The styling attributes are a
fixed set (b / i / color / speed); effects are an open vocabulary —
any other [name]…[/name] opens an effect span the presenter interprets (see
below).
| Tag | Effect |
|---|---|
[b]…[/b] [i]…[/i] | bold / italic |
[color=#ffcc00]…[/color] / [color=gold]… | colour (hex or named) |
[wave] [shake] [pulse] [rainbow] | the four built-in per-glyph effects (any [name]…[/name] is an effect — see below) |
[speed=2]…[/speed] | reveal-speed multiplier for the span |
[/] | closes every open span |
[pause=0.6/] | self-closing — holds the typewriter 0.6s at this offset |
[sfx=ding/] [expression=happy/] [shake amount=3/] | self-closing marker — a reveal event at this char offset (see below) |
[plural value={n} one="% coin" other="% coins"/] | replacement — becomes text (see below) |
[nomarkup]…[/nomarkup] | everything inside is plain text |
\[ \] | a literal bracket |
A tag ending in / is a self-closing reveal token — a [pause=N/] hold or a
[name k=v/] marker. They fire in source order: [pause=0.6/][shake/] holds
then fires, while [shake/][pause=0.6/] fires then holds. A marker adds no
styling — it fires as a reveal event when the cursor reaches it. The Yarn-style
shortcut [name=val/] is exactly [name name=val/], so it composes with explicit
key=value props: [shake=500 amount=3/] → props { shake: "500", amount: "3" }.
Quote a value that holds spaces or / ([sfx name="big boom"/]), and a space may
sit before the closing /]. Translators must keep the trailing / for a
token to survive re-ordering. ([pause=0.6/] is the only pause
spelling — a bare [pause=0.6] without the slash opens an effect span named
pause instead of holding.)
To shake and hold together, order the two tokens: [shake=500/][pause=0.5/]
fires the shake marker, then a 0.5s pause holds the reveal while it plays.
Effects are an open vocabulary. [wave], [shake], [pulse], and [rainbow]
are the four the bundled text presenter animates, but the parser accepts any tag
name: [glitch]…[/glitch] parses to a run with effect: "glitch". A custom text
channel can animate any name it knows; a presenter that doesn’t recognise an
effect (including the bundled one) renders the run as plain styled text. So you
widen the effect set by writing a text channel that reads the effect name —
nothing in the core or parser is a closed list.
Replacement markers become text after {token}s are filled in, so one line
covers every count or choice of word, and each translation writes the forms its
language needs:
[select value={pronoun} he="his" she="her" they="their"/]shows the form named by the value.[plural value={count} one="% coin" other="% coins"/]shows the form for the number in the current locale (zero,one,two,few,many,other;otheris the fallback).[ordinal value={place} one="%st" two="%nd" few="%rd" other="%th"/]does the same with ordinal forms (1st, 2nd, 23rd, 11th).
A % in the chosen form becomes the value. A marker with no value, or no form
for it, stays in the text as written, so a mistake shows on screen.
parseMarkup(str, { locale }) and stripMarkup(str, { locale }) are exported for
tooling.
Every character count in the parsed result — ParsedText.length,
TextRun.graphemeCount, a token’s atChar, and the charsPerSec reveal rate —
is in graphemes (user-perceived characters), not UTF-16 code units. An emoji
like 🔥, a ZWJ sequence like 👩🚀, or an accent composed with a combining mark
counts as one character, matching the glyphs the renderer actually draws, so
reveal timing and pause positions stay put in any language. splitGraphemes(str)
exposes the same segmentation for tooling.
Reveal events: typewriter ticks and inline markers
Section titled “Reveal events: typewriter ticks and inline markers”As a line types, the headless LineReveal clock emits a stream of reveal
beats you can hook for positional sound and effects:
- Per-grapheme ticks — wire the controller’s
onRevealTick(index)callback for a typewriter blip. It is a plain callback, not an event, because it fires once per grapheme (hundreds of times a line).indexis the raw grapheme index including whitespace, so filter spaces yourself if you only want a blip on visible glyphs. - Inline markers — a
[name k=v/]marker surfaces asDialogueRevealMarkerEvent({ marker, viaSkip }) on the entity bus.viaSkipistruewhen a skip / fast-forward drained the marker, so you can suppress a loud one-shot that only fired because the player skipped. Skipping still fires every pending marker’s consequence, but discards the pending ticks (no machine-gun of blips).
import { Component, Entity } from "@yagejs/core";import { AudioManagerKey } from "@yagejs/audio";import { DialogueController, DialogueRevealMarkerEvent } from "@yagejs-addons/dialogue";import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";
/** Plays the sound a `[sfx=door/]` marker names when the text reaches it. */class MarkerSounds extends Component { private readonly audio = this.service(AudioManagerKey);
onAdd(): void { this.listen(this.entity, DialogueRevealMarkerEvent, ({ marker }) => { const alias = marker.props.sfx; if (marker.name === "sfx" && alias) this.audio.play(alias); // [sfx=door/] → play "door" }); }}
/** The conversation's entity: a blip per typed character, plus the marker sounds. */class Conversation extends Entity { setup(): void { const sfx = this.scene.use(AudioManagerKey); this.add(new DialogueController({ ...createBoxDialogue(), onRevealTick: () => sfx.play("type") })); this.add(new MarkerSounds()); }}The addon name-matches no marker. The avatar channel interprets
[expression=happy/] itself — the bundled portrait and scene-figure presenters
call their own setExpression, so a mid-line face change is zero-config — while
every other name (sfx, your own) flows straight through to your event handler. A
registered extra channel sees the whole stream (ticks + markers) via its optional
revealBeat(beat) hook, so a self-contained typewriter-SFX or camera-shake channel
needs no game wiring.
Game state: storage, functions, commands
Section titled “Game state: storage, functions, commands”The bridge between your game and a conversation is installed on the
controller (storage, functions, commands); play(script) is content-only.
A per-play() overrides argument layers entity-specifics on top.
import { DialogueController, cells, compose, MemoryVariableStorage } from "@yagejs-addons/dialogue";import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";import { script } from "./script.js";
const player = { gold: 100 };const inventory = new Set<string>();declare function roll(stat: string): Promise<boolean>; // your game's skill check
const host = scene.spawn("dialogue");const dlg = host.add(new DialogueController({ ...createBoxDialogue(), storage: compose( // two-way: the script can spend gold; reads stay live cells({ gold: { get: () => player.gold, set: (v) => (player.gold = Number(v)) } }), new MemoryVariableStorage(), // dialogue-locals + seeded defaults ), functions: { has_item: (id) => inventory.has(String(id)), // argument-read for conditions }, commands: { "give-item": (cmd) => { inventory.add(cmd.id as string); }, "skill-check": async (cmd, ctx) => { ctx.setVar("passed", await roll(cmd.stat as string)); // result for THIS conversation }, }, fallbackCommand: (cmd) => console.log(cmd), // optional: catch dynamically-typed commands}));
const handle = dlg.play(script); // undefined if the controller was removedhandle?.setVar("rude", true); // set a variable live (typed to keyof declare)handle?.getVars(); // read the storage's current variablescellsgetters andfunctionsmust be cheap and side-effect-free — they’re called on every condition test and every line/choice presented.ctx.setVar/handle.setVar/setall write through the storage. A read-onlycellsgetter (no setter) rejects the write —handle.setVarthrows to you, while a scriptsetis logged (via the engine logger) and ignored, so a misconfigured script can’t get the conversation stuck. Prefer a command for game mutations (so your game rules run); two-waycellsare for when the script owns the arithmetic (set gold = gold - 50).- A per-
play()overrides.storagereplaces the controller’s for that conversation (composeit yourself to layer);overrides.functions/overrides.commandsmerge over the controller’s, call site winning.
After the conversation, the handle no-ops harmlessly (a stale setVar is
ignored; getVars returns an empty snapshot), so you can keep a reference past
DialogueEndedEvent without guarding it.
Commands — rules in, consequences out
Section titled “Commands — rules in, consequences out”The runner owns one built-in command: set, which writes a variable through the
storage (a write to a read-only cells getter is logged and ignored, never
leaving the conversation stuck). Every other command
dispatches to your commands[type] handler (or fallbackCommand) and fires a
DialogueCommandEvent for observation — so the game decides what give-item
or play-sfx actually mean.
- A
sayline’s commands fire by timing:at: "show"(default),"afterReveal"(once typed), or"advance"(as the player leaves the line). - A command with
blocking: truewhose handler returns a promise pauses the conversation until it resolves — for cinematic sequencing (“wait for the NPC to walk off, then continue”). - A command’s
argsare positional values:{ type: "give-item", args: ["sword", 2] }. An expression inargsis evaluated when the command fires, so your handler always gets plain values (cmd.args?.[0]). Yarn commands arrive this way. { type: "wait", seconds: 1.5 }holds the conversation for that long, on the conversation’s own clock: pausing freezes it, and a skip passes straight through. It needs no handler; installing your ownwaithandler, or afallbackCommand, replaces it. Awaitholds the conversation while its handler runs unless it setsblocking: false. When the default handler runs it, a written-out duration that isn’t a number of seconds of 0 or more makesplay()throw. A duration computed by an expression is only known when the command runs: an invalid one is reported throughonErrorand the conversation carries on without waiting.- Every command type a script uses must have a handler or a
fallbackCommand, orplay()throws — no command silently does nothing.setis the only built-in, andwaithas the default handler above. - Faces aren’t a command; they’re set on the line: the line-initial face is
SayStep.expression, and a mid-line face change is the[expression=…/]reveal marker (the avatar interprets it — see Reveal events).
Events
Section titled “Events”The controller emits these from its host entity (they bubble entity → scene, so
a scene can listen with this.on(...)):
| Event | Payload |
|---|---|
DialogueStartedEvent | { scriptId } |
DialogueLineEvent | { speaker?, text } (plain, markup-stripped — handy for a backlog/a11y) |
DialogueChoiceShownEvent | { options } |
DialogueChoiceMadeEvent | { index, text } |
DialogueCommandEvent | { command, mode } (command.args already evaluated) |
DialogueEndedEvent | { scriptId } |
DialogueRevealMarkerEvent | { marker, viaSkip } — an inline [name k=v/] reveal marker reached its char offset (Reveal events) |
DialogueRevealCompletedEvent | { speaker?, text } — a line finished its typewriter reveal (the “typing finished” hook) |
DialogueSelectionChangedEvent | { index, text } — the choice cursor moved (keyboard nav and pointer hover) |
DialogueSkipUsedEvent | { scriptId } — the player skipped a section |
DialogueAutoAdvanceEvent | { scriptId } — a line advanced on its own via the auto-advance clock |
These are the moments games actually hook — a typing-finished blip, a choice-hover tick, skip-used analytics, an auto-advance beat. Observation is events-only: the session owns when the reveal-completed callback fires, so there is no public field a game can accidentally overwrite.
Timed choices (a recipe)
Section titled “Timed choices (a recipe)”A Telltale-style “pick within 5 seconds or a default fires” is a short recipe you own — you run the timer, which keeps the clock policy (does it pause? does it slow down?) entirely in your game.
The shape: a non-blocking choice-timer command just before the choice step
carries the budget and the default; a component on the conversation’s entity
runs a timer on the game’s clock and commits the default with
controller.choose(default) when it runs out. The only addon support is that
the choice step’s meta is handed to the choice presenter (via
ChoiceContext.meta), so a custom presenter can draw a countdown from
meta.timeout.
import type { Step } from "@yagejs-addons/dialogue";
// In the script: arm the timer, then show the choice.const steps: Step[] = [ { kind: "command", commands: [{ type: "choice-timer", seconds: 5, default: 1 }] }, { kind: "choice", text: "Quick — what do you do?", meta: { timeout: 5 }, // a custom presenter can render this options: [ { text: "Fight", target: "fight" }, { text: "Hesitate", target: "hesitate" }, // index 1 — the default on timeout ], },];// In the host: a component owns the timer, as a slot on the entity's// ProcessComponent, and listens for the dialogue events itself.import { Component, Entity, ProcessComponent, type ProcessSlot } from "@yagejs/core";import { DialogueController, DialogueChoiceMadeEvent, DialogueChoiceShownEvent, DialogueEndedEvent, type DialogueScript,} from "@yagejs-addons/dialogue";import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";
/** Commits a default option when a timed choice runs out of time. */class ChoiceTimer extends Component { private readonly dialogue = this.sibling(DialogueController); private readonly processes = this.sibling(ProcessComponent); private timer!: ProcessSlot; private pending: { seconds: number; option: number } | undefined; private option = 0;
onAdd(): void { this.timer = this.processes.slot(); // The default must be an ENABLED option. this.timer.onComplete(() => this.dialogue.choose(this.option));
// Re-arm or cancel on EVERY choice-shown: this keeps a stale timer from // firing into a later, unrelated menu. this.listen(this.entity, DialogueChoiceShownEvent, () => { this.timer.cancel(); // drop any prior timer first… if (!this.pending) return; // …then re-arm only if THIS menu is the timed one. this.option = this.pending.option; this.timer.start({ duration: this.pending.seconds }); this.pending = undefined; }); this.listen(this.entity, DialogueChoiceMadeEvent, () => this.reset()); this.listen(this.entity, DialogueEndedEvent, () => this.reset()); }
/** Called by the `choice-timer` command. The timer starts when the menu shows. */ arm(seconds: number, option: number): void { this.pending = { seconds, option }; }
private reset(): void { this.timer.cancel(); this.pending = undefined; }}
class TimedConversation extends Entity { setup(params: { script: DialogueScript }): void { this.add(new ProcessComponent()); const timer = new ChoiceTimer(); const dlg = this.add( new DialogueController({ ...createBoxDialogue(), commands: { // The command only stashes the budget and the default. "choice-timer": (cmd) => timer.arm(Number(cmd.seconds), Number(cmd.default)), }, }), ); this.add(timer); dlg.play(params.script); }}Two mistakes are easy to make: skipping the choice-shown re-arm/cancel (so a
stale timer fires into the wrong menu later), and forgetting which clock the
timer runs on. The slot runs on the scene’s clock, so it stops while the scene
is paused and follows the scene’s time scale. setPaused freezes the
conversation but not the slot: pause the slot too (pause() / resume())
with whatever pauses the conversation.
Cutscenes, pause, and focus
Section titled “Cutscenes, pause, and focus”Beyond play/stop, the controller gives you three independent controls. All
three are persistent: they survive stop() and the next play(), so if you
hide the UI for a cutscene and forget to show it again, it stays hidden.
| Lever | What it does |
|---|---|
setHidden(bool) | Visual only. Hides/shows the whole UI without ending or freezing the conversation. State-preserving — hide mid-typewriter and show again to resume the exact line and cursor. A composite chrome restores its active variant on show (a bubble line comes back as a bubble, not an empty box frame). |
setPaused(bool) | Freezes time and input. The reveal, auto-advance clock, caret blink, choice animation, and avatar animation all halt, and input is inert — with no state lost (an in-flight blocking command keeps running and completes normally). It does not block host-driven handle.setVar / storage writes: a paused conversation can still be driven programmatically. |
setInputEnabled(bool) | Input focus. The conversation keeps updating (an ambient one stays alive and animating) but stops consuming device input. This is how you hand input focus between conversations. |
Compose them: a cutscene takeover is hidden + paused; a pause menu is paused; an ambient conversation is input-disabled.
Disabling DialogueController, or deactivating its entity, puts the whole
conversation host to sleep. The UI hides, the session pauses, and input
listeners are released. The active conversation and the three requested lever
settings stay intact, while play refuses a new conversation. Enabling the
component again restores the same state.
import { DialogueController } from "@yagejs-addons/dialogue";
const dlg = entity.get(DialogueController);declare function panCamera(): Promise<void>; // your game's camera move; resolves when it ends
// Cutscene takeover: hide + freeze, pan the camera, then restore the exact line.dlg.setHidden(true);dlg.setPaused(true);await panCamera();dlg.setPaused(false);dlg.setHidden(false); // the bubble line and its continue caret reappear in placeTwo conversations, one interactive
Section titled “Two conversations, one interactive”DialogueController is multi-instance friendly: several ambient conversations
can run at once. Which one is interactive is up to your game — you set it
through input focus. (YAGE input is non-consuming by design, so two enabled controllers
would both advance on one key press — focus is the game’s policy.)
import { DialogueController } from "@yagejs-addons/dialogue";import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";
const npcA = scene.spawn("npc-a").add(new DialogueController({ ...createBoxDialogue() }));const npcB = scene.spawn("npc-b").add(new DialogueController({ ...createBoxDialogue() }));
// Talk to whichever NPC the player is near; the other keeps chatting ambiently.function focus(near: "a" | "b") { npcA.setInputEnabled(near === "a"); npcB.setInputEnabled(near === "b");}Narrator and missing actors (speech bubbles)
Section titled “Narrator and missing actors (speech bubbles)”In a mixed box/bubble bundle, a speakerless narrator line types in the box by
convention (a bubble has no head to float over). To position a narrator, use
the invisible-anchor recipe: give it a speaker whose DialogueActor sits on
an invisible entity, and it floats like any other speaker.
If a speaker’s actor goes missing at runtime (despawned, or never registered),
the bubble no longer vanishes off-camera: it anchors at the actor’s last-known
position (or createBubbleDialogue’s fallbackAnchor, defaulting to the world
origin — point it at your camera centre for a pure-bubble narrator bundle), stays
visible with its continue caret, and logs a one-time developer warning through the
engine logger.
Presenters & channels
Section titled “Presenters & channels”The view splits into narrow capability channels — TextChannel,
ChoiceChannel, AvatarChannel, ChromeChannel — so the typewriter, choice UI,
portrait, and frame are each swappable and composable. Presenter adapters add the
YAGE lifecycle (mount/dispose) and pointer hit-test hooks.
| Concern | Default (box) | Default (bubble) |
|---|---|---|
| Chrome | DialogueChrome | BubbleChrome |
| Text | BoxTextView | BubbleTextView |
| Choices | ChoiceListPresenter | BubbleChoicePresenter |
Each coordinate model has a layout owner the presenters share, so they can
never drift. BubbleLayout measures the bubble size + resolves the speaker
anchor (including the missing-actor fallback) once per line for the bubble
chrome, text, and choices. BoxLayout owns the box frame rect and text region:
per-line position (meta.position), frame height fitted to say lines and
choices, and avatar columns that reflow the body text and choice labels.
createMixedDialogue composes box + bubble behind CompositeChrome /
CompositeTextPresenter / CompositeChoicePresenter / CompositeAvatarPresenter.
All four share one route so a line’s chrome, text, choices, and avatar always
agree. The default is speaker-aware (a narrator goes to the box; an explicit
view wins; otherwise a speaker with a registered DialogueActor floats in a
bubble). Override the policy in one place:
import { createMixedDialogue, defaultDialogueTheme } from "@yagejs-addons/dialogue/presenters";
const theme = defaultDialogueTheme();createMixedDialogue(theme, { worldLayer: "world", // e.g. all of the boss's lines in bubbles, everything else boxed route: (line) => (line?.speaker?.id === "boss" ? "bubble" : "box"),});Avatars are decoupled too: PortraitPresenter (a sprite beside the box),
SceneFigurePresenter (an NPC already in the world), the line-driven
InBoxAvatarPresenter (the reflowing in-box portrait below) and
BubbleAvatarPresenter (a portrait inside the bubble), or NullAvatarPresenter.
A DialogueActor component on a world entity self-registers under a speaker id
(via actorRegistryFor(scene)) so a speech bubble can find and follow the
speaker’s head anchor.
PortraitPresenter reads avatar.flipX from speaker data. It mirrors the
artwork horizontally without changing its left/right placement, and keeps the
orientation across expression changes:
import type { SpeakerDef } from "@yagejs-addons/dialogue";const hero: SpeakerDef = { name: "Hero", avatar: { kind: "portrait", ref: "hero", side: "left", flipX: true },};For InBoxAvatarPresenter, set meta.flipX: true on the line. Omitting it on
the next line restores the original orientation.
Disabling a DialogueActor, or deactivating its entity, removes it from the
speaker registry.
The requested expression and speaking state remain intact, while actor
callbacks and world-figure animation sleep. They resume when the actor becomes
effective again.
Writing a custom presenter
Section titled “Writing a custom presenter”A custom box presenter shares a BoxLayout with the text and choices. Bind it
to the renderer’s viewport with layout.mount(scene) in your presenter’s mount.
The built-in box presenters do this automatically. Without a renderer, set the
viewport before reading any geometry:
import { BoxLayout, defaultDialogueTheme } from "@yagejs-addons/dialogue/presenters";const theme = defaultDialogueTheme();const layout = new BoxLayout({ ...theme, choiceGap: theme.choiceGap ?? 6 });layout.setViewport(800, 450);const frame = layout.frameRect();Reading geometry before binding throws. setViewport and setInset reflow the
current content. Use onChange to redraw custom chrome when geometry changes.
The built-in text view preserves reveal progress and pending pauses when it
rewraps; the choice presenter moves labels, highlights and click targets together.
Use finite, nonnegative geometry values, and leave positive content width after
margins, padding and avatar insets.
Use setInset(key, { side, width, height }) to reserve a portrait column and its
minimum content height. height is optional; the built-in in-box avatar supplies
it from the scaled portrait and its optional background.
Built-in box chrome calls setCaretHeight(height) to reserve space below the
text and portraits for its continue indicator. Custom chrome can use the same
method; layouts without a registered caret reserve no footer. The footer is
included before reveal begins and is omitted for choices. textRegion() includes
the remaining content height. In-box portraits center in that area, below the
nameplate and above the footer.
Choice presenters implement update(dt) in seconds. Use that method to animate
a selection or an entrance; a static presenter can leave it empty. Conversation
pause freezes this clock, including choices inside a mixed box/bubble bundle.
A custom presenter implements the channel contract; the Session drives it in a
guaranteed order — chrome.present(line) runs before text.present(line),
geometry is set before present, and the text channel’s reveal-completed listener
fires exactly once per line.
Reuse LineReveal — the headless, pixi-free typewriter clock (grapheme cursor,
the ordered tokens for [pause=N/] holds and [name k=v/] markers,
per-run/line [speed], completion) — instead of re-implementing reveal timing. A
DOM, per-word, or accessibility presenter then only maps the grapheme cursor onto
its own rendering:
import { LineReveal, splitGraphemes } from "@yagejs-addons/dialogue";import type { TextChannel, PresentedLine, RevealBeat } from "@yagejs-addons/dialogue";
class DomTextPresenter implements TextChannel { private reveal = new LineReveal(45 /* graphemes/sec */); private graphemes: string[] = []; private onDone: (() => void) | undefined; private onBeat: ((beat: RevealBeat) => void) | undefined; constructor(private el: HTMLElement) { this.reveal.setCompletionListener(() => this.onDone?.()); // Forward the clock's ticks + inline markers — the Session fans them on. this.reveal.setBeatListener((beat) => this.onBeat?.(beat)); } setRevealListener(fn: (() => void) | undefined) { this.onDone = fn; } setBeatListener(fn: ((beat: RevealBeat) => void) | undefined) { this.onBeat = fn; } present(line: PresentedLine) { this.graphemes = splitGraphemes(line.text.runs.map((r) => r.text).join("")); this.reveal.begin(line.text, line.speed); } replaceVisible(line: PresentedLine) { // The same line in another language: keep the reveal where it is. this.graphemes = splitGraphemes(line.text.runs.map((r) => r.text).join("")); this.reveal.rebase(line.text); this.el.textContent = this.graphemes .slice(0, Math.floor(this.reveal.revealed)) .join(""); } update(dt: number) { this.reveal.update(dt); this.el.textContent = this.graphemes.slice(0, Math.floor(this.reveal.revealed)).join(""); } completeReveal() { this.reveal.complete(); } isRevealComplete() { return this.reveal.isComplete(); } isRevealing() { return this.reveal.isRevealing(); } setSpeedMultiplier(m: number) { this.reveal.setSpeedMultiplier(m); } setVisible(v: boolean) { this.el.style.visibility = v ? "visible" : "hidden"; } clear() { this.el.textContent = ""; }}The Session registers its reveal-completed and reveal-beat listeners through
setRevealListener / setBeatListener (it owns both listeners), so forwarding the
LineReveal clock’s beats is all a presenter does — markers and ticks then reach
DialogueRevealMarkerEvent / onRevealTick and any registered channel.
A line-driven avatar or chrome implements the optional present(line) and reads
line.meta. Two shipped avatar references, both built only from the public
contract: InBoxAvatarPresenter reads meta.portrait / meta.side /
meta.presence and reserves a column via BoxLayout.setInset(...) so the box
body text and choice rows reflow around the portrait (with an optional
background panel); BubbleAvatarPresenter reserves a portrait column
inside the speech bubble (BubbleLayout.setPortraitInset(...)) so the bubble
grows and its text — and a bubble choice panel’s options — reflow past it. Wire them per side — a
CompositeAvatarPresenter routes each line to the matching one, like the other
composites:
import { InBoxAvatarPresenter, BubbleAvatarPresenter, DIALOGUE_LAYER_AVATAR, createMixedDialogue, defaultDialogueTheme,} from "@yagejs-addons/dialogue/presenters";
const theme = defaultDialogueTheme();createMixedDialogue(theme, { worldLayer: "world", avatar: { box: (layout) => new InBoxAvatarPresenter(layout, { layer: DIALOGUE_LAYER_AVATAR, width: 84 }), bubble: (layout) => new BubbleAvatarPresenter(layout, { layer: "world", size: 56 }), },});
// Box-only bundles take the in-box avatar directly:// createBoxDialogue(theme, { avatar: (layout) => new InBoxAvatarPresenter(layout, { layer, width }) });Extra channels: voice-over, shops, and camera FX
Section titled “Extra channels: voice-over, shops, and camera FX”The four built-in channels (text, choices, avatar, chrome) cover presentation. For
everything else a conversation should reach into — a voice clip, a shop that
reacts to a buy command, a camera shake, a history log — the host registers an
extra channel. It’s the open-ended companion to the four built-ins: registering
one leaves the text, choices, avatar, and chrome channels untouched.
A DialogueExtraChannel has only optional methods, so a one-purpose channel
implements just what it cares about:
import type { DialogueExtraChannel } from "@yagejs-addons/dialogue";
const log: string[] = [];export const historyLog: DialogueExtraChannel = { // Fires for each say line once it's fully revealed. A shop would implement // `command`; a camera-shake channel `command` + `update`; voice the lot. revealComplete: (line) => log.push(line.text.runs.map((r) => r.text).join("")),};Register it on the controller — that mounts it (if it needs the scene), wires it into the conversation stream, and hands back a disposer:
import { DialogueController } from "@yagejs-addons/dialogue";import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";import { historyLog } from "./history.js";import { voice } from "./voice.js"; // the voice channel below
const controller = entity.get(DialogueController);const off = controller.addChannel(historyLog);// ...later, to remove it:off();
// Or pre-wire a bundle at construction (a factory can include a voice channel):new DialogueController({ ...createBoxDialogue(), channels: [voice] });Your channel’s methods — present, command, clear, setVisible, setPaused,
completeReveal, update — are called at the same moments as the built-in
channels. A few rules govern a registered channel:
presentis fired for say lines only, never choice prompts, and a channel registered mid-conversation catches up thesetVisible/setPausedlevers but is not replayed the current line (which would restart a clip).- Consequences out, one signal in. A channel changes game state through
ctx.setVarand reads it back through the host-heldhandle.getVars()— never through the session. The only value it hands back isisRevealComplete()(below). - A throwing channel can’t break the conversation — each call is wrapped and
routed to the session’s
onError. Built-in choice updates report throwing callbacks through the engine error boundary and rethrow. - A channel that needs the scene also implements
Mountable(mount(scene)/dispose(), exported from the package root). The controller mounts it on add and disposes it on destroy. A pure observer (voice, a shop) skipsMountable.
Gating auto-advance (max(clipEnd, revealEnd))
Section titled “Gating auto-advance (max(clipEnd, revealEnd))”A channel may implement isRevealComplete() to gate auto-advance. The
auto-advance clock is armed the moment the text finishes revealing, but it
only counts down once the text and every registered gater report complete. So
a voice line that runs longer than its typewriter holds the line until the clip
ends — max(clipEnd, revealEnd) — with no duration arithmetic anywhere. A manual
advance is never gated (a player can always press forward), and a channel that omits
isRevealComplete never blocks.
Voice-over with createVoiceChannel
Section titled “Voice-over with createVoiceChannel”Voice-over is a gating channel the addon provides. It owns no audio — you
hand it a play function wired over @yagejs/audio (or any
player). It starts the clip for a line’s voice id and gates auto-advance until the
clip ends:
import { AudioManagerKey } from "@yagejs/audio";import { DialogueController, createVoiceChannel } from "@yagejs-addons/dialogue";
const audio = scene.use(AudioManagerKey);const controller = entity.get(DialogueController);export const voice = createVoiceChannel({ play: (id, onEnded) => { // The line's voice id is the alias of a preloaded clip. `@yagejs/audio`'s `onEnd` // fires onEnded on natural completion (not on stop()); pause/resume is `h.paused`. const h = audio.play(id, { channel: "voice", onEnd: onEnded }); return { stop: () => h.stop(), pause: () => (h.paused = true), resume: () => (h.paused = false) }; }, onSkip: "cut", // "cut" (default): skipping the line stops the clip and // releases the gate. "ring": let it play out. pauseWithConversation: true, // default: pause the clip whenever the conversation pauses liveness: 30, // optional safety cap in seconds (see below)});controller.addChannel(voice);import type { SayStep } from "@yagejs-addons/dialogue";
// A line with a voice id:const halt: SayStep = { kind: "say", speaker: "guard", text: "Halt! Who goes there?", voice: "vo_guard_halt" };It’s hardened against two real failure modes: a late onEnded from a clip that’s
already been superseded (the next line started) can’t ungate the new line, and the
optional liveness cap force-releases the gate (and reports through
onError) if a clip’s onEnded never arrives — so a stuck audio host can’t
soft-lock the conversation.
The dialogue-addon example wires exactly this over @yagejs/audio — one NPC’s
lines play as real (synthesized) voice clips, gating auto-advance until each finishes.
A shop channel: rules in, consequences out
Section titled “A shop channel: rules in, consequences out”A channel sees every non-built-in command, so a shop can grant the purchase and the host reads the result back through the play handle:
import { DialogueController, defineScript } from "@yagejs-addons/dialogue";
const controller = entity.get(DialogueController);const shopScript = defineScript({ id: "shop", start: "buy", nodes: { buy: { id: "buy", steps: [{ kind: "command", commands: [{ type: "buy", item: "sword" }] }] } },});
controller.addChannel({ command(cmd, ctx) { if (cmd.type !== "buy") return; ctx.setVar(`owns_${cmd.item}`, true); // the consequence (write-only ctx) },});
const handle = controller.play(shopScript); // a step fires { type: "buy", item: "sword" }handle?.getVars(); // → { owns_sword: true }The buy command still needs a registered handler (or fallbackCommand) to pass
validation; the channel layers its consequence on top of the command pipeline.
A camera-effects channel
Section titled “A camera-effects channel”Because a channel receives commands, a { type: "shake" } step drives a camera
effect with zero addon change:
import { CameraEntity } from "@yagejs/renderer";import { DialogueController } from "@yagejs-addons/dialogue";
const camera = scene.spawn(CameraEntity); // or your scene's existing cameraconst controller = entity.get(DialogueController);controller.addChannel({ command: (cmd) => { if (cmd.type === "shake") camera.shake(Number(cmd.power ?? 8), 0.3); // intensity (px), seconds },});// script: { kind: "command", commands: [{ type: "shake", power: 12 }] }Save / restore
Section titled “Save / restore”A conversation in progress is not saved, so a channel never has to restore one mid-line. Dialogue variables persist through the storage you install on the controller. Save / load shows how to save them with your game.
The input option has three modes:
- Omitted — the zero-config default: keyboard/gamepad and mouse/touch,
with pointer hit-testing wired to the bundle’s own choices presenter — a tap
advances lines, and tap/hover picks or highlights choice rows out of the box.
(A custom choices presenter without
choiceAtPointdegrades the pointer side to tap-to-advance only.) The keyboard side polls the action names inFULL_DIALOGUE_ACTIONS(interact,attack,move-up,move-down,skip), so those names must exist in your game’sInputManageraction map. An unmapped action name silently never fires; if none of the default names are mapped, keyboard/gamepad nav does nothing and the controller logs a dev-mode warning at startup. - An
InputBinding— your own device mapping (see the example below). null— NO device input: the ambient/cutscene/host-driven mode. The host callsadvance()/moveSelection()/choose()/skip()itself, andsetInputEnabledis a no-op (there is no binding to gate).
Construct the binding yourself when your action map uses different names (e.g.
camelCase moveUp/moveDown) or you want a hold-to-skip:
import { DialogueController, dialogueControls } from "@yagejs-addons/dialogue";import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";
const bundle = createBoxDialogue();
new DialogueController({ ...bundle, // choice presenter for pointer hit-testing; hold X ~0.6s to skip; // remap the action names to match the game's InputManager map input: dialogueControls(bundle.choices, { actions: { advance: ["interact"], speed: ["attack"], up: ["moveUp"], down: ["moveDown"], skip: ["skip"], }, skipHold: 0.6, }),});KeyboardInputBinding(actions?, skipHold?)— advance / hold-to-fast-forward / choice nav / skip.skipHold > 0(seconds) gives the classic hold-to-confirm skip (default0= fire on press).actionsdefaults toDEFAULT_DIALOGUE_ACTIONS.PointerInputBinding(choiceTarget?)— tap to advance; tap/hover choice rows.CompositeInputBinding/dialogueControls(choiceTarget?, { actions?, skipHold? })— both at once (the zero-config default;actionsdefaults toFULL_DIALOGUE_ACTIONS).DEFAULT_DIALOGUE_ACTIONS/FULL_DIALOGUE_ACTIONS— action-map presets.
For a VN-style auto-advance toggle, call controller.setAutoAdvance(seconds) (and
setAutoAdvance(null) to turn it off); a per-line autoAdvance still overrides
it. An ambient (always auto-advancing) conversation passes input: null.
Theming
Section titled “Theming”A DialogueTheme is one flat data object (geometry, colours, fonts, layers,
reveal rate, and the opt-in textured styles). defaultDialogueTheme() returns a fresh
zero-asset instance each call, so spread-and-tweak is safe:
import { createBoxDialogue, defaultDialogueTheme } from "@yagejs-addons/dialogue/presenters";
const theme = { ...defaultDialogueTheme(), textColor: 0xffe0c0, charsPerSec: 60 };createBoxDialogue(theme);The box is viewport-relative — { marginX, marginY, minHeight } (virtual px),
not absolute coordinates. The box presenters read the renderer’s design size at
mount and resolve a full-width bottom bar (width = viewport.width − 2·marginX,
anchored marginY from the bottom edge), so the default works at any
resolution with no override. meta.position reuses the same margins to anchor
to the top or centre instead. (For a pixel-exact box at a known resolution,
choose margins that resolve to it.)
Presenter-config field names match the theme field names exactly (theme
frameColor becomes config frameColor, theme textSize becomes config
textSize, and so on), so re-skinning is just data. Alongside the colours and
sizes, the theme carries
caret ({ blink, size } for the blinking continue indicator), choiceGap
(the gap between choice rows), and tailLean (the speech-bubble tail-tip
offset).
Graphics chrome + canvas SplitText/Text, native bold/italic, per-glyph
effects. Set fontFamily to choose the canvas font. No bundled files.
Set bitmapFont (baked via installBitmapFont) for a crisp pixel-font
look. Bold/italic are synthesised on the regular atlas (skew +
double-draw), so no variant atlases are needed. Not the default.
theme.textured is a map of named chrome styles, each a nine-slice
frame ({ frame: { texture, insets }, bubble? }) drawn through the
renderer’s createNineSlice — so the addon needs no pixi.js peer. A box
line picks its style by name through its meta.chrome key; the speech
bubble uses the "default" style’s bubble, content-sized per line.
Textured chrome and meta.chrome
Section titled “Textured chrome and meta.chrome”Set theme.textured to a map of styles. Two names are reserved: "default" is
the look for lines that name no style, and "none" (built-in, no entry needed)
hides the box frame for a single line.
import { defaultDialogueTheme, type DialogueTheme } from "@yagejs-addons/dialogue/presenters";
const theme: DialogueTheme = { ...defaultDialogueTheme(), textured: { default: { frame: { texture: "ui/box", insets: { left: 16, top: 16, right: 16, bottom: 16 } }, bubble: { texture: "ui/bubble", insets: { left: 12, top: 12, right: 12, bottom: 12 } }, }, parchment: { frame: { texture: "ui/parchment", insets: { left: 16, top: 16, right: 16, bottom: 16 } } }, },};meta.chrome is line-level metadata. In YAML you write meta directly; in Yarn
it is a #chrome: hashtag (Yarn line hashtags fold straight into meta):
- { speaker: hero, text: "An ornate proclamation.", meta: { chrome: parchment } }- { text: "The cave swallows your words.", meta: { chrome: none } }Hero: An ornate proclamation. #chrome:parchmentThe cave swallows your words. #chrome:noneA line that names no style — or an unknown one — falls back to the "default"
textured style if present, otherwise the drawn Graphics frame. meta.chrome is
box-only; the diegetic bubble keeps its single textured (or drawn) look.
Per-line position and in-box avatars (meta)
Section titled “Per-line position and in-box avatars (meta)”The default box presenters read a few more line-level meta keys (the same
meta bag as meta.chrome, written directly in YAML or as Yarn #key:value
hashtags). view stays the coarse box/bubble selector; these are fine-grained
hints within the box variant. Unknown keys are ignored.
meta.position—top,center, orbottom(default). Moves the box frame and the body text together (RPG-Maker top/middle/bottom).meta.portrait/meta.side/meta.presence— read by the avatar presenters (InBoxAvatarPresenterfor box lines,BubbleAvatarPresenterfor bubble lines): the portrait texture key, which side it sits on (leftdefault /right), andpresence: falseto speak from off-screen (portrait hidden, text reclaims the full width). With an in-box avatar wired, the body text + choice rows reflow around the reserved column.
- { speaker: hero, text: "A message from on high.", meta: { position: top } }- { speaker: hero, text: "Good to see you.", meta: { portrait: hero_smug, side: right } }Hero: A message from on high. #position:topHero: Good to see you. #portrait:hero_smug #side:rightBox height and choice overflow
Section titled “Box height and choice overflow”The box fits the complete wrapped line before the typewriter starts, so the
frame stays stable while text appears. box.minHeight sets a minimum (default
0): use a larger value for a panel that keeps its height across short lines.
The nameplate band takes space only when the line has a nonempty speaker name;
nameSize: 0 reserves no band or gap.
Choices start at the content area’s top, below the name and prompt when present. Labels wrap, and the frame grows to hold them. For the default bottom position, the bottom edge stays at the viewport margin while the top moves upward.
Frame height is capped at the viewport minus vertical margins. There is no
scrolling or pagination: content taller than that continues below the frame.
Split long passages into lines and large menus into smaller menus. More than
softMaxChoices options (default 8) produces an advisory through the engine logger.
Experimental: radial choices
Section titled “Experimental: radial choices”A Mass-Effect-style choice wheel ships under ./presenters as
RadialChoicePresenter, marked @experimental. It shows the choice UI is
swappable, but it isn’t part of any default bundle and is intentionally rough —
its geometry and API may change. Opt in by constructing it yourself
and passing it as the choices channel.
Save / load
Section titled “Save / load”A conversation in progress cannot be saved or resumed. Save between conversations, or replay a script from its start on load.
Dialogue variables persist through the storage you install.
createStoreStorage keeps them in your
game store, which saves with the rest of your game. Game state you exposed
through cells saves with your game state. handle.getVars() reads the current
values. Spent once choices are not stored, so each play() shows them again.
Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/addons/dialogue.md.