scoreboardtools/docs/PLAN.md
Ashraf Shafiq 9169bea79f Scaffold real-time SSBU scoreboard PWA
pnpm monorepo with three workspaces:
- @sbt/shared: zod ScoreboardState + WebSocket protocol (single source of truth)
- @sbt/server: Fastify REST + raw ws WebSocket + Drizzle/Postgres, run via tsx
- @sbt/web: React + Vite + Tailwind installable PWA

Real-time core: the WebSocket server holds authoritative per-board state in
memory, broadcasts to all clients (editors + OBS overlays) instantly, and
debounces Postgres saves (~750ms). One useScoreboardSync hook powers the editor,
the no-login co-edit control page, and the read-only OBS overlay.

Includes email+password auth (JWT cookie), scoreboard CRUD, logo upload,
customizable scorebug (characters/stocks/score/subtitles/callout/side-swap/theme),
Docker Compose + Caddy/nginx deploy configs, and docs/PLAN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:09:27 -04:00

10 KiB
Raw Permalink Blame History

Scoreboard Tools — SSBU Real-Time Scoreboard PWA

Context

scoreboardtools is a greenfield project (empty repo, just a README). The goal is a self-hosted Progressive Web App for running Super Smash Bros Ultimate tournament scoreboards. A user logs in, creates scoreboards from a dashboard, and edits a customizable "scorebug" (logo, two players with characters/stocks/score, subtitles, callouts, side-swap).

The defining requirement is real-time: the scorebug can be opened as a read-only overlay in OBS (transparent background) that updates the instant the score changes, and the scorekeeping can be handed to a second person via a link that requires no login and lets them co-edit live. Scoreboards persist so users can return to them.

Decisions locked in with the user:

  • Backend: lean custom stack — Node (Fastify) + Postgres + WebSocket, self-hosted via Docker.
  • Auth: email + password (simplest for self-hosting; no SMTP/OAuth dependency).
  • Share link: co-edit via unguessable token, separate from the read-only overlay link.
  • Character art: placeholders first (full picker UI now, real fighter icons wired in later).
  • Server: Docker + Compose, a domain with HTTPS/TLS, and a reverse proxy (nginx/Caddy) — deploy steps included since not everything is set up yet.

Architecture Overview

Single source of truth lives in the WebSocket server, which holds each scoreboard's live state in memory and is the authority all clients sync to.

                         ┌──────────────────────────────┐
   Owner editor  ──WS──► │  Fastify + ws server         │ ──debounced──► Postgres
   (JWT auth)            │  - in-memory room per board  │   (durable save)
                         │  - last-write-wins authority │
   Co-editor     ──WS──► │  - broadcasts state to room  │ ◄──snapshot on connect/reconnect
   (control_token)       │                              │
                         └──────────────┬───────────────┘
   OBS overlay   ──WS──►   (read-only)  │ broadcasts authoritative state to ALL room members
   (overlay_token)                      ▼
                              instant scorebug update

Why this is fast AND durable: edits broadcast through in-memory room state immediately (sub-frame latency for the overlay), while saves to Postgres are debounced (~750ms) so we never write per-keystroke. On reconnect/reload, the client gets the latest persisted state, then live deltas. Last-write-wins is acceptable for a 2-person scoreboard.

Repo Structure (pnpm workspace — modular & concise)

scoreboardtools/
  apps/
    web/        React + Vite + TS PWA (dashboard, editor, control, overlay)
    server/     Fastify HTTP/REST + `ws` WebSocket + Drizzle/Postgres
  packages/
    shared/     zod schemas + TS types for ScoreboardState and the WS protocol
  docker-compose.yml
  deploy/       Caddyfile + nginx.conf examples, .env.example
  README.md

packages/shared is the keystone: one zod schema defines the scoreboard state and the WS message protocol, imported by both web and server — types can never drift.

Data Model (Postgres, via Drizzle ORM)

  • users: id, email (unique), password_hash (bcrypt), created_at
  • scoreboards: id, owner_id → users, name, state jsonb, overlay_token (unique), control_token (unique), created_at, updated_at

state is a single JSONB document — the whole scorebug is one small object, ideal for broadcasting and keeps the schema concise:

ScoreboardState = {
  eventLogoUrl?: string
  theme: { accent: string; bg: string }     // customizable scorebug look
  bestOf?: number                            // e.g. Bo3 / Bo5
  centerCallout?: string                     // middle-of-scorebug text
  players: [Player, Player]                  // index 0 = left, 1 = right
  swapped: boolean                           // side-swap toggle
}
Player = { name; character: FighterId; stocks: number; score: number; subtitle?: string }

Tokens (overlay_token, control_token) are unguessable (nanoid/crypto random) so the public links are the access control for the no-login flows.

