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> |
||
|---|---|---|
| apps | ||
| deploy | ||
| docs | ||
| packages/shared | ||
| .dockerignore | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.base.json | ||
Scoreboard Tools
A self-hosted, real-time Super Smash Bros Ultimate tournament scoreboard PWA. Log in, build scoreboards, edit a customizable scorebug, open it as a live OBS overlay, and hand co-scorekeeping to someone else via a no-login link.
The full design rationale is in docs/PLAN.md.
Stack
| Layer | Choice |
|---|---|
| Web | React + Vite + TypeScript + Tailwind, installable PWA (vite-plugin-pwa) |
| Server | Fastify (REST) + raw ws WebSocket, run via tsx |
| Persistence | Postgres via Drizzle ORM (postgres.js driver) |
| Shared | @sbt/shared — zod schema + types for state and the WS protocol |
| Deploy | Docker Compose (db + server) behind your nginx/Caddy TLS reverse proxy |
Monorepo via pnpm workspaces: apps/web, apps/server, packages/shared.
The real-time model (the important bit)
The WebSocket server holds each scoreboard's state in memory as the single source of
truth. Editors push full-state update messages; the server applies them, broadcasts
to every client in that board's room (editors + OBS overlays) for instant updates, and
debounces a Postgres save (~750ms) so we never write per-keystroke.
One hook — apps/web/src/hooks/useScoreboardSync.ts — powers all three consumers; only the
connect credential and role differ:
| Consumer | Connects with | Role |
|---|---|---|
| Owner editor | ?board=<id> (cookie) |
editor |
| Co-scorekeeper | ?control=<token> |
editor |
| OBS overlay | ?overlay=<token> |
viewer (read-only) |
Local development
Requires Node 20+, pnpm, and a Postgres on localhost:5432 (or use the compose db).
pnpm install
# point the server at a database (defaults to postgres://postgres:postgres@localhost:5432/scoreboardtools)
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/scoreboardtools
export JWT_SECRET=dev-secret
pnpm dev # runs web (:5173) and server (:3000) together
Open http://localhost:5173 — sign up, create a scoreboard, open it, and copy the overlay
link into a second tab to watch real-time sync. Vite proxies /api and /ws to :3000.
pnpm test # shared schema/protocol unit tests
pnpm typecheck # type-check all workspaces
Production (self-hosted, Docker)
cp deploy/.env.example .env # set JWT_SECRET (openssl rand -hex 32) and a DB password
docker compose up -d --build # brings up Postgres + the server on 127.0.0.1:3000
Then put your TLS reverse proxy in front (TLS is required — PWA install and OBS browser
sources need HTTPS, and the WebSocket must be wss://):
- Caddy (auto-TLS, simplest): see
deploy/Caddyfile - nginx: see
deploy/nginx.conf— note theUpgrade/Connectionheaders that let/wspass through.
The server container serves the built web app, the REST API, the WebSocket, and uploaded
logos (persisted in the uploads volume).
Status
All six build phases from the plan are scaffolded and wired end to end. Next session picks
up on the Linux server: pnpm install, bring up Docker, point the reverse proxy at it, and
verify the overlay live in OBS. See docs/PLAN.md → Verification.
Known follow-ups (intentionally deferred):
- Character art is placeholder initials — map fighter ids to real stock icons later.
- Schema is created via
ensureSchema()on startup (idempotentCREATE TABLE IF NOT EXISTS); switch to Drizzle Kit migrations once the schema starts evolving. POST /api/uploadsis intentionally not auth-gated so no-login co-editors can set a logo.