add esc party app design spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 11:23:08 +01:00
commit 1d9fc62bf3

View File

@@ -0,0 +1,332 @@
# ESC Party App — Design Spec
**Date:** 2026-03-11
**Status:** Draft
---
## Goal
A web-based party companion for a group of 5-10 people watching the Eurovision Song Contest together. One host device drives a shared display (TV/projector); all other players join on their phones via a room code. Three acts guide the group through five games across the evening, with all scores feeding into one shared leaderboard.
---
## Architecture
### Monorepo Structure
Single repo using Bun workspaces with three packages:
```
celebrate-esc/
├── packages/
│ ├── client/ # React PWA (Vite + shadcn/ui + Tailwind v4 + TanStack Router)
│ ├── server/ # Hono API + WebSocket (Drizzle + PostgreSQL)
│ └── shared/ # Zod schemas, WS message types, TypeScript types
├── package.json # Bun workspace root
├── .mise.toml
└── biome.json
```
### Data Flow
- Server is the single source of truth for all room and game state.
- All state mutations flow through WebSocket messages: client sends an action, server validates, updates state, broadcasts the new state slice to all clients in the room.
- Server sends full state slices on each update (not deltas) — payload is small with ≤10 players.
- Server caches active room state in memory for fast access; writes to PostgreSQL for persistence and crash recovery.
### Client State
- **No PGlite** — deviating from the standard web stack. All data is server-authoritative; the client is a thin real-time UI.
- **Zustand** holds the received room/game state for rendering. One store for the WebSocket connection and room state.
- Session tokens (random UUIDs) stored in `sessionStorage` for reconnection on browser refresh.
### Data Files
All in `packages/server/data/`, human-editable JSON:
| File | Contents |
|---|---|
| `esc-2026.json` | Lineup: country code, country name, artist, song title, running order |
| `bingo-tropes.json` | Array of tropes: id, label, description |
| `quiz-questions.json` | Array: id, question, answer, options, difficulty (easy/medium/hard), category |
| `scoring.json` | All point values in one flat config |
---
## Views & Routing
Three distinct views, all served from the same client app:
| View | URL | Device | Purpose |
|---|---|---|---|
| Landing | `/` | Any | Create room / join room |
| Display | `/display/:roomCode` | TV/laptop on shared screen | Passive presentation — reveals, leaderboard, status. No controls. |
| Host | `/host/:roomCode` | Host's phone | Two tabs: **Play** (same UI as player) and **Host** (control panel) |
| Player | `/play/:roomCode` | Player's phone | Submit votes, tap bingo, buzz quiz |
- The host is also a player — the Play tab gives them the same game UI as everyone else.
- The Host tab contains controls: advance acts, trigger jury voting windows, advance quiz questions, manage players, end room.
- The Display view shows the room code + QR code in the lobby for easy joining.
- No auth gates — session tokens handle identity and reconnection.
---
## Room System
### Lifecycle
1. Host visits `/`, creates a room → server generates a 4-character alphanumeric code (e.g., `A3F7`).
2. Host gets a session token (random UUID) stored in `sessionStorage`. This identifies them as the host.
3. Host opens `/display/:roomCode` on the TV and `/host/:roomCode` on their phone.
4. Players visit `/`, enter the room code + display name → join the room, get their own session token.
5. Room state machine: `lobby``act1``act2``act3``ended`.
6. Only the host can advance between acts.
7. Max 10 participants (1 host + 9 players).
8. Rooms auto-expire after 12 hours. Host can end the room early via the Host tab.
### Reconnection
- If a client's browser refreshes or disconnects, they reconnect using their session token from `sessionStorage`.
- Server recognizes the token, re-associates the WebSocket connection, and sends the current full state.
- If the session token is lost (e.g., incognito tab closed), the player must rejoin with a new name (previous scores are lost).
### WebSocket Protocol
JSON messages with a `type` field discriminator.
**Client → Server (actions):**
| Type | Payload | When |
|---|---|---|
| `join_room` | roomCode, displayName | Joining lobby |
| `reconnect` | roomCode, sessionId | Reconnecting |
| `advance_act` | — | Host advances to next act |
| `end_room` | — | Host ends the party |
| `add_dish` | name, correctCountry | Host adds a dish (Act 1) |
| `reveal_dishes` | — | Host reveals dish answers |
| `submit_prediction` | winner, top3[], nulPoints | Player submits predictions |
| `open_jury_vote` | countryCode | Host opens voting for a country |
| `close_jury_vote` | — | Host closes current voting window |
| `submit_jury_vote` | countryCode, rating (1-12) | Player rates an act |
| `tap_bingo_square` | tropeId | Player taps a bingo square |
| `submit_dish_guess` | dishId, guessedCountry | Player guesses a dish's country |
| `start_quiz` | — | Host starts quiz round |
| `next_question` | — | Host advances to next question |
| `buzz_quiz` | — | Player buzzes in |
| `judge_answer` | correct (boolean) | Host judges buzzer answer |
**Server → Client (state updates):**
| Type | Payload | When |
|---|---|---|
| `room_state` | Full room state snapshot | On join, reconnect, act change |
| `player_joined` | Player info | Someone joins |
| `player_disconnected` | playerId | Someone disconnects |
| `act_changed` | newAct | Host advances act |
| `predictions_locked` | — | Act 2 starts |
| `jury_vote_opened` | countryCode, countryName | Host opens voting |
| `jury_vote_closed` | results (aggregated ratings) | Host closes voting |
| `jury_reveal` | "12 points go to..." data | Dramatic reveal on display |
| `bingo_update` | playerId, square tapped | Someone taps a square |
| `bingo_announced` | playerId, displayName | Someone got bingo |
| `dishes_updated` | dish list | Host adds/reveals dishes |
| `quiz_question` | question, options, difficulty | Next quiz question shown |
| `quiz_buzz` | playerId, displayName | Someone buzzed |
| `quiz_result` | playerId, correct, points | Answer judged |
| `leaderboard_update` | sorted player scores | After any scoring event |
| `room_ended` | finalLeaderboard | Room ends |
---
## Games
### Prediction Voting (Act 1)
- Available as soon as players join the lobby.
- Each player submits: predicted winner (1 country), top 3 finishers (3 countries), nul points candidate (1 country).
- Predictions lock when the host advances to Act 2.
- Scores are hidden until the final leaderboard reveal in Act 3.
- Country selection from the hardcoded ESC 2026 lineup.
### Dish of the Nation (Act 1)
- Host adds dishes via the Host tab as people arrive (name + correct country).
- Players see the list of dishes and submit a country guess per dish.
- Host triggers reveal — display shows each dish with the correct country and who guessed right.
- Low-stakes icebreaker.
### Live Jury Voting (Act 2)
- Host taps "Open Voting" after each country's performance.
- All player phones show a 1-12 rating slider/selector for that country.
- Host taps "Close Voting" → server aggregates ratings.
- Display shows the group's results with a dramatic "12 points go to..." moment for the top-rated act.
- Running totals feed into the leaderboard.
- Scoring: points awarded based on closeness to group consensus (average). Closer to the group average = more points. Exact formula in scoring config.
### ESC Bingo (Act 2, passive)
- Bingo cards generated server-side when Act 2 starts.
- Each player gets a unique card — a random subset of tropes from `bingo-tropes.json` arranged in a grid (e.g., 4×4 = 16 squares).
- Players tap squares on their phone as tropes happen during the broadcast.
- Server validates bingo (full row/column/diagonal) and announces on the display.
- Runs passively alongside jury voting — player phone shows tabs for Jury and Bingo during Act 2.
### Quiz / Trivia (Act 3)
- Host starts the quiz round from the Host tab.
- Display shows the question (from `quiz-questions.json`). Player phones show a buzzer button.
- First player to buzz gets to answer. Host judges correct/incorrect via the Host tab.
- If incorrect, the question reopens for remaining players.
- Points based on difficulty tier (easy/medium/hard), values from scoring config.
- Host advances to next question manually. Can end the quiz at any time.
### Shared Leaderboard
- All five games contribute to one total score per player.
- Leaderboard visible on the display throughout the evening, updated after each scoring event.
- Prediction scores are hidden until the final reveal — the leaderboard shows "??? pts" for predictions until Act 3 ends.
- Final reveal: display shows a dramatic countdown/animation revealing prediction scores and the final standings.
---
## Scoring Config
All values in `packages/server/data/scoring.json`, editable without code changes:
```json
{
"prediction_winner": 25,
"prediction_top3": 10,
"prediction_nul_points": 15,
"jury_max_per_round": 5,
"bingo_per_square": 2,
"bingo_full_bonus": 10,
"quiz_easy": 5,
"quiz_medium": 10,
"quiz_hard": 15,
"dish_correct": 5
}
```
**Jury scoring formula:** Each round, the group average rating is computed. A player's jury points for that round = `jury_max_per_round - abs(player_rating - group_average)`, clamped to a minimum of 0. This rewards voting with the group consensus.
**Prediction scoring:** Resolved after the actual ESC results are known. The host enters the actual top 3 + last place into the Host tab during/after Act 3, and the server calculates prediction scores.
---
## Database Schema
PostgreSQL via Drizzle ORM. Lightweight — most queries are "get everything for this room."
### Tables
**rooms**
- `id` (uuid, PK)
- `code` (varchar(4), unique, indexed)
- `current_act` (enum: lobby, act1, act2, act3, ended)
- `host_session_id` (uuid)
- `created_at` (timestamp)
- `expires_at` (timestamp, default: created_at + 12h)
**players**
- `id` (uuid, PK)
- `room_id` (FK → rooms)
- `session_id` (uuid, unique)
- `display_name` (varchar)
- `is_host` (boolean)
- `connected` (boolean)
- `joined_at` (timestamp)
**predictions**
- `id` (uuid, PK)
- `player_id` (FK → players)
- `room_id` (FK → rooms)
- `predicted_winner` (varchar, country code)
- `top_3` (jsonb, array of country codes)
- `nul_points_pick` (varchar, country code)
**jury_rounds**
- `id` (uuid, PK)
- `room_id` (FK → rooms)
- `country_code` (varchar)
- `status` (enum: open, closed)
- `opened_at` (timestamp)
**jury_votes**
- `id` (uuid, PK)
- `player_id` (FK → players)
- `room_id` (FK → rooms)
- `jury_round_id` (FK → jury_rounds)
- `rating` (integer, 1-12)
**bingo_cards**
- `id` (uuid, PK)
- `player_id` (FK → players)
- `room_id` (FK → rooms)
- `squares` (jsonb, array of { tropeId, tapped })
**dishes**
- `id` (uuid, PK)
- `room_id` (FK → rooms)
- `name` (varchar)
- `correct_country` (varchar)
- `revealed` (boolean)
**dish_guesses**
- `id` (uuid, PK)
- `player_id` (FK → players)
- `dish_id` (FK → dishes)
- `guessed_country` (varchar)
**quiz_rounds**
- `id` (uuid, PK)
- `room_id` (FK → rooms)
- `question_id` (varchar, references quiz-questions.json)
- `status` (enum: showing, buzzing, judging, resolved)
- `buzzed_player_id` (FK → players, nullable)
**quiz_answers**
- `id` (uuid, PK)
- `player_id` (FK → players)
- `quiz_round_id` (FK → quiz_rounds)
- `buzzed_at` (timestamp)
- `correct` (boolean, nullable)
---
## Deployment
- **Server:** Hono + WebSocket service on Uberspace (existing asteroid, accessed via `ssh serve`). Deployed to `~/services/celebrate-esc/`. Managed by systemd user unit.
- **Client:** Vite production build, static files served from `/var/www/virtual/<user>/html/celebrate-esc/`.
- **Backend routing:** `uberspace web backend set /celebrate-esc/api --http --port <port> --remove-prefix` for the API/WebSocket.
- **Database:** PostgreSQL on the same Uberspace host (existing or new instance — check `~/CLAUDE.md` on the server).
- **Deploy script:** Idempotent `deploy.sh` that builds client, syncs files, restarts the service.
---
## Out of Scope (v1)
- AI commentator
- Remote / online play (all players in same room)
- Persistent accounts or multi-session history
- Integration with official Eurovision data feeds
- Multiple simultaneous rooms on one server (technically possible, but not a design priority — single party per evening is the target)
---
## Tech Stack Summary
| Layer | Client | Server |
|---|---|---|
| Runtime | Bun + Vite | Bun (local), Node 22 (Uberspace) |
| Framework | React 19 | Hono |
| UI | shadcn/ui + Tailwind v4 | — |
| Routing | TanStack Router | Hono router |
| State | Zustand | In-memory + PostgreSQL |
| Realtime | WebSocket (native) | Hono WebSocket |
| DB | — | Drizzle + PostgreSQL |
| Validation | Zod | Zod |
| Testing | Vitest + Playwright | Vitest |
| Code Quality | Biome | Biome |