commit 1d9fc62bf32f4c4bd4d7a243f4f5ad43bcacc8d5 Author: Felix Förtsch Date: Wed Mar 11 11:23:08 2026 +0100 add esc party app design spec Co-Authored-By: Claude Opus 4.6 diff --git a/docs/superpowers/specs/2026-03-11-esc-party-app-design.md b/docs/superpowers/specs/2026-03-11-esc-party-app-design.md new file mode 100644 index 0000000..1fc8514 --- /dev/null +++ b/docs/superpowers/specs/2026-03-11-esc-party-app-design.md @@ -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//html/celebrate-esc/`. +- **Backend routing:** `uberspace web backend set /celebrate-esc/api --http --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 |