Skip to content
← Projects

Track Record

A music party game built on Billboard Hot 100 history. Hear three songs that all hit the top 10 in the same mystery year, name the titles and artists, then tune a dial to guess the year. Play pass-and-play on one phone, or open a room and let everyone guess from their own.

  • Astro
  • React
  • TypeScript
  • AWS Lambda
  • DynamoDB
  • API Gateway (WebSockets)
  • Terraform

The problem was one person doing all the work

My family already played this game. It just took a human to run it: pick a year, look up the Billboard top 10, hunt each song down on Spotify, play it while everyone shouts guesses. Title is a point, artist is a point, and after three songs everyone guesses the year. Exactly right is five points, within one year is three, within three is one.

The game was good. The logistics were not. One person spent the night as a jukebox and couldn’t play, because they’d seen every answer. Every round stalled while someone typed “billboard top 10 19..” into a search bar.

So the requirements wrote themselves: pick a random year, play real clips from real chart hits, keep score with our house rules, and run on one phone passed around a table. No accounts, no app store, and no server if I could avoid it.

Dead end one: the obvious answer

Spotify was the obvious answer, since that’s what we already used. It survived about an hour of research. Spotify removed 30 second preview URLs for new API applications in late 2024, full playback requires every listener to be logged in with Premium, and their developer terms don’t permit combining their content with other data sources. A trivia game built on chart data is exactly that combination.

Useful dead end. It pushed me to the iTunes Search API, which returns a 30 second preview for almost any song with no auth and no key. One catch: you can’t call it with fetch() from a browser, because Apple sends no CORS headers. Their documented workaround is JSONP, the old script tag callback trick. It feels like 2009. It works.

Dead end two: the overnight batch job

My first architecture was the textbook one. Take the curated dataset (1,725 songs that reached the Hot 100 top 10 between 1958 and 2026, distilled from about 5,300 candidates), resolve every song to a preview URL up front, commit the enriched JSON, serve everything static.

I wrote the resolver, started it before bed, and woke up to a night of throttling. Apple’s search API allows roughly 20 requests per minute. I had tuned the script several times faster than that, so it spent hours backing off and finished almost nothing.

The failure asked a better question: why resolve 1,725 songs when a game night uses maybe thirty? I flipped it. When a round starts, the app resolves that year’s three clips in the background, and if a song won’t match or has no preview, it swaps in another hit from the same year. There are around 25 per year to choose from, so a round always fills. Three calls every few minutes never approaches a rate limit.

Then, when genre filtering landed on the roadmap, the batch job came back, because the seed data has no genre and iTunes returns one with every lookup. Same script, roughly. What changed was understanding the constraint: it throttles to about 17 requests per minute, checkpoints every 25 songs so a laptop reboot costs nothing, backs off exponentially on 403s, and runs exactly once. Most rounds now start instantly from baked data, and the on demand path stays in the code as the fallback for the stragglers.

The boring architecture is the point

The game is a static Astro site with one React island, deployed through the same Terraform modules, GitHub Actions OIDC pipeline, and S3 plus CloudFront setup as the rest of this site. Adding it to production required zero new infrastructure. I’d argue that’s the strongest decision in the project: the cheapest and most reliable component is the one you don’t build.

One number I like. The enriched song dataset was originally bundled inline with the JavaScript. Moving it to a fetched static file dropped that chunk from 692KB to about 44KB, a 94% reduction, which matters when the venue is someone’s kitchen on mediocre wifi.

The gameplay design leans on the same restraint. It’s one phone in a circle, so instead of networking I built a handoff gate: a full screen “pass the phone to Dana” moment before each secret guess. Social protocol as architecture.

Then six people showed up

Pass and play works great at four. At six it becomes a queue, ninety seconds of dead air per round while a phone travels around the table. The fix was obvious from every Jackbox night I’ve hosted: the game lives on one screen and everyone’s phone becomes a controller.

I wrote a full architecture doc before any code: goals, non goals, message protocol, data model, failure modes, cost model, and five decision records naming what I chose and what I rejected. It’s committed with a timestamp that predates every line of multiplayer code, which is the order I want to work in professionally. The short version:

WebSockets over polling or SSE. Guesses go up, phase changes come down, the lobby updates live. One duplex channel matches the room mental model.

API Gateway WebSockets and Lambda over a container socket server. A socket.io box on Fargate is the flexible answer and the wrong shape here. It idles at real money for a game played a few evenings a month. Serverless scales to zero, bills per message, and forced me to design connection lifecycle, room state, and reconnection explicitly. A couple hundred milliseconds of cold start is irrelevant at a party.

DynamoDB, single table, TTL. Room state is a few KB, accessed strictly by room code, alive for hours. TTL gives garbage collection for free: rooms delete themselves a day after the last activity, so no cleanup job exists because none needs to.

Host authoritative, thin server. The game engine already runs in the browser, so the server is a message router and room registry, not a referee. My threat model is my cousin. The one rule the server does enforce: guesses stay server side until the host opens the reveal, so nobody can peek through devtools.

