[object Object]

← back to Games Agentabrams

Add 3 new-genre arcade games: Abrams Snake, Abrams Break, Abrams 2048

c24ee2bf36a6750fd87135fcd0e68a4b7e03fae6 · 2026-07-24 17:15:08 -0700 · Steve Abrams

- snake (grid arcade), breakout (paddle/ball), 2048 (sliding puzzle) — all
  self-contained zero-dep games/<id>/index.html, wired into games.json
- headless smoke test tests/new-games.mjs (6/6 pass)
- fix stale e2e assertion (RubiksCube.aa -> AbramsCube.aa), suite now 7/7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit c24ee2bf36a6750fd87135fcd0e68a4b7e03fae6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 24 17:15:08 2026 -0700

    Add 3 new-genre arcade games: Abrams Snake, Abrams Break, Abrams 2048
    
    - snake (grid arcade), breakout (paddle/ball), 2048 (sliding puzzle) — all
      self-contained zero-dep games/<id>/index.html, wired into games.json
    - headless smoke test tests/new-games.mjs (6/6 pass)
    - fix stale e2e assertion (RubiksCube.aa -> AbramsCube.aa), suite now 7/7
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 games.json                |  24 +++++
 games/2048/index.html     | 213 ++++++++++++++++++++++++++++++++++++++++
 games/breakout/index.html | 240 ++++++++++++++++++++++++++++++++++++++++++++++
 games/snake/index.html    | 211 ++++++++++++++++++++++++++++++++++++++++
 tests/e2e.mjs             |   2 +-
 tests/new-games.mjs       |  79 +++++++++++++++
 6 files changed, 768 insertions(+), 1 deletion(-)

diff --git a/games.json b/games.json
index e2b3ebf..a3b362c 100644
--- a/games.json
+++ b/games.json
@@ -47,6 +47,30 @@
       "desc": "Polished Flappy-Bird-style flyer — Space/click/tap to flap through scrolling pipes. Twinkling parallax night sky, moon, medals, persistent best. True pause (P/Esc, auto-pauses off-focus), mute (M or speaker icon). Pure Canvas 2D, procedural Web Audio, zero dependencies.",
       "path": "games/flappy-flight/",
       "added": "2026-07-23"
+    },
+    {
+      "id": "snake",
+      "file": "AbramsSnake.aa",
+      "title": "Abrams Snake",
+      "desc": "Classic grid snake — eat the pink, grow, and speed up as you go. Walls and your own tail are fatal. Arrows/WASD to steer, swipe on touch, P pause, M mute. Persistent best. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "path": "games/snake/",
+      "added": "2026-07-24"
+    },
+    {
+      "id": "breakout",
+      "file": "AbramsBreak.aa",
+      "title": "Abrams Break",
+      "desc": "Brick-breaker — steer the paddle with mouse/arrows/drag, launch with Space or click, clear every brick, ride the ball's contact angle. Multi-row levels that get tougher, lives, tough top row, persistent best. P pause, M mute. Pure Canvas 2D, procedural Web Audio, zero dependencies.",
+      "path": "games/breakout/",
+      "added": "2026-07-24"
+    },
+    {
+      "id": "2048",
+      "file": "Abrams2048.aa",
+      "title": "Abrams 2048",
+      "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"
     }
   ]
 }
