Case study: fullhouse
Context
A friend, f8al, built bingo-bango: pick a Spotify playlist, get a randomized bingo card, mark squares as tracks play. Fully client-side — Spotify Authorization Code with PKCE, no backend, no secret, tokens never leave the browser. It works, and the “both facets” card design is genuinely clever: a square is either an exact song title or just an artist name, and an artist square lights up on any track by them, not just one specific song — so a single played track can complete several squares depending on how the card landed.
The one real friction, for an actual game night: every player has to individually authenticate their own Spotify account just to generate a card, even though they’re all sitting in the same room listening to the same host’s music. Worth fixing, not worth losing the original’s cleanliness to do it.
Problem
Keep the card-generation idea, credit it properly, and add:
- A host mode — one person connects Spotify once, picks a playlist, and everyone else just needs a code, no login.
- Live sessions — a caller screen for the room, guests’ cards syncing automatically as the host calls tracks.
- Auto-call from real playback — if the host is actually playing the session’s playlist through Spotify Connect, detect it and call automatically, not just via a manual button.
- Cover-art squares — the same API calls already return album art; use it.
Keep the original’s guest-PKCE, bring-your-own-playlist mode intact alongside all of this, not replaced by it.
Approach
1) Host auth reuses a proven pattern, not a new one
The host connection is Authorization Code with a refresh token persisted server-side — the same shape as this project family’s existing spotify.mjs integration (goonk’s own /wrapped//now pages), not invented fresh. One long-lived server-side connection, distinct from the guest PKCE flow, which stays fully client-side by design: PKCE’s entire point is no secret and no server holding a token, and host mode shouldn’t compromise that for guests who don’t want a session at all.
2) Two real Spotify API surprises, found by testing, not assumed
Building the host’s playlist-track reading hit a 403 Forbidden with no useful message. The obvious read — a permissions problem — turned out to be wrong after actually chasing it: GET /playlists/{id}/tracks, the endpoint every tutorial and the original project’s own code uses, has been quietly superseded by GET /playlists/{id}/items, with a reshaped response (entry.item instead of entry.track). Confirmed by fetching the full unfiltered playlist object directly and reading its actual keys, not by trusting documentation that turned out to be stale. Every route reading playlist tracks needed the same fix.
The second surprise: the “no login at all” public-playlist-by-URL feature was designed around a Client Credentials (app-only) token — no user involved, since the whole point was skipping guest login for a public playlist. Spotify’s actual response: 401 "Valid user authentication required", even for a fully public playlist. Confirmed directly, and confirmed it wasn’t scope-related or ownership-related by testing a plain user token against a private, unlisted, and public playlist in turn. The fix keeps the “guests never log in” property intact without needing an app-only token at all: the request routes through the host’s own already-authenticated connection instead. Genuinely user-authenticated, satisfies Spotify’s requirement, and guests still see nothing but a URL field.
3) Config lives in exactly one place
The client’s Spotify client ID (not sensitive — it’s public in every authorize URL a browser ever sends) initially got baked in at Docker build time, the standard Vite pattern for VITE_* env vars. That meant the same non-secret value had to be configured twice: once in the deploy host’s .env for the API service, and again as a separate CI secret for the client build. Simplified to a tiny GET /api/config the client calls once at runtime and caches — collapses back down to the one .env that was already the source of truth for everything else, and removes a CI secret for a value that was never actually secret.
4) Live sync is deliberately dumb
Guests’ cards poll a session-state endpoint every four seconds — not a WebSocket, not Server-Sent Events. A bingo card doesn’t need sub-second latency, and this matches the rest of the project family’s existing precedent (keep watch’s polling, wisp’s TTL sweep): reach for the boring mechanism unless the problem actually demands otherwise. The one addition beyond pure auto-sync: a guest can self-mark a square the host hasn’t confirmed yet, for the gap between “I’m sure that just played” and the next poll tick — locked out once the server actually confirms it, so self-marks can’t silently disagree with the source of truth.
5) Auto-call, verified live
The host’s real playback gets polled the same way (/me/player/currently-playing, the exact endpoint already proven working elsewhere in this family) every five seconds; a track that matches the session’s pool gets auto-called the moment it’s detected. Verified against an actual live Spotify session, not just reasoned about: played a real track, watched the auto-call poller pick it up and mark it within one tick.
6) Two production redirect-URI debugging sessions, and what they actually taught
Getting OAuth working in production surfaced two separate redirect_uri: Not matching configuration errors — genuinely confusing at first because the registered value and the value being sent looked identical on both ends. The first was a real mismatch (a trailing slash difference between what Spotify’s dashboard exempts for loopback addresses and what the code actually sends). The second looked identical but wasn’t: it turned out to be a completely different flow — guest PKCE mode’s redirect URI, not host mode’s, which had simply never been registered for production at all. Same error message for both, no way to tell which flow was actually failing without checking the browser’s real address bar at the point of failure. That distinction is now called out directly in the README, since the error message alone gives no hint which of the four required redirect URIs (host + guest, local + production) is the one actually missing.
What I’d do differently
The card engine regenerates a fresh random layout per guest per session — fine for gameplay, but there’s no way to verify after the fact whether a claimed bingo was legitimate without replaying the same seed. Worth a “verify” endpoint if this ever needs to resolve a real dispute at an actual game night.
No resumable session state across a server restart — sessions live in memory, matching the deliberately ephemeral “one game night” scope, but a redeploy mid-session currently just loses it. Acceptable for now; would need real persistence (even just serializing sessions to disk) if this ever ran unattended for longer-lived events.
Stack
Client: TypeScript, Vite, no framework — plain DOM manipulation, hash-based routing (no server-side rewrite rules needed for any route, including the guest OAuth callback).
Server: Node.js, Express, in-memory session state, setInterval-based polling for auto-call — no database, no queue, no socket server.
Infrastructure: Docker (two images: nginx serving the built client with same-origin /api proxying, and the Express API), a single named volume for the host’s persisted refresh token, Gitea Actions → private registry → docker-compose deploy. Same operational shape as the rest of this self-hosted stack.