Reconnection by token, not connection. Party wifi drops phones, and API Gateway issues a new connection ID every time. So identity lives in a token issued at join, and reconnecting just re-points it. The host gets the same treatment, which means the host screen can crash, reload, and reclaim the room, because state lives in DynamoDB and not in the socket.

The property I’m proudest of: if the region goes down mid game, the app degrades to exactly the pass and play mode it already had. Multiplayer is an enhancement layer with a built in fallback. Availability by architecture, not by redundancy spend.

The cost. A big game night is eight players for two hours, a few thousand messages, which comes to roughly a tenth of a cent. Lambda and DynamoDB at that volume sit inside the free tier indefinitely. Fixed monthly cost of the real time stack: zero. The Fargate alternative would have been about $180 a year to host six evenings of trivia.

The infrastructure is a Terraform module beside the ones running this site, with a deliberately split pipeline: infrastructure pushes trigger a read only plan for review, and applies happen manually with admin credentials. I chose not to give CI the power to create IAM roles. For a stack that changes a few times a year, that automation would have bought convenience and sold blast radius.

Two bugs worth writing down

The server that connected perfectly and said nothing. First live test, wscat connected cleanly, I sent createRoom, and got silence. CloudWatch told the story in two lines: the room was created, the DynamoDB write succeeded, and then the reply failed with AccessDeniedException. The server was doing everything right and couldn’t say so.

Replies on a WebSocket API go through the connection management API, which only exists on the execute-api host. My Lambda was building that endpoint from the incoming request’s domain, which was my custom domain, producing a request my correctly scoped IAM policy correctly refused. One line changed. The Terraform plan read zero to add, one to change, zero to destroy, which is exactly the kind of diff the manual apply discipline exists to let you read before you act. I left a comment above that line explaining why it must not be simplified back, because this failure is silent while everything around it succeeds.

The cache that killed a working network. Later, clips stopped playing. The obvious suspect was the audio engine, and the first hypothesis was a suspended AudioContext. The console said otherwise: context running, and a TypeError thrown from the service worker, followed by ERR_FAILED on the clip and NotSupportedError from play().

Audio elements request media with HTTP Range headers and get 206 partial responses back. The Cache API refuses to store a 206, so the unguarded cache.put rejected, the fetch handler resolved with nothing usable, and a healthy network request became a hard failure. The service worker I’d added three weeks earlier for offline support was breaking playback. Fix: Range requests pass straight through, only complete 200s get cached, and the whole handler falls back to a plain fetch on any internal error. Offline clip replay went away with it, which I logged in the design doc as an accepted tradeoff rather than a regression to chase.

It also explained why the bug looked multiplayer specific: a service worker only controls a page from the next navigation after it registers. My single device tests ran before it took control. By the multiplayer session, it was live and intercepting.

What playtesting actually changed

Nearly every good decision in this project came from watching people play, not from planning.

The reveal screen showed each song’s chart year, which quietly handed players the answer to the five point question. Gone.

Two people blurt the same answer constantly, so scoring became multi select with both getting full points. Later the whole model changed again: taps became toggles that apply nothing until the round advances, because people mis-tap while laughing and needed to take it back.

A 2023 number one showed up that nobody at the table had heard of, which exposed a flaw in my curation. Chart peak used to imply recognizability, but streaming era fan campaigns can send a song to number one for a single week. The dataset already carried weeks on chart, so eligibility now requires a real chart run.

The same year kept producing the same three songs, because the recognizability fix had been implemented as a ranking rather than a filter. Filter for quality, then pick uniformly at random.

And multiplayer originally allowed hybrid rooms, where locally entered names played alongside phone joiners. In practice a saved roster leaked ghost players into a real game. I removed the feature and amended the design doc with a dated note. Reversing a documented decision because the table disagreed with it is, I think, the doc working correctly rather than failing.

Where it landed

Single device pass and play, or rooms where everyone guesses from their own phone at the same time. A Game Master mode where one player sets the year and picks the songs from their own phone, rotating each round. Filters by era, genre, chart tier, or a custom year range. Installable as a PWA, and it survives a reload mid game. The controller a player loads is about 14KB, because it’s an input device and not a copy of the game.

The visual identity is one machine, a 1970s hi-fi: the setup screen is a record sleeve with the rules on its back cover, a vinyl record with a maroon label spins while a clip plays, the year guess is a tuning dial, the reveal counts up to the answer with accelerating ticks, and the scoreboard is a weekly chart with movement arrows. All CSS and inline SVG, no image assets, and it all stops for prefers-reduced-motion.

What I’d tell past me

Read the rate limit before writing the loop. Let a failure redesign the architecture instead of patching it. Write the design doc first, because it was the cheapest part of the project and did the most work. The cost model is an argument, not an appendix: knowing the container alternative cost roughly a hundred thousand times more per game is what made the tradeoffs easy. And when a distributed system goes quiet, the logs usually show a system that’s half working, and the half that works tells you where to look.

Also: build the thing your family will actually make you fix. The bug reports are better.