JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog

Dx Music League Part 4 : Beta Week

AdminJuly 12, 20266 min read
Dx Music League Part 4 : Beta Week

On this page

  • Visibility: "did everyone submit yet?"
  • Songs per player, and the stable-id trap
  • The disappearing comments
  • Anti-votes, and the field that silently vanished
  • Spectators, leavers, and durable standings
  • What beta week taught me

On this page

  • Visibility: "did everyone submit yet?"
  • Songs per player, and the stable-id trap
  • The disappearing comments
  • Anti-votes, and the field that silently vanished
  • Spectators, leavers, and durable standings
  • What beta week taught me
PreviousDx Music League Part 3 : Shipping ItNextDx Music League Part 5 : Add Youtube Music Provider

Building DX Music League, Part 4: Beta Week

Part 4 of a 5-part series. Part 3 ended with eleven real players in a real league. This part is about what they did to my roadmap.


There's a specific humility that arrives about 48 hours after real users touch your software. Not because everything breaks — the core loop held — but because real people reveal a category of problem no amount of self-testing finds: the difference between what the software does and what players assume it does. The week of July 6–9 was shaped almost entirely by them.

Visibility: "did everyone submit yet?"

The first requests weren't features at all — they were questions the app couldn't answer. Has everyone submitted? Who are we waiting on?

So rounds grew participation panels: during submissions, "7 of 11 songs in" with names of who's done and who's pending; during voting, the same for ballots. The interesting part was deciding what not to show — the panels leak identities only, never which song someone submitted or how they voted. That privacy line is enforced server-side and covered by tests, because "the UI just doesn't display it" is not a privacy model.

Songs per player, and the stable-id trap

Next request: "Can we submit two songs some rounds?" Songs-per-player became an owner setting, 1–5.

Storage-wise this broke an assumption baked into the database schema — one submission per player was enforced by the sort key SUB#<userId>. Multi-song rounds needed SUB#<userId>#<submissionId>, with legacy single-key rows still readable so existing leagues didn't break mid-round.

The subtle bug this feature almost shipped: when a player with a single slot re-submits (changes their pick), the natural implementation deletes the old submission and inserts a new one — with a new id. But ballots reference submission ids. Delete-and-insert would let a re-submit silently orphan votes already cast. The fix: replacement keeps the stable submission id. The song changes; the identity of "this player's pick" doesn't.

The disappearing comments

The best bug report of the beta: "my comments disappeared."

A player voted, wrote thoughtful per-song comments, then came back before the deadline to adjust one point allocation. The vote page — which loaded fresh and empty — submitted their adjusted points along with… empty comment fields, silently wiping everything they'd written. The ballot overwrite semantics were working exactly as designed; the page was the bug. It treated every visit as a first visit.

The fix added a GET my-ballot endpoint and made the vote page pre-fill everything — points, comments, anti-votes — from the ballot you've already cast, with the button switching from "Submit votes" to "Update votes." Editing is now actually editing.

I keep this one filed under: overwrite semantics are only safe if every writer reads first. The backend was never wrong; it just handed the frontend a loaded gun.

Anti-votes, and the field that silently vanished

The most requested game change: negative votes. The league wanted a way to say this song, no — so rounds gained an anti-vote pool (0–2 per voter, owner-configurable, default off). Anti-votes are optional to spend, can't target your own song, subtract from a song's total, and the reveal now shows each song's +for / −against breakdown. Standings bank net points, so yes — a truly bold pick can send your season total negative.

Shipping it surfaced the sharpest edge in the codebase. The route layer doesn't pass request bodies through to handlers — it rebuilds each handler's input from an explicit field list, coercing types as it goes:

ts
return leagues.updateLeagueSettings(deps, req.caller, {
  votePoolSize: asInt(body.votePoolSize),
  maxPointsPerSong: asInt(body.maxPointsPerSong),
  // downvotePoolSize: ...  ← forget this line and the field never arrives
});

I added downvotePoolSize to the settings type, the handler, the validation, and the UI — and the save was a silent no-op, because the route still enumerated the old field list and dropped the new field on the floor. As a security posture, allow-listing input fields is genuinely good; unknown fields can never sneak into storage. But it makes every new field a two-place change, and the failure mode when you forget is silence. The fix commit fixed both the route and my mental checklist.

Spectators, leavers, and durable standings

The last cluster of the week came from the social edges of the game — the stuff a solo tester literally cannot encounter:

  • Owner powers. Someone goes quiet for two rounds; someone's cousin joins and immediately regrets it. Owners can now kick members and regenerate the invite code (retiring the old one, for when a code escapes into the wrong chat). And a fix from the same territory: the invite panel had been hiding when a league hit its player cap — including from the owner, who needs it to manage the roster.
  • Spectator mode. Friends who didn't join wanted to watch. Any signed-in user can now browse running leagues and view revealed results read-only — with the invite code stripped server-side and join/submit/vote still member-only. This came with an honest redefinition, agreed rather than accidental: "private" now means invite-only to play, not invisible.
  • Durable standings. What happens to your points when you leave — or get kicked — and rejoin? The schema had the answer waiting: memberships and standings are separate rows, so removing a member deletes only the membership. The standings board filters to current members, and a rejoining player's points are simply there again. Leaving a league is no longer a way to be erased.
  • A real activity feed. "Kai submitted a song · 2h ago" — derived on read from the submission and ballot timestamps that already existed. No event log, no new table; the data had been there all along, waiting to be narrated.

What beta week taught me

Reading the week's commits back, a pattern: almost nothing was new invention. It was the existing model being made honest — pre-fill what the user already told you, keep ids stable under edits, keep points when people churn, show who we're waiting on, let the curious watch. The architecture didn't need to change; it needed to stop losing information it already had.

And one meta-lesson: every fix in this post shipped to production the same day it was reported, because the whole stack — mock frontend, memory-backed backend, real tests — still runs on a laptop with zero AWS. The seams from part 1 aren't a development convenience. They're what fast response to real users is made of.

Next, the finale: Part 5 — Adding a New Music Provider: YouTube Music — the seam that was designed for this exact moment, what's already proven, and the honest checklist of what's left.