Game share-loop: instrument funnel, deep-link shares, /play metadata

Sharpen the existing daily-game share loop into something measurable (per Codex's
"instrument what you have, then feed people into it" plan), ahead of a Show HN launch.

Analytics:
- Per-game funnel events <game>_{arrival,started,completed,shared} (article_id=0).
  arrival = landed via a shared link (utm_source=game_share); started = first move
  (guess/find/flip); completed = solved/cleared/Full Bloom; shared = on share success.
- trackVisit() moved into the global layout so direct /play landings count; the
  server-rendered /a/ share page now creates a visitor token + sends a daily visit
  beacon (first-time /a/-only visitors were previously dropped).
- Admin "Games funnel" panel: arrivals / engaged / completed / shared, per game.

Sharing:
- Memory Match gains a Share button (it was the only game without one).
- All shares deep-link to the exact game+variant with a full https:// URL +
  utm_source=game_share (gameShareUrl helper), instead of a bare /play.
- "shared" is counted only after navigator.share()/clipboard.writeText() succeeds.

/play social metadata:
- /play served homepage canonical/OG (static SPA, ssr=false). postbuild script
  patches build/play.html's head to /play canonical/title/description/OG; fails the
  build if the homepage tags drift. Caddy try_files now serves {path}.html so /play
  is served from the patched file (snapshot in deploy/caddy/).

