Imported from DRincs-Productions/pixi-vn (
skills/narration/SKILL.md). Install upstream withnpx skills add DRincs-Productions/pixi-vn --skill narration. Copyright stays with the author.
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.
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).
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?)—idmust be unique across the whole game;stepsis an array ofStepLabelTypefunctions, executed in order as the player continues;propsis optional (onStepStart,onStepEnd,onLoadingLabelhooks — rarely needed for basic authoring).- Each step is
(props, { labelId }) => StepLabelResultType | Promise<StepLabelResultType>. Steps can beasyncwhen they need toawaitsomething (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 consumesStepLabelResultTypeexplicitly. - 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. stepscan 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:
// 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):
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, markup-markdown, 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:
import Markdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{narration.dialogue?.text}
</Markdown>;
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):
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:
narration.dialogue = `The night was <span class="inline-block animate-pulse text-violet-400">still</span>.`;
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).
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?)—textis the option's label text;labelis the targetLabelobject or its string id;propsare passed to the target label's steps (pass{}if none are needed);options.typeis"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: trueadditionally closes the current label when chosen.- Other
ChoiceInterfaceoptions 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(aStoredChoiceInterface[], an indexed array — each option carries achoiceIndex), and callnarration.choices.select(choice, props)when the player picks one. Pass through anyStepLabelPropsthe target label needs plus the option's own storedprops:
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, {}).labelcan be theLabelobject itself (what the docs show) or its string id (Game.start("start", {})) — both work,Game.startis a thin wrapper aroundnarration.callplusGame.clear(). - Advance one step:
await narration.continue(props)runs the current label's next step. Guard advancing withnarration.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:
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 andawaitit, so the history bookkeeping stays correct:
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 nextcontinue(). Usenarration.labels.closeCurrent()/narration.labels.closeAll()only for lower-level manual stack control (closeAll()ends the game if nothing is called afterwards). narration.labels.openedandnarration.labels.currentreflect the current call stack of nested labels, useful for debugging/branching logic.- Going back a step is handled by the history module, not
narration— seestepHistory.back()/stepHistory.canGoBackin thepixi-vn-historyskill.
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:
() => {
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?)—infoisInputInfominusisRequired(e.g.{ type: "string" },{ type: "number" },{ type: "html textarea" }); omit it for a plain text input.narration.input.isRequired/narration.input.typetell the UI layer whether a prompt is pending and what kind, so it can render the right control.narration.input.valueholds 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:
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 tonarration.call/jumpback into a menu/loop label if the game should never truly "end".narration.getRandomNumber(...)gives a seeded-safe random number (prefer this over rawMath.randomso 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.currentStepTimesCountercounts how many times the current step has executed (only increments when actually read, and only once per step execution); set it to0to 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/<name>.label.ts, exporting anewLabel(...)result (e.g.second.label.tsexportssecondPart = newLabel("second_part", [...])). Character definitions live alongside undersrc/content/characters/. - Auto-registration:
src/content/index.tsdoesimport.meta.glob(["./**/*.ts", "!./index.ts"], { eager: true })(a Vite feature) to eagerly import every module undercontent/for its side effects at startup. SincenewLabel/character registration runs at module scope, a new label file just needs to exist undercontent/— no manual import list to maintain. - Driving narration from the UI (see
lib/hooks/narration-hooks.tsandlib/query/narration-query.ts): a "next" handler guards onnarration.canContinuebefore callingawait narration.continue(gameProps); a choice handler readsnarration.choices.listand callsawait narration.choices.select(item, gameProps); an input prompt readsnarration.input.isRequired/narration.input.type/narration.input.value. Real code wraps all three in a loading-state guard (e.g. aGameStatus.setLoading(true/false)store) so double-clicks can't fire concurrentcontinue()/choices.select()calls, and a combinednarration.canContinue && !narration.input.isRequiredis a convenient "can the player press next" check for enabling/disabling a next button. gamePropsabove is just that project's augmentedStepLabelProps(translation function, router navigate, toast, etc., declared via TS module augmentation inpixi-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