Skip to content

Repository files navigation

Accessible Bingo

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.


Screenshots

Home How to play
Home screen: name field, create a room, join a room by code How to play page with a table of contents and sections
Lobby Keyboard shortcuts
Lobby showing the room code, player seats, voice status and level picker Shortcuts dialog listing every key binding
In game Calling a number
Play screen with the 4x4 board, word strip, players panel and called list Confirmation dialog reading "You are about to call number 8, at Row 1, Column 3. Press Enter to confirm or Escape to cancel."

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.


Highlights

  • 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 tabindex so the board is a single tab stop, and short cell announcements ("Number 8. Row 2. Column 3.").
  • Two voice modes. Push-to-talk on Space by default, or Always Talk for a continuously open mic. Both keyboard-operable and announced on change.
  • Keyboard-complete. Visible focus indicators everywhere, Escape closes 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.


Tech used

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.


Getting started

Prerequisites

  • 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-server binary, for voice chat

Supabase setup

One-time, in the Supabase dashboard:

  1. 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.

  2. Schema — SQL Editor → New query → paste supabase/migrations/0001_profiles.sql → Run. This creates the profiles table, its RLS policies, and the trigger that derives a display name from each new signup's email.

  3. Email confirmation — Authentication → Providers → Email: make sure Confirm email is on. Sign-in is blocked until the address is verified.

  4. 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.

  5. 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.

  6. 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-password
    

    The 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/callback handles both shapes; this change just makes the reliable one the default.

Install and run

pnpm install
pnpm dev

Then open http://localhost:3000.

Set PORT to use a different port:

PORT=4000 pnpm dev

Note: there is no next dev script. The app runs behind a custom server (server/index.ts) that hosts Next.js and Socket.IO on the same port, so pnpm dev is 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.

Playing locally

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):

  1. Sign up at /signup, click the link in the confirmation email, and repeat for a second address.
  2. 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 @.
  3. As the second account, type that code and press Enter.
  4. 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.

Other scripts

pnpm test    # Vitest unit tests
pnpm build   # production build + typecheck
pnpm lint    # ESLint
pnpm start   # run the production build

There 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.ts

Set 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.


Socket programming

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.

One process, one port

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);
});

Authentication and identity

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.

Events

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.

Server-authoritative state

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 own yourBoard field, 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 typed GameError. 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.

Disconnects

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.


LiveKit voice chat (local server)

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.

1. Run a LiveKit server locally

With Docker:

docker run --rm -p 7880:7880 -p 7881:7881 -p 7882:7882/udp livekit/livekit-server --dev --bind 0.0.0.0

Or 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.

2. Configure the app

cp .env.example .env.local

The 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.

3. How it behaves

  • The LiveKit room name is the canonical game code (TIGER-MOON), and each participant's identity is their persistent playerId.
  • Grants are minimal: roomJoin, canPublish, canSubscribe, and canPublishData: 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 superseded rather 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.


Keyboard shortcuts

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

Project structure

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.


Game rules in brief

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.


Tests

pnpm test

Covers 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).


License

MIT — see LICENSE.

Releases

Packages

Contributors

Languages