Skip to content

Latest commit

 

History

History
466 lines (365 loc) · 24 KB

decisions.md

File metadata and controls

466 lines (365 loc) · 24 KB

Decisions

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.


D1 — No accounts, ever

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.


D2 — Web and PWA, not an App Store listing

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.


D3 — Buzzer rooms run on Cloudflare Durable Objects

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 no SUBSCRIBE to 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]/status allows 200 requests per 10 minutes, i.e. one every three seconds, and buzzing needs sub-second. The key is also room: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.


D4 — Playlists are sampled, not read whole

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.


D5 — Loop before monetisation

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.


D6 — Measure in KV as well as GA4

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.


D7 — A command, not a scheduled digest

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.


D8 — absent and unavailable are different nulls

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.


D9 — The quiz is a loop surface, not a game mode

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 generateMetadata inherits 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 /q was 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 own s-maxage has 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, and quiz_throttled:check is where that stops being true. Side effect worth having: the check on question zero is the funnel's started, 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).


D10 — The host's phone is a remote control, not a small desktop

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 makes env(safe-area-inset-*) non-zero at all, and it lets every page run under the landscape notch, so the side insets are padded once, on body in app/globals.css, outside @layer; a surface pads for itself only when it is position: 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 persisting currentIndex and 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.


D11 — Mixed rooms stay on Upstash, not on a Durable Object

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. loadPlaylist sits 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 hsetnx on 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 refused play()s, a repair path, a batch prefetch keyed on tracks.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 imports lib/preview-client.ts proves 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_VERSION for the new field. releaseDate is 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 through HIT_TTL_SECONDS turnover. A consumer that finds no date treats the track as undealable, and a list mostly without dates is refused as order_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.


Rejected and still rejected

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