$sloprun.dev

Memory Match

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 4 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

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.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/memory-match" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Memory Match — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/memory-match

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 810 lines 35.0 KB index.html
demos/memory-match/index.html
<!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 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong