Pixi'VN Minigames
Official docs: pixi-vn.com/start/minigames.
Pixi'VN has no dedicated minigame API — a minigame is just PixiJS code (and, for its HUD/UI, a
regular framework UI) wired into the game through the same primitives used elsewhere: canvas
layers, hotkeys, and navigation. This skill is about how those pieces fit together for a minigame
specifically, not about introducing new APIs.
When to use this skill
Load this skill whenever a task involves building or wiring in a minigame: a snake/arcade-style
game, a timing/reaction challenge, a puzzle, a quiz mini-mode, or anything else the player breaks
away from the main narration to play. Not for the narrative canvas itself (pixi-vn-canvas) or for
general app UI screens unrelated to a minigame (pixi-vn-ui).
Core mental model
- A minigame renders on its own PixiJS layer, not on
canvas.gameLayer. gameLayer is the
save-able narrative scene graph (backgrounds, characters — see pixi-vn-canvas); a minigame's
moving pieces (snake segments, falling blocks, ...) are transient and don't belong there. Use
canvas.layers.add(name, new Container()) to get a separate, non-save-able layer for the minigame,
exactly like a PixiJS UI layer (pixi-vn-ui) — the official template even reserves a constant for
it, CANVAS_MINIGAME_LAYER_NAME.
- The minigame's HUD/menus (score, game-over overlay, control buttons) should be regular UI, not
PixiJS — built with React/Vue/whatever the project's UI already uses (
pixi-vn-ui), positioned
over the canvas the same way any other UI is.
- A minigame is launched from the narrative, then the player returns to it. The recommended
approach is to link the minigame to its own route (e.g.
/minigame/snake) and navigate to it from
a step, then navigate back when it ends — see pixi-vn-ui's "Navigating between UI screens"
section and interface-navigate. Every minigame
example below is written as a route file for this reason, not a bare component.
- Saving and restoring the minigame's current state (score, board, position, ...) is entirely the
developer's responsibility.
Game.exportGameState() does not know about a minigame in progress
— see pixi-vn-saves. If a minigame's progress must survive a save/reload, persist it yourself
(e.g. via game storage, see pixi-vn-storage).
Routing structure: one route per minigame, plus a shared layout route
With file-based routing (e.g. TanStack Router, used by the
official templates), each minigame gets its own file under src/routes/minigame/, so a project with
several minigames ends up with /minigame/snake, /minigame/quiz, etc.:
import { createFileRoute } from "@tanstack/react-router";
import SnakeGame from "@/screens/minigames/SnakeGame";
export const Route = createFileRoute("/minigame/snake")({
component: SnakeGame,
});
Anything shared by every minigame (pausing narration/audio while one is open, common hotkeys, a
shared HUD wrapper) doesn't belong copy-pasted into each of those files. Factor it into a single
pathless layout route, src/routes/minigame.tsx, that renders an <Outlet /> for whichever
minigame route is active — the same pattern the official React template already uses for /game
(src/routes/game.tsx wraps every /game/* screen with usePauseGameWhenMenuIsOpen +
useGameHotkeys + <Outlet />):
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/minigame")({
component: MinigameLayout,
});
function MinigameLayout() {
// hooks/providers shared by every minigame go here
return <Outlet />;
}
What actually goes in src/routes/minigame.tsx is a judgment call to make while building the
first minigame, not a file to scaffold reflexively — work through these questions before adding
anything to it:
- Is there actually anything shared across minigames? If the game's settings menu, pause menu,
or quick-actions wheel aren't reachable while a minigame is open, and there's no common
hotkey/HUD/provider every minigame needs,
src/routes/minigame.tsx has nothing to do — leave it
empty (just the <Outlet />) or skip creating it at all and let each minigame route stand alone.
- Are there menus specific to minigames only (a minigame pause menu, a "quit minigame and return
to story" confirmation, a minigame-specific settings panel)? If so, that's exactly what belongs in
the shared layout, the same way
src/routes/game.tsx centralizes usePauseGameWhenMenuIsOpen and
useGameHotkeys for every /game/* screen.
- Should the same menus as
src/routes/game.tsx (settings, save/load, quick actions) be reachable
from inside a minigame? If yes, don't assume they behave identically — check case by case whether
each one still makes sense mid-minigame. Saving is the clearest example: Game.exportGameState()
knows nothing about in-progress minigame state (see the Gotchas section below), so a save
triggered from inside a minigame either needs to be disabled/hidden there, or the minigame needs to
persist its own state first (pixi-vn-storage) so the save is still meaningful. Don't wire in the
game's menus unchanged and assume they "just work" inside a minigame.
The useMinigame lifecycle hook
Every official template ships a useMinigame hook (e.g. src/lib/hooks/minigame-hooks.ts) that
handles the repetitive part of wiring a minigame into a layer: creating the layer once, running an
optional async onStart before the game begins, calling back into your game(layer) function, and
cleaning the layer up (plus your onExit) when the screen unmounts.
import { CANVAS_MINIGAME_LAYER_NAME } from "@/constants";
import { canvas, type Layer } from "@drincs/pixi-vn";
import { Container } from "pixi.js";
import { useEffect, useRef } from "react";
export function useMinigame(
game: (layer: Layer) => void,
props?: {
onStart?: () => Promise<void>;
onExit?: (layer: Layer) => void;
},
) {
const loading = useRef(false);
useEffect(() => {
loading.current = true;
const layer = canvas.layers.add(CANVAS_MINIGAME_LAYER_NAME, new Container());
if (!layer) {
console.error("Failed to create UI layer for minigame");
return;
}
let cancelled = false;
(props?.onStart ?? (async () => {}))().then(() => {
if (cancelled) return;
loading.current = false;
game(layer);
});
return () => {
cancelled = true;
canvas.layers.remove(CANVAS_MINIGAME_LAYER_NAME);
props?.onExit?.(layer);
};
}, [game]);
return { loading };
}
Usage inside the minigame's own route file:
import { Layer } from "@drincs/pixi-vn";
import { Graphics, Ticker } from "@drincs/pixi-vn/pixi.js";
import { createFileRoute } from "@tanstack/react-router";
import { useCallback, useMemo, useState } from "react";
import { useHotkey } from "@tanstack/react-hotkeys";
import useMinigame from "@/lib/hooks/minigame-hooks";
export const Route = createFileRoute("/minigame/example")({
component: MiniGame,
});
function MiniGame() {
const [score, setScore] = useState(0);
const [gameOver, setGameOver] = useState(false);
const ticker = useMemo(() => {
const ticker = new Ticker();
ticker.add(({ deltaMS }) => {
// update game logic; call setGameOver(true) + ticker.stop() to end
});
return ticker;
}, []);
useHotkey(
"ArrowUp",
() => {
/* handle input */
},
{ enabled: !gameOver },
);
const game = useCallback(
(layer: Layer) => {
ticker.start();
},
[ticker], // must NOT change during the game or it restarts
);
const options = useMemo(
() => ({
onExit() {
ticker.stop();
ticker.destroy();
},
}),
[ticker], // must NOT change during the game or it restarts
);
useMinigame(game, options);
return (
<>{/* score HUD, game-over overlay, control buttons — regular UI */}</>
);
}
game(layer) is where you build the actual PixiJS content (Graphics, Sprite, Text, a
Ticker) and add it to the given layer. It runs once, after onStart resolves.
onExit(layer) is where you tear down anything game started — stop and destroy() any
Ticker, remove event listeners. useMinigame already removes the layer itself.
game and the options object (specifically its onExit) must keep a stable identity across
re-renders (useCallback/useMemo with the right deps) — since they're the effect's dependency,
changing identity mid-game tears down and restarts the whole minigame.
Controls and rendering
- Use hotkeys for keyboard input (see
pixi-vn-ui/hotkeys)
rather than raw window.addEventListener("keydown", ...), and gate them with enabled: !gameOver
so input stops mounting a listener that outlives the round.
- Build the actual gameplay visuals with PixiJS directly — either the
pixi.js package or the
@drincs/pixi-vn/pixi.js re-export — using Graphics/Sprite/Text and a Ticker for the game
loop, same primitives as pixi-vn-canvas but on the dedicated minigame layer instead of
gameLayer.
- On-screen touch controls, score displays, and game-over messages should still be plain UI
components (buttons, divs) layered over the canvas with
pointerEvents: "auto" where they need to
be clickable — see pixi-vn-ui's "Enabling interaction" section.
Gotchas
- Adding game content to
canvas.gameLayer instead of a dedicated layer makes it save-able and
mixes it with narrative elements — use canvas.layers.add for the minigame instead (pixi-vn-canvas
has the full gameLayer vs. layer distinction).
- An unstable
game/onExit reference restarts the minigame on every render — see the
useCallback/useMemo note above; this is the single most common bug when porting the pattern.
- Nothing about minigame progress is captured by
Game.exportGameState() — a mid-round save/
reload will silently drop it unless you persist state yourself through game storage
(pixi-vn-storage/pixi-vn-saves).
- Forgetting to stop/destroy the
Ticker in onExit leaves the game loop running (and
potentially erroring against removed objects) after the player navigates away.
Related skills
- pixi-vn-canvas: the PixiJS rendering primitives (
Graphics, Sprite, Text, tickers,
canvas.animate) a minigame is built from, and the gameLayer/layer distinction.
- pixi-vn-ui:
canvas.layers.add/htmlLayers.add, hotkeys, and navigating between UI screens/routes —
how the minigame's HUD and route linkage actually work.
- pixi-vn-storage: persisting minigame progress (score, board state) in game storage if it needs to
survive a save/reload.
- pixi-vn-saves: confirms
Game.exportGameState() has no built-in notion of in-progress minigame
state.