# 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: ```ts 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/:id` — **editor** (owner, auth required): live preview + full controls - `/control/:controlToken` — **co-edit control panel**, no login - `/overlay/:overlayToken` — **read-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).