arcade-shelf
v0.2.2
Published
Drop-in mini canvas games as a sidebar widget — a shelf for your mini games.
Maintainers
Readme
Your Arcade Shelf
Drop-in mini canvas games as a sidebar widget — a shelf for your mini games, or canvases.
Not a game engine, not a canvas library. Just a small runtime that takes a list of self-contained canvas games and renders them as a clickable widget.
Install
npm install arcade-shelfQuick start (ES modules)
import { createShelf, pong, minesweeper, snake } from 'arcade-shelf';
import 'arcade-shelf/style.css';
const shelf = createShelf({
container: '#games',
title: "Let's play!",
});
shelf.register(pong);
shelf.register(minesweeper);
shelf.register(snake);
shelf.mount();<div id="games"></div>Only the games you import end up in your bundle — the built-in games are tree-shakeable. Drop any you don't need from the import list.
Quick start (plain HTML, no build step)
<link rel="stylesheet" href="https://unpkg.com/arcade-shelf/dist/style.css">
<div id="games"></div>
<script src="https://unpkg.com/arcade-shelf/dist/index.umd.cjs"></script>
<script>
const shelf = ArcadeShelf.createShelf({
container: '#games',
title: "Let's play!",
});
shelf.register(ArcadeShelf.pong);
shelf.register(ArcadeShelf.minesweeper);
shelf.register(ArcadeShelf.snake);
shelf.mount();
</script>Built-in games
Three reference implementations ship in the box. Pick whichever input
paradigm is closest to the game you want to build, then copy the shape —
each one handles start() / stop() listener lifecycle correctly.
| Game | Input paradigm | What to crib from it |
|---------------|------------------------|-----------------------|
| Pong 🎾 | Mouse / touch drag | mousemove + touchmove to track paddle position; getBoundingClientRect() cached on start() + resize instead of per-event; deltaTime normalization so motion is frame-rate independent. |
| Minesweeper 💣 | Left / right click + long-press | click to reveal, contextmenu to flag, touchstart long-press as the touch equivalent of right-click (with swallow of the trailing click). |
| Snake 🐍 | Keyboard + swipe | keydown on window, swipe on the canvas — two separate layers from the modal's document-level focus trap, so there's no ordering contention to reason about; touchstart → touchend diff for swipe direction; touchmove with passive: false to suppress page scroll while the finger is on the play area. |
All three are ~200–600 lines of pure canvas + DOM — no framework, no dependencies. Read them as recipes, not as a library surface. They're the shortest path from "I want to write a mini canvas game" to "it handles input and teardown correctly on both desktop and mobile."
Integration recipes
React
import { useEffect, useRef } from 'react';
import { createShelf, pong } from 'arcade-shelf';
import 'arcade-shelf/style.css';
export function GameShelf() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const shelf = createShelf({ container: ref.current }).register(pong).mount();
return () => shelf.unmount();
}, []);
return <div ref={ref} />;
}Key detail: unmount() in the cleanup — otherwise HMR / route changes leak
listeners and an open modal's RAF keeps running.
Hexo / Jekyll (no build step)
Drop the UMD bundle into your theme's static assets and reference it from
the layout. Example for Hexo (similar for Jekyll _includes or 11ty
includes):
<!-- themes/your-theme/layout/_widget/games.ejs -->
<link rel="stylesheet" href="https://unpkg.com/arcade-shelf/dist/style.css">
<div id="arcade-shelf-mount"></div>
<script src="https://unpkg.com/arcade-shelf/dist/index.umd.cjs"></script>
<script>
ArcadeShelf.createShelf({ container: '#arcade-shelf-mount' })
.register(ArcadeShelf.pong)
.mount();
</script>If you'd rather self-host (air-gapped builds, offline dev), copy
node_modules/arcade-shelf/dist/ into your theme's assets folder and point
the tags at the local paths.
Your own game
You do not need to fork arcade-shelf to add a game. shelf.register()
accepts any object matching the Game contract, no matter where it's
defined:
import { createShelf, type Game } from 'arcade-shelf';
import 'arcade-shelf/style.css';
const tetris: Game = {
id: 'tetris',
name: 'Tetris',
icon: '🧱',
canvasSize: { width: 320, height: 480 },
init(canvas) {
// ... your game logic
return {
start() { /* attach listeners, start RAF */ },
stop() { /* cancel RAF, remove listeners */ },
};
},
};
createShelf({ container: '#games' })
.register(tetris)
.mount();See src/games/pong.ts for a worked example including dt normalization and cleanup, or src/games/snake.ts for keyboard input + fixed-step simulation.
The game contract
Every game implements this interface:
interface Game {
id?: string; // stable key (defaults to name)
name: string; // display label
icon?: string; // emoji or short string
description?: string;
order?: number; // display order
canvasSize?: { width: number; height: number }; // defaults to 380×280
init(canvas: HTMLCanvasElement): GameHandle;
}
interface GameHandle {
start(): void;
stop(): void; // cancel RAF, remove listeners, etc.
pause?(): void; // optional: tab hidden — suspend RAF/timers
resume?(): void; // optional: tab visible — restart them
resize?(width: number, height: number): void; // optional: canvas display size may have changed
}That's the whole API contract. Write your game inside init, return start/stop,
register it on a shelf, done.
id vs name
id is the stable registration key — what whitelist and order match
against, and what the registry uses to dedup. name is the display label
shown on the button. When id is omitted it defaults to name, so the
simple case stays simple. Set id explicitly when you want to be able to
rename name later (e.g. for translation) without breaking caller config.
init is called every time the modal opens
Closing the modal calls stop() and discards the canvas. Reopening the
game calls init(canvas) again on a fresh canvas — any state held in the
init closure (score, position, RNG state) starts over. If you need
cross-session state, persist it externally (e.g. localStorage).
Optional pause / resume / resize hooks
The modal forwards three lifecycle events to your game so you don't have to register listeners yourself:
pause/resumefire ondocument.visibilitychange. Cancel your RAF and clear interval timers inpause; re-arm them inresume. The built-in games implement these — a Snake left in a backgrounded tab won't burn battery, and Minesweeper's wall-clock timer won't tick while you're away.resizefires onwindow.resize. The canvas's pixel buffer (canvas.width/canvas.height) does not change — only its CSS display size might. Re-cache anything that depends ongetBoundingClientRect()(e.g. input → game coordinate translation).
All three are optional: a game that doesn't implement them simply keeps running.
Beyond games
The runtime contract is just init(canvas) → { start, stop, pause?, resume?, resize? } —
nothing in it is game-specific. Anything you'd put on a <canvas> fits
on the shelf: interactive data viz, shader playgrounds, generative-art
sketches, small tools. The built-ins are games because that's the
project's vibe, not because the contract requires it. Override
--arcade-shelf-canvas-bg if the default black backdrop clashes with
your widget.
Multiple shelves on one page
Safe — the modal's body scroll lock is ref-counted, so two shelves can each open and close their modal without leaking the host page's overflow state.
createShelf({ container: '#shelf-arcade' }).register(pong).mount();
createShelf({ container: '#shelf-widgets' }).register(myChart).mount();License
MIT
