The system in pictures. CLAUDE.md carries the invariants and the hazards;
this is the shape they sit on.
Two hosts, and the split is not arbitrary. Vercel serves the app and everything request-shaped. Cloudflare serves the one thing Vercel structurally cannot: a live room that several phones are connected to at once. See decisions.md. Mixed Playlist rooms are rooms too, and stay on Vercel and Upstash on purpose — D11.
┌───────────────────────────────────────┐
host's laptop │ VERCEL (Next.js) │
┌──────────┐ │ │
│ / │────▶│ POST /api/playlist ──┐ │
│ /game │ │ GET /api/preview │ │
└──────────┘ │ POST /api/preview/ │ lib/kv.ts │ ┌──────────┐
│ │ batch ───┼─────────────────────▶│ UPSTASH │
│ │ POST /api/room/… │ (the only │ │ REDIS │
│ │ GET /r/[surface] │ server │ └──────────┘
│ │ POST /api/pulse ───┘ state) │ TTL'd only:
│ │ │ rooms, rate
│ └──────────┬────────────────────────────┘ limits, caches,
│ │ loop counters
│ ▼
│ ┌──────────────────────────────┐
│ │ Spotify · iTunes · Deezer│ all rate limited per app / per IP,
│ └──────────────────────────────┘ never per user — see §3
│
│ WebSocket
▼
┌─────────────────────────┐ ┌──────────────────────┐
│ CLOUDFLARE WORKER │◀──────▶│ players' phones │
│ worker/src/ │ WS │ /buzz/[code] │
│ one Durable Object │ └──────────────────────┘
│ per room code │
└─────────────────────────┘
There are no user accounts and no database. Everything in Upstash has a TTL and belongs to a room, an IP, a cached lookup, or a daily counter. Nothing is keyed to a person. That is a product decision, not an omission — see decisions.md.
SETUP app/page.tsx
│
│ paste playlist URL, add players, pick clip length
▼
POST /api/playlist ──▶ lib/playlist-cache.ts ──▶ lib/spotify.ts ──▶ Spotify
│ cache → coalesce → budget → cooldown
│ (§3 — all four exist to protect one shared quota)
▼
shuffle, write the whole payload to sessionStorage under `guesssong_game`
│
▼
GAME app/game/page.tsx
│
│ on mount: POST /api/preview/batch for the entire game at once
│ anything unresolved falls back to GET /api/preview lazily
│
│ per track: waiting → playing → guessing → revealed
│ │
│ host awards points by tapping a name (+3 song, +1 album)
│ there is no automated answer checking — the host is the judge
│ │
└─────────────────── next track ──────────────────┘
│
▼
finished
The state machine lives entirely in React state. A reload loses the game, which
is why sessionStorage holds the payload but not the score: recovering a
half-played party would need a server-side game record, and that is the first
step towards accounts. It is also why the game page refuses Android's
pull-to-refresh (overscroll-behavior-y: none in app/game/page.tsx): on a
phone the ordinary gesture for scrolling back up is a reload, and a reload is
round one with the scores wiped.
The single most important thing to understand about this codebase. All three
upstreams throttle on something the app cannot spread out — Spotify on the
client id, iTunes and Deezer on the egress IP — while every limiter in
lib/rate-limit.ts is keyed per visitor IP and therefore hands each new arrival
a fresh allowance. Per-IP limits bound one abusive client. They do nothing about
aggregate load, and aggregate load is the entire problem.
So each cache is four layers, and they read the same way on purpose:
request
│
▼
┌─────────┐ hit
│ CACHE │────────▶ answer, zero upstream calls
└────┬────┘
│ miss
▼
┌─────────────┐ already in flight
│ COALESCING │──────────────────────▶ await the sibling
└────┬────────┘ (one Mixed-mode Start fans out to N identical loads;
│ the cache write lands too late to help its own siblings)
▼
┌──────────────┐ over budget
│GLOBAL BUDGET │──────────────────────▶ refuse here, before upstream does
└────┬─────────┘ a KV incr shared across instances
│
▼
┌──────────────┐ upstream said 429
│ COOLDOWN │──────────────────────▶ park all uncached loads
└────┬─────────┘ without it a throttled window is self-sustaining:
│ everyone errors, everyone retries, the quota stays pinned
▼
upstream
lib/playlist-cache.ts |
lib/preview-cache.ts |
|
|---|---|---|
| Called once per | playlist | track |
| Cold 50-song game | 1 load | 50 lookups, up to 5 calls each |
| Budget env var | SPOTIFY_MAX_LOADS_PER_MINUTE (40) |
PREVIEW_MAX_LOOKUPS_PER_MINUTE (120) |
| Positive TTL | 24h (1h if sampled) | 1 year |
| Negative TTL | 10 min | 1 week (absent) / 90s (unavailable) |
Every layer fails open. Losing the safety net must mean "back to how it was", never "nobody can play".
The third cache is the Spotify token in lib/spotify.ts, deliberately at module
scope rather than in KV: a token is the one thing with no fallback, so a KV
outage on that path would take playlist loading down entirely.
absent and unavailable are both "no clip", and treating them as one value is
a bug that already shipped once:
absent— a fact about the recording. Nothing anywhere has a clip. Cached a week.unavailable— a fact about us. Throttled, out of budget, or the request never got through. Cached 90 seconds.
A wrong absent lasts a week and is invisible. A wrong unavailable costs one
retry. Only a clean, complete reply from upstream may produce absent.
host's laptop DURABLE OBJECT phones
BuzzerHostPanel one per room code /buzz/[code]
│ │ │
│──── host:open ──────────────▶│ │
│ │───── round:open ────────▶│ buttons live
│ │ │
│ │◀──────── buzz ───────────│ ×N, racing
│ │ │
│ │ single-threaded: order │
│ │ is decided by arrival, │
│ │ no locks, no CAS │
│ │ │
│◀───── state (locked) ────────│──── state (locked) ─────▶│ first name up
│ │ │
│─ host:verdict / host:reveal ▶│ │
│ │──── round:resolved ─────▶│ phase → idle
│──── host:next ──────────────▶│ roundIndex += 1 │
└──────────────────────────────┘ │
lib/buzzer-protocol.ts is imported from both sides of that boundary — the
Next.js client and the Worker — so it must stay dependency-free. Types and plain
constants only.
The protocol has no end-of-game signal. BuzzerPhase is
idle | open | locked; ClientMessage has host:open, host:verdict,
host:reveal, host:next and nothing else. Anything on a player's phone that
needs to react to the game finishing would need a protocol change, a Worker
change, and a wrangler deploy. This is why the loop's call to action on that
page is gated on "a round has resolved" rather than "the game ended" — see
viral-loop.md.
| Where | What | Lifetime |
|---|---|---|
| React state | the running game — phase, scores, current track | until reload |
sessionStorage |
the game payload handed from / to /game |
the tab |
localStorage |
player id, host name, host game count, last loop ref, last quiz code + host tokens, a taker's quiz in progress (answers, the verdicts already shown, hints charged) and the submission id a resend replays | the device, until ITP clears it |
| Upstash KV | rooms, rate limits, playlist + preview caches, loop counters | 30s – 1 year, always a TTL |
| Upstash KV | a quiz: its questions (answer key included) and its board, one hash | 7 days |
| Durable Object | one live buzzer room | 3h idle timeout, sliding |
| GA4 | the funnel | Google's retention setting |
Nothing in that table is keyed to a person, and nothing survives being cleared
except the caches, which are keyed to content rather than to anyone. The quiz
board is the closest thing: names typed by takers, claimed once per quiz with
hsetnx, on the same footing as a room roster — and the key stays on the
server, which is what makes the board worth reading (lib/quiz-store.ts).