A local party music guessing game powered by Spotify playlists. Live at guessong.app.
No login, no accounts. The host pastes a public Spotify playlist URL (or picks a built-in one), everyone guesses out loud, and the host awards points.
Current version: 1.1.0 — see CHANGELOG.md.
- Setup — Paste a public Spotify playlist URL (or pick a built-in trial playlist), add player names, choose a clip length and song count, hit Start
- Play — A short audio clip plays; everyone guesses the song
- Score — The host taps whoever got it right
- Finish — Final scoreboard with a shareable results image
Clip lengths are 5 / 10 / 15 / 20 / 30 seconds; song counts are 10 / 20 / 30 / 50 / all.
The host is the judge — there's no automated answer checking.
| Award | Points | Where |
|---|---|---|
| Correct song | +3 | Party & Buzzer modes |
| Correct album | +1 | Party & Buzzer modes |
| Correct "whose playlist is this?" | +2 | Mixed Playlist Mode only |
| Correct guess | +1 | Trial mode (solo) |
One award of each type per round.
Two orthogonal choices: how you play and where the songs come from.
| Mode | What it is |
|---|---|
| Party (default) | Host types the player names, plays clips, and manually awards points. |
| Trial | Zero-setup demo — tap one of the three bundled playlists and play solo, +1 per round. Never calls Spotify. |
| Buzzer | Everyone scans one QR code and gets a full-screen buzzer on their own phone. A Cloudflare Durable Object decides who pressed first, so the host can stop refereeing and actually play. Only offered when NEXT_PUBLIC_BUZZER_WS_URL is set. |
| Source | What it is |
|---|---|
| Own playlist | The host pastes one public Spotify playlist URL. |
| Built-in | Three bundled, preview-verified playlists (華語金曲, Western Classics, 2010s Pop Hits — 16 tracks each). No Spotify credentials needed. |
| Mixed Playlist Mode | Merge everyone's playlists into one pool. Either a QR room (players scan and submit their own playlist URL from their phone) or phone mode (pass one phone around). Tracks are deduped with provenance and fair-sampled per contributor, and a round-scoring history feeds a shareable "group taste card" at the end — most obscure picks, most mainstream picks, most shared tracks. |
Buzzer Mode and Mixed Playlist Mode share a single room code and QR: the host claims the buzzer room first, then opens the playlist mailbox under the same code.
- Spotify playlist import via Client Credentials — no user auth, players never see a Spotify sign-in
- Three game modes and three playlist sources (above)
- 30s audio previews resolved from the iTunes Search API, falling back to Deezer
- Blurred album art hint system, live progress bar + countdown, replay from the guessing phase
- Export the final scoreboard (and the Mixed-mode taste card) as a PNG
- Fully bilingual — English and Traditional Chinese landing pages (
/,/zh), plus every user-facing error string in both languages, picked by device locale - In-app "What's new" release notes overlay
- Installable as a PWA, with Android Web Share Target support — share a Spotify playlist link straight into the app
- GA4 analytics behind a typed event union (opt-in via env var)
- Mobile-first layout
- Next.js 15 App Router, React 18, TypeScript
- Tailwind CSS + shadcn/ui primitives (the setup and game pages use inline styles instead)
- Spotify Web API (Client Credentials) for playlists
- iTunes Search API → Deezer for audio previews
- Upstash Redis for rooms, rate limiting, and the playlist/preview caches (falls back to an in-process
Maplocally) - Cloudflare Workers + Durable Objects for live buzzer rooms (
worker/) - Vitest for both suites;
zodfor request validation,qrcodefor room QR codes
This is a two-deployment project: the Next.js app on Vercel, the buzzer Worker on Cloudflare. Everything except Buzzer Mode works with just the first.
git clone https://github.com/Waynting/GuessSong.git
cd GuessSong
npm installcp .env.example .env.local| Variable | Required? | Notes |
|---|---|---|
SPOTIFY_CLIENT_ID |
For pasted playlists | App-level Client Credentials, not user login — no redirect URI. Get them at developer.spotify.com. Skip if you only want the built-in trial playlists. |
SPOTIFY_CLIENT_SECRET |
⤴ | |
UPSTASH_REDIS_REST_URL |
Production | Backs rooms, rate limits, and both caches (lib/kv.ts). Unset locally → in-process Map, which is fine for one next dev process but not for multi-instance serverless. Free tier at upstash.com. |
UPSTASH_REDIS_REST_TOKEN |
⤴ | |
NEXT_PUBLIC_BUZZER_WS_URL |
Buzzer Mode only | ws://127.0.0.1:8787 locally, wss://guesssong-buzzer.<subdomain>.workers.dev in production. Unset → the Buzzer Mode toggle is hidden. |
NEXT_PUBLIC_BASE_URL |
Optional | Defaults to https://www.guessong.app. |
NEXT_PUBLIC_GA_MEASUREMENT_ID |
Optional | Injects GA4 when set. Events no-op outside production regardless. |
SPOTIFY_MAX_LOADS_PER_MINUTE |
Optional | Global ceiling on uncached Spotify playlist loads. Default 40. |
PREVIEW_MAX_LOOKUPS_PER_MINUTE |
Optional | Global ceiling on iTunes/Deezer lookups. Default 120. |
DEV_ORIGINS |
Optional, dev only | Comma-separated LAN hostnames (no scheme, no port) added to allowedDevOrigins. Needed to test from a phone. |
npm run devOpen http://127.0.0.1:8000.
Use
127.0.0.1:8000specifically — the Spotify app is configured for this origin.
Buzzer Mode needs the Cloudflare Worker running alongside Next.js:
cd worker
cp .dev.vars.example .dev.vars # then add your LAN IP to ALLOWED_ORIGINS
npm install
npm run dev # wrangler dev on :8787Then set NEXT_PUBLIC_BUZZER_WS_URL=ws://127.0.0.1:8787 in .env.local.
Testing with real phones is where this trips people up. Phones on your Wi-Fi hit the dev server by LAN IP, not 127.0.0.1, and that LAN origin has to be allowed in two places:
ipconfig getifaddr en0 # macOS Wi-Fi — e.g. 10.107.0.98.env.local→DEV_ORIGINS=10.107.0.98(hostname only — Next.js refuses cross-origin/_next/*otherwise)worker/.dev.vars→ addhttp://10.107.0.98:8000toALLOWED_ORIGINS(full origin — the browser sends this on the WebSocket upgrade)
Root (Next.js app)
| Command | Description |
|---|---|
npm run dev |
Dev server on port 8000 |
npm run build |
Production build |
npm run start |
Start production server |
npm run lint |
ESLint |
npm test |
Vitest suite in tests/ — does not include the Worker tests |
worker/ (Cloudflare buzzer Worker)
| Command | Description |
|---|---|
npm run dev |
wrangler dev --ip 0.0.0.0 on port 8787 |
npm run deploy |
wrangler deploy |
npm run test |
Durable Object tests, run inside workerd via @cloudflare/vitest-pool-workers |
npm run typecheck |
tsc --noEmit |
npm run types |
Regenerate worker-configuration.d.ts |
Maintenance
node scripts/fetch-builtin-playlists.mjs # re-curate lib/builtin-playlists-data.jsonVerifies every bundled track still has a working 30s iTunes preview. Needs the Spotify credentials.
app/
page.tsx Setup — playlist, players, clip length, mode selection
game/page.tsx The game — phase machine, playback, scoring, result images
about/ "How to play" page
zh/ Traditional-Chinese landing page (written natively, not translated)
j/[code]/ Mixed Playlist Mode join page
buzz/[code]/ Buzzer Mode player page (holds the live WebSocket)
share/ Web Share Target handler + /share/unsupported explainer
icons/[size]/ PWA icons generated at the edge
api/ See the table below
icon.tsx, opengraph-image.tsx, robots.ts, sitemap.ts
components/ Buzzer button + host panel, room panel, mixed collector,
install banner, changelog modal, ui/ (shadcn primitives)
lib/ All shared logic — see "Architecture" below
worker/ Cloudflare Worker + BuzzerRoom Durable Object
tests/ 17 Vitest files, 273 cases
types/ Track, room, and preview wire types
Every route is IP rate limited (lib/rate-limit.ts) with a fixed window; limits below are per IP.
| Route | Method | Purpose | Limit |
|---|---|---|---|
/api/playlist |
POST | {url} → playlist name + tracks, via Spotify Client Credentials. Editorial playlists (IDs starting 37i9) are rejected — they 404 for new apps. |
30 / 10 min |
/api/preview |
GET | ?track=&artist=&id= → {previewUrl, status} where status is found / absent / unavailable. &refresh=1 re-resolves a URL that stopped playing. |
300 / 10 min (refresh: 30) |
/api/preview/batch |
POST | {tracks:[{id,name,artist}]} (max 60) → previews for a whole game in one request. |
20 / 10 min |
/api/room |
POST | Optional {code} → {roomCode, hostToken, expiresAt}. Creates the Mixed Playlist mailbox. |
10 / 10 min |
/api/room/[code]/submit |
POST | {playerName, playlistUrl} → {ok, trackCount}. |
20 / 10 min |
/api/room/[code]/status |
GET | Who has submitted so far (host polls every 4s). | 200 / 10 min |
/api/room/[code]/pool |
GET | ?sampledPerPlayer=N + x-host-token header → the sampled, deduped pool. One-shot consume. |
20 / 10 min |
/share |
GET | Web Share Target — extracts a playlist from shared text and redirects to /?playlist=…. |
— |
/icons/[size] |
GET | Generated PWA icons (192, 512, maskable). |
— |
Worker (separate Cloudflare origin): POST /rooms → {code, hostToken, expiresAt}, GET /rooms/:code/ws → WebSocket upgrade.
Spotify deprecated preview_url for most tracks in Nov 2024, so the game resolves clips itself. On mount the game page prefetches everything with one POST /api/preview/batch; anything unresolved falls back to GET /api/preview lazily when the host presses Play. Both search iTunes first, then Deezer.
Preview results are three-way, not two-way: found, absent (nothing has a clip — cached a week), and unavailable (we were throttled or the request never got through — cached 90 seconds). Collapsing those two nulls is a real bug that shipped once: one throttled minute marked a slice of the catalogue silent for a week.
Spotify throttles per client ID, so every visitor shares one budget — per-IP rate limits bound one abusive client but do nothing about aggregate load. iTunes and Deezer throttle per IP, and a serverless deploy's egress IPs are shared, so the whole user base looks like one very noisy client. Both lib/playlist-cache.ts and lib/preview-cache.ts therefore run the same three layers, all fail-open, all in KV so every instance sees them:
- Cache — a repeat playlist or track costs zero upstream calls
- Global budget — a shared counter that refuses new work before upstream does
- Cooldown — when upstream returns 429, uncached loads are parked for
Retry-After; cached content keeps serving, so a party mid-game is unaffected
lib/playlist-cache.ts also coalesces concurrent loads of the same playlist into one fetch, which matters because a QR room produces a burst of simultaneous submits from one click.
Vercel pins WebSocket connections to a single function instance with no guarantee a second connection lands on the same one — there's nothing to broadcast a room to. So live rooms run on Cloudflare instead. The host POSTs to the Worker's /rooms, which generates a 4-character code from an ambiguity-free alphabet and claims a Durable Object by that name; the DO is the registry, so a non-null return is the collision check. Players connect to /rooms/:code/ws, and ordering is decided by the DO's single-threaded execution — no locks, no CAS. Verdicts stay human: the room decides who was first, a person decides whether they were right. Max 12 players, 3h idle timeout.
lib/error-messages.ts is the only place a user-visible error string exists — one code union and one {en, zh} table, so a missing translation is a compile error. The server sends {error, code} and the client picks the language from the device locale. Localising server-side would be wrong: one room is read by several devices, and cached 404s would freeze one language into the cache for everyone.
Two hand-written changelogs, and a release updates both: CHANGELOG.md is the maintainer's technical record, lib/changelog.ts is the plain-language bilingual copy players read in the footer overlay. tests/changelog.test.ts pins LATEST_VERSION to package.json's version, so bumping one without the other fails the suite.
npm test # 17 files, 273 cases — vitest, jsdom
cd worker && npm test # Durable Object tests inside workerdThe root suite covers the pure logic (pooling, taste card, share-target parsing, game-session round-trips) and the parts most likely to regress expensively: tests/playlist-cache.test.ts asserts upstream call counts for cache hits, coalescing, cooldowns and budgets, and tests/preview.test.ts drives the real route handlers to pin found/absent/unavailable classification. There's no CI workflow in this repo — run both suites before shipping.
- Playlists — use public playlists you created. Spotify editorial playlists (Discover Weekly, Today's Top Hits, …) are not supported: those IDs return 404 for new apps.
- Previews — a small number of tracks have no 30s clip on either iTunes or Deezer and will show a "no audio" state.
- Scoring — the host is the judge. No automated answer checking.
- Found a bug? — use the "Report a problem" link in the footer.
MIT — fork it, remix it, host your own.