Tests: backend 352, frontend 27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-18 16:22:06 -04:00
parent 89c0fbe1f6
commit 59ff48ae90
17 changed files with 360 additions and 21 deletions
+16
View File
@@ -35,6 +35,22 @@ export function track(kind, articleId = 0) {
}
}
// Deep link for a shared game result: full https:// URL straight to the exact game +
// variant (not generic /play), tagged so we can attribute arrivals to the share loop.
const SITE = 'https://upbeatbytes.com';
export function gameShareUrl(game, variant) {
const v = variant ? `&v=${encodeURIComponent(variant)}` : '';
return `${SITE}/play?game=${game}${v}&utm_source=game_share`;
}
// Per-game funnel: trackGame('word', 'started'|'completed'|'shared'). Kinds must mirror
// the backend allowlist (_GAME_EVENT_KINDS in api.py). Deduped per game/kind/day server-side.
const GAME_NAMES = ['word', 'wordsearch', 'bloom', 'match'];
const GAME_EVENTS = ['started', 'completed', 'shared', 'arrival'];
export function trackGame(game, event) {
if (GAME_NAMES.includes(game) && GAME_EVENTS.includes(event)) track(`${game}_${event}`);
}
// Count a visit at most once per day per device.
export function trackVisit() {
try {
+7 -4
View File
@@ -1,6 +1,7 @@
<script>
import { getJSON, postJSON } from '$lib/api.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
// mode: 'daily' (shared, synced, ritual) | 'free' (local-only, infinite wheels)
// format: 'center' (center letter required) | 'wild' (any word from the 7)
@@ -208,13 +209,14 @@
return;
}
const pan = isPangram(w);
if (found.length === 0) trackGame('bloom', 'started'); // first word = started
found = [w, ...found];
flash(pan ? 'Pangram! 🌸 +' + scoreWord(w) : '+' + scoreWord(w));
if (pan) bloomPulse();
persist();
if (!isFree) syncSoon();
if (found.reduce((s, x) => s + scoreWord(x), 0) >= maxScore && !fullShown) {
fullShown = true; bloomPulse();
fullShown = true; bloomPulse(); trackGame('bloom', 'completed'); // Full Bloom
}
}
@@ -225,9 +227,10 @@
found.forEach((w) => (byLen[w.length] = (byLen[w.length] || 0) + 1));
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
const bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
// Quiet "this should count?" — flags a rejected word for the admin queue.
+17 -1
View File
@@ -1,6 +1,7 @@
<script>
import { untrack } from 'svelte';
import { pushGameState } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
import { COLOR_BY_KEY } from '$lib/games/match/palette.js';
import { buildBoard, freeSeed } from '$lib/games/match/board.js';
import MatchIcon from './MatchIcon.svelte';
@@ -104,6 +105,7 @@
function flip(card) {
if (!ready || locked || done) return;
if (matchedKeys.has(card.key) || flipped.includes(card.id)) return;
if (moves === 0 && flipped.length === 0 && matchedKeys.size === 0) trackGame('match', 'started');
// Match off the LOCAL `next`, not the just-assigned reactive `flipped`, so the
// evaluation never runs a click behind.
@@ -118,7 +120,7 @@
matchedKeys = new Set([...matchedKeys, keys[0]]);
flipped = [];
persist();
if (matchedKeys.size >= board.faces.length && !celebrated) celebrated = true;
if (matchedKeys.size >= board.faces.length && !celebrated) { celebrated = true; trackGame('match', 'completed'); }
} else {
locked = true; // show the mismatch briefly, then flip back
persist();
@@ -126,6 +128,16 @@
}
}
const TIER_LABEL = { gentle: 'Gentle', standard: 'Standard', expert: 'Expert' };
let copied = $state(false);
function share() {
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
const when = isFree ? 'Free play' : date;
const text = `Upbeat Bytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
function faceLabel(card) {
if (format === 'colors') return COLOR_BY_KEY[card.key]?.name ?? 'color';
return card.key;
@@ -190,6 +202,7 @@
{#if done && celebrated}
<p class="done">Lovely — you cleared {isFree ? 'the board' : "today's set"}.{isFree ? '' : ' Fresh one tomorrow.'}</p>
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
{/if}
{/if}
</div>
@@ -249,6 +262,9 @@
color: var(--accent-deep); font-weight: 700; }
.done { margin: 4px 0 0; color: var(--accent-deep); font-family: var(--label); text-align: center; }
.share { margin-top: 10px; background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 9px 22px; font: inherit; font-weight: 600; cursor: pointer; }
.share:hover { background: var(--accent-deep); }
@media (prefers-reduced-motion: reduce) {
.inner { transition: none; }
+9 -5
View File
@@ -1,6 +1,7 @@
<script>
import { getJSON, postJSON } from '$lib/api.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
let { variant = '5', onstatus } = $props();
@@ -121,11 +122,12 @@
submitting = true;
try {
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
if (guesses.length === 0) trackGame('word', 'started'); // first real guess = started
guesses = [...guesses, current];
cols = [...cols, res.colors];
current = '';
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); }
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); }
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); trackGame('word', 'completed'); }
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); trackGame('word', 'completed'); }
persist();
syncSoon(); // push this guess (and any completion) to the server, debounced
} catch {
@@ -165,9 +167,11 @@
const label = variant === '6' ? 'Long Word' : 'Daily Word';
const score = status === 'won' ? guesses.length : 'X';
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
// Count a share only once it actually happens (sheet completed / clipboard wrote),
// never on a cancelled share sheet or denied clipboard.
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('word', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
// Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it
@@ -2,6 +2,7 @@
import { getJSON } from '$lib/api.js';
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
let { size = 'med', onstatus } = $props();
@@ -197,6 +198,7 @@
function evaluate(cells) {
const hit = matchWord(cells, grid, words, found);
if (!hit) return;
if (foundWords.length === 0) trackGame('wordsearch', 'started'); // first word found = started
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
okFlash = true; setTimeout(() => (okFlash = false), 500);
if (foundWords.length === words.length) finish();
@@ -206,6 +208,7 @@
function finish() {
pauseClock(false); // close the open segment; persist follows in evaluate()
trackGame('wordsearch', 'completed');
resultMs = playedMs;
if (resultMs && (!best || resultMs < best)) {
best = resultMs;
@@ -220,9 +223,9 @@
function share() {
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
// Load on mount and whenever the size changes. Tracks ONLY `size`.
+7 -1
View File
@@ -3,10 +3,16 @@
import { onMount } from 'svelte';
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
import { trackVisit } from '$lib/analytics.js';
let { children } = $props();
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
// recovery card + timeout as soon as the shell hydrates.
onMount(() => window.__ubBooted?.());
onMount(() => {
window.__ubBooted?.();
// Count the daily visit at the LAYOUT level so every landing page counts —
// direct /play and /a/ arrivals included, not just the news homepage.
trackVisit();
});
</script>
{@render children()}
+2 -2
View File
@@ -14,7 +14,7 @@
import { auth, refresh as refreshAuth, isFollowing, toggleFollow, followKeys } from '$lib/auth.svelte.js';
import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js';
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
import { trackVisit, track } from '$lib/analytics.js';
import { track } from '$lib/analytics.js';
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
import { ritualState, markBriefSeen } from '$lib/ritual.js';
@@ -488,7 +488,7 @@
seenIds = new Set(P.loadJSON(SEEN_KEY, []));
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
refreshAuth();
trackVisit();
// trackVisit() now fires once in the global layout (covers every landing page).
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on direct/shared link
// Instant paint: render the last saved Today brief immediately and refresh
// it behind the scenes, so the first view never blocks on a (personalized,
+26
View File
@@ -1050,6 +1050,26 @@
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
</div>
</section>
{#if stats.games}
<section>
<h2>Games funnel <span class="muted small">· the share loop</span></h2>
<div class="cards">
<div class="stat"><span class="n">{stats.games.totals.arrival}</span><span class="l">Share arrivals</span></div>
<div class="stat"><span class="n">{stats.games.totals.started}</span><span class="l">Engaged (first move)</span></div>
<div class="stat"><span class="n">{stats.games.totals.completed}</span><span class="l">Completed</span></div>
<div class="stat"><span class="n">{stats.games.totals.shared}</span><span class="l">Shared</span></div>
</div>
<table class="gfunnel">
<thead><tr><th>Game</th><th>Arrivals</th><th>Engaged</th><th>Completed</th><th>Shared</th></tr></thead>
<tbody>
{#each [['word', 'Daily Word'], ['wordsearch', 'Word Search'], ['bloom', 'Bloom'], ['match', 'Memory Match']] as [k, label] (k)}
<tr><td>{label}</td><td>{stats.games.by_game[k].arrival}</td><td>{stats.games.by_game[k].started}</td><td>{stats.games.by_game[k].completed}</td><td>{stats.games.by_game[k].shared}</td></tr>
{/each}
</tbody>
</table>
<p class="muted small">“Share arrivals” = landings via a shared game link (utm_source=game_share). “Engaged” = first move (guess/find/flip), not just opening the page.</p>
</section>
{/if}
<section>
<h2>Emotional mix &amp; friction</h2>
<div class="cards">
@@ -1506,6 +1526,12 @@
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
.stat .l { color: var(--muted); font-size: 0.8rem; }
.gfunnel { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.9rem; }
.gfunnel th, .gfunnel td { text-align: right; padding: 6px 10px; border-bottom: 1px solid var(--line); }
.gfunnel th:first-child, .gfunnel td:first-child { text-align: left; }
.gfunnel th { color: var(--muted); font-weight: 600; font-size: 0.78rem; }
.gfunnel td { font-variant-numeric: tabular-nums; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
+8 -1
View File
@@ -8,6 +8,7 @@
import { prefs, initPrefs } from '$lib/prefs.svelte.js';
import { auth } from '$lib/auth.svelte.js';
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
import { trackGame } from '$lib/analytics.js';
import WordGame from '$lib/components/WordGame.svelte';
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
import BloomGame from '$lib/components/BloomGame.svelte';
@@ -248,6 +249,9 @@
let wsTheme = $state('');
onMount(async () => {
// Share-loop acquisition: record an arrival when someone lands via a shared game
// link (gameShareUrl tags it utm_source=game_share), attributed to that game.
if ($page.url.searchParams.get('utm_source') === 'game_share') trackGame(game, 'arrival');
initPrefs(); // so the reader's chosen calm set is available on a direct /play landing
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
@@ -259,7 +263,10 @@
</script>
<svelte:head>
<title>Play · Upbeat Bytes</title>
<!-- Canonical/OG/description for /play are baked into the static play.html at build
time (scripts/patch-play-head.mjs) so non-JS social scrapers get them; we keep
only the browser-tab title + dev-gate noindex here to avoid duplicate tags. -->
<title>Play · Upbeat Bytes — calm daily games</title>
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
</svelte:head>