Custom player hosts
Use @lute-lang/player directly when PixiJS is not the right renderer. The core has four seams: Presenter renders cues and screens, AudioPort plays bound sounds, AssetLoader loads bound files, and PlayerHost handles host-specific bridges, plugin records and idle policy.
Compose the core
Section titled “Compose the core”import { Effect, Layer } from "effect"import { LutePlayer } from "@lute-lang/player"import * as RuntimeBun from "@lute-lang/runtime/bun"
const player = LutePlayer.make({ bindings: bindingsYaml, presenter: myPresenter, audio: myAudio, assets: myAssets, host: { bridge: { "save-score": (request) => sendToNativeHost(request).pipe(Effect.map((fields) => ({ fields }))) }, plugin: { well: (call) => game.drawFromWell(call.attrs) }, idle: (await_) => Effect.succeed(undefined) }})
const requirements = Layer.mergeAll( RuntimeBun.runtime, RuntimeBun.bundle("dist/bundle"), RuntimeBun.inputLog("my-game"), myKeyValueStore)LutePlayerOptions requires bindings, presenter, audio, and assets; it accepts optional host, cueFactory, linePacing, and autosave, plus one start: seed (default { derive: true }), snapshot (a runtime snapshot), or session (a runtime session the host built — the player drives it and never closes it). cueFactory wraps the default CueFactoryShape exactly once; return its fromOutput unchanged when you need the one-cue-per-output-event ordering. Bindings can be decoded data or YAML text. Provide runtime, bundle, input-log, and key-value-store services before running LutePlayer.make in a scope; with session, only the bundle (the same one) and the key-value store are required.
Hosting recipes (0.44)
Section titled “Hosting recipes (0.44)”Three ways a host composes the player, each checked against the published types (names such as bundle, startMinigame or storeCheckpoint stand for your own values). The imports they share:
import { Effect, Layer } from "effect"import { BrowserKeyValueStore } from "@effect/platform-browser"import { Cues, Director, LutePlayer, Scheduler, Session, Stores, type SaveRuntime } from "@lute-lang/player"import { BundleSource, InputLog, Session as RuntimeSession } from "@lute-lang/runtime"import * as RuntimeBrowser from "@lute-lang/runtime/browser"Composing Director.make yourself
Section titled “Composing Director.make yourself”LutePlayer.make builds the director for you. A host that composes it by hand passes the compiled bundle (required since 0.42: the director builds the default cue factory from it), the player session, stores and scheduler, and — optionally — host and a cueFactory wrapper:
const director = Effect.gen(function* () { return yield* Director.make({ bundle, // the compiled bundle value bindings, presenter, audio, assets, session: yield* Session.PlayerSession, // Session.layer({ bindings, … }) over the runtime session profile: yield* Stores.ProfileStore, // Stores.profileLayer({ project }) saves: yield* Stores.SaveStore, // Stores.layer({ project }) scheduler: yield* Scheduler.make(), host: { plugin: { minigame: (call) => startMinigame(call.attrs) } }, cueFactory: (base) => ({ ...base, fromOutput: (output, readLines, locale) => base.fromOutput(output, readLines, locale).pipe(Effect.tap((cues) => Effect.log(`${cues.length} cues`))) }) })})host.pluginreaches the default cue factory; you do not wire it a second time.- A
cueFactoryfunction wraps that default exactly once (keep one cue per output event, in order). - A pre-composed factory value must come from
Cues.make({ bundle, bindings, plugin })forhost.pluginto be merged into it (the host’s handler wins a duplicate tag); any other value together withhost.pluginmakesDirector.makedie rather than drop the handlers.
LutePlayer.make over your own runtime session
Section titled “LutePlayer.make over your own runtime session”When the game owns the runtime session (it queries it, raises occasions, keeps its input log), build that session first and hand it to the player. The player needs the same BundleSource (it reads the artifacts for cues) and a KeyValueStore for saves and the profile:
const bundleSource = BundleSource.value(bundleValue) // or BundleSource.directory(root) / BundleSource.url(base)const runtimeSession = RuntimeSession.layer({ seed: { derive: true } }).pipe( // or { snapshot } Layer.provide(Layer.mergeAll(RuntimeBrowser.runtime(wasmUrl), bundleSource, InputLog.memory)))
const game = Effect.scoped(Effect.gen(function* () { const session = yield* RuntimeSession.Session const player = yield* LutePlayer.make({ session, bindings, presenter, audio, assets, host }) yield* player.actions({ type: "newGame" }) // …}))
Effect.runFork(game.pipe(Effect.provide(Layer.mergeAll(runtimeSession, bundleSource, Stores.memory))))Stores.memorykeeps saves and the profile in memory (tests, previews). For saves that survive a reload use a persistentKeyValueStore:BrowserKeyValueStore.layerLocalStoragein a browser, or your own implementation on Bun/Node/native hosts. The player writessave:quick,save:auto,save:1… andprofile:<project>; give it a prefixed store (KeyValueStore.prefix) if it shares one with the game.InputLog.memorykeeps the runtime’s input log in memory;InputLog.keyValueStore("game-log")persists it in the same store.- Requirements:
make({ session })needsScope,BundleSourceandKeyValueStore.make({ seed })/make({ snapshot })build the runtime session themselves and also needLuteRuntimeandInputLog.
Runtime snapshot versus player save
Section titled “Runtime snapshot versus player save”runtime Contract.Snapshot | player SaveRuntime | |
|---|---|---|
| Holds | runtime state: await, continuation?, project, request, snapshotVersion, world (state, facts, stage, clock) | { snapshot, log, backlog }: that runtime snapshot plus the player’s accepted-input log and backlog |
| Produced by | runtime Session.save | the player handle’s snapshot (DirectorHandle.snapshot) |
| Consumed by | runtime Session.restore; LutePlayer.make({ snapshot }); RuntimeSession.layer({ snapshot }) | the handle’s restore(save); save slots |
| Restores | the story where it was | the story and the player’s log and backlog; read marks added after the checkpoint are removed, rollback history is cleared, the auto slot is rewritten, the stage is rebuilt, its music is begun without waiting for it to start, and the await is presented again |
Checkpoint and restore a running player with the handle:
const save: SaveRuntime = yield* player.snapshot // linearized with send/restoreyield* storeCheckpoint(save)// … later, same player:yield* player.restore(save)To resume in a fresh player (a new page, a new process), start its runtime from the save’s runtime snapshot, then restore the save to bring back the log and backlog. LutePlayer.make returns once the player has left boot, so restore can follow at once:
const resume = Effect.scoped(Effect.gen(function* () { const save = yield* loadCheckpoint const player = yield* LutePlayer.make({ snapshot: save.snapshot, bindings, presenter, audio, assets }) yield* player.restore(save)}))Resuming a fresh player-web mount
Section titled “Resuming a fresh player-web mount”The player-web Promise facade exposes the same save Effects as the scoped
handle. Save the SaveRuntime, then start a fresh mount from its runtime
snapshot and restore the complete save:
import { mountPlayer } from "@lute-lang/player-web"import type { SaveRuntime } from "@lute-lang/player"
const mountOptions = { bindings: { url: "/player.bindings.yaml" }, bundleUrl: "/bundle/", wasmUrl: "/wasm/lute_runtime_wasm_bg.wasm"} as constconst player = await mountPlayer(document.querySelector("#player")!, mountOptions)const save: SaveRuntime = await player.snapshot()await player.unmount()
const fresh = await mountPlayer(document.querySelector("#player")!, { ...mountOptions, snapshot: save.snapshot})await fresh.restore(save)await fresh.send({ type: "raiseOccasion", occasion: "ambient", target: "npc.guide" })const bundle = fresh.bundleawait fresh.unmount()mountPlayer also provides program directly and rejects send, snapshot,
and restore with the same failures as the underlying Effects. Its bundle
is the bundle loaded by that player, so hosts can inspect index.documents
and artifacts without loading a second copy.
Background barks during a conversation (0.45)
Section titled “Background barks during a conversation (0.45)”A host can show an ambient line while the story waits at a choice or a bridge,
without a second presentation (the runtime keeps one open presentation, and
concurrent presentations are not planned). The read-only queries —
candidates, evaluate, def, gate, view on player.program — answer at
every await and never change the state, so the host can pick a bark while the
conversation is suspended, then read its lines from the bundle:
import { Effect, Schema, SubscriptionRef } from "effect"import type { LutePlayerHandle } from "@lute-lang/player"import type { Bundle, Contract } from "@lute-lang/runtime"
interface AmbientLine { readonly text: string; readonly speaker?: string; readonly voiceKey: string; readonly lineId: string }declare const showAmbient: (lines: ReadonlyArray<AmbientLine>) => Effect.Effect<void> // the host's own UI
// The record fields the pattern reads; other fields are ignored.const Artifact = Schema.Struct({ commands: Schema.Array(Schema.Struct({ kind: Schema.String, position: Schema.String, id: Schema.optionalKey(Schema.String), body: Schema.optionalKey(Schema.String), speaker: Schema.optionalKey(Schema.String), text: Schema.optionalKey(Schema.String), texts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), voiceKey: Schema.optionalKey(Schema.String), lineId: Schema.optionalKey(Schema.String), placeholders: Schema.optionalKey(Schema.Unknown) }))})
/** A static entry's lines, or undefined when the bark must be raised instead. */const staticBarkLines = (bundle: Bundle.Bundle, candidate: Contract.Candidate, locale?: string): ReadonlyArray<AmbientLine> | undefined => { if (candidate.kind !== "entry" || candidate.verdict.verdict !== "eligible") return undefined const document = bundle.index.documents.find((item) => item.path === candidate.document) if (document === undefined) return undefined const { commands } = Schema.decodeUnknownSync(Artifact)(bundle.artifacts[document.artifact]) const head = commands.find((record) => record.kind === "entry" && record.id === candidate.id) const start = commands.findIndex((record) => record.position === head?.body) if (start < 0) return undefined const next = commands.findIndex((record, index) => index > start && (record.kind === "entry" || record.kind === "beat")) const body = commands.slice(start, next < 0 ? commands.length : next) const lines: Array<AmbientLine> = [] for (const record of body) { if (record.kind !== "line" || record.placeholders !== undefined) return undefined if (record.text === undefined || record.voiceKey === undefined || record.lineId === undefined) return undefined const speaker = record.speaker === undefined ? undefined : bundle.index.cast?.[record.speaker] lines.push({ text: (locale === undefined ? undefined : record.texts?.[locale]) ?? record.text, voiceKey: record.voiceKey, lineId: record.lineId, ...(speaker === undefined ? {} : { speaker }) }) } return lines}
// While the story waits at a choice or a bridge:const ambientBark = (player: LutePlayerHandle, bundle: Bundle.Bundle) => Effect.gen(function* () { const { state } = yield* SubscriptionRef.get(player.session.current) const candidates = yield* player.program.candidates(state, "ambient", "npc.guide") for (const candidate of candidates) { const lines = staticBarkLines(bundle, candidate) if (lines !== undefined) return yield* showAmbient(lines) }})candidates returns beats in selection order; the helper takes an eligible
lore entry, finds its head in the document’s artifact (index.documents), and
reads the records from the head’s body position up to the next entry or
beat head. The bark is static when every one of them is a line without
placeholders; each line gives its text (texts[locale] when the bundle has
that locale), the speaker’s display name from index.cast (none for
narration), voiceKey and lineId.
Showing a bark this way records nothing: no entry.<id>.read, no once
spending, no writes. A bark that must record — a first read, once, an
authored write such as ::set — or whose body is dynamic ({{…}}
placeholders, match) is raised as its occasion once the story is back at
idle:
yield* player.send({ type: "raiseOccasion", occasion: "ambient", target: "npc.guide" })See 0.45.0 §3.
Upgrading from 0.40
Section titled “Upgrading from 0.40”The player-session option that names speakers is cast (since 0.41.0). A host that composes the session itself with Session.layer (the PlayerSession layer) passes SessionOptions.cast — a plain record, cast id → display name, which is the bundle index’s cast — where 0.40 took castNames, a ReadonlyMap. The old castNamesFrom(artifacts) helper is gone; read the names from bundle.index.cast:
import { Session } from "@lute-lang/player"
// 0.40: Session.layer({ bindings, castNames: Cues.castNamesFrom(artifacts) })Session.layer({ bindings, cast: bundle.index.cast ?? {} })LutePlayer.make fills cast from the bundle for you. The full list of 0.41 migration steps is in the CHANGELOG.
The handle
Section titled “The handle”LutePlayer.make returns actions, view, send, snapshot, restore, shutdown, session, and program:
snapshotis anEffect<SaveRuntime, Errors.RuntimeRejection>checkpoint. It takes the player session’s lock, so it is linearized withsendandrestore; called while a batch is presenting, it does not wait for the presentation and returns the last committed runtime snapshot, accepted-input log, and backlog (each backlog entry includes itsvoiceKey). It does not capture presenter overlays, timers, profile state, or renderer fibers; pass the result directly torestore(save).sessionis the runtime session in use andprogramitssession.program. Readcurrent, subscribe toevents, and runview/evaluate/def/gate/candidateswithout loading the bundle a second time.send(input)hands the runtime an input while no batch runs and the story waits atidleorended(atended, onlynewRunor an outside-run raise is accepted). Elsewhere it fails withDirectorBusy { flow, story }and changes nothing; a runtime rejection fails it with the runtime’s error and the director stays at the same await.- Writes that belong to the ending (0.44). Once the story is at
ended, ahostWriteis refused (RuntimeBusy, its message says so). Send ending metadata aswriteson the input that ends the run — theraiseOccasion,advanceClock,bridgeResultorhostWritethat makesterminalhold (send({ type: "raiseOccasion", occasion: "dawn", writes: { state: [{ path: "run.endedBy", value: "dawnSeven" }] } })).choose,timeoutandworldEventcarry no writes: when one of them ends the run, record the state in the story (a::setin the branch) or in ahostWritesent before it.newRun’s writes seed the next run, where the ended run’s values read asprev.run.*.sendcompletes when the input was accepted and its batch started.
Port contracts
Section titled “Port contracts”Presenter implements stage(cue, mode), showLine(line, mode), revealLine, clearLine, restore(stage), showChoice(menu, remaining), hideChoice, showScreen(screen), hideScreen, setUiHidden, applySettings, capture, and an actions stream. A staging or line effect completes when the visual effect or typewriter completes. mode: "skip" must snap to the end state. restore is used on load, rollback, and presentation start; restore the runtime’s stage directly rather than replaying old records.
A StageCue.Vfx carries an EffectSpec: a catalogue effect (flash, shake, …) or effect: "custom" with a name — a { effect: custom } vfx binding (its name or the binding key) or a vfx type with no binding. Branch on cue.effect.effect === "custom" and read cue.effect.name. LineView.speakerName is the cast name from the bundle index (empty for narration); emotion and variant carry the line’s own values.
AudioPort implements play(channel, sound), stop(channel, fade), and setVolumes. Channels are music, sfx, and voice. AudioPlay.file is already resolved from bindings; loop, volume (0–1), fade and restart are explicit; a music volume is the gain bindings.volumes maps the record’s member to (no member: 1; a member without a gain: 0). Music records follow playback: start plays from the start (restart: true); stop / fadeOut stop (fade duration, fadeOut defaulting to 1 s); any other value changes to the file, and playing the file that already plays without restart only changes its volume. AssetLoader.ensure(files) keeps files loaded for the open scope and reports progress through its stream; missing files fail with AssetMissing.
Restoring a stage (load, restore, rollback) does not await its music call: the director begins the music play (or stop) on a fiber of the player scope and goes on, so that call may still be in progress (waiting for the platform to allow sound, or for a file) when the story continues. An AudioPort MUST let a later music play or stop win over an earlier music play still in progress: the earlier one completes without starting (or is stopped once it starts). PixiAudio does this, so a load before any user gesture reaches its restored await at once and the stage’s music starts when audio unlocks.
While the platform keeps audio locked (player-web before the first user gesture), PixiAudio.play returns at once on every channel and the sound starts when audio unlocks (0.48.1). A music cue, or an sfx/voice cue with wait, therefore never holds a batch on a sound that cannot start: a batch the host drives through act / send before any gesture settles, and the music begins on the first gesture. A music play or stop issued while locked still supersedes an earlier one that has not started. A custom AudioPort that cannot start sound yet SHOULD do the same rather than leave play pending.
The public PlayerAction union is the device-independent input surface: advance, auto/skip, rollback, backlog/menu/save/load/settings, quick save/load, UI visibility, choice, idle, send, voice replay, and settings actions. A native host can emit these actions from any device.
Line pacing
Section titled “Line pacing”By default the director paces lines: showLine completes when the text is fully shown, advance reveals a typing line and releases a shown one. A presenter that types, pages and takes the confirm itself has two options:
linePacing: "presenter":showLinecompletes when the reader released the line.advancethen never reveals or releases a line and the director runs no auto timer for lines (the presenter readsPlayerView.mode).showLine(line, "skip")still completes at once.- Keep director pacing and set
Presenter.confirmsLines: true: anadvancethat arrives while the line is typing is kept and applied as the release when the line is shown, instead of revealing it.
Bridges
Section titled “Bridges”A bridge call suspends the runtime. Add a handler keyed by the bridge tag:
const host = { bridge: { "battle-result": (request) => Effect.tryPromise(() => nativeBattle(request.attrs)).pipe( Effect.map((result) => ({ fields: { won: result.won, score: result.score }, writes: { state: [{ path: "run.gold", value: result.gold }] } })) ) }}The handler receives the typed BridgeAwait (request, tag, document, position, the call’s attrs, the plugin stamps (0.49: the directive’s plugin stamp attributes, separate from attrs, absent when the command has none) and the typed results it must answer) and returns a BridgeAnswer { fields, writes? }. The director sends bridgeResult { request, fields, writes? }, then continues cue playback; the writes land before the lines that follow. The handler runs on a fiber of its own, so menus keep working while the host plays. If no handler exists, the player emits a bridge-unhandled failure rather than inventing an answer.
Plugin records
Section titled “Plugin records”A non-blocking plugin directive reaches the host through PlayerHost.plugin, keyed by tag. The record becomes a waiting cue: the handler runs with a PluginCall { tag, attrs, stamps?, localized?, raises?, document, position } (stamps carries the command’s plugin stamp attributes, separate from attrs and absent when it has none; localized and raises (0.51) are described under Declared raises and Locale, and are absent when the record has none) at the record’s place in the batch, the batch continues when it completes, and it runs in skip mode too (a game action is not presentation). Records of calls answered through a bridge are never handed to plugin. The cue factory returns exactly one cue per output event, in order, so a host may also wrap it.
Director.make accepts the same bundle, bindings, optional cue-factory
wrapper, and host options, so a hand-composed director does not need a
second plugin-wiring call. If a pre-composed Cues.make({ plugin }) and
host.plugin both handle a tag, the host handler wins; unique tags from both
maps remain available and the duplicate runs only once. A pre-composed factory
not built by Cues.make cannot take host.plugin: Director.make dies
rather than drop the handlers — pass a wrapper instead.
Declared raises
Section titled “Declared raises”Since 0.51 a plugin directive or a reward kind may declare effects.raises: occasions the host raises in answer to the call or the grant. The runtime raises nothing itself; it copies the resolved list (raises: [{ occasion, target? }], with occasion.target already bound) onto the emitted plugin record and the grant record, and PluginCall.raises? hands it to a plugin handler. Raising it is the host’s duty:
- after the presentation that ran a directive call ends (
presentationEnd), once the runtime is idle, send each listed occasion as araiseOccasioninput, in record order and list order; - after the output that carries a reward’s
grantrecord has been applied, do the same for its raises.
The reference player does this with a pending raise queue in its session shell:
- Enqueue. When an output is applied, the
raisesof its records are appended in record order and list order, each as aPendingRaise { occasion, target?, document, position?, logIndex }(positionis absent for agrantrecord;logIndexis the input-log entry whose output carried the record). The output of a start (newRun,toStart, a lineage restore) is enqueued exactly once, like any other output. - Drain boundary. The director drains the queue on its idle flow: once a batch, a load or a rollback has settled at
idle(the same turn as the batch settling), and before the hostidleand the bindings’ idle flow. It does not drain inside the batch fiber, so a load or rollback that lands atidlewith raises still pending drains too; nothing drains atended. Each step takes the head of the queue, removes it and sends it as araiseOccasioninput under the session lock, uninterruptibly, so a raise is delivered exactly once. A raise the runtime rejects is logged and dropped, not retried. If writing the input log fails, the head is put back, because the runtime session only advances after its log append. The output of a raise may enqueue further raises; the drain continues until the queue is empty, then the idle flow runs. - One rollback step per player input. The checkpoint is the existing one taken before a player input is submitted, and it now also captures the pending queue. Automatic declared raises are sent through a path that does not append to history, so they never create a checkpoint of their own:
rollback(1)undoes the player input and every raise it caused. Whether none, some or all of those raises had been delivered,rollback(1)restores the queue as it was before that input; replaying the input regenerates the raises, and each is delivered once. (begin’sflow.newGameraise keeps its own checkpoint, as before.) - Starts. An accepted
newRun,toStartor lineage replacement clears the old pending raises before the new start output is enqueued; a rejected one leaves the queue as it was. - Saves. The runtime replays only the undelivered suffix of an output, so a raise delivered before a save cannot be rebuilt from the resumed output. The queue is therefore part of the player’s state:
SaveRuntimegains a requiredpendingRaises, which the save codec writes and reads; each rollback point records the queue as it was before its player input, andrestoreandrollbackreinstate it (a restored raise is sent once the story is idle again). A save of a project without declared raises writespendingRaises: []. The save format moves to"0.51", so"0.50"saves are refused, as every minor’s are. The runtimeContract.Snapshotdoes not change.
A host that composes its own session has the same duties: deliver each declared raise once after the settle point above, keep its own pending queue across saves and rollback, and drop a raise the runtime rejects.
An occasion declared raisedBy: declared is raised only through declared raises, so a host must not raise it from its own configuration: a bindings.flow.idle or bindings.flow.newGame that raises such an occasion breaks the occasion’s own declaration (see Player bindings).
Idle flow
Section titled “Idle flow”flow.newGame raises the configured occasion after the runtime begins. The first new game presents the session’s start (started) followed by that raise; a later new game from the title starts a new run (newRun, keeping user.* progress) instead of replaying the start. At idle, flow.idle may raise an input automatically, or expose a menu of labelled occasions. PlayerHost.idle(await_) runs first, on a fiber of its own, and may return an Input to override the bindings flow. Returning undefined delegates to flow.idle: an input is sent immediately, while a menu becomes an Idle screen and waits for pickIdle. A host that drives the story from its own game (a map, a HUD) returns undefined and calls send when the game acts; a send interrupts a pending idle.
New game at the ending (ended, also on a session restored from an ended snapshot) sends newRun; it never presents started again. At the ending, toTitle goes to the title at once, without the discard-progress confirm (0.47: an ended run has nothing to lose, and its lineage is already stored). toTitle from the in-game menu and in the error flow still asks.
Autosave
Section titled “Autosave”autosave controls the auto slot: omitted, it is written at each presentation start and at every choice; "presentation" or "choice" writes only there; false never writes it (no snapshot, no capture). Quick saves and slot saves are unaffected.
Saves and profile data
Section titled “Saves and profile data”The core stores saves through Effect’s KeyValueStore. Slots are quick, auto, and numbered slots 1..24. A save has this shape:
{ "saveVersion": "0.53", "project": "<bundle fingerprint>", "savedAt": "<ISO timestamp>", "label": "Chapter 1", "runtime": { "snapshot": "<runtime snapshot>", "log": [], "backlog": [], "pendingRaises": [] }, "thumbnail": "data:image/png;base64,..."}The runtime snapshot includes world.stage and continuation.stage where applicable; the player adds its deterministic input log, backlog and (0.51) the declared raises it has not sent yet. Profiles use profileVersion (the release line, "0.53"), the project fingerprint, readLines, and settings. Slot keys (save:quick, save:auto, save:1…) are not namespaced; each save carries the project fingerprint and another project’s save is refused, and the profile is stored under profile:<project>. Give the player its own (or a prefixed) store when the game stores other data. A different project or save format returns SaveIncompatible; malformed data returns SaveCorrupt.
Since 0.47 a profile also holds the lineage when one was written (see the cross-run model below). A profile of another profileVersion is never refused: its read marks and settings are kept, and it has no lineage.
Second play-throughs: the cross-run model (0.47)
Section titled “Second play-throughs: the cross-run model (0.47)”What survives a page reload:
- The user tier —
user.*values, user-tier facts,once: userspends and the read marks of entries — and the ended run’s values, whichnewRunturns intoprev.run.*. Both live in the runtime snapshot; they survive a reload through the lineage. - Read history and settings (
readLines,settings) — in the profile. - A run in progress — only through a save (quick, auto or a slot).
When the lineage is written. Each time the session presents an ended output, the player takes the runtime snapshot (Session.save) and stores it in the profile as lineage, replacing the previous one. It is written at once, not debounced like settings.
When it is used. In a player started without snapshot / session (the default, or a host seed), New game that cannot send newRun where the session waits — an untouched session after a reload, or a run abandoned through the menu — restores the lineage instead of the start point, then sends newRun; the input log then starts with { snapshot: <lineage> }. A host seed is not used on New game while a lineage exists. If the runtime refuses the lineage (for example a snapshot of another build), New game starts from the start point as without one and the lineage is dropped. A host that passes snapshot or session chose its state: endings still write the lineage, but New game never restores it. A host that carried user.* across reloads itself can drop that code.
Locale (0.47)
Section titled “Locale (0.47)”The locale is a setting (settings.locale), not a mount option. A loaded bundle’s source locale is the project index’s contentLang (the manifest’s defaults.contentLang), else the contentLang of the first artifact (in index.documents order) whose meta declares one, else "en"; its compiled locales are the keys of the artifacts’ texts and promptTexts maps (lute compile --locale-bundle), so a locale that translates only a prompt is listed too. The Settings screen model’s locales is the source locale followed by the compiled locales (sorted, without duplicates). A new profile starts at the source locale; a saved locale the bundle does not carry (a 0.46 profile’s "en" in an en-US project) reads source text, and Settings shows the source locale selected.
For the current locale, the dialogue line (texts[locale], localeSegments[locale]), choice and hub option labels (the menu option’s texts[locale]), branch and hub prompts (the menu’s promptTexts[locale]) and the backlog read the translation and fall back to the source text; speaker names are not localized. setSettings({ locale }) applies to the next text shown and to the backlog the next time it opens — no new game needed. The cue factory takes the locale per call, fromOutput(output, readLines, locale) and choiceView(await, locale): a cueFactory wrapper or a custom factory receives it and passes it on.
A dialogue line and a backlog entry take their translation from the runtime’s line record, not from the compiled artifact: the record carries texts and localeSegments with every {{…}} marker already replaced by its value. A translation’s marker gets the value the source line rendered for the marker with the same referent (the text inside {{ }}, trimmed), so a translator may reorder markers; a marker the source line does not have stays as written, and a translation may write \{{ for a literal {{ (lute 0.47.1). The record holds the decoded text; an engine that substitutes placeholders itself must apply the IR’s escaped-interpolation reading (see Localized text).
Menu text arrives rendered (0.52). Option labels and prompts come the same way, from the runtime, not from the artifact: the awaitChoice menu carries each option’s text and texts and the menu’s prompt and promptTexts, with every {{…}} marker replaced by its value at that presentation (a hub re-renders each time it presents) and \{{ read as a literal {{. choiceView reads them (localizedText(option.texts, locale, option.text), and menu.promptTexts / menu.prompt); stamps still come from the IR command. A custom host shows the menu’s strings too: the IR’s options[].text and prompt keep their markers as written. A host token ({{userName}}) stays verbatim for the host to substitute, as in a line.
A directive’s text attributes (0.51, type: text) follow the same rules: the runtime renders each marker once and writes the rendered source into the record’s attrs.<attr> and the rendered translations into localized: { <attr>: { lineId, texts } }. PluginCall.localized? is a Readonly<Record<string, string>> holding each text attribute in the player’s current locale (localized.<attr>.texts[locale], else attrs.<attr>), so a plugin handler shows the right language without reading the record. The cue factory defers a plugin cue like a line cue, so the locale is the one current when the call runs: a locale change while an earlier line of the batch is still typing reaches a later call. Host-token markers stay verbatim, as in a line. Locale discovery reads only each command’s texts and promptTexts, each option’s texts, and, for a plugin command, the keys of every localized.<attr>.texts; it never reads a fields or stamps map, so an attribute named texts or promptTexts is not misread, and a locale that translates only a directive’s text is listed too.
Rollback and history
Section titled “Rollback and history”The session retains the last settings.rollbackDepth input points (default 100). Each point contains runtime state, await, stage, backlog length, and log length. rollback interrupts current cue fibers, restores the selected point’s stage through Presenter.restore, begins its music without waiting for it to start, and resumes from that await. If no earlier point remains, the core returns NothingToRollback. It never serializes an XState actor snapshot as a save.
Read history and backlog
Section titled “Read history and backlog”Each line has a stable lineId. The profile’s readLines set marks lines after they are shown. LineView.read controls read-aware skip behavior; the default read-only skip stops on unread lines, choices, bridges, and idle. Backlog entries retain line id, voice key, speaker name, source text, whether voice is available, and the line’s translations (texts, and localeSegments when present; saved backlogs keep them). The Backlog screen model’s entries carry text resolved for the locale current when it opens. Hosts can render the Backlog screen and replay voice with replayVoice.
For a complete architecture contract, see docs/player/architecture.md. For browser implementations, see the web architecture.
Choice views and restored assets
Section titled “Choice views and restored assets”Presenter.showChoice receives a ChoiceView with the rendered prompt and
option text in the current locale, plus runtime verdict, timeout, default, once, and exit
metadata. Since 0.50 it also carries the plugin stamps authored on the
<branch>/<hub> element (ChoiceView.stamps) and on each <choice>
(options[i].stamps), read from the IR command at the menu’s address; each is
absent when none is authored. A host without the reference player reads the
same stamps from that IR record. On load and rollback, the director ensures
the restored stage’s bound files before calling Presenter.restore.