Files
upbeatBytes/frontend/src/lib/components/MatchGame.svelte
T
thejayman77 59ff48ae90 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>
2026-06-18 16:22:06 -04:00

273 lines
13 KiB
Svelte

<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';
// mode: 'daily' (shared, synced, feeds the ritual) | 'free' (local-only, replayable)
// format: 'icons' | 'colors' · tier: 'gentle' | 'standard' | 'expert'
let { mode = 'daily', format = 'icons', tier = 'standard', date = '', onstatus } = $props();
let isFree = $derived(mode === 'free');
let variant = $derived(`${tier}-${format}`);
// Storage: daily keyed by the server date (cross-device synced); free keeps one
// resumable scratch board per variant.
let stateKey = $derived(isFree ? `goodnews:match:free:${variant}` : `goodnews:match:${variant}:${date}`);
const ASSIST_KEY = 'goodnews:match:assist';
let board = $state(null);
let seed = $state('');
let flipped = $state([]); // card ids face-up, not yet resolved
let matchedKeys = $state(new Set());
let moves = $state(0);
let locked = $state(false); // brief hold while a mismatch is shown
let ready = $state(false);
let assist = $state(false); // colorblind assist: show a tiny letter on swatches
let celebrated = $state(false);
let matchedIds = $derived(board ? new Set(board.cards.filter((c) => matchedKeys.has(c.key)).map((c) => c.id)) : new Set());
let done = $derived(!!board && matchedKeys.size >= board.faces.length);
function read(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }
function write(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* ignore */ } }
function emitStatus() {
onstatus?.({ count: matchedKeys.size, total: board?.faces.length ?? 0, moves, done });
}
function persist() {
const snap = { matched: [...matchedKeys], moves, done, seed: isFree ? seed : undefined };
write(stateKey, snap);
emitStatus();
// Daily resumes across devices; free play stays local.
if (!isFree && date) pushGameState('match', variant, date, { matched: snap.matched, moves, done });
}
let loadSeq = 0;
async function load(forceNew = false) {
const myLoad = ++loadSeq;
ready = false;
// A pending mismatch flip-back must not fire onto a freshly switched board.
if (flipTimer) { clearTimeout(flipTimer); flipTimer = null; }
try { assist = localStorage.getItem(ASSIST_KEY) === '1'; } catch { /* ignore */ }
const useFree = isFree;
let saved, theSeed;
if (useFree) {
saved = forceNew ? null : read(stateKey);
// a free board derives a stable seed so a reload resumes the same layout
theSeed = saved?.seed || freeSeed((Date.now() ^ Math.floor(Math.random() * 1e9)) >>> 0);
} else {
theSeed = date;
saved = read(stateKey);
// pull/merge the server's copy first (cross-device resume)
if (date) {
const merged = await pushGameState('match', variant, date, {
matched: saved?.matched || [], moves: saved?.moves || 0, done: !!saved?.done,
});
if (myLoad !== loadSeq) return; // a newer board load started — drop this stale one
if (merged) saved = merged;
}
}
if (myLoad !== loadSeq) return;
// Commit the new board + restored progress atomically AFTER any await, so a rapid
// daily/free, icon/color, or tier switch can't hydrate an old response onto the
// newer board.
const b = buildBoard({ format, tier, seed: theSeed });
const valid = new Set(b.faces);
board = b;
seed = theSeed;
flipped = []; locked = false;
matchedKeys = new Set((saved?.matched || []).filter((k) => valid.has(k)));
moves = Number.isFinite(saved?.moves) ? saved.moves : 0;
celebrated = matchedKeys.size >= b.faces.length; // don't replay the celebration on resume
// Persist immediately so a freshly-generated free board's seed survives a reload
// even before the first move.
write(stateKey, { matched: [...matchedKeys], moves, done: matchedKeys.size >= b.faces.length,
seed: useFree ? theSeed : undefined });
ready = true;
emitStatus();
}
function reset() { if (isFree) load(true); }
function toggleAssist() {
assist = !assist;
try { localStorage.setItem(ASSIST_KEY, assist ? '1' : '0'); } catch { /* ignore */ }
}
let flipTimer = null;
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.
const next = [...flipped, card.id];
flipped = next;
if (next.length < board.matchN) return; // Expert needs all 3 before judging
moves += 1;
const keys = next.map((id) => board.cards[id].key);
const allSame = keys.every((k) => k === keys[0]);
if (allSame) {
matchedKeys = new Set([...matchedKeys, keys[0]]);
flipped = [];
persist();
if (matchedKeys.size >= board.faces.length && !celebrated) { celebrated = true; trackGame('match', 'completed'); }
} else {
locked = true; // show the mismatch briefly, then flip back
persist();
flipTimer = setTimeout(() => { flipped = []; locked = false; flipTimer = null; }, 850);
}
}
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;
}
// Reload whenever the board identity (mode/format/tier/date, all encoded in
// stateKey) changes. untrack() keeps the effect from tracking load()'s internal
// state reads (board/matchedKeys/moves/done via emitStatus) — which load() also
// writes — so a synchronous free-play load can't self-retrigger into a loop.
$effect(() => {
stateKey;
untrack(() => load());
return () => { if (flipTimer) clearTimeout(flipTimer); };
});
</script>
<div class="match">
<div class="topbar">
<div class="meta">
{#if board}
<span class="tier">{board.label}{format === 'colors' ? ' · Color' : ''}</span>
<span class="prog">{matchedKeys.size}/{board.faces.length}{moves ? ` · ${moves} moves` : ''}</span>
<span class="rule" class:tri={board.matchN === 3}>{#if board.matchN === 3 && flipped.length > 0 && flipped.length < 3}Pick 3 · {flipped.length}/3{:else}{board.matchN === 3 ? 'Match 3 of a kind' : 'Match pairs'}{/if}</span>
{/if}
</div>
<div class="controls">
{#if format === 'colors'}
<button class="ctl" aria-pressed={assist} onclick={toggleAssist}>Assist {assist ? 'on' : 'off'}</button>
{/if}
{#if isFree}
<button class="ctl" onclick={reset}>New board</button>
{/if}
</div>
</div>
{#if board}
<div class="grid" class:locked
style="grid-template-columns: repeat({board.cols}, 1fr); max-width: {board.cols * 76}px;">
{#each board.cards as card (card.id)}
{@const up = matchedIds.has(card.id) || flipped.includes(card.id)}
{@const matched = matchedIds.has(card.id)}
<button class="card" class:up class:matched
aria-label={up ? faceLabel(card) : 'card, face down'}
disabled={matched}
onclick={() => flip(card)}>
<span class="inner">
<span class="back" aria-hidden="true"></span>
<span class="front" class:colored={format === 'colors'} aria-hidden="true">
{#if format === 'colors'}
<span class="colorfill" style="--c: {COLOR_BY_KEY[card.key]?.hex}">
{#if assist}<span class="assist">{COLOR_BY_KEY[card.key]?.assist}</span>{/if}
</span>
{:else}
<MatchIcon name={card.key} />
{/if}
{#if matched}<span class="check" aria-hidden="true"></span>{/if}
</span>
</span>
</button>
{/each}
</div>
{#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>
<style>
.match { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.topbar { width: 100%; max-width: 460px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.meta { display: flex; flex-direction: column; gap: 1px; }
.tier { font-family: var(--label); font-weight: 600; }
.prog { color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.rule { font-size: 0.8rem; color: var(--muted); margin-top: 2px; }
/* Expert's 3-of-a-kind rule is the easy-to-forget one — give it a clear chip. */
.rule.tri { align-self: flex-start; color: var(--accent-deep); background: var(--accent-soft);
padding: 1px 9px; border-radius: 999px; font-weight: 600; }
.controls { display: flex; gap: 8px; }
.ctl { font-size: 0.8rem; padding: 5px 11px; border: 1px solid var(--line); border-radius: 9px;
background: var(--surface); color: var(--accent-deep); cursor: pointer; }
.grid { display: grid; gap: 8px; width: 100%; }
.card { aspect-ratio: 1; min-width: 0; padding: 0; border: none; background: none; cursor: pointer;
perspective: 600px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
.card[disabled] { cursor: default; }
.inner { position: relative; display: block; width: 100%; height: 100%;
transition: transform 0.32s ease; transform-style: preserve-3d; }
.card.up .inner { transform: rotateY(180deg); }
.back, .front { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
border-radius: 13px; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
/* Face-down: a branded card back — azure gradient under a faint dot motif. */
.back { background:
radial-gradient(circle at 50% 50%, rgba(255,255,255,0.18) 1.2px, transparent 1.6px) 0 0 / 11px 11px,
linear-gradient(150deg, var(--accent) 0%, var(--accent-deep) 100%);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18); }
.front { background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
transform: rotateY(180deg); }
.front.colored { background: none; border: none; } /* the colorfill is the whole face */
/* Icon front-and-center: ~70% of the card (≈50% area) so the symbol is memorable. */
.front :global(svg) { width: 70%; height: 70%; }
.colorfill { position: absolute; inset: 0; border-radius: 13px; background: var(--c);
border: 2px solid #3a3a3a; }
.assist { position: absolute; top: 5px; right: 6px; font-family: var(--label); font-weight: 700;
font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.45); padding: 1px 5px; border-radius: 6px; }
/* State hierarchy ----------------------------------------------------------
Inspecting (flipped, unmatched): vivid + lifted, so the unknown cards pop. */
.card.up:not(.matched) .front:not(.colored) { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
.card.up:not(.matched) .colorfill { box-shadow: 0 2px 8px rgba(0,0,0,0.20); }
/* Matched: a settled "paint-chip" tile — receded, desaturated, with a quiet check,
so the last few unknown cards stay obvious instead of blending in. */
.card.matched .inner { transform: rotateY(180deg) scale(0.9); }
.card.matched .front { box-shadow: none; }
.card.matched .front.colored { background: var(--surface); border: 1px solid var(--line); }
.card.matched .colorfill { inset: 5px; border-radius: 8px; border: 1px solid rgba(0,0,0,0.22);
filter: saturate(0.6) brightness(1.06); box-shadow: none; }
.card.matched :global(svg) { opacity: 0.42; }
.card.matched .assist { display: none; }
.check { position: absolute; bottom: 4px; right: 6px; font-size: 0.82rem; line-height: 1;
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; }
}
</style>