A small, fast collection of browser-playable games built with Bun, TypeScript, and plain CSS.
- 13 games: Connect 4, Minesweeper, 2048, Tic-Tac-Toe, Snake, Memory, Tetris, Breakout, Ballz, Space Invaders, Asteroids, Frogger, and Maze Chase.
- No framework runtime: simple TypeScript modules, DOM helpers, and CSS themes.
- Keyboard-first play with mouse/touch support where it fits each game.
- Shared arcade helpers for fixed-step loops, collisions, held-key input, pause overlays, and touch controls.
- Unit tests for game logic plus Playwright coverage for browser behavior.
- Static builds and a Docker image for simple deployment.
- Local-first saves/results with optional Bun SQLite sync when served by the included Bun server.
- Public leaderboards for scores, fastest times, and bot win streaks when the Bun server is available.
- Live private-room multiplayer for Tic-Tac-Toe, Connect 4, Snake, Memory, and 2-player Space Invaders co-op, with spectator room-code viewing when the Bun server is available.
Note
This project uses mise to pin and run tools. Prefer mise run <task> over calling tools directly.
mise install
mise run devOpen http://localhost:3000.
| Game | Notes |
|---|---|
| Connect 4 | Bot, local two-player, and private-room online 1v1 with difficulty-aware bot moves. |
| Minesweeper | Reveal/flag puzzle with scalable difficulty. |
| 2048 | Sliding tile puzzle with keyboard controls. |
| Tic-Tac-Toe | Easy, medium, hard bot plus local and private-room online two-player modes. |
| Snake | Speed and wall behavior change by difficulty. |
| Memory | Concentration card matching with variable pair count. |
| Tetris | Bag pieces, rotation, line clears, levels, pause, and next preview. |
| Breakout | Paddle-and-brick arcade play with level progression. |
| Ballz | Aim-and-launch brick breaker with numbered blocks, pickups, and rising pressure. |
| Space Invaders | Cannon, waves, barriers, descending alien formation, and online 2-player co-op. |
| Asteroids | Thrust, drift, wrap, and split space rocks across endless waves. |
| Frogger | Hop across traffic, ride river lanes, fill home slots, and beat the timer. |
| Maze Chase | Clear dots, dodge ghosts, and turn the tables with power pellets. |
| Command | Description |
|---|---|
mise run dev |
Start the Bun dev server with HMR at http://localhost:3000. |
mise run db:migrate |
Create or migrate the Bun SQLite sync database. |
mise run db:generate |
Generate Drizzle SQLite migrations from the typed schema. |
mise run build |
Build the static app into dist/ with Bun code splitting. |
mise run build:analyze |
Build and write Bun metafile reports into reports/build/. |
mise run build:production |
Build the fullstack Bun production bundle used by Docker. |
mise run build:server |
Build the Bun server bundle into a temporary directory. |
mise run build:single |
Build a standalone single-file browser artifact into dist-single/ with PWA disabled. |
mise run test |
Run Bun unit tests in parallel. |
mise run test:changed |
Run Bun tests affected by changed files. |
mise run test:coverage |
Run Bun unit tests with text and LCOV coverage output. |
mise run test:e2e |
Build and run Playwright browser tests. |
mise run test:watch |
Run unit tests in watch mode. |
mise run lint |
Run hk-managed format/lint checks. |
mise run fix |
Run hk-managed fixers. |
mise run audit |
Run Bun package audit. |
mise run ci |
Install dependencies with Bun's frozen CI installer. |
mise run check |
Run lint, unit tests, build, and e2e tests. |
mise run docker:push |
Build and push the multi-arch Docker image as docker.io/lauritsk/games:latest. |
mise run docker:up |
Run the app with Docker Compose on port 3000. |
.
├── index.html # Bun HTML bundler entrypoint
├── src/
│ ├── app/ # Browser app shell, hash routing, game selection
│ ├── features/ # Results, leaderboards, sync, multiplayer, bot streaks
│ ├── games/ # Game registry plus one folder per game
│ │ ├── shared/ # Game-only helpers: arcade, controls, layout, saves
│ │ └── <game>/ # `index.ts` UI and `logic.ts` pure rules
│ ├── server/ # Bun API/server, DB, leaderboard, multiplayer rooms
│ ├── shared/ # Generic DOM, modal, keyboard, storage, type helpers
│ └── ui/ # Theme/assets/styles/PWA/sound/visual feedback
├── test/ # Bun unit tests
├── e2e/ # Playwright tests
├── Dockerfile
├── compose.yaml
└── mise.toml # Tool versions and tasks
See docs/architecture.md for a quick "where do I edit?" map. See docs/bun.md for Bun build variants, bundle flags, coverage, audit, and profiling helpers.
- Create a game UI module in
src/games/<game>/index.tsthat exports aGameDefinition. - Put non-trivial pure logic in
src/games/<game>/logic.ts. - Add deterministic tests in
test/. - Register the game in
src/games/index.ts. - Reuse helpers from
@shared/core,@games/shared/arcade,@games/shared/controls,@games/shared/game-input, and@shared/keyboardwhere possible. - Check the new game acceptance checklist in
CONTRIBUTING.md. - Run
mise run checkbefore opening a PR.
Themes are shared tokens in src/ui/styles.css and selected by each game's theme field. Current theme names include deep-cave, deep-ocean, outer-space, and deep-forest.
The browser keeps game preferences, saves, and result history in localStorage first. When served by src/server/index.ts, the app also syncs that local data to Bun's native SQLite driver (bun:sqlite) through /api/sync.
Default database path:
GAMES_DB_PATH=data/games.sqliteCreate the database manually, or let the server create it on first request:
mise run db:migrateStatic hosting still works, but sync, public leaderboards, and live multiplayer are disabled because there is no API server.
When served by src/server/index.ts, supported games offer casual live private rooms:
- Open a supported game.
- Select
Play online. - Create a room and share the 6-character code, join with a code from another player, or choose
Spectateto watch without taking a seat. - In Space Invaders, two online players control separate cannons against scaled-up co-op waves.
Room codes use a cryptographically random ambiguity-safe base32 alphabet such as K7P9Q2. Each player also receives a separate high-entropy session token that is required for the WebSocket connection and reconnects. The server enforces room capacity, turn order, move validation, short request rate limits, and room cleanup TTLs.
Multiplayer rooms are process-local memory only in v1. They disappear when the Bun server restarts, and they are intended for friendly private games rather than strong anti-cheat. Online results can appear in local history but are not eligible for public leaderboards.
Static builds cannot host live multiplayer because they have no WebSocket/API server.
When served by src/server/index.ts, games can publish one primary leaderboard metric per game:
- Score leaderboards rank higher values first.
- Fastest-time leaderboards rank lower durations first.
- Bot win-streak leaderboards rank consecutive wins against the bot, separated by game and difficulty.
Tic-Tac-Toe and Connect 4 streaks are only eligible in Vs bot mode. A bot win increments the current streak for that game and difficulty. A bot loss, draw, or abandoned active bot game resets the current streak; leaving a saved game to resume later does not. Current streak state is device-local; submitted result history can still sync. Local two-player results stay in history but are not public-leaderboard eligible.
Leaderboard submissions use a display name plus the local device/run id to prevent duplicate submissions for the same finished run. They are intended as casual, friendly rankings: the server validates payload shape, ranges, allowed outcomes, duplicate runs, and basic moderation rules, but it does not provide strong anti-cheat.
Leaderboard rows live in the leaderboard_scores SQLite table. To remove a bad public row, connect to the database configured by GAMES_DB_PATH, inspect the row, then delete it by id:
SELECT id, game_id, username, metric, metric_value, created_at
FROM leaderboard_scores
ORDER BY created_at DESC
LIMIT 20;
DELETE FROM leaderboard_scores
WHERE id = 'leaderboard-id-to-remove';Create a backup before manual cleanup. Restart is not required because reads query SQLite directly.
Build static assets:
mise run buildServe dist/ with any static host, or run the included container. For persistent sync storage, mount /app/data or set GAMES_DB_PATH to a persistent SQLite path:
mise run docker:up