react-waiting-game
v0.5.1
Published
Tiny one-button monocolor mini-arcade (jellyfish, pixel runner, gravity flip, invaders, rhythm tap) for filling LLM/loading waits in React
Maintainers
Readme
react-waiting-game
A tiny one-button mini-arcade for filling the void while a long task runs (LLMs, builds, uploads, you name it). Every game is monocolor, pure 1-bit pixel art, single canvas, zero runtime dependencies, and shares the same combo / power-up / high-score / achievement framework.

- Pick a game with a single prop:
<WaitingArcade game="runner" /> - One button: keyboard, pointer, and touch all map to the same primary action
- Tints to any colour via the
colorprop (defaults tocurrentColor) - SSR-safe; auto-pauses when the tab is hidden
- Optional
localStorage-backed best score and achievements, namespaced per game - Same component still ships as
<WaitingGame />for backward compatibility
The games
| Game | id | Mechanic | Skins |
|---|---|---|---|
| Jellyfish Drift | jellyfish | Hold to swim up, release to sink. Avoid coral & stalactites. | jellyfish, octopus, paperBoat |
| Pixel Runner | runner | Tap to jump, hold for higher jumps. Hop cacti, dodge birds. | dino, ninja, frog |
| Gravity Flip | gravity | Tap to invert gravity. Arc between floor and ceiling, dodge spikes. | cube, triangle, diamond |
| Invaders | invaders | Auto-fires bullets to the right. Tap to swap lane — be in the alien's lane to shoot it, out of it when it arrives. | ship, fighter, saucer |
| Rhythm Tap | rhythm | Notes scroll into a hit zone. Tap on the beat for short notes, hold for long ones. 3 lives. | bar, dot, arrow |
All five games share the same set of features: combo multiplier, near-miss bonus (or its game-specific equivalent), milestone flashes, tier ramp, screen shake, parallax background, three power-ups, and a five-achievement set. Death model varies: most games are one-hit, rhythm uses 3 lives.
Install
npm install react-waiting-gameQuick start
import { WaitingArcade } from 'react-waiting-game';
function LoadingScreen() {
return <WaitingArcade game="runner" autoStart />;
}Use it while waiting on an LLM
import { useState } from 'react';
import { WaitingArcade } from 'react-waiting-game';
function Chat() {
const [loading, setLoading] = useState(false);
async function ask() {
setLoading(true);
await fetch('/api/chat', { method: 'POST', body: '...' });
setLoading(false);
}
return (
<div>
<button onClick={ask} disabled={loading}>Ask</button>
{loading && (
<WaitingArcade
game="runner"
autoStart
persistHighScore
persistAchievements
/>
)}
</div>
);
}Controls
Every game uses the same single-button input.
| Input | Action | |-------|--------| | Hold Space / Arrow Up / W / Touch | Primary action (game-specific) | | Release | Stop |
- Jellyfish — hold to thrust upward, release to sink under gravity. Walls catch you gently; only obstacles end the run.
- Runner — tap to jump, hold to jump higher (variable height). Land before the next obstacle.
- Gravity — tap to flip gravity. The player accelerates toward the active surface; flip mid-arc to thread between floor and ceiling spikes.
- Invaders — your turret auto-fires bullets to the right at a fixed cadence. Tap to swap between the upper and lower lane. Aliens that escape past you break your combo; aliens that touch you while in your lane end the run.
- Rhythm — short tap notes scroll right-to-left into the hit zone; tap when one is centred. Long notes are hold notes — keep the button down while the bar passes through the cursor and release at the end. Missing a note, breaking a hold, or false-tapping costs one of your three lives.
<WaitingArcade /> props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| game | 'jellyfish' \| 'runner' \| 'gravity' \| 'invaders' \| 'rhythm' | 'jellyfish' | Which mini-game to render |
| width | number | 600 | Canvas width in px |
| height | number | 150 | Canvas height in px |
| color | string | 'currentColor' | Single colour for everything |
| paused | boolean | false | Pause externally (e.g. when the LLM responds) |
| autoStart | boolean | false | Skip the "tap to start" prompt |
| skin | string | game default | Skin id; must be valid for the selected game |
| persistHighScore | boolean | false | Store best score in localStorage, namespaced per game |
| storageKey | string | 'waiting-arcade:hi:<game>' | Override key for the best score |
| persistAchievements | boolean | false | Store unlocked achievements in localStorage, namespaced per game |
| achievementsStorageKey | string | 'waiting-arcade:ach:<game>' | Override key for achievements |
| onScoreChange | (score, hi) => void | — | Fired when the score changes |
| onGameOver | (score) => void | — | Fired when the player dies |
| onComboChange | (combo, mult) => void | — | Fired when the multiplier changes |
| onPickup | (total) => void | — | Fired when pearls/coins are collected |
| onAchievement | (id) => void | — | Fired when a new achievement is unlocked |
| className / style / aria-label | — | — | Standard wrapper props |
Achievements
Every game unlocks five achievements per run.
Jellyfish (jellyfish)
| ID | How to earn |
|---|---|
| century | Reach 100 in a single run |
| half_grand | Reach 500 in a single run |
| survivor | Stay alive ~60 s |
| pearl_diver | Collect 10 pearls in a single run |
| untouchable | Pull off 5 near-misses |
Runner (runner)
| ID | How to earn |
|---|---|
| runner_century | Reach 100 in a single run |
| runner_half_grand | Reach 500 in a single run |
| runner_survivor | Stay alive ~60 s |
| runner_coin_hoarder | Collect 10 coins in a single run |
| runner_dodger | Pull off 5 near-misses |
Gravity (gravity)
| ID | How to earn |
|---|---|
| gravity_century | Reach 100 in a single run |
| gravity_half_grand | Reach 500 in a single run |
| gravity_survivor | Stay alive ~60 s |
| gravity_collector | Collect 10 coins in a single run |
| gravity_dodger | Pull off 5 near-misses |
Invaders (invaders)
| ID | How to earn |
|---|---|
| invaders_century | Reach 100 in a single run |
| invaders_half_grand | Reach 500 in a single run |
| invaders_survivor | Stay alive ~60 s |
| invaders_sharpshooter | Shoot down 10 aliens in a single run |
| invaders_combo_master | Pull off 5 close-range kills in a single run |
Rhythm (rhythm)
| ID | How to earn |
|---|---|
| rhythm_century | Reach 100 in a single run |
| rhythm_half_grand | Reach 500 in a single run |
| rhythm_survivor | Stay alive ~60 s |
| rhythm_virtuoso | Hit 25 notes in a single run |
| rhythm_perfectionist | Land 5 perfect-timing hits in a single run |
Skins
<WaitingArcade game="jellyfish" skin="octopus" />
<WaitingArcade game="runner" skin="ninja" />
<WaitingArcade game="gravity" skin="triangle" />
<WaitingArcade game="invaders" skin="saucer" />
<WaitingArcade game="rhythm" skin="dot" />Use GAMES[game].skins for the full list at runtime, or import the per-game constants:
import {
SKIN_IDS,
RUNNER_SKIN_IDS,
GRAVITY_SKIN_IDS,
INVADERS_SKIN_IDS,
RHYTHM_SKIN_IDS,
} from 'react-waiting-game';Backward compatibility — <WaitingGame />
The original <WaitingGame /> jellyfish-only component still ships unchanged:
import { WaitingGame } from 'react-waiting-game';
<WaitingGame autoStart skin="paperBoat" persistHighScore />It uses the original storage key waiting-game:hi, so existing high scores carry over. New projects should prefer <WaitingArcade game="jellyfish" />.
Adding a new game
Each game is just a GameModule registered in src/games/index.ts:
import type { GameModule } from 'react-waiting-game';
export const myGame: GameModule<MyState, MySkin, MyAch> = {
id: 'mygame',
defaultWidth: 600,
defaultHeight: 150,
skins: ['default'],
defaultSkin: 'default',
achievements: [...],
init, tick, draw,
selectScore, selectHiScore, selectPhase,
selectScreenShake, selectDeathFlash,
selectAchievements, selectNewAchievements,
idlePrompt: 'Tap to start',
deadPrompt: 'Press to retry',
};The shared useGameLoop, input bus, persistence helpers, pixel/digit drawing, screen-shake decay, and feedback types all live under src/shared/.
Development
npm install
npm test # 151 unit tests across all five game engines
npm run lint # tsc --noEmit
npm run build # tsup → dist/ (ESM + CJS + .d.ts)Try the example
cd example
npm install
npm run devThe example simulates an LLM call, lets you switch between games and skins live, shows combo/pickup callbacks, and lists unlocked achievements.
License
MIT
