JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog

Dx Music League Part 3 : Shipping It

AdminJuly 12, 20266 min read
Dx Music League Part 3 : Shipping It

On this page

  • Hosting: the boring twenty minutes
  • Public leagues, in four slices
  • The day of removing lies
  • The bug that would have been a launch story
  • Timed rounds and the scheduler I refused to build
  • Mobile, help, and the tour
  • Beta

On this page

  • Hosting: the boring twenty minutes
  • Public leagues, in four slices
  • The day of removing lies
  • The bug that would have been a launch story
  • Timed rounds and the scheduler I refused to build
  • Mobile, help, and the tour
  • Beta
PreviousDx Music League Part 2 : One Backend, Two WorldsNextDx Music League Part 4 : Beta Week

Building DX Music League, Part 3: Shipping It

Part 3 of a 5-part series. Part 2 built the backend. Now it has to survive contact with the public internet — and with people who didn't build it.


There's a five-day gap in the commit history between June 29th and July 4th. Then July 4th happens: twenty-four commits in one day. This is the part of every project where the distance between "works for me" and "works for people" gets measured precisely, in commits.

Hosting: the boring twenty minutes

The morning started with the last infrastructure piece: S3 + CloudFront for the frontend, added to the same CDK stack as everything else. Private bucket behind Origin Access Control, HTTPS, and the SPA fallback — 403/404 rewrites to /index.html — so React Router deep links work when someone opens /leagues/abc123 directly from a shared link.

Later that day came the deploy script, which encodes the one caching rule that every SPA eventually learns, usually the hard way:

  • Hashed assets (app-3f9c2.js) sync with cache-control: public, max-age=31536000, immutable. They can cache forever; their names change when their contents do.
  • index.html uploads separately with no-cache. It's the pointer to the current bundle. Cache it once and your users are pinned to a stale app until they think to hard-refresh — which they never do.

One npm run deploy builds, syncs, and invalidates CloudFront. Boring. Perfect.

Public leagues, in four slices

The launch feature was making leagues discoverable — until now, you needed an invite code someone sent you. It shipped as four deliberately thin vertical slices, each one deployable on its own:

  1. The model: leagues gain visibility (private/public) and a player cap.
  2. Discovery: an endpoint and UI listing open public leagues with free slots.
  3. Preview: a non-member can view a public league's name, theme, members, and open-slot count.
  4. Claim: a "Claim a spot (3 left)" button that joins without an invite code.

Slicing it this way instead of building one big "public leagues" branch meant each piece was reviewable and testable alone — and when slice 4 needed the join rules, slices 1–3 were already live and boring.

The day of removing lies

Here's the theme of July 4th that no feature list captures: the UI was still lying, in the way every mock-first UI lies, and each lie had to die before strangers saw it.

  • A "Premium Edition" badge, gone.
  • A fake "PRO PLAN · Curator" label on the profile, gone.
  • An Edit Profile button that did nothing — now it actually edits your display name, through Cognito.
  • An "Open round" button visible to players who had no permission to open a round, hidden.
  • A notification bell that would never notify anyone, removed (and the next day, a global search bar that searched nothing).

I've come to believe this pass matters more than any feature: every dead control a real user clicks costs you more trust than a missing feature does.

The auth flows got the same treatment, because mock auth had let me skip the unglamorous edges: a confirm-password field on sign-up, the full forgot-password reset flow, and — the fix that saved actual beta users later — sign-in detecting an unconfirmed account and routing back to email verification with a fresh code, instead of dead-ending on an error.

The bug that would have been a launch story

Buried mid-day is the least glamorous, most important commit: "Generate random league ids + invite codes (fix cross-league collisions)."

League ids and invite codes had been derived from a module-level counter. Fine on a laptop. But Lambda resets module state on every cold start — so two leagues created across different cold starts could mint the same id. On a single dev machine this is nearly impossible to hit; in production it's a time bomb where two groups of strangers get merged into one league.

The fix: ids and invite codes come from crypto randomness, with invite codes drawn from an ambiguity-free alphabet (no I, L, O, 0, or 1 — because someone will always read a code aloud over voice chat). The lesson generalizes: any "it's fine, it's just a counter" in serverless code is a collision waiting for a cold start.

Timed rounds and the scheduler I refused to build

The most architecturally interesting feature of the day: leagues that run themselves. Set a start date and a phase length, and rounds advance automatically — submissions close, voting opens, results reveal — without the owner clicking anything.

The obvious design is a cron job. I didn't build one. Instead, progression is lazy, on read: whenever anyone loads the league (or submits, or votes), the backend checks whether the current phase's deadline has passed — or whether everyone has already finished — and advances the round through every phase it's owed. A league nobody opens for a week catches up in a single read. Finishing early re-bases the next deadline so the league doesn't stall.

No scheduler, no EventBridge rules, no missed-tick edge cases — with one honest trade-off: a round revealed at midnight looks revealed only when the first person shows up. For a game among friends, that's indistinguishable from correct. (A cron upgrade is designed and sitting in a drawer if it ever matters.)

Mobile, help, and the tour

The last stretch before inviting anyone acknowledged two realities. First: everyone would play this on their phone. The desktop sidebar became a bottom tab bar under 820px, with the safe-area inset handled for the iPhone home indicator and input font sizes bumped to 16px to defeat iOS Safari's focus auto-zoom.

Second: nobody reads instructions, but everybody needs them once. A wiki-style Help page explains the phases, the point pool, and the tie-break; and on July 5th, a six-step guided tour that appears on first sign-in — welcome, join or create, submit, listen, vote, leaderboard — replayable from Help forever after.

The same day, two features quietly disappeared behind gates: timed rounds (built, but not yet trusted with real leagues) and the YouTube Music picker option (read path proven, playlist story unfinished — part 5 is about exactly this). Shipping a beta is as much about choosing what to hide as what to show.

Beta

Then the invite went out to the group chat. A league called Szn Music Group 1 filled up with eleven real players, picked a theme, and started submitting songs.

Which is when the actual development began.

Next: Part 4 — Beta Week, in which real users find real bugs (someone's comments vanish, a settings field silently no-ops), demand anti-votes, and teach me what a music league actually needs.