A real-time multiplayer bingo game built to be fully playable by ear. Two or three players share a room, take turns calling numbers, and talk over live voice chat. Every board is navigable with the arrow keys, every game event is spoken aloud, and nothing important is conveyed by colour alone.
Built for Zendalona, which makes free and open-source assistive software.
| Home | How to play |
|---|---|
![]() |
![]() |
| Lobby | Keyboard shortcuts |
|---|---|
![]() |
![]() |
| In game | Calling a number |
|---|---|
![]() |
![]() |
Captured on a default checkout with no LiveKit server running, which is why voice reads as unavailable — the game stays fully playable, as designed. See LiveKit voice chat to enable it.
- The board is the input device. No number entry: move with the arrow keys
and press
Enter, or double-click a cell. A confirmation step reads the number and its position back before the call counts. - Spoken announcements for everything. Turn changes, called numbers, completed lines, the numbers each line still needs, players joining and leaving, errors and winners — via ARIA live regions and the Web Speech API, so the game works with or without a screen reader.
- Wrapping arrow navigation with per-axis wrap, a roving
tabindexso the board is a single tab stop, and short cell announcements ("Number 8. Row 2. Column 3."). - Two voice modes. Push-to-talk on
Spaceby default, or Always Talk for a continuously open mic. Both keyboard-operable and announced on change. - Keyboard-complete. Visible focus indicators everywhere,
Escapecloses any dialog, a skip link jumps to the board, and focus is restored after every dialog, mark and room change. - WCAG AA contrast throughout, Atkinson Hyperlegible type, and earcons for calls, line completions, wins and mic state.
- Accessible sign-in. Labelled fields with spoken hints, errors announced assertively and focused, audible pending states, and a show/hide toggle on every password — plus the forms work with JavaScript turned off.
Full player-facing documentation lives at /how-to-play in the running app.
| Area | Choice |
|---|---|
| Framework | Next.js 16 (App Router) + React 19, TypeScript in strict mode |
| Real-time transport | Socket.IO 4 over a custom Node HTTP server |
| Voice chat | LiveKit (livekit-client in the browser, livekit-server-sdk for tokens) |
| Client state | Zustand 5, plus useSyncExternalStore for the audio and settings stores |
| Styling | Tailwind CSS 4 (CSS-first @theme, no tailwind.config.js) |
| Speech / audio | Web Speech API (speechSynthesis), Web Audio for earcons |
| Tests | Vitest |
| Runtime | Node 20+, run through tsx; pnpm for packages |
| Accounts | Supabase Auth (email + password) with @supabase/ssr |
Deliberately no game database — rooms live in memory in the server process,
so a restart clears them. Supabase Postgres holds only accounts and a one-column
profiles table for display names.
- Node.js 20 or newer (developed on 24)
- pnpm (
npm install -g pnpm) - A Supabase project — required; the game is unplayable without accounts
- Optional: Docker or the
livekit-serverbinary, for voice chat
One-time, in the Supabase dashboard:
-
Keys — Project Settings → API Keys. Copy the project URL and the publishable key into
.env.local(see.env.example). There is no service-role key in this project and you should not add one: every query runs as the signed-in user, so row level security applies. -
Schema — SQL Editor → New query → paste
supabase/migrations/0001_profiles.sql→ Run. This creates theprofilestable, its RLS policies, and the trigger that derives a display name from each new signup's email. -
Email confirmation — Authentication → Providers → Email: make sure Confirm email is on. Sign-in is blocked until the address is verified.
-
Redirect URLs — Authentication → URL Configuration: set the Site URL and add
http://localhost:3000/auth/callback(plus your production origin) to the redirect allow-list. Without this, links in emails fall back to the Site URL. -
Signing keys (recommended) — Project Settings → JWT Keys. With asymmetric keys, tokens are verified locally; with the legacy shared secret, every page render and socket connection costs a round trip to the Auth server.
-
Email templates (recommended) — Authentication → Email Templates. Change Confirm signup and Reset password to use the token-hash form:
{{ .SiteURL }}/auth/callback?token_hash={{ .TokenHash }}&type=signup&next=/ {{ .SiteURL }}/auth/callback?token_hash={{ .TokenHash }}&type=recovery&next=/reset-passwordThe default
{{ .ConfirmationURL }}links work too, but they use PKCE, which needs the browser that started the flow — so they fail when someone opens the link on their phone or inside a mail app's in-app browser./auth/callbackhandles both shapes; this change just makes the reliable one the default.
pnpm installpnpm devThen open http://localhost:3000.
Set PORT to use a different port:
PORT=4000 pnpm devNote: there is no
next devscript. The app runs behind a custom server (server/index.ts) that hosts Next.js and Socket.IO on the same port, sopnpm devis the only correct way to start it.
Voice chat is optional. Without LiveKit credentials the game runs normally and simply reports voice as unavailable — set it up below when you want it.
A room needs at least 2 players (maximum 3), and a player is an account, not a tab — so testing locally needs two confirmed accounts in two browser profiles (or one normal window and one private window):
- Sign up at
/signup, click the link in the confirmation email, and repeat for a second address. - Signed in as the first account, choose Create a room — you get a code like
TIGER-MOON. There is no name to type: your display name is the part of your email before the@. - As the second account, type that code and press
Enter. - Mark both players Ready, then start the game from the host window.
Identity is your Supabase account id, so a reload — or moving to another browser entirely — rejoins the same seat, board and progress. Opening the same account in a second window does not create a second player: the newer window takes over and the older one says so.
pnpm test # Vitest unit tests
pnpm build # production build + typecheck
pnpm lint # ESLint
pnpm start # run the production buildThere is also a headless end-to-end check of the whole multiplayer flow. It signs in as two real accounts, because the socket handshake requires a verified session and there is deliberately no test bypass on the server:
pnpm tsx scripts/smoke.tsSet SMOKE_A_EMAIL/SMOKE_A_PASSWORD and SMOKE_B_EMAIL/SMOKE_B_PASSWORD to
two confirmed accounts; without them the script exits early and says so.
Real-time play runs over Socket.IO, chosen over raw WebSockets for its automatic reconnection, acknowledgement callbacks, and room broadcasting — all three of which the game leans on.
server/index.ts boots Next.js programmatically and attaches Socket.IO to the
same HTTP server, so there is no second service to run or proxy:
app.prepare().then(() => {
const httpServer = createServer((req, res) => handle(req, res));
const io = new Server(httpServer);
registerSocketHandlers(io);
httpServer.listen(port);
});Accounts are Supabase email + password, confirmed by email. There is no guest
mode and no username field: your display name is derived from the email local
part (john.doe@gmail.com plays as john.doe), stored in profiles by a
database trigger, and suffixed if it is already taken.
Identity is verified in three independent places, because none of them can cover the others:
| Layer | What it does |
|---|---|
proxy.ts |
Persists the token refresh — the only request path that can write cookies — and redirects signed-out visitors to /login?next=…. |
lib/auth/user.ts |
requireUser() re-verifies on every protected render. This is the boundary that actually holds: a proxy matcher is a routing rule, and Server Functions are POSTs to their host page, so matcher coverage can change without anyone noticing. |
server/auth.ts |
Verifies the socket handshake. Socket.IO answers /socket.io/* before Next sees it, so it must authenticate itself. |
Everything is verified with getClaims(), never getSession() — the latter
returns whatever is in the cookie without checking the signature.
Why the handshake takes an access token rather than reading cookies.
getClaims() with no argument calls getSession() first, which refreshes an
expiring token. A refresh rotates the refresh token, and a handshake has no HTTP
response to write the new cookies to — so the browser would keep presenting a
rotated-away token until Supabase revoked the session and signed the player out
mid-game. Instead the client passes its token through socket.io's function form
of auth (re-evaluated on every reconnect, so it is never stale) and the server
calls getClaims(token), which verifies and nothing else. The token is a signed
JWT, so this is not a client-supplied identity — forging one needs the signing
key.
socket.data.userId is the account id, which is also the reconnection key and
the LiveKit participant identity. When the token expires the server drops the
socket; the client reconnects with a fresh one, and the 60-second disconnect
grace means the seat, board and progress survive the gap. If the session is
genuinely gone, the handshake is refused and the player is sent to /login.
LiveKit needs no separate protection: tokens are minted inside the authenticated
request_audio_token ack, with identity and name taken from socket.data. There
is no HTTP token endpoint to secure.
Client → server (ClientToServerEvents in server/types.ts):
| Event | Purpose |
|---|---|
create_room |
Create a room, ack returns the code |
join_room |
Join by code, ack returns a room snapshot |
player_ready |
Toggle lobby ready / submit-board state |
set_level |
Host picks the board size (3×3 – 8×8) |
start_game |
Host starts play |
submit_board |
Send your filled board |
call_number |
Call a number on your turn |
rematch |
Host restarts, optionally at a new size |
leave_room |
Leave deliberately |
request_audio_token |
Ack returns a LiveKit access token |
No payload carries a player name or id. create_room sends only a size and
join_room only a code — identity comes from the authenticated handshake, so
there is nothing client-supplied for a handler to trust.
Server → client (ServerToClientEvents):
room_created · player_joined · player_left · room_updated ·
game_started · game_state_updated · turn_changed · winner_declared ·
session_superseded · error
Room membership uses Socket.IO rooms (one channel per game code), so a call broadcasts to exactly the right players.
The server owns all game state; the client never marks optimistically.
- Boards stay private. The public snapshot never contains any board.
emitPersonalized()sends each socket its ownyourBoardfield, so no player can read another's arrangement off the wire. - Every rule is enforced server-side in
server/roomManager.ts: wrong turn, out-of-range and already-called numbers are rejected with a typedGameError. A repeat call does not pass the turn. - Marking is derived, not sent. After each call the server re-runs the pure
engine (
server/bingoGame.ts) over every board to recompute marks, completed lines and wins. The client imports the same pure module to render instantly from confirmed state — one implementation, no drift. - All feedback is diff-driven. Sounds and speech are computed from the
previous→next snapshot diff (
lib/gameStore.ts), so what you hear always matches confirmed server state.
A dropped socket is not an immediate exit. markDisconnected starts a 60
second grace period during which a reconnect restores the seat; only after it
expires is the player removed. Removal then handles host hand-off, turn
advance, and a walkover win if just one player remains — the same path a
deliberate leave_room takes.
Only the socket that currently owns a player triggers this. A second window on the same account supersedes the first, and closing either one must not mark a player whose live socket is elsewhere as disconnected — doing so would show them offline and hand their turn away, since turn advance prefers connected players. Expiry-driven reconnects (above) ride the same grace period.
Voice is a shared room per game, joined listen-only: your mic stays closed
until you hold Space or switch on Always Talk. The game server only mints
tokens; media never flows through it.
With Docker:
docker run --rm -p 7880:7880 -p 7881:7881 -p 7882:7882/udp livekit/livekit-server --dev --bind 0.0.0.0Or with the binary (brew install livekit, or see the
install docs):
livekit-server --dev--dev mode listens on port 7880 and accepts the well-known development
credentials devkey / secret. Use it for local work only.
cp .env.example .env.localThe defaults in .env.example already match --dev mode:
| Variable | Purpose |
|---|---|
LIVEKIT_API_KEY |
Server-side key for minting tokens (devkey in dev mode) |
LIVEKIT_API_SECRET |
Server-side secret (secret in dev mode) — never sent to the browser |
LIVEKIT_TOKEN_TTL_SECONDS |
Token lifetime, default 21600 (6 h) |
NEXT_PUBLIC_LIVEKIT_URL |
WebSocket URL the browser dials, e.g. ws://127.0.0.1:7880 |
Restart pnpm dev after editing .env.local.
- The LiveKit room name is the canonical game code (
TIGER-MOON), and each participant's identity is their persistentplayerId. - Grants are minimal:
roomJoin,canPublish,canSubscribe, andcanPublishData: false. - A fresh token is minted on every connection attempt, so an expired token
can never block a reconnect. Reconnects back off over
1s → 2s → 5s → 10s → 30s. - Degrades gracefully at every step. No key/secret → voice is disabled and
the game runs normally. Mic permission denied → you can still hear everyone.
The same player opening a second tab → the older session is marked
supersededrather than fighting over the mic. - The mic force-closes on blur, tab-hide and unmount, so it can never stick open.
Browsers require a user gesture before audio playback and speech synthesis; the app primes both on the first click or keypress.
Defined once in lib/shortcuts.ts and rendered by both the in-game ? dialog
and the How to Play page.
| Keys | Action |
|---|---|
Space (hold) |
Talk to other players — push-to-talk |
M |
Switch between push-to-talk and Always Talk |
Tab |
Move between controls |
Arrow keys |
Move around your board — wraps at the edges |
Enter |
Call the number on the selected cell (on your turn) |
Home / End |
Jump to the start or end of a board row |
B |
Move focus to your board |
R |
Repeat the last announcement |
S |
Toggle spoken announcements |
? |
Open the shortcuts help |
Esc |
Close a dialog, or cancel a call |
proxy.ts Session refresh + signed-out redirects (Next 16 middleware)
app/ Next.js App Router
page.tsx Home — authenticates, then renders HomeView
HomeView.tsx Create room, join by code
(auth)/ login · signup · forgot-password · reset-password ·
confirm-email, sharing one layout and one Announcer
auth/callback/ Confirmation and recovery links land here
how-to-play/ Player documentation (public)
room/[code]/ Room shell (RoomView) hosting all game phases
components/
auth/ AuthCard, AuthField, the four forms, AccountBar
screens/ Lobby · Setup · Play · GameOver · HowToPlay
ui/ BingoGrid, dialogs, toggles, panels
lib/
auth/user.ts requireUser() — the server-side protection boundary
auth/actions.ts Server actions: sign up / in / out, reset password
username.ts Pure email → display name (mirrors the SQL trigger)
safePath.ts Pure ?next= validator, blocks open redirects
gridNav.ts Pure board-cursor navigation (arrow keys, wrapping)
announcer.ts Snapshot-diff → spoken sentences
announceText.ts Pure sentence builders
audio.ts Earcons
gameStore.ts Zustand store, written only by socket listeners
shortcuts.ts Single source of truth for the shortcut list
livekit/audioRoom.ts Voice room singleton
hooks/ useGameRoom, useBingoGame, usePushToTalk, …
server/
index.ts Next + Socket.IO on one port
auth.ts Handshake token verification
socket.ts Event handlers, personalized emits
roomManager.ts Room lifecycle and rule enforcement
bingoGame.ts Pure engine — imported by server AND client
livekit/ Token minting
utils/supabase/ Browser, server and proxy clients
supabase/migrations/ Schema to paste into the dashboard SQL editor
Logic that can be pure is kept pure (bingoGame, gridNav, announceText,
pushToTalk) and unit-tested without a DOM.
Each player gets an N×N board holding the numbers 1 to N², arranged differently
per player. Players take turns calling numbers, and a called number is marked
on every board. Completing a line — a full row, column, or either diagonal —
earns one letter of the level's word (GAME at 4×4, BINGO at 5×5, and so on,
where the word length equals the number of lines needed). First to spell the
whole word wins. If one call completes the word for several players at once, the
caller wins.
pnpm testCovers the pure layers: the game engine and line detection, board validation, room lifecycle and rule enforcement, grid navigation and wrapping, push-to-talk edge cases, LiveKit token minting, announcement text, display-name derivation, redirect-target validation, and the handshake trust decision (which tokens are accepted, and which username ends up on the player).
MIT — see LICENSE.