Backend (apps/server)

  • Fastify HTTP for REST + static; ws (raw WebSocket — lighter than Socket.IO, no fallback transports needed) for real-time.
  • Auth: @fastify/jwt + bcrypt. JWT in an httpOnly cookie. Routes: POST /api/signup, POST /api/login, POST /api/logout, GET /api/me.
  • Scoreboard REST (owner, JWT-guarded): GET/POST /api/scoreboards, GET/PATCH/DELETE /api/scoreboards/:id. Create generates both tokens.
  • WebSocket GET /ws: client opens with a credential identifying role —
    • editor → JWT cookie (read+write, must own the board)
    • co-editor → control_token (read+write)
    • overlay → overlay_token (read-only) Server resolves the board, joins an in-memory room (Map of boardId → {state, sockets}), sends a snapshot, relays update messages from writers to the whole room, and schedules a debounced Postgres save. Read-only sockets that send writes are ignored.
  • Logo upload: POST /api/uploads (multipart) saves to a Docker-volume /uploads dir, served as static; the returned URL goes into state.eventLogoUrl. (Avoids JSONB bloat from base64.)
  • Migrations: Drizzle Kit generates SQL; run on container start.

WS message protocol (defined in packages/shared)

  • Client→Server: { type: 'update', state } (writers only)
  • Server→Client: { type: 'snapshot', state } on join, { type: 'state', state } on every broadcast
  • Optimization note: start with full-state messages (state is a few KB). JSON-merge-patch deltas are a later optimization if needed.

Frontend (apps/web — React + Vite + TS + Tailwind)

Routes (react-router):

  • /login, /signup — auth screens
  • /dashboard: list / create / rename / delete scoreboards; copy overlay & control links (auth required)
  • /scoreboard/:ideditor (owner, auth required): live preview + full controls
  • /control/:controlTokenco-edit control panel, no login
  • /overlay/:overlayTokenread-only overlay, transparent background for OBS

The real-time core — one shared hook useScoreboardSync(credential, role):

  • Opens the WebSocket, applies snapshot/state messages to local state, exposes a sendUpdate(partial) that optimistically updates locally then pushes to the server.
  • Auto-reconnect with backoff; re-requests snapshot on reconnect.
  • Reused identically by editor, control panel, and overlay — only role/credential differ. This is what makes the app modular: one sync primitive, three consumers.

Components (composed, reused across editor / control / overlay):

  • ScoreBug — the rendered scorebug (the single visual component shown in the editor preview, the OBS overlay, and the control panel preview). Reads ScoreboardState, honors swapped.
  • PlayerPanel — name, CharacterPicker (placeholder grid now, real icons later), StockControls (+/), score +/, subtitle field.
  • ScoreControls — main score, center callout, best-of.
  • LogoUploader, ThemeControls, SwapSidesButton.
  • ShareLinks — copyable overlay + control URLs (dashboard & editor).

PWA: vite-plugin-pwa (manifest + service worker, installable). Scope the service worker to the app shell; keep the /overlay/* route network-first / uncached so OBS never shows stale state. Icons + offline shell for the dashboard/editor.

Deployment (docker-compose.yml + reverse proxy)

  • Services: db (postgres, volume), server (Node; serves REST + WS + built web static + /uploads). The web app is built and served by the server container to keep it to two long-running services.
  • Reverse proxy (their nginx/Caddy) terminates TLS for the domain and proxies to server, including WebSocket upgrade headers for /ws. Provide both:
    • deploy/Caddyfile (auto-TLS, simplest) and deploy/nginx.conf (with Upgrade/Connection headers).
  • Env (deploy/.env.example): DATABASE_URL, JWT_SECRET, PUBLIC_URL, UPLOAD_DIR.
  • TLS is required: OBS browser sources and PWA install both need HTTPS, and the WS must be wss://.

Suggested Build Phases (incremental, each independently runnable)

  1. Scaffold: pnpm workspace, shared zod schema + types, Vite React app, Fastify server, Dockerfiles, compose with Postgres. App boots end to end.
  2. Auth + dashboard: signup/login (JWT cookie), scoreboard CRUD, dashboard list/create/delete. Persists to Postgres.
  3. Editor + ScoreBug + real-time: useScoreboardSync hook, WS rooms, in-memory authority + debounced save. Editing updates a live preview; the overlay route updates in real time (verify with two browser tabs).
  4. Sharing: control-token co-edit panel + overlay-token read-only route, copyable links, no-login access.
  5. Customization polish: logo upload, theme/accent, side-swap, subtitles, center callout, best-of, character picker (placeholder grid).
  6. PWA + deploy: manifest/service worker, install, Caddy/nginx config, ship to the Linux server over the domain, test the overlay live in OBS.

Verification

  • Real-time (the critical path): open /scoreboard/:id (editor) and /overlay/:token in two tabs (or editor on desktop + overlay in OBS). Change score/stocks/callout in the editor → the overlay updates within a frame, no refresh. Open /control/:token in a private window (logged out) → confirm a non-authenticated co-editor can drive the same board and both editor and overlay reflect it.
  • Persistence: reload the editor and reconnect after stopping/starting the server → scoreboard returns from Postgres with the latest state; dashboard still lists it.
  • Auth boundaries: overlay token cannot write (writes ignored); a user cannot fetch/edit another user's scoreboard via REST; overlay/control links work logged-out.
  • Deploy: docker compose up locally brings up db + server; visit over the domain, confirm wss:// connects through the reverse proxy, install the PWA, and load the overlay as an OBS browser source with a transparent background.
  • Tests: a server unit test for the room broadcast + debounced-save logic and the token access rules (the parts most worth locking down).