diff --git a/games/2048/index.html b/games/2048/index.html
new file mode 100644
index 0000000..9b93431
--- /dev/null
+++ b/games/2048/index.html
@@ -0,0 +1,213 @@
+<!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 2048</title>
+<style>
+  :root { --bg:#0a0e14; --ink:#e5e7eb; --board:#141b25; --cell:#1e2735; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; }
+  body {
+    background:radial-gradient(1200px 600px at 50% -10%, #16202e, var(--bg));
+    color:var(--ink); font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
+    display:flex; flex-direction:column; align-items:center; justify-content:center;
+    min-height:100%; gap:12px; padding:14px; overscroll-behavior:none; touch-action:none;
+  }
+  h1 { font-size:14px; letter-spacing:.28em; text-transform:uppercase; color:#93c5fd; font-weight:600; }
+  .hud { display:flex; gap:14px; align-items:center; }
+  .stat { background:var(--cell); border-radius:10px; padding:6px 14px; text-align:center; min-width:76px; }
+  .stat .k { font-size:10px; letter-spacing:.15em; text-transform:uppercase; color:#8b97a7; }
+  .stat .v { font-size:19px; font-weight:700; font-variant-numeric:tabular-nums; }
+  .btn { padding:8px 14px; border-radius:10px; border:1px solid #2a3441; background:var(--cell); color:#cbd5e1; cursor:pointer; font-size:13px; font-weight:600; }
+  #wrap { position:relative; }
+  #board { background:var(--board); border-radius:14px; padding:10px; display:grid; grid-template-columns:repeat(4,1fr); gap:10px; box-shadow:0 10px 40px rgba(0,0,0,.5); }
+  .tile { width:70px; height:70px; border-radius:10px; display:flex; align-items:center; justify-content:center; font-weight:800; font-size:26px; background:var(--cell); color:#e5e7eb; transition:transform .09s ease; }
+  .tile.pop { animation:pop .16s ease; }
+  @keyframes pop { 0%{transform:scale(.6)} 60%{transform:scale(1.08)} 100%{transform:scale(1)} }
+  .overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; text-align:center; background:rgba(8,12,18,.8); border-radius:14px; backdrop-filter:blur(2px); }
+  .overlay.hide { display:none; }
+  .big { font-size:24px; font-weight:800; }
+  .sub { font-size:13px; color:#9aa4b2; max-width:260px; }
+  .tag { font-size:11px; color:#93c5fd; letter-spacing:.2em; text-transform:uppercase; }
+  button.play { margin-top:4px; padding:10px 22px; border:0; border-radius:999px; cursor:pointer; background:linear-gradient(180deg,#60a5fa,#3b82f6); color:#fff; font-weight:700; font-size:14px; }
+  .hint { font-size:11px; color:#5b6673; }
+</style>
+</head>
+<body>
+  <h1>Abrams 2048</h1>
+  <div class="hud">
+    <div class="stat"><div class="k">Score</div><div class="v" id="score">0</div></div>
+    <div class="stat"><div class="k">Best</div><div class="v" id="best">0</div></div>
+    <button class="btn" id="newBtn">New</button>
+  </div>
+  <div id="wrap">
+    <div id="board"></div>
+    <div class="overlay hide" id="ov">
+      <div class="tag" id="ovTag">Nice</div>
+      <div class="big" id="ovTitle">2048!</div>
+      <div class="sub" id="ovSub"></div>
+      <button class="play" id="playBtn">New game</button>
+      <div class="hint">Arrows / WASD · swipe on touch</div>
+    </div>
+  </div>
+
+<script>
+(function () {
+  var boardEl = document.getElementById('board');
+  var scoreEl = document.getElementById('score'), bestEl = document.getElementById('best');
+  var ov = document.getElementById('ov'), ovTag = document.getElementById('ovTag'), ovTitle = document.getElementById('ovTitle'), ovSub = document.getElementById('ovSub');
+  var playBtn = document.getElementById('playBtn'), newBtn = document.getElementById('newBtn');
+
+  var BEST_KEY = 'abrams-2048-best';
+  var best = parseInt(localStorage.getItem(BEST_KEY) || '0', 10) || 0; bestEl.textContent = best;
+
+  var TILE_COLORS = {
+    2:'#334155', 4:'#3f5170', 8:'#5b7fb8', 16:'#38bdf8', 32:'#22c1a6', 64:'#4ade80',
+    128:'#facc15', 256:'#fb923c', 512:'#f472b6', 1024:'#e879f9', 2048:'#a78bfa', 4096:'#fca5a5'
+  };
+  var grid, score, won, ended;
+  var actx = null;
+  function beep(freq, dur, vol) {
+    try {
+      if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
+      var o = actx.createOscillator(), g = actx.createGain();
+      o.type = 'sine'; o.frequency.value = freq; g.gain.value = vol || 0.04;
+      o.connect(g); g.connect(actx.destination);
+      var t = actx.currentTime; o.start(t); g.gain.exponentialRampToValueAtTime(0.0001, t + (dur || 0.08)); o.stop(t + (dur || 0.08));
+    } catch (e) {}
+  }
+
+  function empty() { var g = []; for (var i = 0; i < 16; i++) g.push(0); return g; }
+  function spawn() {
+    var free = [];
+    for (var i = 0; i < 16; i++) if (grid[i] === 0) free.push(i);
+    if (!free.length) return null;
+    var idx = free[(Math.random() * free.length) | 0];
+    grid[idx] = Math.random() < 0.9 ? 2 : 4;
+    return idx;
+  }
+  function newGame() {
+    grid = empty(); score = 0; won = false; ended = false;
+    spawn(); spawn(); ov.classList.add('hide'); render([], -1);
+    updateScore();
+    if (actx && actx.state === 'suspended') actx.resume();
+  }
+  function updateScore() {
+    scoreEl.textContent = score;
+    if (score > best) { best = score; localStorage.setItem(BEST_KEY, best); bestEl.textContent = best; }
+  }
+
+  // slide+merge one row (array of 4) toward index 0; returns {row, gained, moved, merges:[positions]}
+  function collapse(line) {
+    var vals = line.filter(function (v) { return v !== 0; });
+    var out = [], gained = 0, mergedAt = [];
+    for (var i = 0; i < vals.length; i++) {
+      if (i + 1 < vals.length && vals[i] === vals[i + 1]) {
+        var m = vals[i] * 2; out.push(m); gained += m; mergedAt.push(out.length - 1); i++;
+      } else out.push(vals[i]);
+    }
+    while (out.length < 4) out.push(0);
+    var moved = out.some(function (v, k) { return v !== line[k]; });
+    return { row: out, gained: gained, moved: moved, merges: mergedAt };
+  }
+
+  // dir: 'l','r','u','d'. Returns true if anything moved.
+  function move(dir) {
+    if (ended) return false;
+    var moved = false, gained = 0, mergedCells = [];
+    function get(r, c) { return grid[r * 4 + c]; }
+    function set(r, c, v) { grid[r * 4 + c] = v; }
+
+    for (var k = 0; k < 4; k++) {
+      var line = [];
+      for (var i = 0; i < 4; i++) {
+        if (dir === 'l') line.push(get(k, i));
+        else if (dir === 'r') line.push(get(k, 3 - i));
+        else if (dir === 'u') line.push(get(i, k));
+        else line.push(get(3 - i, k));
+      }
+      var res = collapse(line);
+      if (res.moved) moved = true;
+      gained += res.gained;
+      for (var i2 = 0; i2 < 4; i2++) {
+        var v = res.row[i2], r, c;
+        if (dir === 'l') { r = k; c = i2; } else if (dir === 'r') { r = k; c = 3 - i2; }
+        else if (dir === 'u') { r = i2; c = k; } else { r = 3 - i2; c = k; }
+        set(r, c, v);
+        if (res.merges.indexOf(i2) >= 0) mergedCells.push(r * 4 + c);
+      }
+    }
+    if (moved) {
+      score += gained; updateScore();
+      var spawnIdx = spawn();
+      var popCells = mergedCells.slice(); if (spawnIdx !== null) popCells.push(spawnIdx);
+      render(popCells, -1);
+      if (gained > 0) beep(400 + Math.min(600, gained), 0.07, 0.05);
+      if (!won && grid.indexOf(2048) >= 0) { won = true; finish('2048!', 'You hit 2048 — keep going for a higher score.', 'Keep playing'); }
+      else if (isStuck()) finish('Game Over', 'No moves left. Score ' + score + ', best ' + best + '.', 'New game');
+    }
+    return moved;
+  }
+
+  function isStuck() {
+    if (grid.indexOf(0) >= 0) return false;
+    for (var r = 0; r < 4; r++) for (var c = 0; c < 4; c++) {
+      var v = grid[r * 4 + c];
+      if (c < 3 && grid[r * 4 + c + 1] === v) return false;
+      if (r < 3 && grid[(r + 1) * 4 + c] === v) return false;
+    }
+    return true;
+  }
+
+  function finish(title, sub, btn) {
+    if (title === 'Game Over') ended = true;
+    ovTag.textContent = title === 'Game Over' ? 'Done' : 'Milestone';
+    ovTitle.textContent = title; ovSub.textContent = sub; playBtn.textContent = btn;
+    ov.classList.remove('hide');
+    beep(title === 'Game Over' ? 180 : 880, 0.25, 0.07);
+  }
+
+  function render(popCells, _) {
+    boardEl.innerHTML = '';
+    for (var i = 0; i < 16; i++) {
+      var v = grid[i], d = document.createElement('div');
+      d.className = 'tile' + (popCells && popCells.indexOf(i) >= 0 ? ' pop' : '');
+      if (v) {
+        d.textContent = v;
+        d.style.background = TILE_COLORS[v] || '#a78bfa';
+        d.style.color = v <= 4 ? '#cbd5e1' : '#0a0e14';
+        d.style.fontSize = v >= 1024 ? '20px' : v >= 128 ? '23px' : '26px';
+      }
+      boardEl.appendChild(d);
+    }
+  }
+
+  // input
+  window.addEventListener('keydown', function (e) {
+    var k = e.key.toLowerCase(), d = null;
+    if (k === 'arrowleft' || k === 'a') d = 'l';
+    else if (k === 'arrowright' || k === 'd') d = 'r';
+    else if (k === 'arrowup' || k === 'w') d = 'u';
+    else if (k === 'arrowdown' || k === 's') d = 'd';
+    if (d) { move(d); e.preventDefault(); }
+  });
+  var tsx = 0, tsy = 0;
+  boardEl.addEventListener('touchstart', function (e) { var t = e.touches[0]; tsx = t.clientX; tsy = t.clientY; }, { passive: true });
+  boardEl.addEventListener('touchend', function (e) {
+    var t = e.changedTouches[0], dx = t.clientX - tsx, dy = t.clientY - tsy;
+    if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return;
+    if (Math.abs(dx) > Math.abs(dy)) move(dx > 0 ? 'r' : 'l'); else move(dy > 0 ? 'd' : 'u');
+  }, { passive: true });
+
+  playBtn.addEventListener('click', function () {
+    if (won && !ended && playBtn.textContent === 'Keep playing') { ov.classList.add('hide'); return; }
+    newGame();
+  });
+  newBtn.addEventListener('click', newGame);
+
+  newGame();
+})();
+</script>
+</body>
+</html>
diff --git a/games/breakout/index.html b/games/breakout/index.html
new file mode 100644
index 0000000..f5935f2
--- /dev/null
+++ b/games/breakout/index.html
@@ -0,0 +1,240 @@
+<!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 Break</title>
+<style>
+  :root { --bg:#0a0e14; --ink:#e5e7eb; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; }
+  body {
+    background:radial-gradient(1200px 600px at 50% -10%, #16202e, var(--bg));
+    color:var(--ink); font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
+    display:flex; flex-direction:column; align-items:center; justify-content:center;
+    min-height:100%; gap:10px; padding:12px; overscroll-behavior:none; touch-action:none;
+  }
+  h1 { font-size:14px; letter-spacing:.28em; text-transform:uppercase; color:#93c5fd; font-weight:600; }
+  .hud { display:flex; gap:18px; font-variant-numeric:tabular-nums; font-size:13px; color:#9aa4b2; }
+  .hud b { color:var(--ink); font-weight:600; }
+  #wrap { position:relative; }
+  canvas { background:#0d131c; border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,.5); display:block; }
+  .overlay {
+    position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
+    gap:10px; text-align:center; background:rgba(8,12,18,.72); border-radius:12px; backdrop-filter:blur(2px);
+  }
+  .overlay.hide { display:none; }
+  .big { font-size:22px; font-weight:700; }
+  .sub { font-size:13px; color:#9aa4b2; max-width:300px; }
+  .tag { font-size:11px; color:#93c5fd; letter-spacing:.2em; text-transform:uppercase; }
+  button.play { margin-top:4px; padding:10px 22px; border:0; border-radius:999px; cursor:pointer; background:linear-gradient(180deg,#60a5fa,#3b82f6); color:#fff; font-weight:700; font-size:14px; }
+  .btns { position:absolute; top:8px; right:10px; display:flex; gap:6px; }
+  .btns button { width:30px; height:30px; border-radius:8px; border:1px solid #2a3441; background:#141b25; color:#cbd5e1; cursor:pointer; font-size:14px; }
+  .hint { font-size:11px; color:#5b6673; }
+</style>
+</head>
+<body>
+  <h1>Abrams Break</h1>
+  <div class="hud"><span>Score <b id="score">0</b></span><span>Lives <b id="lives">3</b></span><span>Level <b id="lvl">1</b></span><span>Best <b id="best">0</b></span></div>
+  <div id="wrap">
+    <canvas id="c" width="440" height="480"></canvas>
+    <div class="btns">
+      <button id="mute" title="Mute (M)">🔊</button>
+      <button id="pauseBtn" title="Pause (P)">⏸</button>
+    </div>
+    <div class="overlay" id="ov">
+      <div class="tag" id="ovTag">Ready</div>
+      <div class="big" id="ovTitle">Abrams Break</div>
+      <div class="sub" id="ovSub">Clear every brick. Move the paddle with mouse / arrows, or drag on touch. Don't drop the ball.</div>
+      <button class="play" id="playBtn">Play</button>
+      <div class="hint">P pause · M mute · Space launch</div>
+    </div>
+  </div>
+
+<script>
+(function () {
+  var cv = document.getElementById('c'), ctx = cv.getContext('2d');
+  var W = cv.width, H = cv.height;
+  var scoreEl = document.getElementById('score'), livesEl = document.getElementById('lives'),
+      lvlEl = document.getElementById('lvl'), bestEl = document.getElementById('best');
+  var ov = document.getElementById('ov'), ovTag = document.getElementById('ovTag'), ovTitle = document.getElementById('ovTitle'), ovSub = document.getElementById('ovSub');
+  var playBtn = document.getElementById('playBtn'), pauseBtn = document.getElementById('pauseBtn'), muteBtn = document.getElementById('mute');
+
+  var BEST_KEY = 'abrams-break-best';
+  var best = parseInt(localStorage.getItem(BEST_KEY) || '0', 10) || 0; bestEl.textContent = best;
+
+  var COLS = 10, PAD = 6, TOP = 46, BRICK_H = 20;
+  var brickW = (W - PAD * (COLS + 1)) / COLS;
+  var COLORS = ['#f472b6', '#fb923c', '#facc15', '#4ade80', '#38bdf8', '#a78bfa'];
+
+  var paddle, ball, bricks, score, lives, level, running, paused, launched, muted = false;
+  var last = 0;
+
+  var actx = null;
+  function beep(freq, dur, type, vol) {
+    if (muted) return;
+    try {
+      if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
+      var o = actx.createOscillator(), g = actx.createGain();
+      o.type = type || 'square'; o.frequency.value = freq; g.gain.value = vol || 0.05;
+      o.connect(g); g.connect(actx.destination);
+      var t = actx.currentTime; o.start(t);
+      g.gain.exponentialRampToValueAtTime(0.0001, t + (dur || 0.07)); o.stop(t + (dur || 0.07));
+    } catch (e) {}
+  }
+
+  function buildBricks(lv) {
+    bricks = [];
+    var rows = Math.min(4 + lv, 7);
+    for (var r = 0; r < rows; r++) for (var c = 0; c < COLS; c++) {
+      bricks.push({
+        x: PAD + c * (brickW + PAD), y: TOP + r * (BRICK_H + PAD),
+        w: brickW, h: BRICK_H, color: COLORS[r % COLORS.length],
+        hits: r < 1 ? 2 : 1                 // top row tougher
+      });
+    }
+  }
+  function resetBallPaddle() {
+    paddle = { w: 84, h: 12, x: W / 2 - 42, y: H - 28, speed: 7 };
+    var sp = 4.2 + level * 0.4;
+    ball = { x: W / 2, y: paddle.y - 8, r: 7, vx: sp * (Math.random() < 0.5 ? -1 : 1) * 0.7, vy: -sp };
+    launched = false;
+  }
+  function newGame() {
+    score = 0; lives = 3; level = 1; buildBricks(level); resetBallPaddle(); updateHud();
+  }
+  function updateHud() { scoreEl.textContent = score; livesEl.textContent = lives; lvlEl.textContent = level; }
+
+  function launch() { if (!launched) { launched = true; beep(520, 0.06, 'square', 0.05); } }
+
+  function tick() {
+    if (!launched) { ball.x = paddle.x + paddle.w / 2; ball.y = paddle.y - ball.r - 1; return; }
+    ball.x += ball.vx; ball.y += ball.vy;
+    // walls
+    if (ball.x - ball.r < 0) { ball.x = ball.r; ball.vx *= -1; beep(300, 0.04, 'square', 0.04); }
+    if (ball.x + ball.r > W) { ball.x = W - ball.r; ball.vx *= -1; beep(300, 0.04, 'square', 0.04); }
+    if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy *= -1; beep(300, 0.04, 'square', 0.04); }
+    // paddle
+    if (ball.vy > 0 && ball.y + ball.r >= paddle.y && ball.y + ball.r <= paddle.y + paddle.h + 8 &&
+        ball.x >= paddle.x - ball.r && ball.x <= paddle.x + paddle.w + ball.r) {
+      var hit = (ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2); // -1..1
+      var speed = Math.min(9, Math.hypot(ball.vx, ball.vy) + 0.06);
+      var ang = hit * 1.05;                 // steer by contact point
+      ball.vx = speed * Math.sin(ang); ball.vy = -Math.abs(speed * Math.cos(ang));
+      ball.y = paddle.y - ball.r - 1; beep(440, 0.05, 'square', 0.05);
+    }
+    // bricks
+    for (var i = 0; i < bricks.length; i++) {
+      var b = bricks[i];
+      if (ball.x + ball.r > b.x && ball.x - ball.r < b.x + b.w && ball.y + ball.r > b.y && ball.y - ball.r < b.y + b.h) {
+        // decide bounce axis by shallowest overlap
+        var overL = ball.x + ball.r - b.x, overR = b.x + b.w - (ball.x - ball.r);
+        var overT = ball.y + ball.r - b.y, overB = b.y + b.h - (ball.y - ball.r);
+        var minX = Math.min(overL, overR), minY = Math.min(overT, overB);
+        if (minX < minY) ball.vx *= -1; else ball.vy *= -1;
+        b.hits--; score += 10; updateHud(); beep(660, 0.05, 'square', 0.05);
+        if (b.hits <= 0) { bricks.splice(i, 1); score += 5; updateHud(); }
+        break;
+      }
+    }
+    // lost ball
+    if (ball.y - ball.r > H) {
+      lives--; updateHud(); beep(160, 0.25, 'sawtooth', 0.07);
+      if (lives <= 0) { gameOver(); return; }
+      resetBallPaddle();
+    }
+    // level clear
+    if (bricks.length === 0) {
+      level++; score += 100; updateHud(); buildBricks(level); resetBallPaddle();
+      beep(880, 0.12, 'triangle', 0.07);
+      overlay('Level ' + level, 'Cleared!', 'Nice. Space or click to launch the next wave.', 'Continue');
+      running = false;
+    }
+  }
+
+  function draw() {
+    ctx.clearRect(0, 0, W, H);
+    for (var i = 0; i < bricks.length; i++) {
+      var b = bricks[i]; ctx.fillStyle = b.color; ctx.globalAlpha = b.hits > 1 ? 1 : 0.82;
+      round(b.x, b.y, b.w, b.h, 4); ctx.fill(); ctx.globalAlpha = 1;
+    }
+    ctx.fillStyle = '#e5e7eb'; round(paddle.x, paddle.y, paddle.w, paddle.h, 6); ctx.fill();
+    ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.r, 0, 7); ctx.fillStyle = '#a7f3d0'; ctx.fill();
+  }
+  function round(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(ts) {
+    requestAnimationFrame(loop);
+    if (!running || paused) { last = ts; return; }
+    if (!last) last = ts;
+    var steps = Math.min(3, Math.round((ts - last) / 16.67)) || 1; last = ts;
+    for (var s = 0; s < steps; s++) if (running) tick();
+    draw();
+  }
+
+  function overlay(tag, title, sub, btn) { ovTag.textContent = tag; ovTitle.textContent = title; ovSub.textContent = sub; playBtn.textContent = btn; ov.classList.remove('hide'); }
+  function gameOver() {
+    running = false;
+    if (score > best) { best = score; localStorage.setItem(BEST_KEY, best); bestEl.textContent = best; }
+    overlay(score >= best && score > 0 ? 'New Best!' : 'Game Over', 'Score ' + score, 'Best ' + best + '. Play again?', 'Play again');
+  }
+
+  function start() {
+    // If overlay was a level-clear pause, just resume; otherwise fresh game.
+    if (bricks && bricks.length > 0 && lives > 0 && !paused && running === false && launched === false && score > 0) {
+      running = true; ov.classList.add('hide'); last = 0; return;
+    }
+    newGame(); running = true; paused = false; last = 0; ov.classList.add('hide');
+    if (actx && actx.state === 'suspended') actx.resume();
+  }
+  function togglePause() {
+    if (!running && !paused) return;
+    paused = !paused; pauseBtn.textContent = paused ? '▶' : '⏸';
+    if (paused) overlay('Paused', 'Paused', 'Press P to resume.', 'Resume');
+    else { ov.classList.add('hide'); last = 0; }
+  }
+
+  // input
+  function movePaddleTo(px) { paddle.x = Math.max(0, Math.min(W - paddle.w, px - paddle.w / 2)); }
+  cv.addEventListener('mousemove', function (e) { if (!paddle) return; var r = cv.getBoundingClientRect(); movePaddleTo((e.clientX - r.left) * (W / r.width)); });
+  cv.addEventListener('click', function () { if (!running && !paused) start(); else launch(); });
+  cv.addEventListener('touchmove', function (e) { if (!paddle) return; var r = cv.getBoundingClientRect(); movePaddleTo((e.touches[0].clientX - r.left) * (W / r.width)); e.preventDefault(); }, { passive: false });
+  cv.addEventListener('touchstart', function () { if (!running && !paused) start(); else launch(); }, { passive: true });
+
+  var leftDown = false, rightDown = false;
+  window.addEventListener('keydown', function (e) {
+    var k = e.key.toLowerCase();
+    if (k === 'arrowleft' || k === 'a') { leftDown = true; e.preventDefault(); }
+    else if (k === 'arrowright' || k === 'd') { rightDown = true; e.preventDefault(); }
+    else if (k === ' ') { if (!running && !paused) start(); else launch(); e.preventDefault(); }
+    else if (k === 'p' || k === 'escape') togglePause();
+    else if (k === 'm') toggleMute();
+  });
+  window.addEventListener('keyup', function (e) {
+    var k = e.key.toLowerCase();
+    if (k === 'arrowleft' || k === 'a') leftDown = false;
+    if (k === 'arrowright' || k === 'd') rightDown = false;
+  });
+  // keyboard paddle drift
+  setInterval(function () {
+    if (!paddle || !running || paused) return;
+    if (leftDown) movePaddleTo(paddle.x + paddle.w / 2 - paddle.speed);
+    if (rightDown) movePaddleTo(paddle.x + paddle.w / 2 + paddle.speed);
+  }, 16);
+
+  playBtn.addEventListener('click', function () { if (paused) togglePause(); else start(); });
+  pauseBtn.addEventListener('click', togglePause);
+  function toggleMute() { muted = !muted; muteBtn.textContent = muted ? '🔈' : '🔊'; }
+  muteBtn.addEventListener('click', toggleMute);
+  window.addEventListener('blur', function () { if (running && !paused) togglePause(); });
+
+  newGame(); draw();
+  requestAnimationFrame(loop);
+})();
+</script>
+</body>
+</html>
diff --git a/games/snake/index.html b/games/snake/index.html
new file mode 100644
index 0000000..60cb63c
--- /dev/null
+++ b/games/snake/index.html
@@ -0,0 +1,211 @@
+<!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 Snake</title>
+<style>
+  :root { --bg:#0a0e14; --grid:#121821; --snake:#4ade80; --head:#a7f3d0; --food:#f472b6; --ink:#e5e7eb; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; }
+  body {
+    background:radial-gradient(1200px 600px at 50% -10%, #16202e, var(--bg));
+    color:var(--ink); font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
+    display:flex; flex-direction:column; align-items:center; justify-content:center;
+    min-height:100%; gap:10px; padding:12px; overscroll-behavior:none; touch-action:none;
+  }
+  h1 { font-size:14px; letter-spacing:.28em; text-transform:uppercase; color:#93c5fd; font-weight:600; }
+  .hud { display:flex; gap:18px; font-variant-numeric:tabular-nums; font-size:13px; color:#9aa4b2; }
+  .hud b { color:var(--ink); font-weight:600; }
+  #wrap { position:relative; }
+  canvas { background:var(--grid); border-radius:12px; box-shadow:0 10px 40px rgba(0,0,0,.5); display:block; }
+  .overlay {
+    position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
+    gap:10px; text-align:center; background:rgba(8,12,18,.72); border-radius:12px; backdrop-filter:blur(2px);
+  }
+  .overlay.hide { display:none; }
+  .big { font-size:22px; font-weight:700; letter-spacing:.02em; }
+  .sub { font-size:13px; color:#9aa4b2; max-width:280px; }
+  .tag { font-size:11px; color:#93c5fd; letter-spacing:.2em; text-transform:uppercase; }
+  button.play {
+    margin-top:4px; padding:10px 22px; border:0; border-radius:999px; cursor:pointer;
+    background:linear-gradient(180deg,#60a5fa,#3b82f6); color:#fff; font-weight:700; font-size:14px;
+  }
+  .btns { position:absolute; top:8px; right:10px; display:flex; gap:6px; }
+  .btns button { width:30px; height:30px; border-radius:8px; border:1px solid #2a3441; background:#141b25; color:#cbd5e1; cursor:pointer; font-size:14px; }
+  .hint { font-size:11px; color:#5b6673; }
+</style>
+</head>
+<body>
+  <h1>Abrams Snake</h1>
+  <div class="hud"><span>Score <b id="score">0</b></span><span>Best <b id="best">0</b></span><span>Speed <b id="lvl">1</b></span></div>
+  <div id="wrap">
+    <canvas id="c" width="420" height="420"></canvas>
+    <div class="btns">
+      <button id="mute" title="Mute (M)">🔊</button>
+      <button id="pauseBtn" title="Pause (P)">⏸</button>
+    </div>
+    <div class="overlay" id="ov">
+      <div class="tag" id="ovTag">Ready</div>
+      <div class="big" id="ovTitle">Abrams Snake</div>
+      <div class="sub" id="ovSub">Eat the pink. Don't hit the walls or yourself. Arrows / WASD to steer, swipe on touch.</div>
+      <button class="play" id="playBtn">Play</button>
+      <div class="hint">P pause · M mute</div>
+    </div>
+  </div>
+
+<script>
+(function () {
+  var cv = document.getElementById('c'), ctx = cv.getContext('2d');
+  var N = 21, CELL = cv.width / N;               // 21x21 grid
+  var scoreEl = document.getElementById('score'), bestEl = document.getElementById('best'), lvlEl = document.getElementById('lvl');
+  var ov = document.getElementById('ov'), ovTag = document.getElementById('ovTag'), ovTitle = document.getElementById('ovTitle'), ovSub = document.getElementById('ovSub');
+  var playBtn = document.getElementById('playBtn'), pauseBtn = document.getElementById('pauseBtn'), muteBtn = document.getElementById('mute');
+
+  var BEST_KEY = 'abrams-snake-best';
+  var best = parseInt(localStorage.getItem(BEST_KEY) || '0', 10) || 0; bestEl.textContent = best;
+
+  var snake, dir, nextDir, food, score, alive, running, paused, muted = false;
+  var stepMs, acc = 0, last = 0;
+
+  // ---- audio (procedural, zero-dep) ----
+  var actx = null;
+  function beep(freq, dur, type, vol) {
+    if (muted) return;
+    try {
+      if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
+      var o = actx.createOscillator(), g = actx.createGain();
+      o.type = type || 'square'; o.frequency.value = freq;
+      g.gain.value = vol || 0.05;
+      o.connect(g); g.connect(actx.destination);
+      var t = actx.currentTime; o.start(t);
+      g.gain.exponentialRampToValueAtTime(0.0001, t + (dur || 0.09));
+      o.stop(t + (dur || 0.09));
+    } catch (e) {}
+  }
+
+  function reset() {
+    snake = [{ x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 }];
+    dir = { x: 1, y: 0 }; nextDir = dir;
+    score = 0; alive = true; stepMs = 130; acc = 0;
+    placeFood(); updateHud();
+  }
+  function placeFood() {
+    do { food = { x: (Math.random() * N) | 0, y: (Math.random() * N) | 0 }; }
+    while (snake.some(function (s) { return s.x === food.x && s.y === food.y; }));
+  }
+  function updateHud() {
+    scoreEl.textContent = score;
+    lvlEl.textContent = Math.max(1, Math.min(9, 1 + ((score / 5) | 0)));
+  }
+
+  function step() {
+    dir = nextDir;
+    var head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
+    // wall or self collision = death
+    if (head.x < 0 || head.y < 0 || head.x >= N || head.y >= N ||
+        snake.some(function (s) { return s.x === head.x && s.y === head.y; })) {
+      die(); return;
+    }
+    snake.unshift(head);
+    if (head.x === food.x && head.y === food.y) {
+      score++; updateHud(); beep(660, 0.08, 'square', 0.06);
+      stepMs = Math.max(60, 130 - score * 4);   // speed up
+      placeFood();
+    } else {
+      snake.pop();
+    }
+  }
+
+  function die() {
+    alive = false; running = false; beep(160, 0.3, 'sawtooth', 0.08);
+    if (score > best) { best = score; localStorage.setItem(BEST_KEY, best); bestEl.textContent = best; }
+    ovTag.textContent = score >= best && score > 0 ? 'New Best!' : 'Game Over';
+    ovTitle.textContent = 'Score ' + score;
+    ovSub.textContent = 'Best ' + best + '. Press Play or hit Space to go again.';
+    playBtn.textContent = 'Play again';
+    ov.classList.remove('hide');
+  }
+
+  function draw() {
+    ctx.clearRect(0, 0, cv.width, cv.height);
+    // subtle grid dots
+    ctx.fillStyle = 'rgba(255,255,255,0.03)';
+    for (var i = 0; i < N; i++) for (var j = 0; j < N; j++) ctx.fillRect(i * CELL + CELL / 2 - 1, j * CELL + CELL / 2 - 1, 2, 2);
+    // food
+    ctx.fillStyle = '#f472b6';
+    roundRect(food.x * CELL + 3, food.y * CELL + 3, CELL - 6, CELL - 6, 5); ctx.fill();
+    // snake
+    for (var k = snake.length - 1; k >= 0; k--) {
+      ctx.fillStyle = k === 0 ? '#a7f3d0' : 'rgba(74,222,128,' + (0.55 + 0.45 * (1 - k / snake.length)) + ')';
+      roundRect(snake[k].x * CELL + 2, snake[k].y * CELL + 2, CELL - 4, CELL - 4, 5); ctx.fill();
+    }
+  }
+  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(ts) {
+    requestAnimationFrame(loop);
+    if (!running || paused) { last = ts; return; }
+    if (!last) last = ts;
+    acc += ts - last; last = ts;
+    while (acc >= stepMs) { acc -= stepMs; if (alive) step(); }
+    draw();
+  }
+
+  function start() {
+    reset(); running = true; paused = false; last = 0;
+    ov.classList.add('hide'); draw();
+    if (actx && actx.state === 'suspended') actx.resume();
+  }
+  function togglePause() {
+    if (!running) return;
+    paused = !paused;
+    pauseBtn.textContent = paused ? '▶' : '⏸';
+    if (paused) { ovTag.textContent = 'Paused'; ovTitle.textContent = 'Paused'; ovSub.textContent = 'Press P to resume.'; playBtn.textContent = 'Resume'; ov.classList.remove('hide'); }
+    else { ov.classList.add('hide'); last = 0; }
+  }
+
+  function setDir(x, y) {
+    // no 180° reversal
+    if (x === -dir.x && y === -dir.y) return;
+    nextDir = { x: x, y: y };
+  }
+
+  // ---- input ----
+  window.addEventListener('keydown', function (e) {
+    var k = e.key.toLowerCase();
+    if (k === 'arrowup' || k === 'w') { setDir(0, -1); e.preventDefault(); }
+    else if (k === 'arrowdown' || k === 's') { setDir(0, 1); e.preventDefault(); }
+    else if (k === 'arrowleft' || k === 'a') { setDir(-1, 0); e.preventDefault(); }
+    else if (k === 'arrowright' || k === 'd') { setDir(1, 0); e.preventDefault(); }
+    else if (k === 'p' || k === 'escape') { togglePause(); }
+    else if (k === 'm') { toggleMute(); }
+    else if (k === ' ') { if (!running) start(); e.preventDefault(); }
+  });
+  playBtn.addEventListener('click', function () { if (paused) { togglePause(); } else { start(); } });
+  pauseBtn.addEventListener('click', togglePause);
+  function toggleMute() { muted = !muted; muteBtn.textContent = muted ? '🔈' : '🔊'; }
+  muteBtn.addEventListener('click', toggleMute);
+
+  // touch swipe
+  var tsx = 0, tsy = 0;
+  cv.addEventListener('touchstart', function (e) { var t = e.touches[0]; tsx = t.clientX; tsy = t.clientY; }, { passive: true });
+  cv.addEventListener('touchend', function (e) {
+    var t = e.changedTouches[0], dx = t.clientX - tsx, dy = t.clientY - tsy;
+    if (Math.abs(dx) < 12 && Math.abs(dy) < 12) { if (!running) start(); return; }
+    if (Math.abs(dx) > Math.abs(dy)) setDir(dx > 0 ? 1 : -1, 0); else setDir(0, dy > 0 ? 1 : -1);
+  }, { passive: true });
+
+  // pause when iframe loses focus
+  window.addEventListener('blur', function () { if (running && !paused) togglePause(); });
+
+  reset(); draw();
+  requestAnimationFrame(loop);
+})();
+</script>
+</body>
+</html>
diff --git a/tests/e2e.mjs b/tests/e2e.mjs
index 8d56876..d1952ee 100644
--- a/tests/e2e.mjs
+++ b/tests/e2e.mjs
@@ -41,7 +41,7 @@ const check = (name, ok) => { results.push({ name, ok }); console.log((ok ? 'PAS
 await page.goto(url);
 await page.waitForSelector('.game');
 check('game list renders from manifest', await page.locator('.game').count() >= 1);
-check('RubiksCube.aa listed', (await page.locator('.game .fname').first().textContent()) === 'RubiksCube.aa');
+check('AbramsCube.aa listed', (await page.locator('.game .fname').first().textContent()) === 'AbramsCube.aa');
 check('first game auto-selected', await page.locator('.game.active').count() === 1);
 
 const frame = page.frameLocator('#frame');
diff --git a/tests/new-games.mjs b/tests/new-games.mjs
new file mode 100644
index 0000000..4269bc8
--- /dev/null
+++ b/tests/new-games.mjs
@@ -0,0 +1,79 @@
+// Headless smoke test for the newer games (snake, breakout, 2048).
+// Loads each standalone in a real Chromium page, drives a few inputs, and
+// asserts it renders and throws no console/page errors. Serves over http
+// because several games fetch localStorage/AudioContext that dislike file://.
+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();
+}
+
+// --- snake: press Play, steer, confirm canvas + a running loop (no error) ---
+await run('snake', '/games/snake/', async (page) => {
+  await page.click('#playBtn');
+  for (const k of ['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft']) { await page.keyboard.press(k); await page.waitForTimeout(120); }
+}, async (page) => (await page.locator('canvas#c').count()) === 1 && await page.locator('#ov').evaluate(el => el.classList.contains('hide')));
+
+// --- breakout: Play, launch, move paddle, confirm canvas + overlay hidden ---
+await run('breakout', '/games/breakout/', async (page) => {
+  await page.click('#playBtn');
+  await page.keyboard.press('Space');
+  await page.keyboard.down('ArrowRight'); await page.waitForTimeout(200); await page.keyboard.up('ArrowRight');
+  await page.waitForTimeout(300);
+}, async (page) => (await page.locator('canvas#c').count()) === 1 && await page.locator('#ov').evaluate(el => el.classList.contains('hide')));
+
+// --- 2048: starts with 2 tiles; a move keeps a valid board and score >= 0 ---
+await run('2048', '/games/2048/', async (page) => {
+  for (const k of ['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown']) { await page.keyboard.press(k); await page.waitForTimeout(120); }
+}, async (page) => {
+  const tiles = await page.locator('#board .tile').count();                 // always 16 cells
+  const filled = await page.locator('#board .tile').evaluateAll(els => els.filter(e => e.textContent.trim() !== '').length);
+  const score = parseInt(await page.locator('#score').textContent(), 10);
+  return tiles === 16 && filled >= 2 && score >= 0;
+});
+
+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);

← 8784aaf Add Abrams Blocks (Tetris) to the arcade — a genuinely diffe  ·  back to Games Agentabrams  ·  Fix Flappy Sky ghosting: opaque per-frame sky clear (was tra a788194 →