Memory Match
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
The card game everyone already knows, with an instrument panel bolted on — and it is already playing when you arrive. A demo player with perfect recall works the board in front of you, remembering every face it has ever turned over and always cashing in a known pair before gambling on a fresh one, so the whole game explains itself in about twenty seconds. Click any card and the board is yours: sixteen or thirty-six cards, three emoji decks, a move counter and timer in mono, and a best score kept per board size for as long as the tab is open.
The interesting number is par. The first time you open a board size the page plays 2,500 games against itself with that same perfect-recall strategy and averages the moves, which lands on 12.4 for the 4×4 and 28.5 for the 6×6 — a measured benchmark of roughly 1.6 moves per pair, not a made-up target. Try this first: take over, then watch the deal wave instead of the board — every face shows for half a second on the way in, and two of them remembered is most of the gap between par and beating it. Arrow keys and space play the whole game from the keyboard, and finishing at or under par is the only thing on the page that turns green.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Memory Match — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local additions (shadows + veils need alpha; tokens are opaque hex) */
:root { --shadow: 0 1px 2px rgba(26,29,33,.05); --lift: 0 6px 18px rgba(26,29,33,.10); --veil: rgba(246,246,243,.86); --stripe: rgba(192,86,0,.16); --dot: rgba(192,86,0,.24); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --shadow: 0 1px 2px rgba(0,0,0,.35); --lift: 0 8px 22px rgba(0,0,0,.45); --veil: rgba(20,22,26,.88); --stripe: rgba(255,180,84,.14); --dot: rgba(255,180,84,.22); }
}
:root[data-theme="dark"] { --shadow: 0 1px 2px rgba(0,0,0,.35); --lift: 0 8px 22px rgba(0,0,0,.45); --veil: rgba(20,22,26,.88); --stripe: rgba(255,180,84,.14); --dot: rgba(255,180,84,.22); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap { max-width: 880px; margin: 0 auto; padding: 22px 16px 26px; display: flex; flex-direction: column; gap: 16px; }
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 66ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.stage { display: grid; grid-template-columns: minmax(0, 1fr) 246px; gap: 16px; align-items: start; }
aside { width: 100%; max-width: 566px; margin-inline: auto; }
@media (max-width: 840px) { .stage { grid-template-columns: minmax(0, 1fr); } }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px; }
.card h2 { margin: 0 0 8px; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
.card h2 + .seg, .card h2 + .bestlist { margin-bottom: 14px; }
.card h2.mt { margin-top: 14px; }
/* stat rail */
.rail { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; max-width: 566px; margin-inline: auto; }
.stat { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 7px 9px 8px; min-width: 0; }
.stat .k { display: block; font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
.stat .v { display: block; font-family: var(--font-mono); font-size: clamp(16px, 4.4vw, 20px); font-variant-numeric: tabular-nums; margin-top: 3px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: clip; }
.stat.good .v { color: var(--run); }
.stat.tick .v { color: var(--accent); }
/* board */
.boardwrap { position: relative; padding: 12px; max-width: 566px; margin-inline: auto; }
.board { container-type: inline-size; width: 100%; max-width: 540px; margin: 0 auto; display: grid; gap: 7px; }
.board.s6 { gap: 6px; }
.cell {
appearance: none; -webkit-appearance: none; background: transparent; border: 0; padding: 0; margin: 0;
aspect-ratio: 1 / 1; perspective: 700px; cursor: pointer; border-radius: calc(var(--radius) + 2px);
transition: transform .16s ease;
}
.cell:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.cell:not(.done):not(.up):hover { transform: translateY(-2px); }
.cell.done { cursor: default; }
.inner { position: relative; display: block; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform .38s cubic-bezier(.2,.8,.25,1); }
.cell.up .inner, .cell.done .inner { transform: rotateY(180deg); }
.face {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
border: 1px solid var(--line); border-radius: var(--radius);
-webkit-backface-visibility: hidden; backface-visibility: hidden;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
line-height: 1; user-select: none;
}
.back { background-color: var(--surface); background-image: repeating-linear-gradient(135deg, var(--stripe) 0 1.5px, transparent 1.5px 9px); }
.back::after { content: ""; width: 20%; height: 20%; border-radius: 50%; background: var(--dot); transition: background .14s ease, transform .14s ease; }
.cell:not(.done):not(.up):hover .back { border-color: var(--accent); }
.cell:not(.done):not(.up):hover .back::after { background: var(--accent); transform: scale(1.12); }
.front { transform: rotateY(180deg); background: var(--surface); }
.cell.done .front { background: var(--run-soft); border-color: var(--run); }
.cell.done .front::after { content: ""; position: absolute; right: 5px; bottom: 5px; width: 5px; height: 5px; border-radius: 50%; background: var(--run); }
.cell.nope .front { border-color: var(--danger); }
/* card face sizing keyed off the board's own width */
.board .face { font-size: clamp(19px, 5.2vw, 32px); }
.board.s4 .face { font-size: 13cqi; }
.board.s6 .face { font-size: 8.6cqi; }
@keyframes pop { 0% { transform: rotateY(180deg) scale(1); } 42% { transform: rotateY(180deg) scale(1.12); } 100% { transform: rotateY(180deg) scale(1); } }
@keyframes ring { 0% { box-shadow: 0 0 0 0 var(--run); } 100% { box-shadow: 0 0 0 12px transparent; } }
@keyframes shake { 12%, 88% { transform: translateX(-2px); } 32%, 70% { transform: translateX(3px); } 50% { transform: translateX(-3px); } }
.cell.pop .inner { animation: pop .46s ease-out; }
.cell.pop .front { animation: ring .55s ease-out; }
.cell.nope { animation: shake .4s ease; }
@media (max-width: 420px) {
.boardwrap { padding: 10px; }
.board.s6 { gap: 5px; }
.rail { gap: 6px; }
}
.hint { margin: 10px 0 0; text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .02em; }
.status { margin: 8px 0 0; text-align: center; font-family: var(--font-mono); font-size: 12.5px; color: var(--ink); min-height: 17px; letter-spacing: .01em; }
.status.good { color: var(--run); }
.status.dim { color: var(--muted); }
/* controls */
.seg { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; }
.seg.wrap3 { grid-auto-flow: row; grid-template-columns: repeat(3, 1fr); }
button.opt, button.act {
font: inherit; font-size: 12.5px; color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 6px; cursor: pointer;
transition: background .14s ease, border-color .14s ease, color .14s ease;
}
button.opt { font-family: var(--font-mono); letter-spacing: .01em; }
button.opt:hover:not(:disabled), button.act:hover:not(:disabled) { border-color: var(--accent); }
button.opt[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
button.act { width: 100%; font-weight: 600; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--surface); }
button.primary:hover:not(:disabled) { filter: brightness(1.06); border-color: var(--accent); }
button.opt:disabled, button.act:disabled { color: var(--muted); border-color: var(--line); background: var(--surface); cursor: default; opacity: .55; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.bestlist { display: flex; flex-direction: column; gap: 6px; }
.brow { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-family: var(--font-mono); font-size: 12.5px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; }
.brow .bk { color: var(--muted); font-size: 11.5px; letter-spacing: .06em; }
.brow .bv { font-variant-numeric: tabular-nums; color: var(--muted); }
.brow.set .bv { color: var(--ink); }
.brow.good { border-color: var(--run); background: var(--run-soft); }
.brow.good .bv, .brow.good .bk { color: var(--run); }
.note { margin: 10px 0 0; font-size: 12px; line-height: 1.5; color: var(--muted); }
.note code { font-family: var(--font-mono); font-size: 11.5px; }
/* win overlay */
.win { position: absolute; inset: 0; background: var(--veil); display: flex; align-items: center; justify-content: center; padding: 14px; border-radius: var(--radius); z-index: 3; }
.win[hidden] { display: none; }
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.winCard { background: var(--surface); border: 1px solid var(--run); border-radius: var(--radius); box-shadow: var(--lift); padding: 16px; text-align: center; max-width: 300px; width: 100%; animation: rise .3s ease-out; }
.winCard .tag { margin: 0 0 4px; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em; text-transform: uppercase; color: var(--run); }
.winCard h3 { margin: 0 0 12px; font-size: 19px; letter-spacing: -.015em; }
.wstats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 0 0 12px; }
.wstats div { border: 1px solid var(--line); border-radius: 8px; padding: 6px 4px; }
.wstats dt { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
.wstats dd { margin: 3px 0 0; font-family: var(--font-mono); font-size: 15px; font-variant-numeric: tabular-nums; }
.wstats dd.good { color: var(--run); }
.badge { display: inline-block; margin: 0 0 12px; font-family: var(--font-mono); font-size: 11px; letter-spacing: .06em; color: var(--run); background: var(--run-soft); border: 1px solid var(--run); border-radius: 999px; padding: 3px 10px; }
.badge[hidden] { display: none; }
.winbtns { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.confetti { position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 4; border-radius: var(--radius); }
.conf { position: absolute; left: 50%; top: 50%; font-size: 20px; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; animation: burst 1.15s cubic-bezier(.16,.7,.35,1) forwards; }
@keyframes burst { 0% { transform: translate3d(-50%, -50%, 0) scale(.3) rotate(0deg); opacity: 0; } 18% { opacity: 1; } 100% { transform: translate3d(var(--dx), var(--dy), 0) scale(1) rotate(var(--r)); opacity: 0; } }
footer { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); letter-spacing: .04em; padding-top: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) {
.inner, .cell, .back::after, button.opt, button.act { transition: none !important; }
.cell, .cell .inner, .cell .front, .winCard { animation: none !important; }
.cell.nope .front { border-color: var(--danger); border-width: 2px; }
.cell:not(.done):not(.up):hover { transform: none; }
.cell:not(.done):not(.up):hover .back::after { transform: none; }
.confetti { display: none; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Memory Match</h1>
<p class="lede">Flip two cards. If the faces match, they stay up. A demo player with perfect recall is going first — <b>click any card to take over</b>, and the clock starts on your first flip.</p>
</header>
<main class="stage">
<div>
<div class="rail">
<div class="stat" id="statMoves"><span class="k">moves</span><span class="v" id="sMoves">0</span></div>
<div class="stat" id="statTime"><span class="k">time</span><span class="v" id="sTime">0:00</span></div>
<div class="stat"><span class="k">pairs</span><span class="v" id="sPairs">0/8</span></div>
<div class="stat"><span class="k">par</span><span class="v" id="sPar">—</span></div>
</div>
<section class="card boardwrap">
<div class="board s4" id="board" role="group" aria-label="Memory board" aria-describedby="hint"></div>
<p class="status dim" id="status">dealing…</p>
<p class="hint" id="hint">dealing…</p>
<div class="confetti" id="confetti" aria-hidden="true"></div>
<div class="win" id="win" hidden>
<div class="winCard" role="dialog" aria-modal="false" aria-labelledby="winTitle">
<p class="tag">solved</p>
<h3 id="winTitle">Board cleared</h3>
<dl class="wstats">
<div><dt>moves</dt><dd id="wMoves">0</dd></div>
<div><dt>time</dt><dd id="wTime">0:00</dd></div>
<div><dt>vs par</dt><dd id="wPar">0</dd></div>
</dl>
<p class="badge" id="wBadge" hidden>new best</p>
<div class="winbtns">
<button type="button" class="act primary" id="againBtn">Play again</button>
<button type="button" class="act" id="dismissBtn">See board</button>
</div>
</div>
</div>
</section>
</div>
<aside>
<section class="card">
<h2>Board size</h2>
<div class="seg" id="sizeSeg">
<button type="button" class="opt" data-size="4" aria-pressed="true">4 × 4</button>
<button type="button" class="opt" data-size="6" aria-pressed="false">6 × 6</button>
</div>
<h2 class="mt">Deck</h2>
<div class="seg wrap3" id="deckSeg">
<button type="button" class="opt" data-deck="snacks" aria-pressed="true">snacks</button>
<button type="button" class="opt" data-deck="critters" aria-pressed="false">critters</button>
<button type="button" class="opt" data-deck="outdoors" aria-pressed="false">outdoors</button>
</div>
<div style="height:14px"></div>
<button type="button" class="act primary" id="dealBtn">Play it myself</button>
<div style="height:7px"></div>
<button type="button" class="act" id="watchBtn">Watch a perfect game</button>
</section>
<div style="height:16px"></div>
<section class="card">
<h2>Best this session</h2>
<div class="bestlist">
<div class="brow" id="best4"><span class="bk">4 × 4</span><span class="bv">—</span></div>
<div class="brow" id="best6"><span class="bk">6 × 6</span><span class="bv">—</span></div>
</div>
<p class="note"><b>Par</b> is the average number of moves taken by a player with perfect recall — measured here by simulating 2,500 deals in your browser. The demo player uses that exact strategy. Finish at or under par and the score turns green.</p>
</section>
</aside>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
/* ---------- decks: 18 pairs each, all local emoji ---------- */
var DECKS = {
snacks: [
["🍕", "pizza"], ["🍔", "burger"], ["🍟", "fries"],
["🌮", "taco"], ["🍩", "donut"], ["🍪", "cookie"],
["🧁", "cupcake"], ["🍎", "apple"], ["🍇", "grapes"],
["🍓", "strawberry"], ["🍉", "watermelon"], ["🥑", "avocado"],
["🥨", "pretzel"], ["🍿", "popcorn"], ["🧀", "cheese"],
["🍣", "sushi"], ["🍜", "noodles"], ["🍦", "ice cream"]
],
critters: [
["🐶", "dog"], ["🐱", "cat"], ["🦊", "fox"],
["🐼", "panda"], ["🐨", "koala"], ["🦁", "lion"],
["🐸", "frog"], ["🐵", "monkey"], ["🦉", "owl"],
["🐙", "octopus"], ["🦋", "butterfly"], ["🐢", "turtle"],
["🦄", "unicorn"], ["🐧", "penguin"], ["🐳", "whale"],
["🐴", "horse"], ["🐝", "bee"], ["🐞", "ladybug"]
],
outdoors: [
["🌞", "sun"], ["🌙", "moon"], ["⭐", "star"],
["🌈", "rainbow"], ["☁️", "cloud"], ["⚡", "lightning"],
["❄️", "snowflake"], ["🔥", "fire"], ["🌊", "wave"],
["🌵", "cactus"], ["🌻", "sunflower"], ["🍄", "mushroom"],
["🌴", "palm tree"], ["🏔️", "mountain"], ["🍁", "maple leaf"],
["🌷", "tulip"], ["🪐", "planet"], ["🚀", "rocket"]
]
};
var reduce = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : { matches: false };
var lessMotion = function () { return !!reduce.matches; };
/* ---------- par: expected moves for a perfect-recall player ---------- */
var PAR_CACHE = {};
function par(pairs) {
if (PAR_CACHE[pairs] !== undefined) return PAR_CACHE[pairs];
var trials = 2500, seed = (2654435761 ^ (pairs * 40503)) >>> 0, total = 0, i, j, t;
function rnd() {
seed ^= seed << 13; seed >>>= 0;
seed ^= seed >>> 17;
seed ^= seed << 5; seed >>>= 0;
return seed / 4294967296;
}
for (t = 0; t < trials; t++) {
var deck = [];
for (i = 0; i < pairs; i++) { deck.push(i, i); }
for (i = deck.length - 1; i > 0; i--) { j = (rnd() * (i + 1)) | 0; var tmp = deck[i]; deck[i] = deck[j]; deck[j] = tmp; }
var n = deck.length;
var gone = new Array(n); // matched and removed
var known = new Array(n); // seen but unmatched -> id, else undefined
var moves = 0, found = 0;
while (found < pairs) {
moves++;
// 1. two known cards with the same face
var seen = {}, a = -1, b = -1, p;
for (p = 0; p < n; p++) {
if (gone[p] || known[p] === undefined) continue;
var id = known[p];
if (seen[id] !== undefined) { a = seen[id]; b = p; break; }
seen[id] = p;
}
if (a >= 0) { gone[a] = gone[b] = true; known[a] = known[b] = undefined; found++; continue; }
// 2. turn over an unseen card
var unk = [];
for (p = 0; p < n; p++) { if (!gone[p] && known[p] === undefined) unk.push(p); }
var p1 = unk[(rnd() * unk.length) | 0], id1 = deck[p1], partner = -1;
for (p = 0; p < n; p++) { if (!gone[p] && known[p] === id1) { partner = p; break; } }
if (partner >= 0) { gone[p1] = gone[partner] = true; known[partner] = undefined; found++; continue; }
if (unk.length < 2) { known[p1] = id1; continue; }
var p2 = p1;
while (p2 === p1) { p2 = unk[(rnd() * unk.length) | 0]; }
var id2 = deck[p2];
if (id1 === id2) { gone[p1] = gone[p2] = true; found++; }
else { known[p1] = id1; known[p2] = id2; }
}
total += moves;
}
PAR_CACHE[pairs] = total / trials;
return PAR_CACHE[pairs];
}
var missTimer = null;
var finishDeal = null; // set while the deal cascade is running; a click collapses it
/* ---------- state ---------- */
var S = {
size: 4, deck: "snacks",
cards: [], up: [], moves: 0, found: 0, pairs: 8,
lock: false, dealing: true, done: false,
t0: 0, elapsed: 0, running: false, tick: null,
focus: 0,
bot: false, mem: {},
best: { 4: null, 6: null }
};
var timers = [];
function later(fn, ms) { var id = window.setTimeout(fn, ms); timers.push(id); return id; }
function clearTimers() { for (var i = 0; i < timers.length; i++) window.clearTimeout(timers[i]); timers = []; }
var board = $("board"), statusEl = $("status"), live = $("live");
function fmt(ms) {
var s = Math.floor(ms / 1000);
return Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
}
function say(text, tone) {
statusEl.textContent = text;
statusEl.className = "status" + (tone === "good" ? " good" : tone === "dim" ? " dim" : "");
}
function announce(text) { if (S.bot) return; live.textContent = text; }
/* ---------- rendering ---------- */
function shuffle(arr) {
for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1)), t = arr[i];
arr[i] = arr[j]; arr[j] = t;
}
return arr;
}
function label(card) {
var pos = "row " + (Math.floor(card.i / S.size) + 1) + " column " + ((card.i % S.size) + 1);
if (card.matched) return pos + ", " + card.name + ", matched";
if (card.faceUp) return pos + ", " + card.name;
return pos + ", face down";
}
function syncLabel(card) { card.el.setAttribute("aria-label", label(card)); }
function deal(auto) {
clearTimers();
stopBot();
finishDeal = null;
if (missTimer) { window.clearTimeout(missTimer); missTimer = null; }
stopClock();
S.pairs = (S.size * S.size) / 2;
S.moves = 0; S.found = 0; S.up = []; S.elapsed = 0; S.running = false;
S.lock = true; S.dealing = true; S.done = false; S.focus = 0;
S.bot = !!auto; S.mem = {};
syncMode();
hideWin();
$("statMoves").classList.remove("good");
$("confetti").textContent = "";
var faces = shuffle(DECKS[S.deck].slice()).slice(0, S.pairs);
var pool = [];
for (var i = 0; i < faces.length; i++) { pool.push(faces[i], faces[i]); }
shuffle(pool);
board.textContent = "";
board.className = "board s" + S.size;
board.style.gridTemplateColumns = "repeat(" + S.size + ", minmax(0, 1fr))";
S.cards = pool.map(function (f, i) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "cell";
btn.tabIndex = i === 0 ? 0 : -1;
btn.innerHTML = '<span class="inner"><span class="face back" aria-hidden="true"></span>' +
'<span class="face front" aria-hidden="true"></span></span>';
btn.querySelector(".front").textContent = f[0];
var card = { i: i, key: f[1], name: f[1], glyph: f[0], el: btn, faceUp: false, matched: false };
btn.addEventListener("click", function () { onPick(card); });
btn.addEventListener("focus", function () { setFocus(card.i, false); });
syncLabel(card);
board.appendChild(btn);
return card;
});
paint();
updatePar();
var pairsWord = S.pairs + " pairs";
function ready() {
finishDeal = null;
S.lock = false; S.dealing = false;
if (S.bot) { startDemo(); return; }
say(pairsWord + " dealt · " + S.deck, "dim");
announce("New deal. " + S.size + " by " + S.size + ", " + pairsWord + ", " + S.deck + " deck.");
}
if (lessMotion()) { ready(); return; }
// deal cascade: a diagonal wave that shows each face for a moment
say("dealing…", "dim");
S.cards.forEach(function (card) {
var r = Math.floor(card.i / S.size), c = card.i % S.size, d = (r + c) * 45;
later(function () { card.el.classList.add("up"); }, d + 60);
later(function () { card.el.classList.remove("up"); }, d + 560);
});
var total = (2 * (S.size - 1)) * 45 + 560 + 380;
finishDeal = ready;
later(ready, total);
}
// an impatient click during the cascade ends it now instead of being swallowed
function skipWave() {
if (!S.dealing || !finishDeal) return;
clearTimers();
S.cards.forEach(function (c) { c.el.classList.remove("up"); });
var fn = finishDeal;
finishDeal = null;
fn();
}
function paint() {
$("sMoves").textContent = String(S.moves);
$("sPairs").textContent = S.found + "/" + S.pairs;
$("sTime").textContent = fmt(S.elapsed);
$("statTime").classList.toggle("tick", S.running);
}
function updatePar() {
var p = par(S.pairs);
$("sPar").textContent = p.toFixed(1);
}
/* ---------- clock ---------- */
function startClock() {
if (S.running) return;
S.running = true;
S.t0 = Date.now() - S.elapsed;
S.tick = window.setInterval(function () {
S.elapsed = Date.now() - S.t0;
$("sTime").textContent = fmt(S.elapsed);
}, 200);
$("statTime").classList.add("tick");
}
function stopClock() {
if (S.tick) { window.clearInterval(S.tick); S.tick = null; }
if (S.running) { S.elapsed = Date.now() - S.t0; }
S.running = false;
$("statTime").classList.remove("tick");
}
/* ---------- play ---------- */
function resolveMiss() {
if (missTimer) { window.clearTimeout(missTimer); missTimer = null; }
S.up.forEach(function (c) {
c.faceUp = false;
c.el.classList.remove("up", "nope");
syncLabel(c);
});
S.up = [];
S.lock = false;
}
function onPick(card, fromBot) {
if (!fromBot && S.bot) { takeOver(); }
if (S.cards[card.i] !== card) return; // takeOver re-dealt: this card is off the board
if (S.done) return;
if (S.dealing) { skipWave(); if (S.dealing) return; }
if (S.lock && missTimer) { resolveMiss(); } // quick-flip: a third click clears a miss
if (S.lock || card.faceUp || card.matched) return;
startClock();
card.faceUp = true;
card.el.classList.add("up");
syncLabel(card);
S.up.push(card);
if (!fromBot) setFocus(card.i, false);
if (S.up.length === 1) { say(card.name, "dim"); announce(card.name + ", face up."); return; }
S.moves++;
var a = S.up[0], b = S.up[1];
if (a.key === b.key) {
a.matched = b.matched = true;
S.found++;
S.up = [];
[a, b].forEach(function (c) {
c.el.classList.remove("up");
c.el.classList.add("done");
syncLabel(c);
if (!lessMotion()) {
c.el.classList.add("pop");
later(function () { c.el.classList.remove("pop"); }, 600);
}
});
paint();
say("match · " + a.name + " · " + S.found + "/" + S.pairs, "good");
announce("Match, " + a.name + ". " + S.found + " of " + S.pairs + " pairs found.");
if (S.found === S.pairs) {
stopClock(); // freeze the clock on the last match, not after the celebration
paint();
later(win, lessMotion() ? 120 : 480);
}
return;
}
S.lock = true;
paint();
say("no match · " + a.name + " + " + b.name, "dim");
announce("No match: " + a.name + " and " + b.name + ".");
a.el.classList.add("nope"); b.el.classList.add("nope"); // red edge is a cue, not motion
missTimer = window.setTimeout(function () { missTimer = null; resolveMiss(); }, 820);
}
/* ---------- win ---------- */
function win() {
stopClock();
S.done = true;
paint();
if (S.bot) { demoWin(); return; }
var p = par(S.pairs), delta = S.moves - p, under = S.moves <= p;
var prev = S.best[S.size];
var better = !prev || S.moves < prev.moves || (S.moves === prev.moves && S.elapsed < prev.ms);
if (better) { S.best[S.size] = { moves: S.moves, ms: S.elapsed }; }
renderBest();
$("wMoves").textContent = String(S.moves);
$("wTime").textContent = fmt(S.elapsed);
var wp = $("wPar");
wp.textContent = (delta >= 0 ? "+" : "−") + Math.abs(delta).toFixed(1);
wp.className = under ? "good" : "";
document.getElementById("statMoves").classList.toggle("good", under);
$("wBadge").hidden = !better;
$("wBadge").textContent = better && prev ? "new best" : better ? "first clear" : "";
$("win").hidden = false;
board.inert = true; // nothing behind the veil should take focus
$("againBtn").focus();
say("solved · " + S.moves + " moves · " + fmt(S.elapsed), "good");
announce("Solved in " + S.moves + " moves, " + fmt(S.elapsed) + "." +
(under ? " Under par." : "") +
(better ? (prev ? " New best for this size." : " First clear at this size.") : ""));
if (!lessMotion()) burst();
}
function hideWin() { $("win").hidden = true; board.inert = false; }
function burst() {
var box = $("confetti");
box.textContent = "";
var glyphs = S.cards.map(function (c) { return c.glyph; });
for (var i = 0; i < 18; i++) {
var s = document.createElement("span");
s.className = "conf";
s.textContent = glyphs[(Math.random() * glyphs.length) | 0];
var ang = Math.random() * Math.PI * 2, dist = 70 + Math.random() * 150;
s.style.setProperty("--dx", "calc(-50% + " + Math.cos(ang) * dist + "px)");
s.style.setProperty("--dy", "calc(-50% + " + Math.sin(ang) * dist + "px)");
s.style.setProperty("--r", (Math.random() * 540 - 270) + "deg");
s.style.animationDelay = (Math.random() * 160) + "ms";
box.appendChild(s);
}
later(function () { box.textContent = ""; }, 1600);
}
function renderBest() {
[4, 6].forEach(function (n) {
var row = $("best" + n), b = S.best[n];
row.classList.toggle("set", !!b);
row.classList.toggle("good", !!b && b.moves <= par((n * n) / 2));
row.querySelector(".bv").textContent = b ? b.moves + " moves · " + fmt(b.ms) : "—";
});
}
/* ---------- the demo player: same perfect recall the par number is built on ---------- */
var botTimers = [];
function botLater(fn, ms) { var id = window.setTimeout(fn, ms); botTimers.push(id); return id; }
function stopBot() {
for (var i = 0; i < botTimers.length; i++) window.clearTimeout(botTimers[i]);
botTimers = [];
}
function syncMode() {
$("hint").textContent = S.bot
? "demo player · click any card to take over"
: "arrow keys move · enter or space flips";
$("dealBtn").textContent = S.bot ? "Play it myself" : "New deal";
$("dealBtn").classList.toggle("primary", S.bot);
$("watchBtn").disabled = S.bot;
}
function memPair() { // two remembered faces that match
var seen = {}, i, k;
for (i = 0; i < S.cards.length; i++) {
if (S.cards[i].matched || S.mem[i] === undefined) continue;
k = S.mem[i];
if (seen[k] !== undefined) return [seen[k], i];
seen[k] = i;
}
return null;
}
function memPartner(key, not) {
for (var i = 0; i < S.cards.length; i++) {
if (i !== not && !S.cards[i].matched && S.mem[i] === key) return i;
}
return -1;
}
function unseen(skip) {
var out = [];
for (var i = 0; i < S.cards.length; i++) {
if (i !== skip && !S.cards[i].matched && S.mem[i] === undefined) out.push(i);
}
return out;
}
function botFlip(i) {
if (!S.bot) return;
S.mem[i] = S.cards[i].key;
onPick(S.cards[i], true);
}
function startDemo() {
say("watching · perfect recall", "dim");
live.textContent = "A demo player with perfect recall is playing. Activate any card to take over.";
botLater(botMove, 420);
}
function botMove() {
if (!S.bot || S.done) return;
var pair = memPair();
if (pair) {
botFlip(pair[0]);
botLater(function () {
if (!S.bot) return;
botFlip(pair[1]);
botLater(botMove, 880);
}, 430);
return;
}
var unk = unseen(-1);
if (!unk.length) { botLater(function () { if (S.bot) deal(true); }, 900); return; } // never stall the demo
var p1 = unk[(Math.random() * unk.length) | 0];
botFlip(p1);
botLater(function () {
if (!S.bot) return;
var p2 = memPartner(S.cards[p1].key, p1);
if (p2 < 0) {
var rest = unseen(p1);
if (!rest.length) { botLater(botMove, 600); return; }
p2 = rest[(Math.random() * rest.length) | 0];
}
botFlip(p2);
botLater(botMove, 1080);
}, 470);
}
function demoWin() {
say("demo cleared it in " + S.moves + " moves · par " + par(S.pairs).toFixed(1), "good");
botLater(function () { if (S.bot) deal(true); }, 2400);
}
function takeOver() {
if (!S.bot) return;
var fromBoard = board.contains(document.activeElement);
S.bot = false;
stopBot();
syncMode();
if (S.dealing) { // demo hadn't moved yet — this board is yours now
skipWave();
say("your board · the clock starts on your first flip", "dim");
announce("You have taken over. Your board, " + S.pairs + " pairs.");
return;
}
if (S.moves || S.found || S.up.length || S.done) {
deal();
if (fromBoard) later(function () { if (S.cards[0]) S.cards[0].el.focus(); }, 30);
return;
}
say("your board · the clock starts on your first flip", "dim");
announce("You have taken over. Your board, " + S.pairs + " pairs.");
}
/* ---------- keyboard: roving focus across the grid ---------- */
function setFocus(i, moveFocus) {
if (!S.cards.length) return;
i = Math.max(0, Math.min(S.cards.length - 1, i));
S.cards.forEach(function (c, n) { c.el.tabIndex = n === i ? 0 : -1; });
S.focus = i;
if (moveFocus) S.cards[i].el.focus();
}
board.addEventListener("keydown", function (e) {
var n = S.size, i = S.focus, next = null;
if (e.key === "ArrowRight") next = (i % n === n - 1) ? i : i + 1;
else if (e.key === "ArrowLeft") next = (i % n === 0) ? i : i - 1;
else if (e.key === "ArrowDown") next = i + n;
else if (e.key === "ArrowUp") next = i - n;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = S.cards.length - 1;
else return;
if (next === null || next < 0 || next >= S.cards.length) { e.preventDefault(); return; }
e.preventDefault();
setFocus(next, true);
});
/* ---------- controls ---------- */
function segment(id, attr, apply) {
var box = $(id);
box.addEventListener("click", function (e) {
var btn = e.target.closest ? e.target.closest("button[" + attr + "]") : null;
if (!btn || !box.contains(btn)) return;
var val = btn.getAttribute(attr);
Array.prototype.forEach.call(box.querySelectorAll("button[" + attr + "]"), function (b) {
b.setAttribute("aria-pressed", String(b === btn));
});
apply(val);
});
}
segment("sizeSeg", "data-size", function (v) { S.size = parseInt(v, 10); deal(S.bot); });
segment("deckSeg", "data-deck", function (v) { S.deck = v; deal(S.bot); });
$("dealBtn").addEventListener("click", function () { deal(); });
$("watchBtn").addEventListener("click", function () { deal(true); });
$("againBtn").addEventListener("click", function () {
deal();
later(function () { if (S.cards[0]) S.cards[0].el.focus(); }, 30);
});
$("dismissBtn").addEventListener("click", function () {
hideWin();
$("dealBtn").focus();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !$("win").hidden) { hideWin(); $("dealBtn").focus(); }
});
/* ---------- platform theme ---------- */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* ---------- boot ---------- */
renderBest();
deal(true);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.