← 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
M games.jsonA games/constellation-clock/index.htmlA games/emoji-memory/index.htmlA games/pong/index.htmlA games/spirograph/index.htmlA tests/new-games-2.mjs
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 · ' + 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> · 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 ' + 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>
+ 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 →