add esc party app design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
332
docs/superpowers/specs/2026-03-11-esc-party-app-design.md
Normal file
332
docs/superpowers/specs/2026-03-11-esc-party-app-design.md
Normal 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 |
|
||||
Reference in New Issue
Block a user