Pixi'VN Minigames

SkillDev tools

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Pixi'VN Minigames skill

What this skill tells your AI

The instructions your AI receives, as published by drincs-productions/pixi-vn in skills/minigames/SKILL.md and read by ahel’s review.

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.

Signals

GitHub stars
145
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
pixi-vn-minigames
Source
github.com/drincs-productions/pixi-vn