[object Object]

← back to Games Agentabrams

Add 4 arcade games: Pong 2P, Emoji Memory, Spirograph, Constellation Clock

aeeb0bff3cf4e25ddb0b4d3a2d8774e3e5b421eb · 2026-07-24 18:45:29 -0700 · Steve Abrams

- pong/emoji-memory: fresh house-standard builds (Canvas 2D / DOM grid,
  procedural Web Audio, keyboard+touch, pause/mute, persistent best)
- spirograph: reused polished Spirograph Studio build from graphics-agentabrams
- constellation-clock: reused star-clock.html from model-arena
- registered all 4 in games.json (9 -> 13 games)
- tests/new-games-2.mjs: headless Chromium smoke test, 8/8 pass

Files touched

Diff

commit aeeb0bff3cf4e25ddb0b4d3a2d8774e3e5b421eb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 24 18:45:29 2026 -0700

    Add 4 arcade games: Pong 2P, Emoji Memory, Spirograph, Constellation Clock
    
    - pong/emoji-memory: fresh house-standard builds (Canvas 2D / DOM grid,
      procedural Web Audio, keyboard+touch, pause/mute, persistent best)
    - spirograph: reused polished Spirograph Studio build from graphics-agentabrams
    - constellation-clock: reused star-clock.html from model-arena
    - registered all 4 in games.json (9 -> 13 games)
    - tests/new-games-2.mjs: headless Chromium smoke test, 8/8 pass
---
 games.json                           |  32 +++
 games/constellation-clock/index.html | 364 ++++++++++++++++++++++++++
 games/emoji-memory/index.html        | 217 +++++++++++++++
 games/pong/index.html                | 293 +++++++++++++++++++++
 games/spirograph/index.html          | 495 +++++++++++++++++++++++++++++++++++
 tests/new-games-2.mjs                |  84 ++++++
 6 files changed, 1485 insertions(+)

diff --git a/games.json b/games.json
index a3b362c..427b947 100644
--- a/games.json
+++ b/games.json
@@ -71,6 +71,38 @@
       "desc": "Sliding-tile puzzle — merge equal tiles to reach 2048 and beyond. Arrows/WASD or swipe to slide the whole board, merges pop and chime, persistent best, keep-playing after the win. Pure DOM grid, procedural Web Audio, zero dependencies.",
       "path": "games/2048/",
       "added": "2026-07-24"
+    },
+    {
+      "id": "pong",
+      "file": "AbramsPong.aa",
+      "title": "Abrams Pong 2P",
+      "desc": "Two-player table tennis — Left: W/S, Right: ↑/↓, first to 11. Contact-angle english off the paddle, speed-up rallies, procedural blips. Toggle 'vs CPU' to play solo. Touch: drag your own half of the screen. P pause, M mute. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "path": "games/pong/",
+      "added": "2026-07-24"
+    },
+    {
+      "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.",
+      "path": "games/emoji-memory/",
+      "added": "2026-07-24"
+    },
+    {
+      "id": "spirograph",
+      "file": "Spirograph.aa",
+      "title": "Spirograph Studio",
+      "desc": "Hypotrochoid/epitrochoid pattern toy — tune the fixed & rolling gear radii and pen offset to draw endless looping curves, layer colors, and export the art. A generative-art playground, not a scored game. Pure Canvas 2D, zero dependencies.",
+      "path": "games/spirograph/",
+      "added": "2026-07-24"
+    },
+    {
+      "id": "constellation-clock",
+      "file": "ConstellationClock.aa",
+      "title": "Constellation Clock",
+      "desc": "A live clock rendered as a drifting star-field constellation — the current time drawn in connected stars against a twinkling night sky. An ambient visualizer, not a scored game. Pure Canvas 2D, zero dependencies.",
+      "path": "games/constellation-clock/",
+      "added": "2026-07-24"
     }
   ]
 }
