--- name: pixi-vn-minigames description: Use when adding a minigame (snake, quiz, puzzle, timing/reaction game, ...) to a Pixi'VN visual novel — the `useMinigame` lifecycle hook pattern, using PixiJS directly on its own layer instead of `gameLayer`, hotkeys for controls, linking the minigame to a route, and why saving/restoring minigame state is the developer's own responsibility. --- # Pixi'VN Minigames Official docs: [pixi-vn.com/start/minigames](https://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](https://pixi-vn.com/start/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](https://pixi-vn.com/start/storage), see `pixi-vn-storage`). ## Routing structure: one route per minigame, plus a shared layout route With file-based routing (e.g. [TanStack Router](https://tanstack.com/router/latest), 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.: ```tsx title="src/routes/minigame/snake.tsx" 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 `` 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` + ``): ```tsx title="src/routes/minigame.tsx" 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 ; } ``` **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 ``) 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. ```ts title="lib/hooks/minigame-hooks.ts (React)" 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; 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: ```tsx title="src/routes/minigame/example.tsx" 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](https://pixi-vn.com/start/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.