--- name: pixi-vn-narration description: Use when writing or editing Pixi'VN story content — defining labels (scenes) with newLabel, writing dialogue steps, adding player choices with newChoiceOption/newCloseChoiceOption, conditional/branching labels, or wiring up narration.call/jump/continue and Game.start to progress the story. --- # Pixi'VN Narration module ## When to use this skill Use this skill whenever the task is about the story itself: creating a new scene/label, writing a line of dialogue, adding a menu of player choices, branching a label's content based on game state, or driving story progression (starting the game, moving to the next step, calling/jumping between labels). This is the core engine of Pixi'VN — almost every "write a scene" or "add a choice" request touches this module. Do not use this skill for: character definitions/appearance (see `pixi-vn-characters`), undo/go-back mechanics (see `pixi-vn-history`), save files (see `pixi-vn-saves`), or persistent game variables (see `pixi-vn-storage`). Official docs for this module: https://pixi-vn.com/start/narration (overview), https://pixi-vn.com/start/labels and https://pixi-vn.com/start/labels-flow (labels/steps/flow), https://pixi-vn.com/start/labels-advanced (hooks, dynamic steps), https://pixi-vn.com/start/dialogue, https://pixi-vn.com/start/choices, https://pixi-vn.com/start/input. ## Optional runtime AI: `@drincs/pixi-vn-ai` For dialogue or images generated during play, install the separate `@drincs/pixi-vn-ai` package and its required peers; import its `ai` singleton from that package. This API is experimental: check the installed types before implementing provider-specific options. Await `ai.init(...)` once during startup. Without options it downloads a local WebLLM text model in the browser; image generation still needs an external provider. Configure `textProvider` and/or `imageProvider` for AI SDK models, or use `ComfyUIImageModel` from `@drincs/pixi-vn-ai/comfyui` with a ComfyUI API-format workflow for images. In an async label step, generate the text and assign it to the usual dialogue state: ```ts import { narration } from "@drincs/pixi-vn"; import { ai } from "@drincs/pixi-vn-ai"; // Inside an async step, after startup has awaited ai.init(...): narration.dialogue = await ai.text.generateDialog( "Describe the empty station as the last train leaves.", { scene: "A rural station at dusk", language: "English", history: true }, ); ``` `ai.image.generateBackground` and `ai.image.generateElement` generate scene imagery; the library builds prompts from the request and structured context. Consult the [AI guide](https://pixi-vn.com/start/ai-generated-content) and [plugin README](https://github.com/DRincs-Productions/pixi-vn-ai) for dependencies, provider configuration, image results, and display helpers before wiring images into canvas. ## 1. Defining a label A **label** is the Pixi'VN equivalent of a Ren'Py "bookmark"/landmark in the story (the same concept is called a `knot` in the _ink_ language): a container for an ordered list of **steps** (plain functions), each one run when the player advances the story. Labels are created with `newLabel` and are automatically registered so they can be referenced by id anywhere (choices, `narration.call`, `narration.jump`, `Game.start`). ```ts import { narration, newLabel } from "@drincs/pixi-vn"; const startLabel = newLabel("start", [ () => { narration.dialogue = "Welcome to the game!"; }, () => { narration.dialogue = { character: "liam", text: "Hi, I'm Liam." }; }, ]); ``` Key points: - `newLabel(id, steps, props?)` — `id` must be unique across the whole game; `steps` is an array of `StepLabelType` functions, executed in order as the player continues; `props` is optional (`onStepStart`, `onStepEnd`, `onLoadingLabel` hooks — rarely needed for basic authoring). - Each step is `(props, { labelId }) => StepLabelResultType | Promise`. Steps can be `async` when they need to `await` something (e.g. calling another label, loading assets). Return value is optional — most steps return nothing (`void`); returning a string/object is only needed by code that consumes `StepLabelResultType` explicitly. - Every label file should call `newLabel(...)` at module scope so the label registers itself simply by being imported. Make sure the module gets imported somewhere reachable at startup. - `steps` can also be a **function that returns the array**, e.g. `() => [...]`, which lets step content change dynamically based on game state — see "Conditional branching" below. ## 2. Dialogue Set `narration.dialogue` inside a step to display a line. It accepts either a plain string/array of strings, or a `DialogueInterface`-shaped object with `text` and an optional `character`: ```ts // Narrator line (no character) narration.dialogue = "He thrusts out his hand."; // A line with a speaker — character can be a registered character's string id... narration.dialogue = { character: "james", text: `You're my roommate's replacement, huh?`, }; // ...or the character object itself, e.g. imported from your characters module narration.dialogue = { character: liam, text: "Hi, I'm Liam." }; ``` Which form to prefer depends on the project's setup — see `pixi-vn-characters` for the full rationale, but in short: prefer the **instance** unless the `@drincs/pixi-vn/vite` plugin's `typeFilePath` generation (narrowing `CharacterIdType` to known ids, see `pixi-vn-getting-started`) is set up, in which case prefer the **string-id form** — it's just as typo-safe then, without needing to import the character module in every label file. `narration.dialogue` is a getter too: reading it returns `{ text, character }` (with `character` resolved to the full `CharacterInterface` when a registered id was used, or `undefined`/the raw string otherwise). **Gluing dialogue across steps**: set `narration.dialogGlue = true` _before_ assigning `narration.dialogue` in a step to have that line's text appended to the current dialogue text instead of replacing it. This lets a paragraph be split across several steps (each free to change a character's pose/sprite in between) while it reads to the player as one continuous block of text, with no waiting/empty-box flash in between. From the official React template (`src/content/labels/second.label.ts`): ```ts async () => { await showImageContainer("steph", ["fm02-body", "fm02-eyes-joy", "fm02-mouth-smile00"]); narration.dialogue = `She enters my room before I'VE even had a chance to.`; }, async () => { await showImageContainer("steph", ["fm02-body", "fm02-eyes-joy", "fm02-mouth-smile00"]); narration.dialogGlue = true; narration.dialogue = `\n\n...I could've just come back and gotten the platter later...`; }, ``` The player still advances one step at a time as usual (`dialogGlue` only affects how the _text_ accumulates, not whether `continue()`/player input is needed); it resets automatically once a non-glued `narration.dialogue` assignment is made. Character objects (`CharacterBaseModel` and registration via `RegisteredCharacters`) are covered by the `pixi-vn-characters` skill — this module only needs a character's `id` (or the object) to attach it to a line. Docs: https://pixi-vn.com/start/dialogue ## 3. Rich text: Markdown and HTML in dialogue Docs: [pixi-vn.com/start/markup](https://pixi-vn.com/start/markup), [markup-markdown](https://pixi-vn.com/start/markup-markdown), [markup-tailwindcss](https://pixi-vn.com/start/markup-tailwindcss). Pixi'VN isn't tied to any markup language for `narration.dialogue` text, but **Markdown is the recommended one**, and Markdown can be freely mixed with raw HTML for anything Markdown itself can't express. Every official template already renders dialogue this way — in the React template specifically, via `react-markdown` with the `remark-gfm` (GFM tables/strikethrough/etc.) and `rehype-raw` (allows raw HTML inside the Markdown source) plugins: ```tsx import Markdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; {narration.dialogue?.text} ; ``` **When writing dialogue text, default to plain Markdown's classic inline styles** — `*italic*`, `**bold**`, and similar simple emphasis — rather than reaching for raw HTML/CSS. Only use HTML (or anything fancier) when the user actually asks for it (e.g. a specific color, an animation, an underline a specific character always has): ```ts narration.dialogue = "*She* was **not** amused."; ``` When HTML *is* asked for, the recommended CSS approach (per the docs) is **Tailwind CSS** — already installed and configured in every official template, optionally with plugins like `@tailwindcss/typography`, `tw-animate-css`, or `tailwind-animations` for richer text effects. **A Tailwind-styled inline element inside dialogue text must be `inline-block`** — a plain `inline` element won't apply `transform`/animation utilities correctly mid-paragraph: ```ts narration.dialogue = `The night was still.`; ``` ## 4. Choices A choice menu is created by assigning an array to `narration.choices`, typically built with `newChoiceOption` (opens another label) and/or `newCloseChoiceOption` (just closes the menu and lets the current label's next step run). ```ts import { narration, newChoiceOption, newCloseChoiceOption, newLabel, } from "@drincs/pixi-vn"; const choiceLabel = newLabel("choice", [ () => { narration.dialogue = "What do you choose?"; narration.choices = [ newChoiceOption("A", ALabel, {}), // default type is "call" newChoiceOption("B", BLabel, {}, { type: "jump" }), newCloseChoiceOption("Close"), ]; }, () => { narration.dialogue = "end"; }, ]); ``` - `newChoiceOption(text, label, props, options?)` — `text` is the option's label text; `label` is the target `Label` object or its string id; `props` are passed to the target label's steps (pass `{}` if none are needed); `options.type` is `"call"` (default — the target runs and can return to this label afterwards) or `"jump"` (the current label is closed first, no return). - `newCloseChoiceOption(text, options?)` — closes the choice menu without opening any label; narration simply continues into the current label's next step. `options.closeCurrentLabel: true` additionally closes the current label when chosen. - Other `ChoiceInterface` options available in both: `oneTime` (option disappears once chosen), `onlyHaveNoChoice` (shown only as a fallback if every other option has been removed), `autoSelect` (if it ends up being the only available option, it's chosen automatically without player input). - Read `narration.choices.list` (a `StoredChoiceInterface[]`, an indexed array — each option carries a `choiceIndex`), and call `narration.choices.select(choice, props)` when the player picks one. Pass through any `StepLabelProps` the target label needs plus the option's own stored `props`: ```ts const item = narration.choices.list![0]; await narration.choices.select(item, { ...item.props }); ``` `choices.select` dispatches to `narration.call`/`narration.jump`/closing the menu based on the chosen option's `type`, and records the pick so `narration.queries.alreadyCurrentStepMadeChoices` / `queries.timesChoiceMade` can reflect past choices (useful for `oneTime` options across replays). Docs: https://pixi-vn.com/start/choices ## 5. Progressing and closing labels Docs: https://pixi-vn.com/start/labels-flow - **Start the game**: `Game.start(label, props)` clears all game data and calls the given label — this is the standard entry point, e.g. `Game.start(startLabel, {})`. `label` can be the `Label` object itself (what the docs show) or its string id (`Game.start("start", {})`) — both work, `Game.start` is a thin wrapper around `narration.call` plus `Game.clear()`. - **Advance one step**: `await narration.continue(props)` runs the current label's next step. Guard advancing with `narration.canContinue` (false while a step is running, or a choice menu / required input is pending) — this is the shape a "next" button/handler should use: ```ts while (narration.canContinue) { await narration.continue({}); } ``` - **Call a sub-label** (like a function call — returns to the caller when it runs out of steps): `await narration.call(otherLabel, props)`. Can be used both to start the very first label and, from inside a step, to invoke a nested label. If you call it from inside a step, **return** the result and `await` it, so the history bookkeeping stays correct: ```ts async (props) => { return await narration.call(otherLabel, props); }; ``` - **Jump to another label** (closes the current label first, no return): `await narration.jump(otherLabel, props)`. Same rule applies — `return await narration.jump(...)` when called from within a step. - A label closes automatically once its steps are exhausted; the previously calling label (if any) resumes automatically. To close explicitly from within a step (e.g. after setting state), just `return` — Pixi'VN advances past the label's end on the next `continue()`. Use `narration.labels.closeCurrent()` / `narration.labels.closeAll()` only for lower-level manual stack control (`closeAll()` ends the game if nothing is called afterwards). - `narration.labels.opened` and `narration.labels.current` reflect the current call stack of nested labels, useful for debugging/branching logic. - **Going back a step** is handled by the history module, not `narration` — see `stepHistory.back()` / `stepHistory.canGoBack` in the `pixi-vn-history` skill. ## 6. Input prompts Docs: https://pixi-vn.com/start/input To ask the player for a value (string, number, or an HTML element like a textarea), call `narration.input.request` inside a step; the engine will not let the story continue (`narration.canContinue` is `false`) until a value is provided by the UI: ```ts () => { narration.dialogue = "What is your name?"; narration.input.request({ type: "string" }); }, () => { narration.dialogue = `My name is ${narration.input.value}`; }, () => { narration.dialogue = "How old are you?"; narration.input.request({ type: "number" }, 18); // second arg is an optional default value }, ``` - `narration.input.request(info?, defaultValue?)` — `info` is `InputInfo` minus `isRequired` (e.g. `{ type: "string" }`, `{ type: "number" }`, `{ type: "html textarea" }`); omit it for a plain text input. - `narration.input.isRequired` / `narration.input.type` tell the UI layer whether a prompt is pending and what kind, so it can render the right control. - `narration.input.value` holds the submitted value once the UI resolves the prompt — read it in a later step to use what the player typed. - `narration.input.removeRequest()` cancels a pending request without a value (rarely needed in normal authoring). ## 7. Conditional branching Because `steps` can be a function, a label's content can change based on stored state — e.g. showing different dialogue on a repeat visit: ```ts import { narration, newLabel, storage } from "@drincs/pixi-vn"; const talkAliceQuest = newLabel("talk-alice-quest", () => { if (storage.flags.get("test") === false) { return [ () => { narration.dialogue = { character: "alice", text: "Hi, can you order me a book?", }; }, () => { narration.dialogue = { character: "mc", text: "Ok" }; }, async (props) => { storage.flags.set("test", true); await narration.continue(props); }, ]; } return [ () => { narration.dialogue = { character: "alice", text: "Thanks for the book." }; }, ]; }); ``` The function re-runs every time the engine needs to know the label's steps, so branch on stable storage flags/variables (see `pixi-vn-storage`) rather than on anything that changes mid-step. The last step in a branch can `await narration.continue(props)` itself if it wants to immediately auto-advance past a "setup" step instead of waiting for another player input. ## 8. Other useful narration bits Docs: https://pixi-vn.com/start/other-narrative-features - `Game.onEnd(async (props) => {...})` runs once every open label's steps are exhausted and nothing else is called — the standard place to navigate to an ending screen, or to `narration.call`/`jump` back into a menu/loop label if the game should never truly "end". - `narration.getRandomNumber(...)` gives a seeded-safe random number (prefer this over raw `Math.random` so saves/rollback stay deterministic). - `narration.queries.isLabelAlreadyCompleted(label | labelId)` checks whether a label has fully run before — handy for "seen this scene already" branching. - `narration.currentStepTimesCounter` counts how many times the _current_ step has executed (only increments when actually read, and only once per step execution); set it to `0` to reset. Useful for "show this the first time only" logic inside a dynamic step-list function. ## 9. Real-world project convention (official React template) The library itself doesn't mandate any file layout or UI wiring — the points below are how `npm create pixi-vn@latest`'s official "TS narration + React" template does it, as seen in `pixi-vn-react-template`. Treat this as one convention worth following/recognizing, not a hard requirement: other narration styles (e.g. an _ink_-based project) will have their own skill and their own conventions. - **File layout**: each scene lives in its own `src/content/labels/.label.ts`, exporting a `newLabel(...)` result (e.g. `second.label.ts` exports `secondPart = newLabel("second_part", [...])`). Character definitions live alongside under `src/content/characters/`. - **Auto-registration**: `src/content/index.ts` does `import.meta.glob(["./**/*.ts", "!./index.ts"], { eager: true })` (a Vite feature) to eagerly import every module under `content/` for its side effects at startup. Since `newLabel`/character registration runs at module scope, a new label file just needs to exist under `content/` — no manual import list to maintain. - **Driving narration from the UI** (see `lib/hooks/narration-hooks.ts` and `lib/query/narration-query.ts`): a "next" handler guards on `narration.canContinue` before calling `await narration.continue(gameProps)`; a choice handler reads `narration.choices.list` and calls `await narration.choices.select(item, gameProps)`; an input prompt reads `narration.input.isRequired` / `narration.input.type` / `narration.input.value`. Real code wraps all three in a loading-state guard (e.g. a `GameStatus.setLoading(true/false)` store) so double-clicks can't fire concurrent `continue()`/`choices.select()` calls, and a combined `narration.canContinue && !narration.input.isRequired` is a convenient "can the player press next" check for enabling/disabling a next button. - `gameProps` above is just that project's augmented `StepLabelProps` (translation function, router navigate, toast, etc., declared via TS module augmentation in `pixi-vn.d.ts`) — it's plain application data, not something the narration module requires. ## Related skills pixi-vn-getting-started, pixi-vn-characters, pixi-vn-history, pixi-vn-storage