Shipped in 1.3.0. How it works, and — the longer half — how to read what it measures without drawing the wrong conclusion.
Buzzer Mode had been putting a phone in every hand for weeks and never told any of them what they were holding:
app/buzz/[code]/page.tsx— 264 lines, zero<a>tags, and not one occurrence of the string "GuessSong"app/j/[code]/page.tsx— ended at a confirmation card with nowhere to golib/result-image.ts— printed "Played with GuessSong" on the one artifact that leaves the party, with no address on it
Every party put four or five phones on those pages, and the product never spoke to any of them. The expensive half of a loop — rooms, live sockets, canvas share cards — had already shipped. This is the cheap half nobody had written.
Each is declared once in lib/loop-links.ts and derived from there by the
link, the analytics param, and the server-side validator. The order below is the
order of that declaration, which reads down the funnel: the two passive footers,
the two moments a player has just finished doing something, then the two QR
codes, and last the one surface whose carrier is a URL rather than a QR.
| Surface | Where | When |
|---|---|---|
buzz_footer |
buzzer page, all three return paths (app/buzz/[code]/page.tsx:223, :271) |
always, including the pre-join form |
buzz_cta |
buzzer page, full-width button (app/buzz/[code]/page.tsx:316) |
between rounds, after the first resolves |
join_footer |
Mixed Playlist submit page (app/j/[code]/page.tsx:146) |
always |
join_submitted |
Mixed Playlist confirmation screen (app/j/[code]/page.tsx:103) |
after a playlist is submitted |
game_over |
QR on the host's Game Over screen (app/game/page.tsx, <LoopQr />) |
party mode, end of game |
share |
retired 2026-09-22 — was a QR drawn into the result card image (lib/result-image.ts's drawCardFooter) |
old cards only; the card prints the address as text now |
quiz_result |
the result screen of a Taste Quiz (app/q/[code]/quiz-client.tsx:1295, <LoopCtaButton surface="quiz_result">) |
after a taker has submitted their answers |
quiz_result (1.9.0) is the first surface reached by tapping a URL in a group
chat rather than by scanning a QR off a screen or out of an image. It is kept
apart from share even though both leave the party: share had converted 0 of
50 when the quiz was built, and the question the quiz exists to answer is
whether that was the audience or the carrier. Merging the two rows would bury
the answer — decisions.md D9.
Answered, 2026-09-22: the carrier. share finished at 0 of 94 over eleven
weeks while quiz_result read 13% in its first full week and join_submitted
31%. The QR came off the card (1.15.0); the name stays in LOOP_SURFACES so a
card saved before then still redirects and still counts a click, which is why
share is the one arm allowed to show followed with no shown (§7).
The name is needed in three places at once, and hand-syncing them fails
silently. A renamed href against a stale validator still redirects — the
counter simply stops incrementing, and that arm reads as "nobody clicked it".
You would then correctly conclude the call to action was useless and delete one
that was working. Same single-union trick lib/buzzer-protocol.ts uses across
the Worker boundary.
Since 1.12.0 the copy is declared beside the names, for the same reason:
LOOP_CTA_LABEL (the button), LOOP_FOOTER_LABEL ("Made with GuessSong — host
your own") and LOOP_QR_CAPTION replaced seven phrasings across seven files.
The quiz result keeps its own line, makeYourOwn in lib/quiz-copy.ts, because
it is read in two languages and lands somewhere else (below).
snapshot.phase === "idle" && snapshot.roundIndex >= 1.
Two things about that are easy to get wrong:
- Not a
locked → idletransition.handleResolveinworker/src/buzzer-room.tsreachesidlefrom bothopenandlocked, so a round nobody buzzed at is indistinguishable from one that was answered.roundIndexadvances only onhost:next, which is exactly "a round finished". - Read off the snapshot, not a component ref. A reconnect adopts the whole snapshot by design, so a ref would reset and the button would vanish for the rest of the game.
It stays mounted and hidden rather than unmounting, so appearing between rounds cannot shove the buzz button down the screen under someone's thumb.
player taps ──▶ GET /r/buzz_cta ──▶ 302 to /?ref=buzz_cta ──▶ app/page.tsx
│ │
│ KV: click:buzz_cta ++ │ remembers
│ │ the ref for
▼ │ 60 days
lib/loop-redirect.ts │
decides; the route is a shell ▼
later: game_started
carries arrived_from
The link is a real navigation, not a click handler. The click being measured is the click that leaves the page, and browsers cancel in-flight requests as a document tears down — so a background report fired at that moment is the report most likely to be lost, silently, in exactly the cases worth measuring. Routing through the server makes the navigation itself the measurement. There is nothing left to cancel.
Consequences worth not undoing:
- Plain
<a>, nevernext/link. Prefetching a counting endpoint invents hits. Cache-Control: no-store, or an intermediary caches the 302 and later clicks from that network never reach the counter — the redirect keeps working while the measurement stops./ris inapp/robots.ts's disallow list, for the same reason.
Every branch still redirects. Unknown segment, spent rate limit, KV unavailable: the visitor reaches the setup page regardless, and only the count is lost. The person clicking is precisely the person this feature exists to reach.
The quiz arm lands on /quiz, not /. handleLoopHit sends a
quiz_result click to /quiz?ref=quiz_result (isQuizSurface in
lib/setup-arrival.ts), because the person was just promised "make your own"
and the party form is not that; every other arm still lands on /. The quiz
page remembers the ref the same way / does (rememberLoopRef, 60 days), so
the conversion to read for this arm is quiz_created.arrived_from — a
game_started carrying quiz_result is the same person weeks later. Before
1.12.0 the redirect went to /?ref=quiz_result and app/page.tsx opened its
form on the quiz pill — the party page doing two jobs, which is what /quiz
ends. The old spelling is redirected by next.config.js (see
operations.md).
arrived_from is credited to the last loop touch within 60 days, not to the
visit that carried the ?ref=. The conversion is not same-session: somebody
taps a call to action on a friend's sofa and hosts their own party a fortnight
later. Crediting only within the pageview would record almost every real
conversion as organic and report a working loop as dead.
| GA4 | KV (lib/loop-stats.ts) |
|
|---|---|---|
| Read by | a human, in a browser | npm run stats |
| Good for | cohorts, sessions, unasked questions | decisions |
| Dies to | ad blockers | a spent rate-limit window |
KV is authoritative for any decision. The reason the second copy exists is that GA4 requires someone to go and look, and the measured rate at which that happened here was zero across four attempts over eight weeks — during which every feature decision was made on an n of 1.
The two will disagree, and the gap is itself a reading: it is roughly how much of this audience blocks analytics.
npm run stats # last 7 UTC days, today included
npm run stats -- 3 # last 3 (also --days 3)
npm run stats -- --today # today so far
npm run stats -- --month # last 30, the cap — counters have a 30-day TTL (also --all)
npm run stats -- --since 2026-09-30 # that UTC day through today
npm run stats -- --helpEvery window ends today, and today is a partial UTC day; the header prints the
range. Use --since to read a feature from its release day: a 7-day window that
starts before a counter existed averages days of zero into it (the
first-clip row read "53% of games pressed Play" that way on 2026-10-03 — the
days after the release said 78%).
No setup needed. The script reads .env.local and .env (both gitignored) via
Node's built-in process.loadEnvFile, the same files next dev reads.
Precedence matches Next: shell export > .env.local > .env, so pointing
at another database is a prefix away:
UPSTASH_REDIS_REST_URL=https://other-db.upstash.io npm run statsThe load order in the script is inverted (
.env.localfirst) becauseloadEnvFiledoes not overwrite a variable that is already set — first writer wins. Remember that if you touch it.
These are production values, from the Vercel project's environment variables.
Without them there is nothing to read: lib/kv.ts falls back to an in-process
Map, so a local run has no data. The script says where it looked and exits 1
rather than printing a misleading empty table.
GuessSong loop — last 7 days (UTC)
Days with any activity: 2/7
Surface shown followed rate
────────────────────────────────────────────────
buzz_cta 136 14 10.3%
buzz_footer 120 4 3.3%
game_over 22 9 40.9%
join_footer 44 2 4.5%
join_submitted 31 7 22.6%
share 14 1 7.1%
quiz_result 19 3 15.8%
Games started 33
Repeat hosts 4 12.1% of games
Reached Game Over 19 57.6% of games — 14 played out · 5 ended early
the other 14 closed the tab mid-game (a floor: a lost beacon lands here too)
Games by host's game number
1 21 █████████████████████████
2 3 ████
10+ 1 █
Ended early at round
2 3 ████████████████████████
9 1 ████████
20+ 1 ████████
Playlist quiz — the link-shaped surface
created 12 en 4 · zh 8
opened 31 2.6 per quiz
owner share 9 75.0% of quizzes tapped it — shared 5 · copied 2 · dismissed 2 · failed 0
started 24 77.4% of opens answered a question
completed 19 61.3% of opens · 79.2% of starts
board 7 58.3% of quizzes had the owner back for results
acquaintance 7 ██████████████████████████████
close 6 ██████████████████████████
guessing 3 █████████████
soulmate 2 █████████
stranger 1 ████
questions quizzes finishers per quiz
10 preset 3 9 3.0
20 default 6 8 1.3
35 typed 1 0 0.0
50 preset 2 2 1.0
2 of 12 quizzes were built shorter than the host asked for — the playlist had fewer usable tracks, and the panel does not say so
hints heard 21 · no clip 3 · unavailable 2 · repaired 1
1.1 heard per completed quiz, against an allowance of 2.1
⚠ refused by the limiter: answer 1. Each is a request the funnel above
never saw — an answer refused is a friend who finished and was
turned away, which reads as a low completion rate.
the CTA on the result screen is the quiz_result row above
(The length, hint, share and refusal blocks print only once they have
something to say; a fresh deploy shows the five stages and the verdicts
alone. The same goes for Reached Game Over and the two lines under the
cache table added in 1.15.0 — Playlist links refused — … — which sit
between the cache rows and the Spotify budget histogram.)
| Field | Meaning |
|---|---|
Days with any activity |
days that recorded anything. Read this first |
shown |
the surface was rendered, once per surface per tab. share is the exception — see below |
followed |
someone clicked and the server saw it |
rate |
followed ÷ shown |
Games started |
real hosted parties — only the paths that call recordHostedStart |
Repeat hosts |
games at index ≥ 2. The number this work is waiting on |
Reached Game Over |
games whose host saw the end screen, split played out (the last track ended) and ended early (End Game with tracks left). Beacons from trackGameFinished in app/game/page.tsx, under the same once-per-game guard as GA4's game_finished. The line under it is Games started minus this: the tab that closed mid-party — and the Game Over screen is where every host-side loop surface lives, so it is the share of games the loop never saw. It was ~5,000 of 6,252 the week this was added. Three floors and a subtraction: read the direction |
Ended early at round |
early ends only, by countRoundsPlayed at the moment of End Game, capped at GAME_ROUND_CEILING (20, the default song count). Round one or two is a game that could not play — no clip, wrong playlist — and round fifteen is a room that had enough; they need opposite fixes |
created / opened / started / completed / board |
the Taste Quiz funnel (recordQuizStage in lib/loop-stats.ts), bumped by the route that did the thing — POST /api/quiz, GET /api/quiz/[code], POST /api/quiz/[code]/check with q=0, POST /api/quiz/[code]/answer, GET /api/quiz/[code]/board — not beaconed from a page, so nothing here is lost to a tab closing. The block is printed only once something has been recorded |
en 4 · zh 8 |
the language each quiz was made in (quiz_locale:<l>, the locale the quiz page (/quiz) sends). Which audience the bilingual panel is reaching — the share sentence and the friend's page render in this language |
per quiz |
opened ÷ created. Below 1 means quizzes are being made and not sent — a share-step problem, not a quiz problem |
owner share / taker share |
every tap on a share button and what the sheet said (quiz_share:<by>:<outcome>, beaconed by reportQuizShare in lib/loop-client.ts). owner is the panel on /quiz, the step between created and opened; taker is the result screen. shared left through the share sheet, copied is the clipboard fallback, dismissed is the sheet backed out of, failed is neither path. A tap is not a quiz, so tapped it against created is a ceiling — one owner sending twice is two. The split is the reading: no taps is a panel the owner never reached, many dismissed is a sheet nobody finishes, shared with few opens is a link sent to nobody. Exists because per quiz read 0.6 with no way to say which |
started / answered a question |
the first question's check — the first half tapped, once per attempt, since an answered question is locked. started ÷ opened is the intro card: a friend who read it and left. Dated: it began with the per-question reveal (1.11.0), so a window straddling that deploy reads low against opens |
of opens |
completed ÷ opened, the whole taker side. opened is a ceiling, not a floor — see §6 |
of starts |
completed ÷ started, the quiz itself. This, not of opens, is the number to read against the length table — but it is a ceiling on finishing, not a floor: completed is bumped on every replay ("See my result again", and a resend after a lost response) while started is bumped once per attempt, so one taker who reopens their result three times is 3 over 1, and the ratio can read above 100%. Read the direction, not the figure |
board |
the owner opened their results page with the token — a guessed URL lands on 403 and is not counted. board ÷ created is the owner's half of the loop: a quiz whose board is never opened was sent and forgotten. Also a ceiling — the page fetches on every mount |
| the verdict bars | how completed quizzes came out (quiz_verdict:<bucket>, from verdictFor in lib/quiz.ts): soulmate ≥ 90%, close ≥ 75%, acquaintance ≥ 60%, guessing ≥ 50% (the band a coin lands in), stranger below chance. The difficulty gauge — see §7 |
the questions table |
one row per length that had a quiz made or finished (quiz_len:created:<n> / quiz_len:completed:<n>). quizzes is what hosts chose, tagged default / preset / typed so the typed field's use is visible; finishers is answer sheets graded for quizzes of that length; per quiz is finishers ÷ quizzes — two floors over each other, so unlike of opens it needs no ceiling. A row with finishers and no quizzes is a quiz made before the window and finished inside it |
built shorter than the host asked for |
quiz_clamped: the playlist had fewer usable tracks than the requested count, so createQuiz shortened it. The panel shows the count it got and says nothing about the one asked for — this is the only record that anyone wanted more |
the hints line |
the quiz's only per-question upstream path (quiz_hint:<status>, from GET /api/quiz/[code]/hint). heard is a clip served; no clip is a recording nothing has a clip for (a cached fact, free); unavailable is us — throttled or out of budget, and the page refunds the hint; repaired is a refresh=1 re-resolve of a rotted URL. The second line is heard ÷ completed beside the mean allowance (hintAllowance, one per ten questions) of the quizzes that were finished |
refused by the limiter |
quiz_throttled:<route>: requests a quiz route's own enforceRateLimit turned away, per route. Printed only when non-zero. Exact, not a floor — the limiter said no, so KV was up |
Playlist links refused |
under the cache table: playlist_refused:<code>, written by loadPlaylist in lib/playlist-cache.ts on the way out, for the four refusals that will never change — private or deleted (playlist_not_found), Spotify's own (editorial), empty, not a playlist URL (an album or track link). Counts every caller — the party form, a Mixed room's submit, the quiz — and every replay from the negative cache, because each is a person told their link is dead. The editorial count exists nowhere else: those are refused before the cache is read, so the hit-rate line never saw them. Throttling codes are not here; the budget block has them. Written server-side on the path, so like the cache table it is a measurement, not a floor |
Every row below is new, or changed meaning on the deploy. A window that straddles 2026-09-30 is two series for each changed row; read the days after it against each other, not against the week before.
| Field | Meaning |
|---|---|
Ended early at round row 0 |
game_end_round:0 — End Game before any clip started. It used to be clamped into row 1, so a window straddling 2026-09-30 still has some zeros in row 1 |
Playlist came from |
host_setup:<typed|restored|recent|starter|shared|mixed> on the game_started pulse. restored + recent over single-playlist games is the share started on a remembered link — read it beside Repeat hosts. A repeat host who still typed either lost storage to iOS or chose a new playlist; the two cannot be told apart. starter is 0 until the list is filled. A floor; pages from before the deploy send nothing, so the six sum to at most games |
| end by host kind | game_end_host:<first|repeat|unknown>:<end> and game_end_early:<kind>:<r0|r1_2|r3_plus>. The question it exists for: is the round 0–2 pile first-time visitors trying the site (heavier under first) or games breaking for returning hosts (heavier under repeat)? first is a ceiling and repeat a floor, because iOS evicts the host count. unknown is storage refused, not an old client |
game_end_screen |
phone|desktop, from matchMedia at the end. The denominator for the phone Game Over's Mixed link |
First clip |
first_clip:<prefetched|lazy>:<played|rejected|no_audio|unavailable|error|abandoned>, once per game, first Play press only. Tests the iOS hypothesis: if lazy rejected is well above prefetched rejected, the autoplay policy is catching a play() that ran after an await, outside the tap. If they are level, it is not why round one fails. abandoned is Skip/Reveal/End/leave before any outcome |
Left mid-game at round |
game_left_round:<0..20> and game_left_host:<kind>:<band>, sent on pagehide (and on an in-app navigation away) when the game had not finished, only by a game's first page — a reload counts as one leave at the round it happened, and the restarted game sends no second one. The restarted game can still send an end, so one game can be in both "left" and "reached": games − reached − left can go negative, and the script says so. A floor: a tab the OS kills sends nothing. Never visibilitychange — a locked phone is not leaving |
Game Over taps |
game_over_tap:<play_again|mixed>. mixed ÷ game_end_screen:phone is the number to set against the phone QR it replaced (3 of 994) |
not a playlist URL, by what it was |
playlist_invalid:<album|track|artist|shortlink|other>, written by recordPlaylistInvalid in the same call as playlist_refused:invalid_playlist_url, so the five sum to it and that series stays comparable with the 748 of the week to 09-29. album is the one the app could choose to serve — this line is what decides album support. shortlink is a spotify.link that was followed and led nowhere usable; a short link to an album is album. It counts the party form and the quiz only: the four room forms (/j, /buzz, the collector, the room panel) block a wrong link in the browser and never send it (GA4's playlist_link_named is the only record of those). Exact, server-side; attempts, not people |
Short links (spotify.link) |
playlist_shortlink:<resolved|unusable|unavailable>, from resolveShortlink in lib/spotify-shortlink.ts, for both doors (forms and Android's share sheet), cached answers included. The resolver's only health check: how spotify.link treats a datacentre address could not be tested before deploy. If unavailable is a quarter or more the script says so, and short links are not working from production — hosts are told to paste the full link, which is safe but means the feature does nothing |
from |
quiz_from:<source>, from recordQuizCreated when the page sent a recognised source: a loop surface name (last loop touch within 60 days), internal (a full navigation from this site), external (another host — search, a chat app), none (no referrer). A floor per source; Σ from ≤ created, the gap being pages from before the deploy. The referrer belongs to the document, so a visitor who landed on / from search and clicked through to /quiz reads external |
owner opened / owner played |
quiz:owner_opened / quiz:owner_completed, written instead of opened / completed when the request carries the quiz's host token (x-host-token, from the creating device's localStorage). The owner's preview is graded, never written to the board, and bumps no verdict and no length row. owner_opened ÷ created tests whether owners want to play their own quiz. A floor on owners: the owner on another device, in an in-app browser, or after iOS evicted storage is counted as a friend |
owner checks |
quiz:owner_dashboard, one per fetch of My quizzes (/q/mine) that found at least one live quiz. A ceiling: Refresh and a tab coming back each fetch again. Read it beside board: an owner who watches from the dashboard may see enough there and never open a board, so a falling board after 1.20.0 is not by itself owners losing interest. Same floor on owners as the board — the tokens live in the creating browser |
owner copy / board copy |
quiz_copy:<owner|taker|board>:<copied|failed> — the explicit Copy link button, apart from Share. Read beside the share rows: together they are everyone who tried to send. taker is the result screen's Copy link, written from 1.17.0 (2026-10-01) |
owner social / taker social / board social |
quiz_social:<owner|taker|board>:<line|threads|x|facebook|whatsapp>, from reportQuizSocial. The platform row renders only where there is no share sheet (mostly desktops), so its denominator is the share taps on the same row that fell back to copied, not every tap. A floor: the click opens a new tab, so it reaches the server, but the post itself is never seen |
board share |
quiz_share:board:<outcome> — the results page's share button, which reported to GA4 only before. Read against board |
What stepped down on 2026-09-30, and why:
quiz_share:owner:copiednow means only "the share button had no share sheet and fell back to the clipboard". Copy-button taps moved toquiz_copy:owner:copied. Before this date the two were one number.openedno longer counts the result screen's Refresh (?refetch=1) or a recognised owner — so it is now one per page load by a non-owner. Still a ceiling on friends. The error screen's Retry still counts, because it only appears when no load has succeeded.started,completed, the verdict bars andquiz_len:completed:<n>lose owner previews. Expectsoulmateto fall most. This touches the 2026-10-06 read of the ten-question default: the ten-row'sper quizcan step down on deploy day for a reason that has nothing to do with length. Compare 10-01…10-06 against itself, not against 09-22…09-29.- Owner rows are no longer written to the public board.
This is the section that matters. The failure mode is reading a low number as "the call to action does not work" when it means "we could not see that it did".
- Repeat hosts are systematically undercounted. The count lives in
localStorage, and iOS clears script-writable storage after seven days without a visit — precisely the gap between two parties. Private windows start empty. A laptop passed around a room is several hosts wearing one identity. Only the direction of this number over time means anything. followedmisses clicks that never reached the server. Throttled ones are reported separately; a dropped connection is invisible.shareis retired, and reads differently from every other row. Until 2026-09-22 it was a QR in the result card, itsshownfired when a card was saved, and it finished at 0 followed of 94 shown. Nothing reports its impression now; the card prints the address as text. The name is kept so a card saved before then — forwarded into a chat, scanned months on — still redirects and still lands aclick:share. So asharerow withfollowedand noshownis a scan of an old card, not the plumbing fault §7 describes for the other arms. Once the last old card stops being scanned the row disappears on its own.Reached Game Overis three floors and a subtraction. Starts and ends are both beacons; a lost end beacon reads as a closed tab and a lost start beacon shrinks the gap. The gap cannot read as zero by accident and cannot be read as a level — only as a direction, and against the round histogram.organicis a catch-all for every lost attribution: a PWA launched from the home screen, a stripped query string, a retyped bare domain. Organic is already nearly all traffic, so the loop's share of starts is a floor.openedandboardare the two figures here that are ceilings. Each is bumped on every successful fetch, and both pages fetch on every mount, reload and Retry, so one friend opening the link twice is two opens and an owner refreshing their board twice is two boards.openedinflates the denominator ofof opens, so that rate reads low;boardinflates the numerator of its own rate, so that one reads high — either way the opposite direction from every other number on this page.createdandstartedare floors like the rest: one write per quiz made, one per attempt's first check.completedis one write per sheet the server answered, replays included — "See my result again" re-POSTs the stored row and the route counts it again — so it is exact on sheets and a ceiling on finishers, which is whyof startsin §5 can read above 100%. The length table is built fromcreatedandcompleted, so itsper quizcarries that replay inflation and nothing else: no ceiling in its denominator. (The link unfurler in the chat app is not inopened: it is bumped by the API the page's own script calls, deliberately not bygenerateMetadata, which every unfurler fetches.)heardcounts hint requests that returned a clip, not clips heard. A clip that then fails to play is refunded on the phone and re-requested on the next tap, so a rotting CDN URL is oneheard, onerepaired, and one hint. It also does not know who asked: a taker who reloads and taps again is two. Against the allowance it is a ceiling; against the "no audio in a question" rule it is the honest number, because upstream was asked either way.refused by the limiteris the exception in the other direction — it is exact. A refusal means theincrthat said no succeeded, so the counter beside it lands too.
What the table answers reliably is trend and relative difference between surfaces. Not absolute level.
No hard thresholds, because there is no baseline yet and the first version's placement and wording dominate the numbers. A made-up percentage would get a working call to action deleted. Collect two weeks first. Shapes, not numbers:
| Observation | Reading | Next |
|---|---|---|
Days with any activity is 0 |
plumbing, not a result — a real zero still bumps the liveness marker | check credentials, that /r deployed, that robots did not over-block |
one arm's shown is 0, others fine |
that surface is not being counted at all | its surface string or active condition broke — it is not that nobody saw it |
shown is 0 but followed is not |
proof it is the impression that is missing, not the surface — except on share, where it is a scan of a card saved before the QR came off (§6) |
a click cannot arrive from a surface nobody was shown, so the link works and only the denominator is absent — check that something actually calls reportLoopImpression for it |
Reached Game Over is a small share of games |
most parties never see the screen every host-side surface is on | read Ended early at round first: a pile at rounds 1–2 is a game that could not play (previews unavailable below, a wrong playlist, the phone layout) and is a reliability fix; a spread through the teens is a default song count longer than a room wants, and is DEFAULT_SONG_COUNT_STATE in lib/song-count.ts. The closed-tab remainder is the same question with no round to read, so move the surfaces, not the screen: whatever the loop wants to say has to be said mid-game |
| arms differ sharply | placement and timing are being measured | make the low arm look like the high arm; do not delete it |
buzz_cta well below join_submitted |
"a round resolved" is the wrong proxy for the right moment | that is the signal that changing the buzzer protocol for a real end-of-game CTA is worth it |
Repeat hosts share rising |
someone actually came back | monetisation moves from next quarter to next month |
Repeat hosts stays low |
not "nobody returns" | cross-check against GA4 returning users, which rides a cookie and is unaffected by the ITP eviction above |
quiz_result reads like share after two weeks |
this audience does not convert off-site, link or QR alike | the reading D9 in decisions.md said it would reopen on — a real answer, worth having |
per quiz below 1 |
quizzes are being made and not sent | read owner share before touching the panel: no taps at all is a panel the owner never got to (the quiz was made and the tab closed); dismissed dominant is a share sheet nobody finishes, which is the text and title ownerShareText fills; shared well above opened is a link that went out and was not tapped — the friend's side, the unfurled card (app/q/[code]/opengraph-image.tsx), not the panel |
of starts well under 40% |
takers start and do not finish | read the questions table before touching anything: if per quiz falls with length, the default is too long — QUIZ_DEFAULT_QUESTION_COUNT in types/quiz.ts; if it is flat, length is not the reason. Done once, 2026-09-22: ten read 0.4 finishers per quiz, twenty 0.1, fifty 0.1, and the default went from twenty to ten. If the ten-row's per quiz does not rise over the following weeks, length was not it either |
started well under opened |
takers open the card and never tap a half | the intro — the name field and the Start button on app/q/[code]/quiz-client.tsx — not the questions. Remember opened is a ceiling (§6), so this reads worse than it is |
typed rows are empty after two weeks |
nobody uses the typed field | leave it; it costs nothing on screen. Delete it only if the quiz form needs the room |
built shorter is a large share of created |
hosts want longer quizzes than their playlists give | say so on the panel (components/quiz-panel.tsx) before the link is shared, or cap the picker at the playlist's usable count once it is known |
board well under created |
owners send the link and do not come back for results | the board is where the owner's share button is, so this is a second share arm going unused — put the results where the owner already is (/quiz's QuizPanel already remembers the last quiz) rather than growing the board |
heard per completed near the allowance |
takers spend every hint they have | at two options a hint is a whole point, so the verdict spread is flattering; read stranger as the honest bucket, and consider one per twenty |
unavailable a visible share of hints |
the quiz is being served in throttled minutes | same reading as the preview cache's unavailable row below it: the shared egress IP is being throttled, and the quiz is one more caller on it. Not a quiz problem |
repaired climbing |
the year-long positive cache is rotting under the quiz | expected at a low rate; a jump means the CDN rotated a batch. Nothing to do unless heard falls with it |
refused: answer above 0 |
a room of phones behind one address hit the answer limit | raise QUIZ_ANSWER_LIMIT in app/api/quiz/[code]/answer/route.ts — it was 20 and refused the 21st finisher in an office, which is why it is 60 |
refused: read above 0 |
the same room hit the read limit — opens that never became opens | QUIZ_READ_LIMIT in app/api/quiz/[code]/route.ts; the two limits are sized together, keep them so |
refused: check above 0 |
a room of phones behind one address answered faster than the check limit — questions answered with no verdict shown, which nobody reports because the page just advances | QUIZ_CHECK_LIMIT in app/api/quiz/[code]/check/route.ts; it is per question, not per taker, so size it to takers × questions per window. The other half of a lost verdict — timeouts, offline, a slow KV — never reaches the server; GA4's quiz_check_lost (bucketed reason) is where those are |
refused: card above 0 |
one address asked for more than sixty uncached card renders in ten minutes — every one past that was sent the site's generic picture instead | QUIZ_CARD_LIMIT in app/q/[code]/opengraph-image.tsx. Sixty is sixty different quizzes unfurled through one crawler address; a real chat app's crawler farm spreads over many, so a non-zero here is more likely a scraper than a good day |
verdicts pile at soulmate |
the decoys are too easy to tell from the playlist | the trigger for a Spotify-backed decoy source (artists/{id}/top-tracks) — CHANGELOG.md 1.9.0, known gaps. Read heard per completed beside it first: at two options a hint is a whole point, so takers spending over their allowance flatters this bucket. And not before per quiz is above 1 — a harder quiz that is never sent is the wrong end to work on |
Playlist links refused is a visible share of loads |
hosts are pasting links the app cannot play | which code dominates is the product question: Spotify's own (editorial) is a room that wants the charts and is told no (the one refusal the app could not lift — 37i9… returns 404 to new apps); not a playlist URL is split by not a playlist URL, by what it was since 2026-09-30 — if album dominates it, album support is worth building (one Spotify call per album, the same cache); private or deleted is the help text on / (and the negative cache replaying it, which is fine). The script prints a line when editorial is a quarter or more |
First clip lazy rejected well above prefetched rejected |
the browser refuses a play() that runs after await fetchPreview |
the host is already sent back to Play with "tap again"; the next fix is to await the batch before enabling Play on round one, not to retry play() |
round 0–2 early ends heavier under first than repeat |
new visitors trying the site and leaving | a first-visit problem (what / promises, the starter slot), not a reliability one |
Playlist came from mostly typed among repeat hosts |
the setup memory is not reaching them | iOS eviction, or hosts choose new playlists each time; read the recent chip use before touching anything |
owner opened well under created |
owners do not open their own link | the "play it yourself" hypothesis is weak; do not build a solo mode. If it is high and per quiz stays low, owners are playing instead of sending — that is the reading that justifies one |
Short links mostly could not be reached |
spotify.link refuses or stalls this deployment's egress |
lib/spotify-shortlink.ts — check what it answers from a Vercel function; nothing about the links is wrong |
verdicts pile at guessing and stranger |
takers are at or below a coin: the decoys are indistinguishable from the playlist, or the link is reaching people who do not know the owner | read the two apart from close/acquaintance before touching the decoys — a spread that is only the bottom two is the sending, not the questions |
No counters found under "loop:stats:" — not empty data, a missing
namespace. Either nothing has been recorded yet, or the key prefix in
lib/loop-stats.ts changed without the script. Deliberately loud rather than a
table of zeros.
Table empty but Days with any activity is not 0 — something is writing,
but not loop events. If only games moves, people are playing and no surface is
being shown; usually a component that stopped rendering.
Everything low, just after a deploy — expected. Counters start at deploy and do not backfill, and the 60-day attribution window means today's clicks convert weeks from now. The first few days carry almost no information.
N click(s) were dropped by the rate limiter — expected, not an attack. The
limiter is keyed by IP and a party is a dozen phones behind one Wi-Fi address.
It means every rate above is understated by that much. Only a persistently large
figure justifies raising LOOP_LIMIT in app/r/[surface]/route.ts.
scripts/loop-stats.mjs walks loop:stats:* with SCAN and parses what comes back
rather than rebuilding keys from a hardcoded list. That list already exists in
lib/loop-stats.ts, and a second copy would drift silently — the script would
read keys nobody writes and print a confident table of zeros. Discovery also
means a metric added later appears here without anyone editing the script — as
far as the counting goes. Rendering is per key shape, so a new metric that no
block recognises falls into the "Other counters" list at the bottom rather than
being read, summed and silently dropped, which is what used to happen.
The only shared knowledge is the loop:stats: prefix, and changing that makes
the script print "no counters found", which is loud rather than wrong.
One shape of drift the "Other counters" block does not catch: a new key
under a prefix a renderer already claims. RENDERED_PREFIXES marks quiz:,
quiz_len:, quiz_hint: and the rest as consumed, so a quiz:reopened added
to lib/loop-stats.ts without a line in the quiz block would be read, summed,
and printed nowhere — the exact silence the leftovers block exists to end,
back through a side door. A new metric under a claimed prefix needs its own
line in that block; a new metric under a new prefix can lean on the leftovers
until it deserves better. tests/loop-stats.test.ts pins the writer's key
set and tests/quiz-routes.test.ts pins that the routes still call it, but
nothing can pin the script's output — read it once after adding a counter.
KEYS matches against every key in the instance, not every key under the
prefix — so the size of this namespace was never the number that decided
whether it worked. lib/preview-cache.ts writes one key per track and holds
positive entries for a year, and when that set crossed Upstash's ceiling the
server started refusing outright:
ERR KEYS command is disabled because total number of keys is too large, please use SCAN
npm run stats then exits 1 and prints nothing, for a reason with no
connection to the loop. And it had been wrong before it was loud. On
2026-08-15 the same seven-day window read, minutes apart:
KEYS |
SCAN |
|
|---|---|---|
| Days with any activity | 5/7 | 7/7 |
| Games started | 4918 | 6740 |
join_submitted |
42 shown / 19 followed — 45.2% | 92 / 30 — 32.6% |
game_over |
936 / 8 — 0.9% | 1274 / 10 — 0.8% |
The truncation was silent and it was not uniform: two whole days of liveness markers were missing, which is why the report claimed 5/7 — the exact reading §7 says to treat as a plumbing problem rather than a result. Any figure quoted from a run before this fix is a partial sum. The relative ordering of the surfaces survived, which is what §6 says the table is good for; the levels did not, which is what §6 says it is not.
MGET is chunked at 256 for the neighbouring reason: the REST transport puts
the whole command in one request body, and that body would otherwise grow with
the namespace.