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>
177 lines
10 KiB
Markdown
177 lines
10 KiB
Markdown
# 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).
|