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
), undo/go-back
mechanics (see
), save files (see
), or persistent game variables
(see
).
Optional runtime AI:
For dialogue or images generated during play, install the separate
package and its required peers; import its
singleton from that package. This API is
experimental: check the installed types before implementing provider-specific options.
Await
once during startup. Without options it downloads a local WebLLM text
model in the browser; image generation still needs an external provider. Configure
and/or
for AI SDK models, or use
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
generate scene imagery;
the library builds prompts from the request and structured context. Consult the
AI guide and
plugin README 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
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
and
are automatically registered so they can be referenced by id anywhere (choices,
,
,
).
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?)
— must be unique across the whole game; is an array of
functions, executed in order as the player continues; is optional
(, , hooks — rarely needed for basic authoring).
- Each step is
(props, { labelId }) => StepLabelResultType | Promise<StepLabelResultType>
. Steps
can be when they need to something (e.g. calling another label, loading assets).
Return value is optional — most steps return nothing (); returning a string/object is only
needed by code that consumes explicitly.
- Every label file should call at module scope so the label registers itself simply
by being imported. Make sure the module gets imported somewhere reachable at startup.
- 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
inside a step to display a line. It accepts either a plain string/array of
strings, or a
-shaped object with
and an optional
:
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
for the full
rationale, but in short: prefer the
instance unless the
plugin's
generation (narrowing
to known ids, see
)
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.
is a getter too: reading it returns
(with
resolved to the full
when a registered id was used, or
/the raw
string otherwise).
Gluing dialogue across steps: set
narration.dialogGlue = true
before assigning
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 (
only affects how the
text
accumulates, not whether
/player input is needed); it resets automatically once a
non-glued
assignment is made.
Character objects (
and registration via
) are covered by
the
skill — this module only needs a character's
(or the object) to attach
it to a line.
3. Rich text: Markdown and HTML in dialogue
Pixi'VN isn't tied to any markup language for
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
with the
(GFM tables/strikethrough/etc.) and
(allows raw HTML inside the Markdown source) plugins:
tsx
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 —
,
, 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
,
, or
for richer text effects.
A
Tailwind-styled inline element inside dialogue text must be — a plain
element won't apply
/animation utilities correctly mid-paragraph:
ts
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
, typically built with
(opens another label) and/or
(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?)
— is the option's label text; is the
target object or its string id; are passed to the target label's steps (pass
if none are needed); is (default — the target runs and can return to this
label afterwards) or (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 options available in both: (option disappears once chosen),
(shown only as a fallback if every other option has been removed),
(if it ends up being the only available option, it's chosen automatically without player input).
- Read (a , an indexed array — each option carries
a ), and call
narration.choices.select(choice, props)
when the player picks one.
Pass through any the target label needs plus the option's own stored :
ts
const item = narration.choices.list![0];
await narration.choices.select(item, { ...item.props });
dispatches to
/
/closing the menu based on the chosen
option's
, and records the pick so
narration.queries.alreadyCurrentStepMadeChoices
/
can reflect past choices (useful for
options across replays).
5. Progressing and closing labels
- Start the game: clears all game data and calls the given label —
this is the standard entry point, e.g.
Game.start(startLabel, {})
. can be the
object itself (what the docs show) or its string id () — both work,
is a thin wrapper around plus .
- Advance one step:
await narration.continue(props)
runs the current label's next step. Guard
advancing with (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 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
— Pixi'VN advances past the label's end on the next . Use
narration.labels.closeCurrent()
/ narration.labels.closeAll()
only for lower-level manual stack
control ( ends the game if nothing is called afterwards).
- and reflect the current call stack of nested
labels, useful for debugging/branching logic.
- Going back a step is handled by the history module, not — see /
in the skill.
6. Input prompts
To ask the player for a value (string, number, or an HTML element like a textarea), call
inside a step; the engine will not let the story continue
(
is
) 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?)
— is minus (e.g.
, , { type: "html textarea" }
); omit it for a plain text
input.
narration.input.isRequired
/ tell the UI layer whether a prompt is pending
and what kind, so it can render the right control.
- 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
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
) 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
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 /
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
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 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
. 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 a
result (e.g. exports secondPart = newLabel("second_part", [...])
).
Character definitions live alongside under .
- Auto-registration: does
import.meta.glob(["./**/*.ts", "!./index.ts"], { eager: true })
(a Vite feature) to eagerly import
every module under for its side effects at startup. Since /character
registration runs at module scope, a new label file just needs to exist under — 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 before calling
await narration.continue(gameProps)
; a choice handler reads and calls
await narration.choices.select(item, gameProps)
; an input prompt reads
narration.input.isRequired
/ / . 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 / calls, and a combined
narration.canContinue && !narration.input.isRequired
is a convenient "can the player press next"
check for enabling/disabling a next button.
- above is just that project's augmented (translation function,
router navigate, toast, etc., declared via TS module augmentation in ) — 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