15 KiB
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
sessionStoragefor 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
- Host visits
/, creates a room → server generates a 4-character alphanumeric code (e.g.,A3F7). - Host gets a session token (random UUID) stored in
sessionStorage. This identifies them as the host. - Host opens
/display/:roomCodeon the TV and/host/:roomCodeon their phone. - Players visit
/, enter the room code + display name → join the room, get their own session token. - Room state machine:
lobby→act1→act2→act3→ended. - Only the host can advance between acts.
- Max 10 participants (1 host + 9 players).
- Rooms auto-expire after 12 hours. Host can end the room early via the Host tab.
- Room codes are generated with collision detection — if a code is already in use by an active room, regenerate.
- Display names must be unique within a room. Server rejects a join if the name is taken.
- Late joiners: Players can join at any point before
ended. Late joiners miss games that have already started — no retroactive predictions after Act 1, no bingo card if joining mid-Act 2. They participate in whatever is currently active.
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 |
submit_actual_results |
winner, second, third, lastPlace | Host enters real ESC results |
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 |
actual_results_entered |
winner, second, third, lastPlace | Host submitted real results |
predictions_revealed |
per-player prediction scores | Prediction scores unveiled |
room_ended |
finalLeaderboard | Room ends |
Games
Prediction Voting (lobby + Act 1)
- Available as soon as players join the lobby, through Act 1. Predictions lock when the host advances to Act 2.
- Each player submits: predicted winner (1 country), top 3 finishers (3 countries, excluding the predicted winner), nul points candidate (1 country).
- Scores are hidden until the final leaderboard reveal in Act 3.
- Country selection from the hardcoded ESC 2026 lineup.
Dish of the Nation (lobby + Act 1)
- Host adds dishes via the Host tab as people arrive (name + correct country). This can start during the lobby phase and continue into Act 1.
- Players see the list of dishes and submit a country guess per dish.
- Host triggers reveal before Act 2 — 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 discrete integer rating for that country (1 = worst, 12 = best, matching Eurovision convention).
- 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.jsonarranged in a 4×4 grid (16 squares, no free space). The trope pool must contain at least 30 tropes to ensure sufficient card variety. - Players tap squares on their phone as tropes happen during the broadcast.
- Server validates bingo: any complete row, column, or diagonal (4 in a line). Multiple players can achieve bingo. Each player receives the bingo bonus once (first bingo only); subsequent lines score nothing extra.
- 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 (verbal answer in the room). Host judges correct/incorrect via the Host tab.
- If incorrect, that player is excluded from re-buzzing on this question. The question reopens for remaining players. Multiple incorrect buzzes are tracked in the
quiz_answerstable. - 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:
{
"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 - Math.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 results into the Host tab during/after Act 3 via a dedicated "Enter Results" form: winner, 2nd place, 3rd place, and last place finisher. The server compares each player's predictions against these actual results and calculates scores. This triggers the dramatic final leaderboard reveal on the display.
Jury scoring edge case: With very few voters (1-2), the consensus formula produces trivial results (a solo voter always matches the average). This is acceptable for a party game — the scenario is unlikely with 5-10 players, and the points are still valid.
Database Schema
PostgreSQL via Drizzle ORM. Lightweight — most queries are "get everything for this room."
Score computation: Scores are computed on the fly by querying the individual game tables (jury_votes, bingo_cards, quiz_answers, dish_guesses, predictions + actual_results). No materialized scores table — with ≤10 players the joins are trivial and this avoids consistency issues.
Tables
rooms
id(uuid, PK)code(varchar(4), unique, indexed)current_act(enum: lobby, act1, act2, act3, ended)host_session_id(uuid)actual_winner(varchar, nullable — set when host enters results)actual_second(varchar, nullable)actual_third(varchar, nullable)actual_last(varchar, nullable)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 — must not include predicted_winner, enforced in application logic)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)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, positionally ordered array of { tropeId, tapped } — index 0-15 maps left-to-right, top-to-bottom on the 4×4 grid)
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)
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-prefixfor the API and WebSocket. - WebSocket endpoint:
/celebrate-esc/api/ws/:roomCode— clients connect here with their session token as a query parameter. - Database: PostgreSQL on the same Uberspace host (existing or new instance — check
~/CLAUDE.mdon the server). - Deploy script: Idempotent
deploy.shthat 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 |
| PWA | vite-plugin-pwa (asset caching) | — |
| Realtime | WebSocket (native) | Hono WebSocket |
| DB | — | Drizzle + PostgreSQL |
| Validation | Zod | Zod |
| Testing | Vitest + Playwright | Vitest |
| Code Quality | Biome | Biome |