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

177 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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