diff --git a/games/constellation-clock/index.html b/games/constellation-clock/index.html
new file mode 100644
index 0000000..8ffc77f
--- /dev/null
+++ b/games/constellation-clock/index.html
@@ -0,0 +1,364 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Constellation Clock</title>
+<style>
+  html, body {
+    margin: 0;
+    padding: 0;
+    height: 100%;
+    overflow: hidden;
+    background: #05060f;
+  }
+  #sky {
+    display: block;
+    width: 100vw;
+    height: 100vh;
+  }
+  #digital {
+    position: fixed;
+    right: 22px;
+    bottom: 18px;
+    font-family: "Courier New", ui-monospace, monospace;
+    color: rgba(200, 220, 255, 0.85);
+    letter-spacing: 3px;
+    font-size: 26px;
+    text-shadow: 0 0 8px rgba(120,170,255,0.6), 0 0 18px rgba(80,120,255,0.35);
+    user-select: none;
+    z-index: 5;
+  }
+  #digital .date {
+    display: block;
+    font-size: 12px;
+    letter-spacing: 4px;
+    text-align: right;
+    margin-top: 4px;
+    color: rgba(150, 180, 230, 0.6);
+  }
+  #label {
+    position: fixed;
+    left: 22px;
+    top: 16px;
+    font-family: "Courier New", ui-monospace, monospace;
+    color: rgba(160, 190, 240, 0.5);
+    font-size: 12px;
+    letter-spacing: 4px;
+    text-transform: uppercase;
+    user-select: none;
+    z-index: 5;
+  }
+</style>
+</head>
+<body>
+<canvas id="sky"></canvas>
+<div id="label">Celestial Chronometer</div>
+<div id="digital"><span id="clock">00:00:00</span><span class="date" id="date"></span></div>
+
+<script>
+(function () {
+  "use strict";
+
+  const canvas = document.getElementById("sky");
+  const ctx = canvas.getContext("2d");
+  const clockEl = document.getElementById("clock");
+  const dateEl = document.getElementById("date");
+
+  let W = 0, H = 0, CX = 0, CY = 0, DPR = 1;
+
+  function resize() {
+    DPR = Math.min(window.devicePixelRatio || 1, 2);
+    W = window.innerWidth;
+    H = window.innerHeight;
+    canvas.width = Math.floor(W * DPR);
+    canvas.height = Math.floor(H * DPR);
+    canvas.style.width = W + "px";
+    canvas.style.height = H + "px";
+    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+    CX = W / 2;
+    CY = H / 2;
+    buildStars();
+  }
+
+  // ---------- Background starfield ----------
+  let bgStars = [];
+  function buildStars() {
+    bgStars = [];
+    const count = Math.round((W * H) / 5200);
+    for (let i = 0; i < count; i++) {
+      bgStars.push({
+        x: Math.random() * W,
+        y: Math.random() * H,
+        r: Math.random() * 1.3 + 0.2,
+        base: Math.random() * 0.5 + 0.2,
+        amp: Math.random() * 0.5 + 0.1,
+        speed: Math.random() * 2 + 0.5,
+        phase: Math.random() * Math.PI * 2,
+        hue: 210 + Math.random() * 40
+      });
+    }
+  }
+
+  function drawBackground(t) {
+    // subtle vertical nebula gradient
+    const g = ctx.createRadialGradient(CX, CY, 0, CX, CY, Math.max(W, H) * 0.75);
+    g.addColorStop(0, "#0a1230");
+    g.addColorStop(0.5, "#070a1c");
+    g.addColorStop(1, "#03040c");
+    ctx.fillStyle = g;
+    ctx.fillRect(0, 0, W, H);
+
+    for (let i = 0; i < bgStars.length; i++) {
+      const s = bgStars[i];
+      const tw = s.base + s.amp * Math.sin(t * s.speed + s.phase);
+      const a = Math.max(0, Math.min(1, tw));
+      ctx.beginPath();
+      ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
+      ctx.fillStyle = "hsla(" + s.hue + ",70%,85%," + a + ")";
+      ctx.fill();
+    }
+  }
+
+  // ---------- Constellation hand definitions ----------
+  // Each hand is a chain of stars at given radius fractions along the hand.
+  // Points: {r: fraction of handRadius, o: sideways offset fraction, mag: star size}
+  const secondShape = [
+    { r: 0.00, o: 0.00, mag: 2.0 },
+    { r: 0.30, o: 0.05, mag: 1.6 },
+    { r: 0.55, o: -0.05, mag: 1.8 },
+    { r: 0.78, o: 0.04, mag: 1.5 },
+    { r: 0.95, o: 0.00, mag: 2.6 }
+  ];
+  const minuteShape = [
+    { r: 0.00, o: 0.00, mag: 2.4 },
+    { r: 0.22, o: 0.07, mag: 2.0 },
+    { r: 0.42, o: -0.06, mag: 2.2 },
+    { r: 0.60, o: 0.08, mag: 2.0 },
+    { r: 0.78, o: -0.04, mag: 2.4 },
+    { r: 0.92, o: 0.05, mag: 3.2 }
+  ];
+  const hourShape = [
+    { r: 0.00, o: 0.00, mag: 2.8 },
+    { r: 0.25, o: 0.10, mag: 2.4 },
+    { r: 0.30, o: -0.10, mag: 2.4 },
+    { r: 0.52, o: 0.06, mag: 2.6 },
+    { r: 0.70, o: -0.08, mag: 2.6 },
+    { r: 0.88, o: 0.00, mag: 3.6 }
+  ];
+
+  function drawConstellation(shape, angle, handRadius, color, glow, t, twinkle) {
+    // angle: 0 = 12 o'clock (up). rotate coordinate.
+    const ca = Math.cos(angle - Math.PI / 2);
+    const sa = Math.sin(angle - Math.PI / 2);
+    const pts = [];
+    for (let i = 0; i < shape.length; i++) {
+      const p = shape[i];
+      const along = p.r * handRadius;
+      const side = p.o * handRadius;
+      // local coords: along axis + perpendicular offset
+      const lx = along;
+      const ly = side;
+      const x = CX + lx * ca - ly * sa;
+      const y = CY + lx * sa + ly * ca;
+      pts.push({ x: x, y: y, mag: p.mag });
+    }
+
+    // connecting lines
+    ctx.save();
+    ctx.lineWidth = 1.2;
+    ctx.strokeStyle = color.line;
+    ctx.shadowColor = color.glow;
+    ctx.shadowBlur = glow;
+    ctx.beginPath();
+    ctx.moveTo(pts[0].x, pts[0].y);
+    for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
+    ctx.stroke();
+    ctx.restore();
+
+    // stars
+    for (let i = 0; i < pts.length; i++) {
+      const p = pts[i];
+      const tw = twinkle ? (0.75 + 0.25 * Math.sin(t * 3 + i * 1.7)) : 1;
+      const rad = p.mag * tw;
+      ctx.save();
+      ctx.shadowColor = color.glow;
+      ctx.shadowBlur = glow * 1.4;
+      const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, rad * 3);
+      grd.addColorStop(0, color.core);
+      grd.addColorStop(0.4, color.star);
+      grd.addColorStop(1, "rgba(0,0,0,0)");
+      ctx.fillStyle = grd;
+      ctx.beginPath();
+      ctx.arc(p.x, p.y, rad * 3, 0, Math.PI * 2);
+      ctx.fill();
+      // bright core
+      ctx.shadowBlur = 0;
+      ctx.fillStyle = color.core;
+      ctx.beginPath();
+      ctx.arc(p.x, p.y, rad * 0.8, 0, Math.PI * 2);
+      ctx.fill();
+      ctx.restore();
+    }
+    return pts;
+  }
+
+  // ---------- Hour ticks as faint stars around the dial ----------
+  function drawDialStars(t) {
+    const R = Math.min(W, H) * 0.44;
+    for (let i = 0; i < 12; i++) {
+      const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
+      const x = CX + Math.cos(a) * R;
+      const y = CY + Math.sin(a) * R;
+      const tw = 0.5 + 0.5 * Math.sin(t * 1.5 + i);
+      const big = (i % 3 === 0);
+      ctx.beginPath();
+      ctx.arc(x, y, big ? 2.4 : 1.4, 0, Math.PI * 2);
+      ctx.fillStyle = "rgba(180,205,255," + (0.35 + 0.35 * tw) + ")";
+      ctx.shadowColor = "rgba(140,180,255,0.8)";
+      ctx.shadowBlur = big ? 10 : 5;
+      ctx.fill();
+      ctx.shadowBlur = 0;
+    }
+    // center star (the pole)
+    const cg = ctx.createRadialGradient(CX, CY, 0, CX, CY, 14);
+    cg.addColorStop(0, "rgba(255,255,255,0.95)");
+    cg.addColorStop(0.4, "rgba(180,210,255,0.6)");
+    cg.addColorStop(1, "rgba(0,0,0,0)");
+    ctx.fillStyle = cg;
+    ctx.beginPath();
+    ctx.arc(CX, CY, 14, 0, Math.PI * 2);
+    ctx.fill();
+  }
+
+  // ---------- Shooting stars ----------
+  let shootings = [];
+  let nextShoot = 2 + Math.random() * 4;
+  function spawnShoot() {
+    const edge = Math.random();
+    const startX = Math.random() * W;
+    const startY = Math.random() * H * 0.5;
+    const angle = Math.PI * 0.15 + Math.random() * Math.PI * 0.2; // downward-right-ish
+    const speed = 500 + Math.random() * 400;
+    shootings.push({
+      x: startX,
+      y: startY,
+      vx: Math.cos(angle) * speed * (Math.random() < 0.5 ? 1 : -1),
+      vy: Math.sin(angle) * speed,
+      life: 0,
+      maxLife: 0.9 + Math.random() * 0.5,
+      len: 80 + Math.random() * 120
+    });
+  }
+  function updateShoot(dt) {
+    nextShoot -= dt;
+    if (nextShoot <= 0) {
+      spawnShoot();
+      nextShoot = 4 + Math.random() * 7;
+    }
+    for (let i = shootings.length - 1; i >= 0; i--) {
+      const s = shootings[i];
+      s.life += dt;
+      s.x += s.vx * dt;
+      s.y += s.vy * dt;
+      if (s.life > s.maxLife) shootings.splice(i, 1);
+    }
+  }
+  function drawShoot() {
+    for (let i = 0; i < shootings.length; i++) {
+      const s = shootings[i];
+      const prog = s.life / s.maxLife;
+      const alpha = Math.sin(prog * Math.PI); // fade in/out
+      const mag = Math.hypot(s.vx, s.vy) || 1;
+      const ux = s.vx / mag, uy = s.vy / mag;
+      const tx = s.x - ux * s.len;
+      const ty = s.y - uy * s.len;
+      const grd = ctx.createLinearGradient(s.x, s.y, tx, ty);
+      grd.addColorStop(0, "rgba(255,255,255," + (0.9 * alpha) + ")");
+      grd.addColorStop(1, "rgba(255,255,255,0)");
+      ctx.strokeStyle = grd;
+      ctx.lineWidth = 2;
+      ctx.shadowColor = "rgba(200,220,255,0.9)";
+      ctx.shadowBlur = 8;
+      ctx.beginPath();
+      ctx.moveTo(s.x, s.y);
+      ctx.lineTo(tx, ty);
+      ctx.stroke();
+      ctx.shadowBlur = 0;
+      // head
+      ctx.beginPath();
+      ctx.arc(s.x, s.y, 2, 0, Math.PI * 2);
+      ctx.fillStyle = "rgba(255,255,255," + alpha + ")";
+      ctx.fill();
+    }
+  }
+
+  // ---------- Main loop ----------
+  const colHour = {
+    line: "rgba(255,200,120,0.45)",
+    glow: "rgba(255,180,90,0.9)",
+    star: "rgba(255,190,110,0.9)",
+    core: "rgba(255,240,210,1)"
+  };
+  const colMinute = {
+    line: "rgba(140,200,255,0.5)",
+    glow: "rgba(120,180,255,0.9)",
+    star: "rgba(150,200,255,0.9)",
+    core: "rgba(235,245,255,1)"
+  };
+  const colSecond = {
+    line: "rgba(180,255,220,0.45)",
+    glow: "rgba(140,255,200,0.85)",
+    star: "rgba(170,255,210,0.9)",
+    core: "rgba(240,255,250,1)"
+  };
+
+  let lastTime = null;
+
+  function frame(now) {
+    if (lastTime === null) lastTime = now;
+    let dt = (now - lastTime) / 1000;
+    if (dt > 0.1) dt = 0.1;
+    lastTime = now;
+    const t = now / 1000;
+
+    const d = new Date();
+    const ms = d.getMilliseconds();
+    const sec = d.getSeconds() + ms / 1000;
+    const min = d.getMinutes() + sec / 60;
+    const hr = (d.getHours() % 12) + min / 60;
+
+    const secAngle = (sec / 60) * Math.PI * 2;
+    const minAngle = (min / 60) * Math.PI * 2;
+    const hrAngle = (hr / 12) * Math.PI * 2;
+
+    const base = Math.min(W, H);
+    const hourR = base * 0.20;
+    const minR = base * 0.33;
+    const secR = base * 0.40;
+
+    drawBackground(t);
+    drawDialStars(t);
+
+    updateShoot(dt);
+    drawShoot();
+
+    drawConstellation(hourShape, hrAngle, hourR, colHour, 16, t, true);
+    drawConstellation(minuteShape, minAngle, minR, colMinute, 14, t, true);
+    drawConstellation(secondShape, secAngle, secR, colSecond, 12, t, true);
+
+    // digital readout
+    const pad = (n) => (n < 10 ? "0" + n : "" + n);
+    clockEl.textContent = pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
+    dateEl.textContent = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
+
+    requestAnimationFrame(frame);
+  }
+
+  window.addEventListener("resize", resize);
+  resize();
+  requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>
diff --git a/games/emoji-memory/index.html b/games/emoji-memory/index.html
new file mode 100644
index 0000000..4f5fa5d
--- /dev/null
+++ b/games/emoji-memory/index.html
@@ -0,0 +1,217 @@
+<!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>
+<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;
+    transform:rotateY(180deg);
+  }
+  .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;
+  }
+  #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;
+  }
+</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>
+<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; }
+  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';
+    loadBest();
+
+    board.style.gridTemplateColumns = 'repeat(' + cols + ',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);
+    });
+  }
+
+  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 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;
+    }
+    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('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){} }
+  });
+
+  build();
+})();
+</script>
+</body>
+</html>
diff --git a/games/pong/index.html b/games/pong/index.html
new file mode 100644
index 0000000..3ce1461
--- /dev/null
+++ b/games/pong/index.html
@@ -0,0 +1,293 @@
+<!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 Pong 2P</title>
+<style>
+  :root { --accent:#7c5cff; --accent2:#ff4d9d; --ink:#e8e9ef; }
+  * { box-sizing:border-box; }
+  html,body { height:100%; margin:0; }
+  body {
+    background:#0e1016; color:var(--ink);
+    font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
+    overflow:hidden; display:flex; align-items:center; justify-content:center;
+    user-select:none; -webkit-user-select:none; touch-action:none;
+  }
+  #wrap { position:relative; width:100%; height:100%; }
+  canvas { display:block; width:100%; height:100%; }
+  #hud {
+    position:absolute; top:10px; left:50%; transform:translateX(-50%);
+    display:flex; gap:8px; align-items:center; font-size:11px; letter-spacing:.5px;
+    background:rgba(20,23,31,.72); border:1px solid #2b3140; border-radius:999px;
+    padding:6px 12px; backdrop-filter:blur(6px); z-index:3;
+  }
+  #hud button {
+    background:none; border:1px solid #3d4763; color:var(--ink); font:inherit;
+    font-size:11px; border-radius:999px; padding:3px 9px; cursor:pointer;
+  }
+  #hud button:hover { background:#222839; }
+  #hud button.on { background:var(--accent); border-color:var(--accent); }
+  #msg {
+    position:absolute; inset:0; display:flex; flex-direction:column; gap:12px;
+    align-items:center; justify-content:center; text-align:center; z-index:2;
+    background:rgba(14,16,22,.55); pointer-events:none;
+  }
+  #msg h2 { margin:0; font-size:26px; letter-spacing:2px; }
+  #msg p { margin:0; font-size:12px; opacity:.6; line-height:1.9; max-width:340px; }
+  #msg .go { color:var(--accent); font-size:12px; opacity:1; margin-top:6px; }
+  .hidden { display:none !important; }
+</style>
+</head>
+<body>
+<div id="wrap">
+  <canvas id="c"></canvas>
+  <div id="hud">
+    <span id="mode-lbl">2 PLAYER</span>
+    <button id="mode">vs CPU</button>
+    <button id="pause">⏸</button>
+    <button id="mute">🔊</button>
+  </div>
+  <div id="msg">
+    <h2 id="title">ABRAMS PONG</h2>
+    <p id="sub">Left: <b>W / S</b> &nbsp;·&nbsp; Right: <b>↑ / ↓</b><br>Touch: drag your own half of the screen<br>First to 11 wins</p>
+    <p class="go" id="go">Press SPACE or tap to serve</p>
+  </div>
+</div>
+<script>
+(function () {
+  var cv = document.getElementById('c'), ctx = cv.getContext('2d');
+  var msg = document.getElementById('msg'), titleEl = document.getElementById('title');
+  var subEl = document.getElementById('sub'), goEl = document.getElementById('go');
+  var modeBtn = document.getElementById('mode'), modeLbl = document.getElementById('mode-lbl');
+  var pauseBtn = document.getElementById('pause'), muteBtn = document.getElementById('mute');
+
+  var W = 0, H = 0, dpr = 1;
+  function resize() {
+    dpr = Math.min(window.devicePixelRatio || 1, 2);
+    W = cv.clientWidth; H = cv.clientHeight;
+    cv.width = Math.floor(W * dpr); cv.height = Math.floor(H * dpr);
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+  }
+  window.addEventListener('resize', resize); resize();
+
+  // ---- audio (procedural, zero-dep) ----
+  var AC = null, muted = false;
+  try { muted = localStorage.getItem('aa-pong-mute') === '1'; } catch (e) {}
+  function actx() { if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} } return AC; }
+  function beep(freq, dur, 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 || 'square'; o.frequency.value = freq;
+    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 + (dur || 0.06));
+    o.stop(t + (dur || 0.06) + 0.02);
+  }
+  function updMute() { muteBtn.textContent = muted ? '🔇' : '🔊'; }
+  updMute();
+
+  // ---- state ----
+  var WIN = 11;
+  var vsCPU = false;
+  var padH = 92, padW = 12, ballR = 8;
+  var L = { y: 0, score: 0 }, R = { y: 0, score: 0 };
+  var ball = { x: 0, y: 0, vx: 0, vy: 0, speed: 0 };
+  var state = 'idle';   // idle | serve | play | point | over
+  var paused = false, server = 1, winner = 0, flash = 0;
+  var keys = {};
+
+  function centerPads() { L.y = H / 2 - padH / 2; R.y = H / 2 - padH / 2; }
+
+  function resetBall(dir) {
+    ball.x = W / 2; ball.y = H / 2;
+    ball.speed = Math.max(4.2, W * 0.006);
+    var ang = (Math.random() * 0.7 - 0.35);
+    ball.vx = Math.cos(ang) * ball.speed * dir;
+    ball.vy = Math.sin(ang) * ball.speed;
+  }
+
+  function newGame() {
+    L.score = 0; R.score = 0; winner = 0;
+    centerPads(); server = Math.random() < 0.5 ? 1 : -1;
+    state = 'serve'; showServe();
+  }
+  function showServe() {
+    titleEl.textContent = (L.score + R.score === 0) ? 'ABRAMS PONG' : (L.score + ' — ' + R.score);
+    subEl.style.display = 'none';
+    goEl.textContent = 'Press SPACE or tap to serve';
+    msg.classList.remove('hidden');
+  }
+  function hideMsg() { msg.classList.add('hidden'); }
+
+  function serve() {
+    if (state !== 'serve') return;
+    resetBall(server); state = 'play'; hideMsg();
+    if (actx() && actx().state === 'suspended') actx().resume();
+    beep(440, 0.05);
+  }
+
+  function point(side) {
+    if (side === 1) L.score++; else R.score++;
+    beep(180, 0.14, 'sawtooth', 0.05); flash = 8;
+    if (L.score >= WIN || R.score >= WIN) {
+      winner = L.score > R.score ? 1 : -1; state = 'over';
+      titleEl.textContent = (winner === 1 ? 'LEFT' : (vsCPU ? 'CPU' : 'RIGHT')) + ' WINS';
+      subEl.style.display = 'block';
+      subEl.innerHTML = 'Final &nbsp;' + L.score + ' — ' + R.score;
+      goEl.textContent = 'Press SPACE or tap to play again';
+      msg.classList.remove('hidden');
+      beep(660, 0.18, 'triangle', 0.06);
+      setTimeout(function(){ beep(880, 0.22, 'triangle', 0.06); }, 120);
+    } else {
+      server = -side;      // loser serves
+      state = 'serve'; showServe();
+    }
+  }
+
+  // ---- input ----
+  window.addEventListener('keydown', function (e) {
+    var k = e.key;
+    if (k === 'w' || k === 'W' || k === 's' || k === 'S' ||
+        k === 'ArrowUp' || k === 'ArrowDown' || k === ' ') e.preventDefault();
+    keys[k.toLowerCase()] = true;
+    if (k === ' ') { if (state === 'serve') serve(); else if (state === 'over') newGame(); }
+    if (k === 'p' || k === 'P') togglePause();
+    if (k === 'm' || k === 'M') toggleMute();
+  });
+  window.addEventListener('keyup', function (e) { keys[e.key.toLowerCase()] = false; });
+
+  function togglePause() {
+    if (state !== 'play') return;
+    paused = !paused; pauseBtn.classList.toggle('on', paused);
+    pauseBtn.textContent = paused ? '▶' : '⏸';
+    if (paused) { titleEl.textContent = 'PAUSED'; subEl.style.display='none'; goEl.textContent='Press P to resume'; msg.classList.remove('hidden'); }
+    else hideMsg();
+  }
+  function toggleMute() {
+    muted = !muted; updMute();
+    try { localStorage.setItem('aa-pong-mute', muted ? '1' : '0'); } catch (e) {}
+  }
+  pauseBtn.onclick = togglePause;
+  muteBtn.onclick = toggleMute;
+  modeBtn.onclick = function () {
+    vsCPU = !vsCPU;
+    modeLbl.textContent = vsCPU ? '1 P vs CPU' : '2 PLAYER';
+    modeBtn.textContent = vsCPU ? '2 Player' : 'vs CPU';
+    modeBtn.classList.toggle('on', vsCPU);
+    newGame();
+  };
+
+  // ---- touch: left half controls left paddle, right half controls right ----
+  var touches = {};
+  function handleTouch(e) {
+    e.preventDefault();
+    for (var i = 0; i < e.touches.length; i++) {
+      var t = e.touches[i];
+      var rect = cv.getBoundingClientRect();
+      var x = t.clientX - rect.left, y = t.clientY - rect.top;
+      if (x < W / 2) L.y = y - padH / 2;
+      else if (!vsCPU) R.y = y - padH / 2;
+    }
+  }
+  cv.addEventListener('touchstart', function (e) {
+    if (state === 'serve') serve(); else if (state === 'over') newGame();
+    handleTouch(e);
+  }, { passive: false });
+  cv.addEventListener('touchmove', handleTouch, { passive: false });
+  // mouse fallback for the message overlay / desktop serve
+  cv.addEventListener('mousedown', function () {
+    if (state === 'serve') serve(); else if (state === 'over') newGame();
+  });
+
+  // ---- update ----
+  function clampPad(p) { if (p.y < 0) p.y = 0; if (p.y > H - padH) p.y = H - padH; }
+
+  function step() {
+    var sp = Math.max(6, H * 0.011);
+    if (keys['w']) L.y -= sp; if (keys['s']) L.y += sp;
+    if (!vsCPU) { if (keys['arrowup']) R.y -= sp; if (keys['arrowdown']) R.y += sp; }
+    else {
+      // CPU: track ball with capped speed + a little lag
+      var target = ball.vx > 0 ? ball.y - padH / 2 : H / 2 - padH / 2;
+      var diff = target - R.y, cap = sp * 0.82;
+      R.y += Math.max(-cap, Math.min(cap, diff));
+    }
+    clampPad(L); clampPad(R);
+
+    if (state !== 'play' || paused) return;
+
+    ball.x += ball.vx; ball.y += ball.vy;
+
+    // top / bottom walls
+    if (ball.y - ballR < 0) { ball.y = ballR; ball.vy = Math.abs(ball.vy); beep(520, 0.03); }
+    if (ball.y + ballR > H) { ball.y = H - ballR; ball.vy = -Math.abs(ball.vy); beep(520, 0.03); }
+
+    // left paddle
+    if (ball.vx < 0 && ball.x - ballR < 24 + padW && ball.x - ballR > 12 &&
+        ball.y > L.y && ball.y < L.y + padH) {
+      bounce(L, 24 + padW, 1);
+    }
+    // right paddle
+    if (ball.vx > 0 && ball.x + ballR > W - 24 - padW && ball.x + ballR < W - 12 &&
+        ball.y > R.y && ball.y < R.y + padH) {
+      bounce(R, W - 24 - padW, -1);
+    }
+
+    // out of bounds → point
+    if (ball.x < -20) point(-1);
+    else if (ball.x > W + 20) point(1);
+  }
+
+  function bounce(pad, edgeX, dir) {
+    var rel = (ball.y - (pad.y + padH / 2)) / (padH / 2);   // -1..1
+    rel = Math.max(-1, Math.min(1, rel));
+    ball.speed = Math.min(ball.speed * 1.045, W * 0.02);
+    var ang = rel * 1.05;                                   // up to ~60°
+    ball.vx = Math.cos(ang) * ball.speed * dir;
+    ball.vy = Math.sin(ang) * ball.speed;
+    ball.x = edgeX + dir * (ballR + 1);
+    beep(680, 0.04, 'square', 0.05);
+  }
+
+  // ---- draw ----
+  function draw() {
+    ctx.clearRect(0, 0, W, H);
+    // subtle vignette bg
+    ctx.fillStyle = '#0e1016'; ctx.fillRect(0, 0, W, H);
+    // center dashed net
+    ctx.strokeStyle = 'rgba(124,92,255,.28)'; ctx.lineWidth = 3;
+    ctx.setLineDash([10, 14]); ctx.beginPath();
+    ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H); ctx.stroke(); ctx.setLineDash([]);
+    // scores
+    ctx.fillStyle = 'rgba(232,233,239,.16)';
+    ctx.font = '700 ' + Math.min(120, H * 0.22) + 'px ui-monospace, monospace';
+    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+    ctx.fillText(L.score, W * 0.28, H * 0.24);
+    ctx.fillText(R.score, W * 0.72, H * 0.24);
+    // paddles
+    ctx.fillStyle = '#37c7ff'; roundRect(24, L.y, padW, padH, 6); ctx.fill();
+    ctx.fillStyle = '#ff4d9d'; roundRect(W - 24 - padW, R.y, padW, padH, 6); ctx.fill();
+    // ball
+    if (state === 'play' || state === 'point') {
+      ctx.fillStyle = flash > 0 ? '#fff' : '#ffd23e';
+      ctx.beginPath(); ctx.arc(ball.x, ball.y, ballR, 0, Math.PI * 2); ctx.fill();
+    }
+    if (flash > 0) flash--;
+  }
+  function roundRect(x, y, w, h, r) {
+    ctx.beginPath();
+    ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r);
+    ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r);
+    ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
+  }
+
+  function loop() { step(); draw(); requestAnimationFrame(loop); }
+  centerPads(); newGame(); loop();
+  window.focus();
+})();
+</script>
+</body>
+</html>
diff --git a/games/spirograph/index.html b/games/spirograph/index.html
new file mode 100644
index 0000000..1d7b450
--- /dev/null
+++ b/games/spirograph/index.html
@@ -0,0 +1,495 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Spirograph Studio</title>
+<style>
+  :root{
+    --bg:#0d0f1a; --panel:#161a2e; --panel2:#1e2340;
+    --ink:#e8ecff; --muted:#8b91b8; --accent:#5eead4; --accent2:#a78bfa;
+    --line:#2a3055;
+  }
+  *{box-sizing:border-box}
+  [hidden]{display:none!important}
+  html,body{height:100%}
+  body{
+    margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    background:radial-gradient(1200px 800px at 70% -10%,#1b2145 0%,var(--bg) 55%);
+    color:var(--ink); display:flex; min-height:100%; overflow:hidden;
+  }
+  .wrap{display:flex; width:100%; height:100vh}
+  .stage{
+    flex:1; display:flex; align-items:center; justify-content:center;
+    position:relative; padding:24px; min-width:0;
+  }
+  #canvas{
+    background:#07080f;
+    border-radius:16px;
+    box-shadow:0 20px 60px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.04);
+    max-width:100%; max-height:100%;
+    touch-action:none;
+  }
+  .panel{
+    width:320px; flex-shrink:0; background:linear-gradient(180deg,var(--panel),var(--panel2));
+    border-left:1px solid var(--line);
+    padding:22px 22px 26px; display:flex; flex-direction:column; gap:18px;
+    overflow-y:auto;
+  }
+  .brand{display:flex; align-items:center; gap:11px}
+  .brand .dot{
+    width:30px;height:30px;border-radius:9px;
+    background:conic-gradient(from 0deg,var(--accent),var(--accent2),#f472b6,var(--accent));
+    box-shadow:0 0 18px rgba(94,234,212,.4);
+  }
+  .brand h1{font-size:17px; margin:0; letter-spacing:.4px; font-weight:650}
+  .brand span{font-size:11px; color:var(--muted); display:block; margin-top:1px; letter-spacing:.5px}
+
+  .ctrl{display:flex; flex-direction:column; gap:8px}
+  .ctrl label{display:flex; justify-content:space-between; align-items:baseline; font-size:12.5px; color:var(--muted); letter-spacing:.3px}
+  .ctrl label b{color:var(--ink); font-weight:600; font-variant-numeric:tabular-nums; font-size:13px}
+  input[type=range]{
+    -webkit-appearance:none; appearance:none; width:100%; height:6px; border-radius:6px;
+    background:linear-gradient(90deg,var(--accent),var(--accent2)); outline:none; cursor:pointer;
+  }
+  input[type=range]::-webkit-slider-thumb{
+    -webkit-appearance:none; width:18px;height:18px;border-radius:50%;
+    background:#fff; border:3px solid var(--accent); box-shadow:0 2px 8px rgba(0,0,0,.5); cursor:pointer;
+  }
+  input[type=range]::-moz-range-thumb{
+    width:16px;height:16px;border-radius:50%; background:#fff; border:3px solid var(--accent); cursor:pointer;
+  }
+  select{
+    width:100%; font:inherit; font-size:12.5px; color:var(--ink);
+    background:rgba(255,255,255,.04); border:1px solid var(--line); border-radius:9px;
+    padding:8px 10px; cursor:pointer; outline:none;
+  }
+  select:focus{border-color:var(--accent2)}
+  input[type=color]{
+    -webkit-appearance:none; appearance:none; width:34px; height:26px; padding:0;
+    border:1px solid var(--line); border-radius:7px; background:none; cursor:pointer;
+  }
+  input[type=color]::-webkit-color-swatch-wrapper{padding:2px}
+  input[type=color]::-webkit-color-swatch{border:none; border-radius:5px}
+  .colorwrap{display:flex; align-items:center; gap:10px}
+  .row{display:flex; gap:10px}
+  .toggle{
+    display:flex; align-items:center; justify-content:space-between; gap:10px;
+    font-size:12.5px; color:var(--muted); background:rgba(255,255,255,.03);
+    padding:10px 12px; border-radius:10px; border:1px solid var(--line);
+  }
+  .switch{position:relative; width:42px; height:23px; flex-shrink:0}
+  .switch input{opacity:0; width:0; height:0}
+  .slider-sw{
+    position:absolute; inset:0; background:#3a4066; border-radius:23px; transition:.2s; cursor:pointer;
+  }
+  .slider-sw:before{
+    content:""; position:absolute; height:17px; width:17px; left:3px; top:3px;
+    background:#fff; border-radius:50%; transition:.2s;
+  }
+  .switch input:checked + .slider-sw{background:linear-gradient(90deg,var(--accent),var(--accent2))}
+  .switch input:checked + .slider-sw:before{transform:translateX(19px)}
+
+  button{
+    flex:1; font:inherit; font-size:13px; font-weight:600; letter-spacing:.3px;
+    padding:12px 10px; border-radius:11px; border:1px solid var(--line);
+    background:rgba(255,255,255,.04); color:var(--ink); cursor:pointer; transition:.15s;
+  }
+  button:hover{background:rgba(255,255,255,.09); transform:translateY(-1px)}
+  button:active{transform:translateY(0)}
+  button.primary{
+    background:linear-gradient(90deg,var(--accent),var(--accent2)); color:#0b0d18; border:none;
+  }
+  button.primary:hover{filter:brightness(1.08)}
+  .seg{display:flex; gap:6px; flex-wrap:wrap}
+  .seg button{flex:1 1 auto; padding:8px 6px; font-size:11.5px; font-weight:600}
+
+  .readout{
+    font-size:11px; color:var(--muted); line-height:1.6; letter-spacing:.2px;
+    background:rgba(255,255,255,.02); border:1px solid var(--line); border-radius:9px; padding:9px 11px;
+    font-variant-numeric:tabular-nums;
+  }
+  .readout b{color:var(--accent)}
+  .foot{margin-top:auto; font-size:11px; color:var(--muted); line-height:1.5; letter-spacing:.2px}
+  .hint{font-size:11px; color:var(--muted); margin-top:-4px}
+  kbd{
+    font-family:ui-monospace,monospace; font-size:10px; background:rgba(255,255,255,.06);
+    border:1px solid var(--line); border-bottom-width:2px; border-radius:5px; padding:1px 5px; color:var(--ink);
+  }
+  @media (max-width:760px){
+    body{overflow:auto}
+    .wrap{flex-direction:column; height:auto}
+    .panel{width:100%; border-left:none; border-top:1px solid var(--line)}
+    .stage{padding:16px; min-height:56vh}
+  }
+</style>
+</head>
+<body>
+<div class="wrap">
+  <div class="stage">
+    <canvas id="canvas"></canvas>
+  </div>
+
+  <aside class="panel">
+    <div class="brand">
+      <div class="dot"></div>
+      <div>
+        <h1>Spirograph Studio</h1>
+        <span>HYPOTROCHOID PLOTTER</span>
+      </div>
+    </div>
+
+    <div class="ctrl">
+      <label>Fixed gear radius <span>R</span> <b id="vR">140</b></label>
+      <input type="range" id="R" min="40" max="230" value="140" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Rolling gear radius <span>r</span> <b id="vr">53</b></label>
+      <input type="range" id="r" min="8" max="220" value="53" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Pen offset <span>d</span> <b id="vd">78</b></label>
+      <input type="range" id="d" min="0" max="180" value="78" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Speed <b id="vs">6</b></label>
+      <input type="range" id="speed" min="1" max="40" value="6" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Line width <b id="vw">1.6</b></label>
+      <input type="range" id="lw" min="0.4" max="6" value="1.6" step="0.1">
+    </div>
+
+    <div class="ctrl">
+      <label>Color mode</label>
+      <select id="colormode">
+        <option value="cycle">Rainbow — cycle</option>
+        <option value="angle">Rainbow — by angle</option>
+        <option value="solid">Solid</option>
+      </select>
+    </div>
+
+    <div class="ctrl" id="solidRow" hidden>
+      <label>Ink color</label>
+      <div class="colorwrap">
+        <input type="color" id="ink" value="#5eead4">
+        <span class="hint" style="margin:0">used in Solid mode</span>
+      </div>
+    </div>
+
+    <div class="toggle">
+      <span>Show gears</span>
+      <label class="switch"><input type="checkbox" id="gears" checked><span class="slider-sw"></span></label>
+    </div>
+
+    <div class="toggle">
+      <span>Glow</span>
+      <label class="switch"><input type="checkbox" id="glow"><span class="slider-sw"></span></label>
+    </div>
+
+    <div class="ctrl">
+      <label>Presets</label>
+      <div class="seg">
+        <button data-preset="rose">Rose</button>
+        <button data-preset="star">Star</button>
+        <button data-preset="web">Web</button>
+        <button data-preset="loop">Loops</button>
+      </div>
+    </div>
+
+    <div class="row">
+      <button id="play" class="primary">⏸ Pause</button>
+      <button id="random">🎲 Random</button>
+    </div>
+    <div class="row">
+      <button id="clear">Clear</button>
+      <button id="save" class="primary">Save PNG</button>
+    </div>
+
+    <div class="readout" id="readout">—</div>
+
+    <div class="hint">
+      <kbd>Space</kbd> play/pause · <kbd>C</kbd> clear · <kbd>R</kbd> random · <kbd>S</kbd> save
+    </div>
+
+    <div class="foot">
+      Curve: x = (R−r)·cos t + d·cos((R−r)/r·t)<br>
+      &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;y = (R−r)·sin t − d·sin((R−r)/r·t)
+    </div>
+  </aside>
+</div>
+
+<script>
+(function(){
+  "use strict";
+  const canvas = document.getElementById('canvas');
+  const ctx = canvas.getContext('2d');
+
+  // Logical drawing size (device-independent). Backing stores are scaled by DPR
+  // so the curve is crisp on retina displays — the original blurred on hi-DPI.
+  const W = 760, H = 760;
+  const cx = W/2, cy = H/2;
+  const DPR = Math.min(window.devicePixelRatio || 1, 2);
+
+  canvas.width = W*DPR; canvas.height = H*DPR;
+  canvas.style.width = W+'px'; canvas.style.height = H+'px';
+  ctx.scale(DPR, DPR);
+
+  // Offscreen trail layer (persists between frames; gear overlay is drawn fresh each frame)
+  const trail = document.createElement('canvas');
+  trail.width = W*DPR; trail.height = H*DPR;
+  const tctx = trail.getContext('2d');
+  tctx.scale(DPR, DPR);
+
+  // Controls
+  const el = id => document.getElementById(id);
+  const R = el('R'), r = el('r'), d = el('d'), speed = el('speed'), lw = el('lw');
+  const colormode = el('colormode'), ink = el('ink');
+  const gears = el('gears'), glow = el('glow');
+  const vR=el('vR'), vr=el('vr'), vd=el('vd'), vs=el('vs'), vw=el('vw');
+  const readout = el('readout'), playBtn = el('play'), solidRow = el('solidRow');
+
+  // Drawing state
+  let t = 0;                    // parameter angle
+  let hue = Math.random()*360;
+  let lastPt = null;            // last plotted point on the persistent trail
+  let period = 0;               // t value at which the curve closes
+  let running = true;
+  let laps = 0;                 // completed closed patterns since last clear
+
+  const num  = inp => parseFloat(inp.value);
+  const inti = inp => parseInt(inp.value,10);
+
+  function gcd(a,b){ a=Math.round(a); b=Math.round(b); while(b){ const tmp=a%b; a=b; b=tmp; } return a||1; }
+
+  function computePeriod(){
+    const rr = Math.max(1, inti(r));
+    const RR = inti(R);
+    // Full pattern closes after r/gcd(R,r) revolutions of the driving angle.
+    const revs = rr / gcd(RR, rr);
+    period = 2*Math.PI*revs;
+  }
+
+  function point(tt){
+    const RR = inti(R);
+    const rr = Math.max(1, inti(r));
+    const dd = inti(d);
+    const k = RR - rr;
+    const ratio = k / rr;
+    return {
+      x: cx + k*Math.cos(tt) + dd*Math.cos(ratio*tt),
+      y: cy + k*Math.sin(tt) - dd*Math.sin(ratio*tt)
+    };
+  }
+
+  function updateReadout(){
+    const RR = inti(R), rr = Math.max(1, inti(r)), dd = inti(d);
+    const g = gcd(RR, rr);
+    const revs = rr / g;
+    const pct = period ? Math.min(100, (t/period)*100) : 0;
+    readout.innerHTML =
+      'R/r ratio <b>'+ (RR/rr).toFixed(3) +'</b> · closes in <b>'+ revs +'</b> rev'+(revs===1?'':'s')+'<br>'+
+      'petals <b>'+ (RR/g) +'</b> · pen d <b>'+ dd +'</b> · pass <b>'+ Math.round(pct) +'%</b> · laps <b>'+ laps +'</b>';
+  }
+
+  function strokeFor(){
+    const mode = colormode.value;
+    if(mode === 'solid') return ink.value;
+    if(mode === 'angle'){
+      const h = period ? (t/period*360*3) % 360 : hue;
+      return 'hsl('+h.toFixed(1)+',85%,62%)';
+    }
+    return 'hsl('+hue.toFixed(1)+',85%,62%)'; // cycle
+  }
+
+  function resetTrail(){
+    tctx.clearRect(0,0,W,H);
+    t = 0; laps = 0;
+    lastPt = point(0);
+    computePeriod();
+    updateReadout();
+  }
+
+  // Called when a shape parameter changes: fresh curve, new base hue.
+  function restart(){
+    hue = (hue + 47) % 360;
+    resetTrail();
+    render();
+  }
+
+  // Rolling-gear schematic for the current t (overlay only).
+  function drawGears(octx, tt){
+    const RR = inti(R), rr = Math.max(1, inti(r));
+    const k = RR - rr;
+    octx.save();
+    octx.strokeStyle = 'rgba(255,255,255,.16)';
+    octx.lineWidth = 1;
+    octx.beginPath(); octx.arc(cx, cy, RR, 0, Math.PI*2); octx.stroke();
+    const gx = cx + k*Math.cos(tt);
+    const gy = cy + k*Math.sin(tt);
+    octx.strokeStyle = 'rgba(167,139,250,.55)';
+    octx.beginPath(); octx.arc(gx, gy, rr, 0, Math.PI*2); octx.stroke();
+    const p = point(tt);
+    octx.strokeStyle = 'rgba(94,234,212,.5)';
+    octx.beginPath(); octx.moveTo(gx,gy); octx.lineTo(p.x,p.y); octx.stroke();
+    octx.fillStyle = '#5eead4';
+    octx.beginPath(); octx.arc(p.x,p.y,3.2,0,Math.PI*2); octx.fill();
+    octx.fillStyle = 'rgba(167,139,250,.9)';
+    octx.beginPath(); octx.arc(gx,gy,2.5,0,Math.PI*2); octx.fill();
+    octx.restore();
+  }
+
+  function render(){
+    ctx.clearRect(0,0,W,H);
+    ctx.drawImage(trail,0,0,W,H);
+    if(gears.checked && running) drawGears(ctx, t);
+  }
+
+  function frame(){
+    if(running){
+      const steps = inti(speed);
+      const width = num(lw);
+      const dt = 0.012; // base angular step per micro-segment
+      tctx.lineCap = 'round';
+      tctx.lineJoin = 'round';
+      tctx.lineWidth = width;
+      if(glow.checked){ tctx.shadowBlur = 8; } else { tctx.shadowBlur = 0; }
+
+      for(let i=0;i<steps;i++){
+        const nt = t + dt;
+        const p = point(nt);
+        if(colormode.value === 'cycle') hue = (hue + 0.35) % 360;
+        const stroke = strokeFor();
+        tctx.strokeStyle = stroke;
+        if(glow.checked) tctx.shadowColor = stroke;
+        tctx.beginPath();
+        tctx.moveTo(lastPt.x,lastPt.y);
+        tctx.lineTo(p.x,p.y);
+        tctx.stroke();
+        lastPt = p;
+        t = nt;
+        if(t >= period){
+          // Pattern complete: keep it, start a fresh overlaid pass with a new base hue.
+          t = 0; laps++;
+          lastPt = point(0);
+          hue = (hue + 63) % 360;
+        }
+      }
+      tctx.shadowBlur = 0;
+      updateReadout();
+    }
+    render();
+    requestAnimationFrame(frame);
+  }
+
+  // ---- Value labels + persistence ----
+  function bind(input, label, fmt){
+    const upd = ()=> label.textContent = fmt ? fmt(input.value) : input.value;
+    input.addEventListener('input', upd); upd();
+  }
+  bind(R, vR); bind(r, vr); bind(d, vd); bind(speed, vs); bind(lw, vw);
+
+  const SETTINGS = ['R','r','d','speed','lw','colormode','ink'];
+  const TOGGLES = ['gears','glow'];
+  function saveSettings(){
+    try{
+      const s = {};
+      SETTINGS.forEach(k=> s[k] = el(k).value);
+      TOGGLES.forEach(k=> s[k] = el(k).checked);
+      localStorage.setItem('spiro-settings', JSON.stringify(s));
+    }catch(e){}
+  }
+  function loadSettings(){
+    try{
+      const s = JSON.parse(localStorage.getItem('spiro-settings')||'null');
+      if(!s) return;
+      SETTINGS.forEach(k=>{ if(s[k]!=null) el(k).value = s[k]; });
+      TOGGLES.forEach(k=>{ if(s[k]!=null) el(k).checked = s[k]; });
+      [vR,vr,vd,vs,vw].forEach(()=>{});
+      vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
+      vs.textContent=speed.value; vw.textContent=lw.value;
+    }catch(e){}
+  }
+  loadSettings();
+
+  // Shape-changing sliders restart the curve; all controls persist.
+  [R,r,d].forEach(inp => inp.addEventListener('input', restart));
+  [R,r,d,speed,lw,colormode,ink,gears,glow].forEach(inp =>
+    inp.addEventListener('input', saveSettings));
+
+  colormode.addEventListener('input', ()=>{ solidRow.hidden = colormode.value !== 'solid'; });
+  solidRow.hidden = colormode.value !== 'solid';
+
+  // ---- Buttons ----
+  function setPlaying(on){
+    running = on;
+    playBtn.textContent = on ? '⏸ Pause' : '▶ Play';
+  }
+  playBtn.addEventListener('click', ()=> setPlaying(!running));
+  el('clear').addEventListener('click', restart);
+
+  el('random').addEventListener('click', ()=>{
+    const rnd = (min,max)=> Math.floor(min + Math.random()*(max-min+1));
+    R.value = rnd(90, 230);
+    r.value = rnd(20, Math.max(30, +R.value - 10)); // keep a real hypotrochoid (r < R mostly)
+    d.value = rnd(20, 170);
+    const modes = ['cycle','angle','solid'];
+    colormode.value = modes[rnd(0,2)];
+    solidRow.hidden = colormode.value !== 'solid';
+    vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
+    saveSettings();
+    restart();
+    setPlaying(true);
+  });
+
+  const PRESETS = {
+    rose: {R:200, r:60, d:120, mode:'cycle'},
+    star: {R:150, r:37, d:90,  mode:'angle'},
+    web:  {R:220, r:99, d:150, mode:'cycle'},
+    loop: {R:120, r:29, d:170, mode:'solid'}
+  };
+  document.querySelectorAll('[data-preset]').forEach(btn=>{
+    btn.addEventListener('click', ()=>{
+      const p = PRESETS[btn.dataset.preset]; if(!p) return;
+      R.value=p.R; r.value=p.r; d.value=p.d; colormode.value=p.mode;
+      solidRow.hidden = colormode.value !== 'solid';
+      vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
+      saveSettings(); restart(); setPlaying(true);
+    });
+  });
+
+  el('save').addEventListener('click', ()=>{
+    // Composite onto a dark background so the PNG isn't transparent.
+    const out = document.createElement('canvas');
+    out.width = trail.width; out.height = trail.height;
+    const octx = out.getContext('2d');
+    octx.fillStyle = '#07080f';
+    octx.fillRect(0,0,out.width,out.height);
+    octx.drawImage(trail,0,0);
+    const link = document.createElement('a');
+    link.download = 'spirograph-' + Date.now() + '.png';
+    link.href = out.toDataURL('image/png');
+    link.click();
+  });
+
+  // ---- Keyboard shortcuts ----
+  addEventListener('keydown', e=>{
+    if(e.target.matches('input,select,textarea')) return;
+    const k = e.key.toLowerCase();
+    if(k === ' '){ e.preventDefault(); setPlaying(!running); }
+    else if(k === 'c'){ restart(); }
+    else if(k === 'r'){ el('random').click(); }
+    else if(k === 's'){ el('save').click(); }
+  });
+
+  // Init
+  resetTrail();
+  requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>
diff --git a/tests/new-games-2.mjs b/tests/new-games-2.mjs
new file mode 100644
index 0000000..a20ceb1
--- /dev/null
+++ b/tests/new-games-2.mjs
@@ -0,0 +1,84 @@
+// Headless smoke test for the 2026-07-24 batch (pong, emoji-memory, spirograph,
+// constellation-clock). Loads each standalone in a real Chromium page, drives a
+// few inputs, and asserts it renders and throws no console/page errors.
+import { createRequire } from 'module';
+import { fileURLToPath } from 'url';
+import path from 'path';
+import http from 'http';
+import fs from 'fs';
+
+const require = createRequire(import.meta.url);
+let chromium;
+try {
+  ({ chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright'));
+} catch {
+  ({ chromium } = require('playwright'));
+}
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const MIME = { '.html': 'text/html', '.json': 'application/json', '.png': 'image/png', '.js': 'text/javascript' };
+const server = http.createServer((req, res) => {
+  let p = decodeURIComponent(req.url.split('?')[0].split('#')[0]);
+  if (p.endsWith('/')) p += 'index.html';
+  const f = path.join(root, p);
+  if (!f.startsWith(root) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); res.end('nf'); return; }
+  res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] || 'text/plain' });
+  fs.createReadStream(f).pipe(res);
+});
+await new Promise(r => server.listen(0, '127.0.0.1', r));
+const base = `http://127.0.0.1:${server.address().port}`;
+
+const browser = await chromium.launch();
+const results = [];
+const check = (name, ok) => { results.push({ name, ok }); console.log((ok ? 'PASS' : 'FAIL') + '  ' + name); };
+
+async function run(id, urlPath, drive, assertFn) {
+  const page = await browser.newPage({ viewport: { width: 700, height: 760 } });
+  const errors = [];
+  page.on('pageerror', e => errors.push('pageerror: ' + e.message));
+  page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
+  await page.goto(base + urlPath);
+  await drive(page);
+  await page.waitForTimeout(300);
+  const extraOk = await assertFn(page);
+  check(`${id}: renders + interactive`, extraOk);
+  check(`${id}: no console/page errors`, errors.length === 0);
+  if (errors.length) console.log('   ' + errors.join('\n   '));
+  await page.close();
+}
+
+// --- pong: serve, move both paddles, confirm canvas + overlay gone ---
+await run('pong', '/games/pong/', async (page) => {
+  await page.keyboard.press('Space');                       // serve
+  for (const k of ['w', 's', 'ArrowUp', 'ArrowDown']) { await page.keyboard.down(k); await page.waitForTimeout(120); await page.keyboard.up(k); }
+}, async (page) =>
+  (await page.locator('canvas#c').count()) === 1 &&
+  await page.locator('#msg').evaluate(el => el.classList.contains('hidden'))
+);
+
+// --- emoji-memory: flip two cards, confirm board built + moves increments ---
+await run('emoji-memory', '/games/emoji-memory/', async (page) => {
+  const cards = page.locator('.card');
+  await cards.nth(0).click(); await page.waitForTimeout(120);
+  await cards.nth(1).click(); await page.waitForTimeout(200);
+}, async (page) => {
+  const n = await page.locator('.card').count();            // 4x4 default = 16
+  const moves = parseInt(await page.locator('#moves').textContent(), 10);
+  return n === 16 && moves >= 1;
+});
+
+// --- spirograph: generative toy — just confirm it renders a canvas cleanly ---
+await run('spirograph', '/games/spirograph/', async (page) => {
+  await page.waitForTimeout(400);
+}, async (page) => (await page.locator('canvas').count()) >= 1);
+
+// --- constellation-clock: ambient visualizer — canvas renders, no errors ---
+await run('constellation-clock', '/games/constellation-clock/', async (page) => {
+  await page.waitForTimeout(400);
+}, async (page) => (await page.locator('canvas').count()) >= 1);
+
+await browser.close();
+server.close();
+const failed = results.filter(r => !r.ok);
+console.log(`\n${results.length - failed.length}/${results.length} passed`);
+process.exit(failed.length ? 1 : 0);

← a788194 Fix Flappy Sky ghosting: opaque per-frame sky clear (was tra  ·  back to Games Agentabrams  ·  Add 3 newest Model Arena game winners: DVD Bounce, Verlet Ro 9a1b82f →