Files
esc/docs/superpowers/specs/2026-03-11-esc-party-app-design.md
2026-03-11 11:26:45 +01:00

15 KiB
Raw Blame History

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: lobbyact1act2act3ended.
  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.
  9. Room codes are generated with collision detection — if a code is already in use by an active room, regenerate.
  10. Display names must be unique within a room. Server rejects a join if the name is taken.
  11. 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.json arranged 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_answers table.
  • 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 - 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)
  • 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, 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)

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 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.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
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