Why the product is shaped this way, and what was considered and rejected.
Kept in the repo on purpose. Most of these were argued out in design documents that live outside it, which means a fresh clone — or the same person in six months — has the constraints without the reasoning, and re-litigates a settled question from scratch. Each entry names the alternative and what would make it worth reopening.
Decided: from launch. Reaffirmed 2026-08.
There is no login, no user record, and nothing in storage keyed to a person. A host pastes a link and starts; a player scans a QR and types a name.
Rejected: accounts, so that scores, history, and repeat hosting could be tracked properly.
Why: the entire product advantage is that a party can start in about twenty seconds, at a moment when everyone is already half-distracted. An auth screen at that moment is not a small tax, it is the whole funnel. Every measurement problem downstream of this — see D6 — is a price knowingly paid.
Would reopen if: something genuinely impossible without identity becomes the main thing people want. Wanting better analytics is not that; it is the motivation this decision exists to refuse.
Decided: 2026-07-29.
Rejected: shipping a native app.
Why: there was no user signal for it — no requests, no GA4 evidence, no capability the PWA lacked. The actual driver was a feeling that a website "is not a real product", and that feeling does not survive contact with the App Store; it just changes units to "not enough downloads". Meanwhile the cost is two platforms and a human review cycle, which turns a one-line copy change from three minutes into three days.
The legitimacy problem was real and got a product answer instead: Buzzer Mode. What makes Jackbox feel like a real game is not that it is installed, it is that everybody's phone is in it.
Unchecked risk if this is reopened: Spotify's developer terms on game usage are loosely enforced against a website and would be read line by line in a manual app review. Verify that first — it could be an architectural rejection, not a screen to fix.
Would reopen if: a concrete user signal appears, or a real PWA capability gap. Not a feeling.
Decided: 2026-07-29. This reversed an earlier decision to avoid a realtime layer entirely.
Rejected: Vercel's native WebSockets, self-hosted Socket.IO, PartyKit, Supabase Realtime, and short-interval polling.
Why:
- Vercel WebSockets cannot broadcast. The documentation is explicit that new
connections are not guaranteed to reach the same function instance and that
rooms and pub/sub belong in external storage. So
io.to(room).emit()has no meaning there, and Upstash's REST API has noSUBSCRIBEto back one. Staying on one platform would still have required a second dependency, without any of the benefit. - Connections are bounded by
maxDuration— 300s on Hobby. A thirty-minute game means every player reconnecting six times. - Polling was the original plan and does not survive contact with the rate
limiter.
app/api/room/[code]/statusallows 200 requests per 10 minutes, i.e. one every three seconds, and buzzing needs sub-second. The key is alsoroom:status:${ip}, so an entire party shares one bucket. - A Durable Object is single-threaded per room, which makes "who pressed first" an ordinary function call instead of a distributed consensus problem. No locks, no CAS, no retry loop. Hibernation means an idle room costs nothing.
Cost accepted: a second platform, a second deploy that is not automatic (see operations.md), and a protocol file that must stay dependency-free because both sides import it.
Decided: 2026-08-03.
fetchPlaylistTracks reads at most MAX_PLAYLIST_TRACKS (500), choosing random
pages when a playlist is larger.
Rejected: following next until the playlist ends.
Why: Spotify throttles on the client id, so every visitor shares one budget. An unbounded loop made a 4,000-track playlist cost 40 upstream requests for a game that plays at most 50 songs. Sampling by page rather than by track because a page is what a request buys.
Cost accepted: "All" now means "a random 500". Invisible for any playlist a
party would realistically use, but a real behaviour change — truncated is
returned and still not surfaced in the UI, so a host with a huge playlist gets
no indication they are hearing a sample. Sampled results cache for 1h rather
than 6h, because the TTL is also how long everyone is stuck with the same draw.
Decided: 2026-08-09, at 13,000+ MAU, 100% organic, zero revenue.
Order: close the loop, make repeat hosting countable, then ask about money.
Rejected: a paid fake door first.
Why: the product has no limit worth paying to remove. It is free and
unrestricted — ROOM_MAX_SUBMISSIONS = 12, MAX_PLAYLIST_TRACKS = 500, 5–30s
clips — and none of those hurt. A fake door needs a door. Creating a pain worth
charging for is a separate design problem and was not solved.
The loop went first for a different reason than "it is more important": it is the only item that is correct regardless of what the data says. If nobody returns, that proves the loop is needed; if many do, the loop amplifies it. It was therefore not blocked on evidence nobody had.
A related premise was wrong and is recorded here so it is not repeated. The
first version of this argument claimed the product structurally could not
recognise a repeat host and deferred monetisation a full quarter on that basis.
The mechanism already existed — getPersistentPlayerId in
lib/use-buzzer-socket.ts, a persisted host name in components/room-panel.tsx,
and GA4's own client_id. The true statement was "nobody ever attached a host
identity to an event", which is a small change, not a quarter. An adversarial
review caught it. Watch for that shape of error: an observation failure
laundered into an architectural constraint.
Would reopen if: the share of games at host_game_index >= 2 climbs. That
is the number that moves monetisation from next quarter to next month.
Decided: 2026-08-09.
Loop events are recorded twice: GA4 through trackEvent, and KV through
lib/loop-stats.ts, read by npm run stats.
Rejected: GA4 alone, and — separately — the GA4 Data API behind a scheduled digest.
Why GA4 alone is not enough: it requires someone to open it, and across four attempts over eight weeks that did not happen once. Every feature decision in that period was made on an n of 1. Treat a step with a measured completion rate of zero as unavailable and design around it, rather than assigning it a fifth time.
Why not the GA4 Data API: parameter-level breakdowns need the parameter registered as a custom dimension, registration is not retroactive, and GA4's default event retention is 2 months — so the historical questions were already unanswerable. It also put a five-step Google Cloud service-account setup on the critical path of a plan whose entire premise was that manual steps do not get done.
KV is authoritative for decisions. GA4 keeps cohorting and the questions nobody has asked yet. They will disagree, and the gap approximates how much of this audience blocks analytics.
Decided: 2026-08-09, overturning the plan from earlier the same day.
npm run stats reads the counters. There is no cron and no webhook.
Rejected: a weekly Vercel Cron posting a digest to Discord or Slack.
Why: the digest was designed on the premise that data must arrive. That framing was wrong. The variable is not push versus pull, it is whether reading requires leaving the editor. A webhook adds a deploy surface, three environment variables, and one more feed to ignore. A command in the repo runs from the terminal that is already open — and, decisively, a coding agent can run it unprompted.
The line in CLAUDE.md telling an agent to run it is the delivery
mechanism, not documentation of one. Delete that line and this reverts to the
same defect as GA4.
A corollary worth generalising: a single discrete setting change is a reliable thing to ask a human for — flipping GA4 retention from 2 to 14 months happened within minutes. "Open the dashboard, find the report, work out what it means" is not. Size asks accordingly, and turn the second kind into a command.
Decided: 2026-08-03, after the collapsed version shipped and caused an outage.
Why it is here rather than only in the changelog: it is the most expensive mistake this codebase has made, and it is the kind that looks like a simplification. Mapping every failure onto "no preview" and caching it for a week meant one throttled minute at peak marked a slice of the catalogue silent for seven days. It never reproduced locally, because a laptop's own IP is never the one being throttled, and it presented as a catalogue gap rather than as throttling.
Only a clean, complete reply from upstream may produce absent. Everything else
is unavailable, cached 90 seconds. A wrong absent lasts a week and is
invisible; a wrong unavailable costs one retry.
Decided: 2026-09-14.
npm run stats that morning: 4,648 games, 58.2% repeat hosts — retention is
fine — and the share arm, the only surface whose carrier leaves the party, at
0 of 50 ever. Every other arm is a footer (1–2%) or a QR on a television (1%).
Nothing that a friend reaches by tapping a URL in a chat had been tried, so
"off-site does not convert" and "QR out of a PNG does not convert" were
indistinguishable. Taste Quiz is the first link-shaped surface, and the first
off-site one whose impression happens on a page of ours. Its number is
click:quiz_result against impression:quiz_result; the quiz is the reason
someone sends the link.
Mechanic: two titles, one really in the playlist, no audio in the question. A clip is a hint, fetched on tap, rationed at one per ten questions, a tiebreak rather than a penalty. Started as four titles and one hint per five; cut to two before release so the screen is the two answers and nothing else, which moved the floor to ten questions (a coin lands 7/10 17% of the time, 15/20 2%) and the lowest passing verdict to 60%, above chance. Previews are the hottest path in the app; a quiz that played one per question would multiply it by the number of friends, and a throttled minute would land as a silent quiz on a cold visitor.
Decoys: a built-in pool (lib/quiz-decoys.ts), tiered so the decoy is as
close to the playlist as the pool allows — same artist first.
Rejected: a second Spotify call per creation for top-tracks decoys (doubles
the cold cost of the one thing the playlist path is shaped around; reopen if
the verdict distribution piles at soulmate); decoys from other users' cached
playlists (a data flow between strangers); a stateless /q/<playlistId>?seed=
link (a cold Spotify load per active day per quiz, unstable under playlist
edits, and the board needs KV anyway); any per-person view — "my quizzes",
"claim this quiz" — which is D1 again.
Would reopen if: click:quiz_result reads like share after two weeks.
That would be the answer that this audience does not convert off-site, and it
is worth having.
Amended 2026-09-14 (1.11.0), twice, both from the first owner who shared one:
- The card names the quiz. The link unfurled on Facebook as the home page —
the party-game title over the party-game picture, the owner's name nowhere —
because a nested
generateMetadatainherits the root layout's canonical and Facebook resolves a canonical before it reads a card; and it drew no card at all where the unfurler honours robots.txt, because/qwas disallowed. Both fixed (app/q/[code]/page.tsx,app/robots.ts,app/q/layout.tsx). And the image is now rendered per quiz (app/q/[code]/opengraph-image.tsx), which 1.10.0 had rejected asƒon the most expensive render in the app paid once per unfurler per share. What changed is where the cost is paid: the route's owns-maxagehas the edge render a quiz's card once a day per region. The picture is most of the card; a card whose picture sells a different product is canned whatever its title says. - Right or wrong per question, the score at the end. A tap now shows the
verdict at once (
POST /api/quiz/[code]/check, the answer to one question against a pick for it) and the result screen's folded answer list is gone. The key-stays-on-the-server rule survives at question granularity: the question is locked before the phone asks, and the check records nothing, so the board is still one write from the sheet. Rejected: shipping the key with the view, obfuscated, so the reveal costs no request — the rule the taker page exists to keep. Cost: two KV commands per question rather than two per taker; at thirteen quizzes a week, nothing, andquiz_throttled:checkis where that stops being true. Side effect worth having: the check on question zero is the funnel'sstarted, which splits "read the card and left" from "played and stopped" — one number before.
Amended 2026-09-15 (1.12.0): its own page, not a mode of the form. The
quiz had been the third pill on the party form at / — "not a party" beside
the two things that are, on the page whose whole job is starting one — and it
was 1.4% of what that page created. It now lives at /quiz
(app/quiz/page.tsx): static, indexable, in the sitemap, linked from the party
form as text. The loop arm follows it: /r/quiz_result lands on
/quiz?ref=quiz_result (lib/loop-redirect.ts), so the conversion to read
for the warm arm is quiz_created.arrived_from rather than game_started's —
a person promised "make your own" is making a quiz, not hosting a party. The
old /?mode=quiz and /?ref=quiz_result spellings redirect in
next.config.js. Would reopen stands as written; the earlier question is
whether a pill becoming a link costs creations at all, which is created in
npm run stats two weeks after the deploy (CHANGELOG.md 1.12.0, known gaps).
Decided: 2026-09-19 (1.13.0), the first time the game page was measured on a phone viewport.
Most games are hosted from a phone, and until this release the game page had
only ever been looked at on a laptop. On 390×844 the grid ran 45px past the
right edge, the reveal ran to 807px in a 648px area so Next Track was below
the fold every round, and the first tap on the setup page zoomed it to 107%.
Under @media (max-width: 768px) the page is now laid out around the round
rather than around the card: the art is sized by the viewport's height and
collapses at the reveal, the scoreboard is one row of chips ranked left to
right, and every control tapped once a round is at least 40px tall
(app/game/page.tsx). The rules that hold it together, and why each fails with
the desktop looking fine, are CLAUDE.md's "Phones are the host's screen";
this entry is the alternatives.
Rejected:
- A scaled-down desktop — the same grid, narrowed. That is what the page had: a 140px sidebar under the card that showed two of four players and cost the reveal its last button on every phone measured.
- Animating the art's collapse. It is a cut, not a transition: animating width reflows the whole card for every frame, on top of the un-blur that is already running, and a phone GPU drops frames on both.
- Padding for the safe area per page.
viewportFit: "cover"is what makesenv(safe-area-inset-*)non-zero at all, and it lets every page run under the landscape notch, so the side insets are padded once, onbodyinapp/globals.css, outside@layer; a surface pads for itself only when it isposition: fixed. The quiz shell had padded by the insets for a release while they computed to 0px, which is the failure per-page handling invites. - Persisting progress so a reload is not round one. The sessionStorage
payload is the setup, not the score (architecture.md §2). Pull-to-refresh is
refused instead (
overscroll-behavior-y: none); a webview evicting the tab or a mis-swiped back gesture still lands on round one, and persistingcurrentIndexand the scores is its own change.
Cost accepted: a landscape phone gets the desktop grid, because every notched phone is over 768px wide in landscape; the chip strip gives no sign that a fifth player is off screen; and a wake lock the platform drops while the page stays visible is not re-requested until the next hide and show. All three are in 1.13.0's known gaps.
Would reopen if: one of those gaps draws a report — a landscape host, or a game lost to a reload the refusal does not cover. The second is the point to persist progress, not to loosen the refusal.
Decided: 2026-07-12, when the QR room shipped on the stack that existed. Written down 2026-10-03, after D3 put a Durable Object next to it and the question "why not both?" came up.
A Mixed Playlist room is a Redis hash behind /api/room/* (lib/room.ts),
polled by the host. The buzzer room, two weeks younger, is a Durable Object
holding WebSockets. They look like the same thing; they are not.
Rejected: moving Mixed rooms onto the buzzer Worker as a second Durable Object class.
Why:
- A submit loads a Spotify playlist, and that path lives on Vercel.
loadPlaylistsits on the playlist cache, in-flight coalescing, the per-minute and daily budgets and the 429 cooldown, all in Upstash. A room in a DO would either call back into Vercel for every submit — one more hop, the room's logic split across two platforms — or read Spotify from the Worker, which is the "one uncached path" that puts the shared per-app quota back at risk. - Nothing in it is latency-bound. D3's argument was sub-second "who pressed first". A Mixed room is each player submitting once and the host consuming the pool once; a roster that updates four seconds late costs nothing.
- The concurrency problem is already one command. A dozen phones submitting
in the same few seconds is handled by
hsetnxon a per-name field — no lock, no retry loop. The DO's single-threadedness would solve a problem that is already solved. - A second platform has a cost D3 only accepted because it had to. The Worker deploy is manual (operations.md §1); moving rooms there would put the feature that converts behind that step too.
Cost accepted: the roster is polled rather than pushed. Polling spends
Upstash commands against the same monthly cap that once took the whole site
down (operations.md §5). lib/room-poll.ts backs the interval off on silence
(4s → 8s after a minute → 20s after five) so an idle lobby is cheap, and resets
on every arrival so a filling one is not slower.
Would reopen if: room polling shows up as a meaningful share of Upstash commands, or a Mixed feature needs the server to push mid-game (live voting, shared reveal). Either is a reason to move the roster to a DO; the playlist load should stay on Vercel regardless.
D12 — "Put them in order" is a second page, a GameMode, and a date field the cache was not versioned for
Decided: 2026-10-09, with the mode (1.21.0) and the field it reads (1.20.1).
The first way to play that fetches no clip: four songs from the playlist as
cards, the room puts them in order of release year, the host reveals and
awards. lib/order-game.ts has the rules, app/order/page.tsx the screen.
Rejected:
- A branch inside
app/game/page.tsx. That page is 2,700 lines around one<audio>element — a round token, a request ledger for refusedplay()s, a repair path, a batch prefetch keyed ontracks.length— every piece of it pinned by a test that reads the file. A branch would have had to prove on every render that none of it ran; a page that never importslib/preview-client.tsproves it by construction, and a one-line test holds it. Cost accepted: the top bar, scoreboard and Game Over overlay are duplicated for now. - Ordering by playlist position or popularity. Position is known to the playlist's owner and nobody else; popularity is a Spotify-wide figure that moves weekly, so last month's answer is wrong this month. Release year is the one attribute a room can reason about from a title and a cover.
- Pass-the-phone with drag-to-order and automatic grading. Real per-player input, but every round becomes a minute of one person holding the screen, and the point of the mode is that the whole room is looking at the same four cards. The host stays the judge, as in the guess game. (Superseded in part, after the drag shipped: with a buzzer room each player now arranges on their own phone and is graded by the host's page — no phone passed around, so the objection does not apply. Without a room the host is still the judge. Data at the time: 86% of rounds dragged, full order 36.8% dragged vs 15.2% untouched. CLAUDE.md "Put them in order".)
- Bumping
CACHE_VERSIONfor the new field.releaseDateis an optional addition, and a bump turns every warm playlist key into a miss at once: ~2,900 extra Spotify loads a day against a ceiling of 2,000 that the real misses already half fill. The field shipped as 1.20.1, the mode as 1.21.0 at least a day later, and the cache upgraded itself throughHIT_TTL_SECONDSturnover. A consumer that finds no date treats the track as undealable, and a list mostly without dates is refused asorder_dates_pending(our cache, try later), never as a playlist with too few years. - Ties broken by month or by a hidden rule. Two 2019 songs have no right order at year level, and a point taken away for a reason nobody can see is worse than a shorter game. Every card in a round has a distinct year; a playlist that cannot make one is refused on the setup page.
Why a third GameMode and not a flag: the stored payload has one field
that decides which page plays it, and parseGamePayload's allow-list
fallback means a rollback mid-party reads order as party and lands on
the guess game instead of failing to parse — the designed degradation.
Would reopen if: order_round:exact dominates (four cards too easy —
raise ORDER_ROUND_SIZE or add a hard variant), or order_round:none does
(reissue and remaster dates — compilations are already skipped through
album.album_type, as albumCompilation); or if game_end_mode:order
shows the mode played out at a rate that justifies pulling the shared game
chrome out of the guess page.
Short entries, so they are not re-proposed as new ideas.
| Idea | Why not | Reopen if |
|---|---|---|
| Automated answer checking | The host being the judge is what makes it a party game rather than a quiz app; arguing with a string matcher is not fun | never, probably |
| Full-site Chinese localisation | Real leak — Chinese-keyword SEO lands on an English UI — but it connects no feedback loop, and useErrorLocale starts at "en" and switches in an effect, which is fine for an empty error slot and a full-page flash for body copy |
after the loop numbers arrive, with a server-side Accept-Language hint |
| More game modes | Presume repeat play, the one evidence category that is entirely absent. (The 1.9.0 quiz is not one: it is a loop surface with a toy attached, see D9, and the repeat-host number had reached 58% by then) | host_game_index >= 2 climbs |
A /admin stats page |
Same failure mode as GA4 with a different URL — you still have to remember to open it | never; extend npm run stats instead |
| Apple Music, Discord bot | Proposed and withdrawn by the author for lack of evidence, twice | a concrete user signal |