A tiny Canvas 2D minigame you show during loading states — instead of a boring spinner.
dino-runner (default) |
orbit-dodger |
|---|---|
![]() |
![]() |
Spinners are dead time. If a loading state takes long enough for a user to notice, it's long enough for them to play a few seconds of a tiny game instead of watching a circle spin. boredload swaps in a minimal, original geometric runner game once your loading state crosses a configurable threshold — and instead of yanking it away the instant loading finishes, it lets the player keep playing (to see how far they get) until they choose to continue or a safety timeout kicks in.
To be clear: this doesn't replace fixing your load time — always do that first. It's for the waits that are still real after you've optimized: a cold start on serverless, a large file the user just uploaded, a slow third-party API, a bad connection on an old phone. The default threshold is 1.5s specifically so it never shows up on fast loads. It's also a good fit for longer background processes with a web UI attached — a deploy status page, a migration progress screen in an admin panel, a background job.
- Framework-agnostic core. Pure TypeScript, Canvas 2D, zero runtime dependencies.
- Optional React wrapper.
boredload/reactgives you a hook and a drop-in component. - Optional Angular wrapper.
boredload/angulargives you a standalone[boredload]directive. - Tiny. Core bundle stays under 15kb gzip.
- Player-controlled exit. The game doesn't vanish the moment loading ends — a "Continue" affordance appears once it's safe to leave, and the player decides when to use it.
- Configurable. Threshold, minimum/maximum play time, theme, seed, and your own games via a simple registry.
- Framework-friendly. Works in Next.js App Router, Angular, Vite, and plain HTML/TS.
- Two built-in Canvas 2D minigames (original visuals — neither is a clone of anything):
dino-runner(default) — jump over obstacles with Space / ArrowUp / tap.orbit-dodger— dodge falling obstacles by moving left/right with Arrow keys or WASD, or by dragging/holding a pointer or finger — the ship follows it directly.
- Keyboard and touch/pointer input, with press-to-retry on game over
- DPR-aware canvas scaling
- Configurable
threshold(delay before the game appears),minPlayMs(minimum time before the player can leave), andmaxPlayMs(safety cap that auto-dismisses if they never do) - A "Continue" button appears once the real loading is done and
minPlayMshas elapsed — the player keeps playing until they click it, lose, ormaxPlayMsruns out - Deterministic obstacle generation via a seeded PRNG (great for tests/demos)
- Themeable colors, with automatic theming from your own CSS custom properties
- Persisted high score across sessions (
localStorage), shown in-game as "Best" - Respects
prefers-reduced-motionby default — never forces an animated game on users who opted out - Optional adaptive threshold that shows the game sooner on a confirmed-slow connection
- Extensible
Gameinterface + registry for building your own minigame 'use client'on all React entry points — safe to import in Next.js App Router
npm install boredloadimport { mount } from 'boredload';
const container = document.getElementById('loading-slot')!;
const game = mount(container, {
threshold: 1500,
minPlayMs: 3000,
maxPlayMs: 30000,
onExit: () => {
// real content is ready and the player has left the game — show it now.
},
});
game.setLoading(true);
fetchSomething().then(() => {
game.setLoading(false);
// the game keeps running — a "Continue" button appears once minPlayMs has
// elapsed, and the player decides when to click it (or it auto-dismisses
// after maxPlayMs).
});import { useState } from 'react';
import { LoadingGame } from 'boredload/react';
function DataPanel() {
const { data, isLoading } = useData();
const [dismissed, setDismissed] = useState(false);
return (
<div>
<LoadingGame
isLoading={isLoading}
width={320}
height={160}
onExit={() => setDismissed(true)}
/>
{!isLoading && dismissed && <Results data={data} />}
</div>
);
}Note the two-part gate: !isLoading alone isn't enough to reveal your real content, because the
game may still be showing (the player is allowed to keep playing). Wait for onExit too — or, if
you'd rather the game stay purely decorative and never delay your content, render <Results>
based on !isLoading alone and let <LoadingGame> overlay/hide itself independently.
<div
boredload
[isLoading]="isLoading()"
[width]="320"
[height]="160"
(exit)="dismissed.set(true)"
></div>import { Component, signal } from '@angular/core';
import { BoredloadGameDirective } from 'boredload/angular';
@Component({
selector: 'app-data-panel',
standalone: true,
imports: [BoredloadGameDirective],
templateUrl: './data-panel.html',
})
export class DataPanel {
protected readonly isLoading = signal(true);
protected readonly dismissed = signal(false);
}BoredloadGameDirective is an attribute directive — attach it to any host element and it becomes
the loading widget, same two-part isLoading/exit gate as the React example above. All
GameConfig options are available as @Input()s (threshold, minPlayMs, theme, etc.), and
gameOver/readyToContinue/exit are @Output()s.
LoadingGame and useLoadingGame both start with 'use client', so they can be imported
directly into a Client Component:
'use client';
import { LoadingGame } from 'boredload/react';
export function LoadingSlot({ isLoading }: { isLoading: boolean }) {
return <LoadingGame isLoading={isLoading} />;
}Import them only from a file that is (or is rendered inside) a Client Component boundary.
Works out of the box with boredload and boredload/react — no special configuration needed.
Use the mount() helper directly against any DOM element; see the Quick Start above.
Works with a standard ng build/ng serve (Angular CLI ≥15, tested against the newer esbuild
Vite-based dev server too). BoredloadGameDirective is standalone: true, so just add it to your
component's imports array — no NgModule needed. See the Angular section above.
interface MountOptions extends GameConfig {
width?: number;
height?: number;
}
interface MountHandle {
setLoading(isLoading: boolean): void;
destroy(): void;
getScore(): number;
}class GameEngine {
constructor(options: GameEngineOptions);
start(): void;
stop(): void;
destroy(): void; // idempotent
resize(width: number, height: number): void;
setTheme(theme: Partial<Theme>): void;
getScore(): number;
}| Option | Type | Default | Description |
|---|---|---|---|
game |
string |
'dino-runner' |
Registered game id — built in: 'dino-runner', 'orbit-dodger' |
threshold |
number |
1500 |
Ms of loading before the game appears |
minPlayMs |
number |
3000 |
Ms the game must stay visible (from when it appeared) before the player can leave |
maxPlayMs |
number |
30000 |
Ms the "Continue" affordance waits for a manual click before auto-dismissing |
continueLabel |
string |
'Continue →' |
Label for the manual continue button (fully custom text/locale — your call) |
onGameOver |
(score: number) => void |
— | Called with the score at the moment the game is dismissed |
onReadyToContinue |
() => void |
— | Called once the "Continue" affordance becomes available |
onExit |
() => void |
— | Called when the game is dismissed (manual click or maxPlayMs timeout) |
theme |
Partial<Theme> |
— | Color overrides — takes precedence over auto-detected CSS vars |
seed |
number |
Date.now() |
PRNG seed for deterministic obstacle spawning |
persistHighScore |
boolean |
true |
Persist the best score across sessions via localStorage |
highScoreKey |
string |
`boredload:${game}` |
Storage key for the persisted high score |
respectReducedMotion |
boolean |
true |
Never show the game (stay in spinner mode) when the OS/browser requests reduced motion |
adaptiveThreshold |
boolean |
false |
Opt in to a shorter threshold on a confirmed-slow connection |
slowConnectionThresholdMs |
number |
500 |
Threshold used instead of threshold when adaptiveThreshold detects a slow connection |
GameConfig & { isLoading: boolean; width?: number; height?: number; className?: string }
Attribute directive, selector [boredload]. Every GameConfig field is an @Input() of the same
name, plus isLoading (required), width, height. Outputs: gameOver (number),
readyToContinue (void), exit (void). Also exposes getScore()/getHighScore() methods if
you grab a template reference to the host directive.
useLoadingGame(isLoading, config?) => { canvasRef, showGame, readyToContinue, score, highScore, dismiss }
Lower-level hook if you want to build your own presentation around the canvas. dismiss() is what
a custom "Continue" button should call; readyToContinue tells you when it's safe to show one;
highScore is the persisted best score, updated once the current run ends.
- Reduced motion: if the user's OS/browser requests
prefers-reduced-motion: reduce, boredload never shows the canvas game — it stays in accessible spinner mode for the whole loading state. SetrespectReducedMotion: falseif you want the game to show regardless (not recommended). - Adaptive threshold:
adaptiveThreshold: truechecks the (Chromium-only) Network Information API — if it reports a confirmed slow connection (slow-2g/2g), the game appears almost immediately (slowConnectionThresholdMs, default 500ms) instead of waiting forthreshold. It's a no-op wherever the API is unavailable (Safari, Firefox) — boredload never guesses a connection is fast, only acts when it's confirmed slow.
Both mount() and <LoadingGame /> render the same structure: a .boredload-stage wrapper
containing .boredload-canvas (or .boredload-spinner while waiting) and, once available,
.boredload-continue — laid out in normal flow below the canvas, never overlapping the game
area. Target these classes from your own CSS to restyle them; the button's label is controlled via
continueLabel (see GameConfig above).
interface Theme {
background: string;
ground: string;
player: string;
obstacle: string;
text: string;
accent: string;
}Pass any subset via theme in GameConfig / LoadingGameProps.
You don't have to pass theme at all — boredload reads --boredload-* custom properties from the
element it mounts into (and its ancestors, since custom properties inherit), so it can pick up your
site's colors automatically:
:root {
--boredload-background: #0f172a;
--boredload-player: #38bdf8;
--boredload-obstacle: #f472b6;
--boredload-accent: #facc15;
}Precedence: explicit theme prop > --boredload-* CSS vars > built-in default theme.
boredload's engine is decoupled from the dino-runner — implement the Game interface and
register it:
import { registerGame, type Game } from 'boredload';
const myGame: Game<MyState> = {
id: 'my-game',
init(ctx) {
/* return initial state */
},
update(state, dt, input) {
/* return next state */
},
render(ctx, state, viewport) {
/* draw */
},
destroy(state) {
/* cleanup */
},
};
registerGame('my-game', () => myGame);Then pass game: 'my-game' in your config.
InputState also carries optional heldDirection (-1/0/1, from Arrow keys / WASD) and pointerX
(canvas-local logical-pixel X of an actively-down pointer/touch, for "follow the pointer" style
control) — see orbit-dodger's source for a reference implementation that uses both.
Any browser with Canvas 2D and requestAnimationFrame support — all evergreen browsers.
ResizeObserver is used when available, with a window.resize fallback.
npm test
npm run test:coverageTests run with Vitest + jsdom, using a hand-written Canvas 2D context stub (no
vitest-canvas-mock dependency). Visual/pixel output is intentionally not tested.
See CONTRIBUTING.md.
MIT — see LICENSE.
If boredload saved you from shipping another boring spinner, consider supporting development:


