[object Object]

← back to Games Agentabrams

games.aa: upgrade Emoji Memory to the richer free-roster build (theme toggle, 4×4/6×6/8×8)

36c7d1165f3f728732b34fe2f3aef2be4d5992ba · 2026-07-24 23:37:26 -0700 · Steve Abrams

Files touched

Diff

commit 36c7d1165f3f728732b34fe2f3aef2be4d5992ba
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 24 23:37:26 2026 -0700

    games.aa: upgrade Emoji Memory to the richer free-roster build (theme toggle, 4×4/6×6/8×8)
---
 games.json                    |   2 +-
 games/emoji-memory/index.html | 558 ++++++++++++++++++++++++++++--------------
 2 files changed, 372 insertions(+), 188 deletions(-)

diff --git a/games.json b/games.json
index 6ac0e7b..385ca3f 100644
--- a/games.json
+++ b/games.json
@@ -172,7 +172,7 @@
       "id": "emoji-memory",
       "file": "EmojiMemory.aa",
       "title": "Abrams Emoji Memory",
-      "desc": "Flip-and-match memory game — find every emoji pair in the fewest moves. 3D card flips, move counter + timer, persistent best-per-size, difficulty toggle (2×2 / 4×4 / 6×6). Click/tap to flip, N new game, M mute. Pure DOM grid, procedural Web Audio, zero dependencies.",
+      "desc": "Flip-and-match memory game — find every emoji pair in the fewest moves. 3D card flips, move counter + timer, persistent best-per-size, three difficulties (4×4 / 6×6 / 8×8), and a light/dark theme toggle. Click/tap to flip, N new game. Pure DOM grid, procedural Web Audio, zero dependencies.",
       "path": "games/emoji-memory/",
       "added": "2026-07-24"
     },
diff --git a/games/emoji-memory/index.html b/games/emoji-memory/index.html
index 4f5fa5d..0ceb407 100644
--- a/games/emoji-memory/index.html
+++ b/games/emoji-memory/index.html
@@ -1,217 +1,401 @@
 <!DOCTYPE html>
 <html lang="en">
 <head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
-<title>Abrams Emoji Memory</title>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Free-Roster — Emoji Memory Match</title>
 <style>
-  :root { --accent:#7c5cff; --accent2:#ff4d9d; --ink:#e8e9ef; }
-  * { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
-  html,body { height:100%; margin:0; }
-  body {
-    background:radial-gradient(1000px 700px at 70% -10%,#1b2145 0%,#0e1016 55%);
-    color:var(--ink); font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
-    display:flex; flex-direction:column; align-items:center; overflow:hidden;
-    user-select:none; -webkit-user-select:none;
-  }
-  header {
-    width:100%; max-width:560px; display:flex; align-items:center; gap:10px;
-    padding:14px 16px 10px; flex-wrap:wrap;
-  }
-  header h1 { margin:0; font-size:14px; letter-spacing:1.5px; font-weight:700; flex:1; }
-  header h1 .dot { color:var(--accent); }
-  .stat { font-size:11px; opacity:.7; letter-spacing:.5px; }
-  .stat b { color:var(--ink); opacity:1; }
-  button.ctl {
-    background:none; border:1px solid #3d4763; color:var(--ink); font:inherit;
-    font-size:11px; border-radius:999px; padding:4px 10px; cursor:pointer;
-  }
-  button.ctl:hover { background:#222839; }
-  button.ctl.on { background:var(--accent); border-color:var(--accent); }
-  #board {
-    display:grid; gap:8px; padding:8px 16px 16px;
-    width:100%; max-width:560px; flex:1; align-content:center;
-  }
-  .card {
-    position:relative; aspect-ratio:1/1; cursor:pointer;
-    perspective:600px; min-height:0;
-  }
-  .inner {
-    position:absolute; inset:0; transform-style:preserve-3d;
-    transition:transform .32s cubic-bezier(.4,.2,.2,1);
-    border-radius:12px;
-  }
-  .card.up .inner, .card.done .inner { transform:rotateY(180deg); }
-  .face {
-    position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
-    border-radius:12px; backface-visibility:hidden; -webkit-backface-visibility:hidden;
-    font-size:clamp(20px,7vw,42px);
-  }
-  .back {
-    background:linear-gradient(145deg,#2a3147,#1a1e28); border:1px solid #3d4763;
-    color:var(--accent); font-size:clamp(14px,4vw,22px);
-  }
-  .front {
-    background:linear-gradient(145deg,#242a3d,#191d29); border:1px solid #3d4763;
+  :root{
+    --bg1:#1e1b4b; --bg2:#312e81; --accent:#a78bfa; --accent2:#f472b6;
+    --card-back:linear-gradient(135deg,#7c3aed,#db2777);
+    --card-face:#ffffff; --matched:#34d399; --miss:#f87171;
+    --text:#f8fafc; --panel:rgba(255,255,255,.07); --panel-bd:rgba(255,255,255,.1);
+    --seg-bg:rgba(255,255,255,.08); --seg-bd:rgba(255,255,255,.12); --seg-txt:#e2e8f0;
+    --foot:rgba(255,255,255,.4);
+  }
+  body.light{
+    --bg1:#ede9fe; --bg2:#c7d2fe;
+    --card-face:#ffffff;
+    --text:#1e1b4b; --panel:rgba(30,27,75,.06); --panel-bd:rgba(30,27,75,.12);
+    --seg-bg:rgba(30,27,75,.06); --seg-bd:rgba(30,27,75,.14); --seg-txt:#312e81;
+    --foot:rgba(30,27,75,.5);
+  }
+  *{box-sizing:border-box;margin:0;padding:0}
+  body{
+    min-height:100vh;
+    font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
+    background:linear-gradient(160deg,var(--bg1),var(--bg2));
+    color:var(--text);
+    display:flex;flex-direction:column;align-items:center;
+    padding:20px;gap:16px;
+    transition:background .3s,color .3s;
+  }
+  h1{
+    font-size:clamp(1.6rem,5vw,2.6rem);
+    font-weight:800;letter-spacing:.5px;
+    background:linear-gradient(90deg,var(--accent),var(--accent2));
+    -webkit-background-clip:text;background-clip:text;color:transparent;
+    text-align:center;
+  }
+  .controls{
+    display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;
+  }
+  .seg{
+    display:inline-flex;background:var(--seg-bg);
+    border-radius:999px;padding:4px;gap:4px;
+    border:1px solid var(--seg-bd);
+  }
+  .seg button{
+    border:none;background:transparent;color:var(--seg-txt);
+    padding:8px 16px;border-radius:999px;cursor:pointer;
+    font-size:.95rem;font-weight:600;transition:.2s;
+  }
+  .icon-btn{
+    border:1px solid var(--seg-bd);background:var(--seg-bg);color:var(--seg-txt);
+    cursor:pointer;font-size:1.1rem;font-weight:600;line-height:1;
+    width:42px;height:42px;border-radius:50%;transition:.2s;
+    display:inline-flex;align-items:center;justify-content:center;
+  }
+  .icon-btn:hover{transform:translateY(-2px)}
+  .icon-btn:focus-visible{outline:3px solid #fde68a;outline-offset:2px}
+  .icon-btn.off{opacity:.45}
+  .seg button.active{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 4px 14px rgba(167,139,250,.4)}
+  .seg button:focus-visible,.btn:focus-visible{outline:3px solid #fde68a;outline-offset:2px}
+  .btn{
+    border:none;cursor:pointer;font-weight:700;font-size:.95rem;
+    padding:10px 22px;border-radius:999px;color:#fff;
+    background:linear-gradient(135deg,var(--accent),var(--accent2));
+    box-shadow:0 4px 14px rgba(244,114,182,.35);transition:transform .15s,box-shadow .15s;
+  }
+  .btn:hover{transform:translateY(-2px);box-shadow:0 8px 22px rgba(244,114,182,.5)}
+  .btn:active{transform:translateY(0)}
+  .stats{
+    display:flex;gap:22px;font-size:1.05rem;font-weight:600;flex-wrap:wrap;justify-content:center;
+    background:var(--panel);padding:12px 26px;border-radius:16px;
+    border:1px solid var(--panel-bd);
+  }
+  .stats span{color:var(--accent);font-variant-numeric:tabular-nums}
+  .stats .best{color:#fcd34d}
+  .board-wrap{position:relative;width:100%;max-width:560px}
+  #board{
+    display:grid;gap:10px;perspective:900px;width:100%;
+  }
+  #board.locked{pointer-events:none}
+  .card{
+    position:relative;aspect-ratio:1;cursor:pointer;
+    transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,.2,.2,1);
+    border-radius:14px;border:none;padding:0;background:transparent;
+  }
+  .card.flipped,.card.matched{transform:rotateY(180deg)}
+  .card.matched{cursor:default}
+  .card:focus-visible{outline:3px solid #fde68a;outline-offset:3px}
+  .card.miss{animation:shake .4s}
+  @keyframes shake{
+    0%,100%{transform:rotateY(180deg) translateX(0)}
+    25%{transform:rotateY(180deg) translateX(-7px)}
+    75%{transform:rotateY(180deg) translateX(7px)}
+  }
+  .face{
+    position:absolute;inset:0;border-radius:14px;
+    display:flex;align-items:center;justify-content:center;
+    backface-visibility:hidden;-webkit-backface-visibility:hidden;
+    user-select:none;
+  }
+  .back{
+    background:var(--card-back);
+    box-shadow:0 4px 12px rgba(0,0,0,.3), inset 0 0 0 2px rgba(255,255,255,.15);
+  }
+  .back::after{content:"?";font-size:clamp(1.2rem,5vw,2rem);font-weight:800;color:rgba(255,255,255,.85)}
+  .front{
+    background:var(--card-face);color:#111;
     transform:rotateY(180deg);
+    font-size:clamp(1.4rem,6vw,2.8rem);
+    box-shadow:0 4px 12px rgba(0,0,0,.3);
   }
-  .card.done .front { border-color:var(--accent2); box-shadow:0 0 0 2px rgba(255,77,157,.35) inset; }
-  .card.done { animation:pop .4s ease; }
-  @keyframes pop { 0%{transform:scale(1);} 40%{transform:scale(1.08);} 100%{transform:scale(1);} }
-  #win {
-    position:fixed; inset:0; display:none; flex-direction:column; gap:12px;
-    align-items:center; justify-content:center; text-align:center;
-    background:rgba(14,16,22,.78); backdrop-filter:blur(4px); z-index:5;
+  .card.miss .front{background:var(--miss)}
+  .card.matched .front{background:var(--matched);animation:pop .4s ease}
+  @keyframes pop{0%{transform:rotateY(180deg) scale(1)}50%{transform:rotateY(180deg) scale(1.12)}100%{transform:rotateY(180deg) scale(1)}}
+  .overlay{
+    position:fixed;inset:0;background:rgba(15,12,41,.8);
+    display:flex;align-items:center;justify-content:center;
+    opacity:0;pointer-events:none;transition:opacity .35s;backdrop-filter:blur(4px);z-index:10;
   }
-  #win h2 { margin:0; font-size:26px; letter-spacing:2px; }
-  #win p { margin:0; font-size:13px; opacity:.75; line-height:1.9; }
-  #win button {
-    margin-top:6px; background:var(--accent); border:0; color:#fff; font:inherit;
-    font-size:13px; border-radius:999px; padding:9px 22px; cursor:pointer; letter-spacing:.5px;
+  .overlay.show{opacity:1;pointer-events:auto}
+  .win-card{
+    background:linear-gradient(160deg,#312e81,#1e1b4b);
+    border:1px solid rgba(255,255,255,.15);
+    padding:36px 40px;border-radius:24px;text-align:center;
+    box-shadow:0 20px 60px rgba(0,0,0,.5);transform:scale(.85);transition:transform .35s;
+    max-width:90vw;
   }
+  .overlay.show .win-card{transform:scale(1)}
+  .win-card h2{font-size:2rem;margin-bottom:8px}
+  .win-card .emoji{font-size:3rem;margin-bottom:6px}
+  .win-card p{color:#cbd5e1;margin-bottom:6px;font-size:1.05rem}
+  .win-card p b{color:var(--accent)}
+  .win-card .newbest{color:#fcd34d;font-weight:700;margin-top:4px}
+  .win-card .btn{margin-top:18px}
+  .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
+  footer{font-size:.8rem;color:var(--foot);margin-top:6px}
 </style>
 </head>
 <body>
-<header>
-  <h1><span class="dot">▸</span> EMOJI MEMORY</h1>
-  <span class="stat">Moves <b id="moves">0</b></span>
-  <span class="stat">Time <b id="time">0:00</b></span>
-  <span class="stat">Best <b id="best">—</b></span>
-  <button class="ctl" id="size">4×4</button>
-  <button class="ctl" id="mute">🔊</button>
-  <button class="ctl" id="new">New</button>
-</header>
-<div id="board"></div>
-<div id="win">
-  <h2 id="wtitle">SOLVED!</h2>
-  <p id="wsub"></p>
-  <button id="again">Play again</button>
-</div>
+  <h1>🧠 Emoji Memory Match</h1>
+
+  <div class="controls">
+    <div class="seg" id="difficulty" role="group" aria-label="Difficulty">
+      <button data-size="4" class="active" aria-pressed="true">4 × 4</button>
+      <button data-size="6" aria-pressed="false">6 × 6</button>
+      <button data-size="8" aria-pressed="false">8 × 8</button>
+    </div>
+    <button class="btn" id="restart">↻ Restart</button>
+    <button class="icon-btn" id="soundToggle" title="Toggle sound" aria-pressed="true">🔊</button>
+    <button class="icon-btn" id="themeToggle" title="Toggle light / dark theme">🌙</button>
+  </div>
+
+  <div class="stats" aria-hidden="true">
+    <div>Moves: <span id="moves">0</span></div>
+    <div>Time: <span id="time">0:00</span></div>
+    <div>Pairs: <span id="pairs">0 / 0</span></div>
+    <div class="best">Best: <span id="best">—</span></div>
+  </div>
+
+  <div class="board-wrap">
+    <div id="board" role="grid" aria-label="Memory board"></div>
+  </div>
+
+  <div class="sr-only" role="status" aria-live="polite" id="live"></div>
+
+  <div class="overlay" id="overlay" role="dialog" aria-modal="true" aria-labelledby="winTitle">
+    <div class="win-card">
+      <div class="emoji">🎉</div>
+      <h2 id="winTitle">You Won!</h2>
+      <p>Solved in <b id="winMoves">0</b> moves</p>
+      <p>Time: <b id="winTime">0:00</b></p>
+      <p class="newbest" id="newBest" hidden>🏆 New best time!</p>
+      <button class="btn" id="playAgain">Play Again</button>
+    </div>
+  </div>
+
+  <footer>Free-Roster · flip two cards to find a match · use mouse or keyboard</footer>
+
 <script>
-(function () {
-  var board = document.getElementById('board');
-  var movesEl = document.getElementById('moves'), timeEl = document.getElementById('time'), bestEl = document.getElementById('best');
-  var win = document.getElementById('win'), wsub = document.getElementById('wsub');
-  var EMOJI = ['🍕','🚀','🎧','🐙','🌈','⚡','🍄','🎲','👾','🦊','🌵','🍩','🎸','🐳','🔮','🌻','🧊','🪐'];
-
-  var GRIDS = [ {n:4, label:'4×4'}, {n:6, label:'6×6'}, {n:2, label:'2×2'} ]; // rotate on click
-  var gridIdx = 0;
-  var cols = 4, pairs = 8;
-  var deck = [], flipped = [], matched = 0, moves = 0, lock = false;
-  var startT = 0, timer = null, running = false;
-
-  var muted = false;
-  try { muted = localStorage.getItem('aa-mem-mute') === '1'; } catch (e) {}
-  var AC = null;
-  function actx() { if (!AC) { try { AC = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){} } return AC; }
-  function beep(f, d, type, vol) {
-    if (muted) return; var a = actx(); if (!a) return;
-    if (a.state === 'suspended') a.resume();
-    var o=a.createOscillator(), g=a.createGain();
-    o.type=type||'sine'; o.frequency.value=f; g.gain.value=vol||0.06;
-    o.connect(g); g.connect(a.destination);
-    var t=a.currentTime; o.start(t);
-    g.gain.exponentialRampToValueAtTime(0.0001, t+(d||0.08));
-    o.stop(t+(d||0.08)+0.02);
-  }
-  function updMute(){ document.getElementById('mute').textContent = muted ? '🔇' : '🔊'; }
-  updMute();
-
-  function bestKey(){ return 'aa-mem-best-' + cols; }
+  const EMOJIS = ['🍕','🎸','🚀','🐱','🌈','⚽','🎮','🍩','🦊','🌵','🐙','🎈',
+                  '🍔','🦄','🎧','🍦','🐳','🔥','🌸','🎲','🧩','🍭','🐢','🎁',
+                  '🐝','🌙','🍉','🎯','🦋','🍄','⭐','🐧','🍎','🐸','🎃','🌻',
+                  '🍇','🐬','🎹','🍓','🦖','🌴','🍒','🐨','🎨','🍿','🦉','🌊'];
+
+  const board   = document.getElementById('board');
+  const movesEl = document.getElementById('moves');
+  const timeEl  = document.getElementById('time');
+  const pairsEl = document.getElementById('pairs');
+  const bestEl  = document.getElementById('best');
+  const liveEl  = document.getElementById('live');
+  const overlay = document.getElementById('overlay');
+
+  let size = 4, first = null, lock = false;
+  let moves = 0, matched = 0, totalPairs = 0;
+  let timer = null, seconds = 0, started = false;
+  let soundOn = localStorage.getItem('freeRoster.sound') !== 'off';
+
+  /* ---- self-contained Web Audio sound engine (no files) ---- */
+  let audioCtx = null;
+  function tone(freq, start, dur, type='sine', gain=0.18){
+    if(!audioCtx) return;
+    const o = audioCtx.createOscillator(), g = audioCtx.createGain();
+    o.type = type; o.frequency.value = freq;
+    o.connect(g); g.connect(audioCtx.destination);
+    const t0 = audioCtx.currentTime + start;
+    g.gain.setValueAtTime(0, t0);
+    g.gain.linearRampToValueAtTime(gain, t0 + 0.01);
+    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
+    o.start(t0); o.stop(t0 + dur);
+  }
+  function ensureAudio(){
+    if(!soundOn) return;
+    if(!audioCtx){
+      const AC = window.AudioContext || window.webkitAudioContext;
+      if(AC) audioCtx = new AC();
+    }
+    if(audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
+  }
+  const sfx = {
+    flip(){ if(soundOn){ ensureAudio(); tone(520, 0, 0.09, 'triangle', 0.14); } },
+    match(){ if(soundOn){ ensureAudio(); tone(660,0,0.12,'sine',0.18); tone(880,0.08,0.16,'sine',0.16); } },
+    miss(){ if(soundOn){ ensureAudio(); tone(200,0,0.18,'sawtooth',0.12); } },
+    win(){ if(soundOn){ ensureAudio(); [523,659,784,1047].forEach((f,i)=>tone(f, i*0.13, 0.28, 'sine', 0.18)); } }
+  };
+
+  const fmt = s => Math.floor(s/60) + ':' + String(s%60).padStart(2,'0');
+  const bestKey = () => 'freeRoster.best.' + size;
+  const say = msg => { liveEl.textContent = msg; };
+
+  function shuffle(a){
+    for(let i=a.length-1;i>0;i--){
+      const j = Math.floor(Math.random()*(i+1));
+      [a[i],a[j]]=[a[j],a[i]];
+    }
+    return a;
+  }
+
   function loadBest(){
-    var v = null; try { v = localStorage.getItem(bestKey()); } catch(e){}
-    bestEl.textContent = v ? v + ' mv' : '—';
-  }
-
-  function shuffle(a){ for (var i=a.length-1;i>0;i--){ var j=(Math.random()*(i+1))|0; var t=a[i]; a[i]=a[j]; a[j]=t; } return a; }
-
-  function build() {
-    cols = GRIDS[gridIdx].n;
-    document.getElementById('size').textContent = GRIDS[gridIdx].label;
-    pairs = (cols * cols) / 2;
-    var chosen = shuffle(EMOJI.slice()).slice(0, pairs);
-    deck = shuffle(chosen.concat(chosen));
-    matched = 0; moves = 0; flipped = []; lock = false; running = false;
-    movesEl.textContent = '0'; timeEl.textContent = '0:00';
-    if (timer) { clearInterval(timer); timer = null; }
-    win.style.display = 'none';
+    const raw = localStorage.getItem(bestKey());
+    if(!raw){ bestEl.textContent = '—'; return null; }
+    try{
+      const b = JSON.parse(raw);
+      bestEl.textContent = fmt(b.seconds) + ' · ' + b.moves + ' mv';
+      return b;
+    }catch{ bestEl.textContent = '—'; return null; }
+  }
+
+  function startTimer(){
+    if(started) return;
+    started = true;
+    timer = setInterval(()=>{ seconds++; timeEl.textContent = fmt(seconds); }, 1000);
+  }
+  function stopTimer(){ clearInterval(timer); timer = null; }
+
+  function newGame(){
+    stopTimer();
+    started = false; seconds = 0; moves = 0; matched = 0; first = null; lock = false;
+    totalPairs = (size*size)/2;
+    movesEl.textContent = '0';
+    timeEl.textContent = '0:00';
+    pairsEl.textContent = '0 / ' + totalPairs;
+    overlay.classList.remove('show');
+    board.classList.remove('locked');
     loadBest();
 
-    board.style.gridTemplateColumns = 'repeat(' + cols + ',1fr)';
+    const chosen = shuffle([...EMOJIS]).slice(0, totalPairs);
+    const deck = shuffle([...chosen, ...chosen]);
+
+    board.style.gridTemplateColumns = `repeat(${size}, 1fr)`;
     board.innerHTML = '';
-    deck.forEach(function (em, i) {
-      var c = document.createElement('div'); c.className = 'card'; c.dataset.i = i;
-      c.innerHTML = '<div class="inner">' +
-        '<div class="face back">?</div>' +
-        '<div class="face front">' + em + '</div></div>';
-      c.addEventListener('click', function () { flip(c, i); });
-      board.appendChild(c);
+
+    deck.forEach((emoji)=>{
+      const card = document.createElement('button');
+      card.className = 'card';
+      card.type = 'button';
+      card.dataset.emoji = emoji;
+      card.setAttribute('aria-label', 'Hidden card');
+      card.innerHTML = `<span class="face back" aria-hidden="true"></span><span class="face front" aria-hidden="true">${emoji}</span>`;
+      card.addEventListener('click', ()=>flip(card));
+      board.appendChild(card);
     });
+    say(size + ' by ' + size + ' game started. ' + totalPairs + ' pairs.');
   }
 
-  function startTimer() {
-    running = true; startT = Date.now();
-    timer = setInterval(function () {
-      var s = Math.floor((Date.now() - startT) / 1000);
-      timeEl.textContent = Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
-    }, 500);
-  }
-
-  function flip(c, i) {
-    if (lock || c.classList.contains('up') || c.classList.contains('done')) return;
-    if (!running) startTimer();
-    c.classList.add('up'); beep(520 + Math.random()*80, 0.05, 'triangle');
-    flipped.push({ c: c, i: i });
-    if (flipped.length === 2) {
-      moves++; movesEl.textContent = moves; lock = true;
-      var a = flipped[0], b = flipped[1];
-      if (deck[a.i] === deck[b.i]) {
-        setTimeout(function () {
-          a.c.classList.add('done'); b.c.classList.add('done');
-          a.c.classList.remove('up'); b.c.classList.remove('up');
-          flipped = []; lock = false; matched++;
-          beep(760, 0.09, 'sine'); setTimeout(function(){ beep(1020, 0.1, 'sine'); }, 70);
-          if (matched === pairs) finish();
-        }, 260);
-      } else {
-        setTimeout(function () {
-          a.c.classList.remove('up'); b.c.classList.remove('up');
-          flipped = []; lock = false; beep(200, 0.12, 'sawtooth', 0.05);
-        }, 640);
-      }
+  function flip(card){
+    if(lock) return;
+    if(card.classList.contains('flipped') || card.classList.contains('matched')) return;
+
+    startTimer();
+    sfx.flip();
+    card.classList.add('flipped');
+    card.setAttribute('aria-label', card.dataset.emoji);
+
+    if(!first){ first = card; return; }
+    if(first === card) return;
+
+    moves++;
+    movesEl.textContent = moves;
+
+    if(first.dataset.emoji === card.dataset.emoji){
+      const a = first; first = null;
+      a.classList.add('matched');
+      card.classList.add('matched');
+      a.setAttribute('aria-label', a.dataset.emoji + ', matched');
+      card.setAttribute('aria-label', card.dataset.emoji + ', matched');
+      matched++;
+      pairsEl.textContent = matched + ' / ' + totalPairs;
+      say('Match! ' + matched + ' of ' + totalPairs + ' pairs.');
+      if(matched === totalPairs){ win(); } else { sfx.match(); }
+    } else {
+      lock = true;
+      board.classList.add('locked');
+      const a = first, b = card;
+      first = null;
+      a.classList.add('miss'); b.classList.add('miss');
+      sfx.miss();
+      say('No match.');
+      setTimeout(()=>{
+        [a,b].forEach(c=>{
+          c.classList.remove('flipped','miss');
+          c.setAttribute('aria-label','Hidden card');
+        });
+        lock = false;
+        board.classList.remove('locked');
+      }, 800);
     }
   }
 
-  function finish() {
-    running = false; if (timer) { clearInterval(timer); timer = null; }
-    var secs = Math.floor((Date.now() - startT) / 1000);
-    var tstr = Math.floor(secs / 60) + ':' + ('0' + (secs % 60)).slice(-2);
-    var prev = null, rec = false;
-    try { prev = localStorage.getItem(bestKey()); } catch(e){}
-    if (!prev || moves < parseInt(prev, 10)) {
-      try { localStorage.setItem(bestKey(), String(moves)); } catch(e){}
-      rec = true;
+  function win(){
+    stopTimer();
+    const prev = loadBest();
+    const isBest = !prev || seconds < prev.seconds ||
+                   (seconds === prev.seconds && moves < prev.moves);
+    if(isBest){
+      localStorage.setItem(bestKey(), JSON.stringify({seconds, moves}));
+      loadBest();
     }
-    loadBest();
-    wsub.innerHTML = moves + ' moves &nbsp;·&nbsp; ' + tstr + (rec ? '<br><span style="color:#ffd23e">★ new best</span>' : '');
-    win.style.display = 'flex';
-    beep(660,0.16,'triangle',0.06); setTimeout(function(){beep(880,0.2,'triangle',0.06);},130);
+    document.getElementById('winMoves').textContent = moves;
+    document.getElementById('winTime').textContent = fmt(seconds);
+    document.getElementById('newBest').hidden = !isBest;
+    say('You won in ' + moves + ' moves and ' + fmt(seconds) + '.');
+    sfx.win();
+    setTimeout(()=>{ overlay.classList.add('show'); document.getElementById('playAgain').focus(); }, 500);
   }
 
-  document.getElementById('new').onclick = build;
-  document.getElementById('again').onclick = build;
-  document.getElementById('size').onclick = function () { gridIdx = (gridIdx + 1) % GRIDS.length; build(); };
-  document.getElementById('mute').onclick = function () {
-    muted = !muted; updMute();
-    try { localStorage.setItem('aa-mem-mute', muted ? '1' : '0'); } catch(e){}
-  };
-  window.addEventListener('keydown', function (e) {
-    if (e.key === 'n' || e.key === 'N') build();
-    if (e.key === 'm' || e.key === 'M') { muted = !muted; updMute(); try { localStorage.setItem('aa-mem-mute', muted?'1':'0'); } catch(err){} }
+  document.getElementById('difficulty').addEventListener('click', (e)=>{
+    const b = e.target.closest('button');
+    if(!b) return;
+    document.querySelectorAll('#difficulty button').forEach(x=>{
+      const on = x === b;
+      x.classList.toggle('active', on);
+      x.setAttribute('aria-pressed', on ? 'true' : 'false');
+    });
+    size = parseInt(b.dataset.size, 10);
+    newGame();
+  });
+
+  document.getElementById('restart').addEventListener('click', newGame);
+  document.getElementById('playAgain').addEventListener('click', newGame);
+  // Close win overlay with Escape → restart
+  document.addEventListener('keydown', (e)=>{
+    if(e.key === 'Escape' && overlay.classList.contains('show')) newGame();
+  });
+
+  /* ---- sound toggle ---- */
+  const soundBtn = document.getElementById('soundToggle');
+  function renderSound(){
+    soundBtn.textContent = soundOn ? '🔊' : '🔇';
+    soundBtn.classList.toggle('off', !soundOn);
+    soundBtn.setAttribute('aria-pressed', soundOn ? 'true' : 'false');
+  }
+  soundBtn.addEventListener('click', ()=>{
+    soundOn = !soundOn;
+    localStorage.setItem('freeRoster.sound', soundOn ? 'on' : 'off');
+    renderSound();
+    if(soundOn){ ensureAudio(); sfx.flip(); }
+  });
+  renderSound();
+
+  /* ---- theme toggle ---- */
+  const themeBtn = document.getElementById('themeToggle');
+  function applyTheme(theme){
+    const light = theme === 'light';
+    document.body.classList.toggle('light', light);
+    themeBtn.textContent = light ? '☀️' : '🌙';
+    themeBtn.setAttribute('aria-label', light ? 'Switch to dark theme' : 'Switch to light theme');
+  }
+  applyTheme(localStorage.getItem('freeRoster.theme') || 'dark');
+  themeBtn.addEventListener('click', ()=>{
+    const next = document.body.classList.contains('light') ? 'dark' : 'light';
+    localStorage.setItem('freeRoster.theme', next);
+    applyTheme(next);
   });
 
-  build();
-})();
+  newGame();
 </script>
 </body>
 </html>

← 423a750 auto-save: 2026-07-24T20:29:36 (10 files) — games/chomp/ gam  ·  back to Games Agentabrams  ·  Arcade: add Starling build + per-game icon + open-in-own-win f7583fe →