[object Object]

← back to Games Agentabrams

initial scaffold: games hub (left panel manifest list, right panel iframe) + RubiksCube.aa

cd005d2e235ecc13772e99ee3743017c5fb773ba · 2026-07-23 11:03:28 -0700 · Steve Abrams

Files touched

Diff

commit cd005d2e235ecc13772e99ee3743017c5fb773ba
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 23 11:03:28 2026 -0700

    initial scaffold: games hub (left panel manifest list, right panel iframe) + RubiksCube.aa
---
 .gitignore                   |   9 +
 README.md                    |  31 ++++
 games.json                   |  12 ++
 games/rubiks-cube/index.html | 399 +++++++++++++++++++++++++++++++++++++++++++
 index.html                   | 121 +++++++++++++
 tests/e2e.mjs                |  70 ++++++++
 6 files changed, 642 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..31b0a23
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+tests/screenshot.png
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..512b784
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# games.agentabrams.com — Agent Abrams Arcade
+
+Static games hub: left panel lists every game (file-browser style, `*.aa` names),
+right panel runs the selected game in an iframe. Zero dependencies, zero build step.
+
+Live: https://games.agentabrams.com (Kamatera, `/var/www/games.agentabrams.com`, nginx static)
+
+## Adding a game
+
+1. Drop a self-contained game at `games/<id>/index.html` (must run standalone in an iframe).
+2. Add an entry to `games.json`:
+
+```json
+{ "id": "<id>", "file": "<Name>.aa", "title": "...", "desc": "...", "path": "games/<id>/", "added": "YYYY-MM-DD" }
+```
+
+3. Deploy: `rsync -az --delete --exclude .git --exclude tests ./ root@45.61.58.125:/var/www/games.agentabrams.com/`
+
+The hub auto-selects the last-played game (localStorage) and supports `#<id>` deep links.
+
+## Games
+
+| File | What | Source |
+|---|---|---|
+| `RubiksCube.aa` | 3D Rubik's cube, pure CSS 3D — orbit, zoom, scramble, undo, timer, solved detection | synced from `~/Projects/rubik-cube-3d` |
+
+## Tests
+
+```sh
+node tests/e2e.mjs   # headless: manifest render, iframe game playable, deep links
+```
diff --git a/games.json b/games.json
new file mode 100644
index 0000000..e6e6c6c
--- /dev/null
+++ b/games.json
@@ -0,0 +1,12 @@
+{
+  "games": [
+    {
+      "id": "rubiks-cube",
+      "file": "RubiksCube.aa",
+      "title": "Rubik's Cube 3D",
+      "desc": "Pure CSS-3D Rubik's cube — drag to orbit, scramble, solve, undo, timer.",
+      "path": "games/rubiks-cube/",
+      "added": "2026-07-23"
+    }
+  ]
+}
diff --git a/games/rubiks-cube/index.html b/games/rubiks-cube/index.html
new file mode 100644
index 0000000..911cb58
--- /dev/null
+++ b/games/rubiks-cube/index.html
@@ -0,0 +1,399 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Rubik's Cube — CSS 3D</title>
+<style>
+  :root { --s: 60px; }
+  * { box-sizing: border-box; user-select: none; -webkit-user-select: none; }
+  html, body { height: 100%; }
+  body {
+    margin: 0; display: flex; flex-direction: column; align-items: center;
+    background: radial-gradient(circle at 50% 30%, #2b303c, #12141a 75%);
+    font-family: system-ui, -apple-system, sans-serif; color: #e8e9ef; overflow: hidden;
+  }
+  h1 { font-size: 16px; font-weight: 600; letter-spacing: 3px; text-transform: uppercase; margin: 14px 0 2px; opacity: .85; }
+  .hint { font-size: 12px; opacity: .5; margin: 0 0 2px; text-align: center; padding: 0 12px; }
+  #hud {
+    display: flex; gap: 18px; font-size: 13px; opacity: .85; margin: 4px 0 0;
+    font-variant-numeric: tabular-nums; letter-spacing: .5px;
+  }
+  #hud b { font-weight: 700; color: #fff; }
+  #viewport {
+    flex: 1; width: 100%; display: flex; align-items: center; justify-content: center;
+    perspective: 1000px; cursor: grab; touch-action: none; position: relative;
+  }
+  #viewport.dragging { cursor: grabbing; }
+  #banner {
+    position: absolute; left: 50%; top: 14%; transform: translateX(-50%);
+    background: linear-gradient(180deg, #2f8f5b, #1f6f44); border: 1px solid #3fae72;
+    border-radius: 12px; padding: 12px 22px; font-size: 16px; font-weight: 700;
+    letter-spacing: .5px; box-shadow: 0 8px 30px rgba(0,0,0,.45);
+    opacity: 0; transition: opacity .35s; pointer-events: none; white-space: nowrap;
+  }
+  #banner.show { opacity: 1; }
+  #cube { position: relative; width: 0; height: 0; transform-style: preserve-3d; }
+  .cubie {
+    position: absolute; width: var(--s); height: var(--s);
+    left: calc(var(--s) / -2); top: calc(var(--s) / -2);
+    transform-style: preserve-3d;
+  }
+  .face {
+    position: absolute; inset: 0; border-radius: 14%;
+    border: 3px solid #0b0d11;
+    box-shadow: inset 0 0 6px rgba(0,0,0,.35);
+  }
+  .f-front  { transform: translateZ(calc(var(--s) / 2)); }
+  .f-back   { transform: rotateY(180deg) translateZ(calc(var(--s) / 2)); }
+  .f-right  { transform: rotateY(90deg)  translateZ(calc(var(--s) / 2)); }
+  .f-left   { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
+  .f-top    { transform: rotateX(90deg)  translateZ(calc(var(--s) / 2)); }
+  .f-bottom { transform: rotateX(-90deg) translateZ(calc(var(--s) / 2)); }
+  #controls { padding: 8px 12px 16px; display: flex; flex-direction: column; gap: 7px; align-items: center; }
+  .row { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
+  button {
+    font: 600 14px/1 system-ui, sans-serif; color: #e8e9ef;
+    background: linear-gradient(180deg, #3a4152, #2a3040);
+    border: 1px solid #4a5268; border-radius: 9px;
+    min-width: 44px; padding: 10px 8px; cursor: pointer;
+    transition: transform .08s, background .15s;
+  }
+  button:hover { background: linear-gradient(180deg, #4a5268, #333a4c); }
+  button:active { transform: scale(.93); }
+  button.slice { opacity: .8; }
+  .big { min-width: 96px; letter-spacing: 1px; }
+  #scramble { background: linear-gradient(180deg, #7c5cff, #5b3fe0); border-color: #8f78ff; }
+  #scramble:hover { background: linear-gradient(180deg, #8d70ff, #6a4ef0); }
+  #reset { background: linear-gradient(180deg, #2f8f5b, #1f6f44); border-color: #3fae72; }
+  #reset:hover { background: linear-gradient(180deg, #37a066, #27804f); }
+  #undo { background: linear-gradient(180deg, #b3823a, #8f6425); border-color: #cf9c50; }
+  #undo:hover { background: linear-gradient(180deg, #c4914a, #a0722e); }
+</style>
+</head>
+<body>
+<h1>Rubik's Cube</h1>
+<p class="hint">Drag to orbit &middot; scroll to zoom &middot; keys U/D/L/R/F/B/M/E/S turn faces (Shift = counter-clockwise) &middot; &#8984;Z / Ctrl+Z undo</p>
+<div id="hud"><span>Moves <b id="movesHud">0</b></span><span>Time <b id="timeHud">0:00</b></span></div>
+<div id="viewport">
+  <div id="cube"></div>
+  <div id="banner"></div>
+</div>
+<div id="controls">
+  <div class="row" id="movesRow"></div>
+  <div class="row" id="primesRow"></div>
+  <div class="row">
+    <button id="scramble" class="big">Scramble</button>
+    <button id="undo" class="big">Undo</button>
+    <button id="reset" class="big">Reset</button>
+  </div>
+</div>
+<script>
+(function () {
+  var viewport = document.getElementById('viewport');
+  var cubeEl = document.getElementById('cube');
+  var banner = document.getElementById('banner');
+  var movesHud = document.getElementById('movesHud');
+  var timeHud = document.getElementById('timeHud');
+
+  var S = 60;
+  function sizeCube() {
+    S = Math.max(34, Math.min(72, Math.floor(Math.min(innerWidth, innerHeight - 230) / 5.2)));
+    document.documentElement.style.setProperty('--s', S + 'px');
+  }
+  sizeCube();
+
+  var DARK = '#171a20';
+  var I3 = [1,0,0, 0,1,0, 0,0,1];
+
+  function mul(a, b) { // 3x3 row-major
+    var r = new Array(9);
+    for (var i = 0; i < 3; i++) for (var j = 0; j < 3; j++)
+      r[i*3+j] = a[i*3]*b[j] + a[i*3+1]*b[3+j] + a[i*3+2]*b[6+j];
+    return r;
+  }
+  function applyM(m, v) {
+    return [ m[0]*v[0]+m[1]*v[1]+m[2]*v[2],
+             m[3]*v[0]+m[4]*v[1]+m[5]*v[2],
+             m[6]*v[0]+m[7]*v[1]+m[8]*v[2] ];
+  }
+  function roundM(m) { return m.map(function (v) { return Math.round(v); }); }
+  function axisRot(axis, deg) {
+    var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
+    if (axis === 'x') return [1,0,0, 0,c,-s, 0,s,c];
+    if (axis === 'y') return [c,0,s, 0,1,0, -s,0,c];
+    return [c,-s,0, s,c,0, 0,0,1];
+  }
+
+  var cubies = [];
+  function makeFace(cls, color) {
+    var f = document.createElement('div');
+    f.className = 'face ' + cls;
+    f.style.background = color === DARK ? DARK
+      : 'linear-gradient(145deg, ' + color + ', ' + color + ' 60%, rgba(0,0,0,.18))';
+    if (color !== DARK) f.style.backgroundColor = color;
+    return f;
+  }
+  function build() {
+    cubeEl.innerHTML = '';
+    cubies = [];
+    for (var x = -1; x <= 1; x++) for (var y = -1; y <= 1; y++) for (var z = -1; z <= 1; z++) {
+      if (!x && !y && !z) continue;
+      var el = document.createElement('div');
+      el.className = 'cubie';
+      el.appendChild(makeFace('f-top',    y === -1 ? '#f4f6f5' : DARK)); // white up
+      el.appendChild(makeFace('f-bottom', y ===  1 ? '#ffd60a' : DARK)); // yellow down
+      el.appendChild(makeFace('f-right',  x ===  1 ? '#e5484d' : DARK)); // red right
+      el.appendChild(makeFace('f-left',   x === -1 ? '#ff9f1c' : DARK)); // orange left
+      el.appendChild(makeFace('f-front',  z ===  1 ? '#30c04f' : DARK)); // green front
+      el.appendChild(makeFace('f-back',   z === -1 ? '#3b82f6' : DARK)); // blue back
+      cubeEl.appendChild(el);
+      var c = { x: x, y: y, z: z, ix: x, iy: y, iz: z, m: I3.slice(), el: el };
+      cubies.push(c);
+      render(c, null);
+    }
+  }
+  function render(c, R) {
+    var m = R ? mul(R, c.m) : c.m;
+    var p = [c.x * S, c.y * S, c.z * S];
+    if (R) p = applyM(R, p);
+    c.el.style.transform = 'matrix3d(' +
+      m[0] + ',' + m[3] + ',' + m[6] + ',0,' +
+      m[1] + ',' + m[4] + ',' + m[7] + ',0,' +
+      m[2] + ',' + m[5] + ',' + m[8] + ',0,' +
+      p[0] + ',' + p[1] + ',' + p[2] + ',1)';
+  }
+
+  // Face turns: clockwise viewed from outside = +90deg about the outward normal.
+  // Slices follow standard notation: M follows L, E follows D, S follows F.
+  var MOVES = {
+    U: { axis: 'y', layer: -1, dir: -1 },
+    D: { axis: 'y', layer:  1, dir:  1 },
+    R: { axis: 'x', layer:  1, dir:  1 },
+    L: { axis: 'x', layer: -1, dir: -1 },
+    F: { axis: 'z', layer:  1, dir:  1 },
+    B: { axis: 'z', layer: -1, dir: -1 },
+    M: { axis: 'x', layer:  0, dir: -1 },
+    E: { axis: 'y', layer:  0, dir:  1 },
+    S: { axis: 'z', layer:  0, dir:  1 }
+  };
+  var OUTER = ['U','D','L','R','F','B'];
+
+  // ---- state ----
+  var queue = [], animating = false, gen = 0;
+  var history = [], moves = 0, scrambled = false;
+  var timerStart = null, elapsed = 0;
+
+  function coordOf(c, axis) { return axis === 'x' ? c.x : axis === 'y' ? c.y : c.z; }
+
+  function enqueue(name, prime, dur, src) {
+    var mv = MOVES[name];
+    if (!mv) return;
+    src = src || 'user';
+    if (src === 'user') {
+      hideBanner();
+      if (scrambled && timerStart === null) timerStart = performance.now();
+    }
+    queue.push({ name: name, prime: !!prime, src: src,
+                 axis: mv.axis, layer: mv.layer, dir: mv.dir * (prime ? -1 : 1),
+                 dur: dur || 260 });
+    if (!animating) next();
+  }
+
+  function next() {
+    var item = queue.shift();
+    if (!item) { animating = false; return; }
+    animating = true;
+    var myGen = gen;
+    var affected = cubies.filter(function (c) { return coordOf(c, item.axis) === item.layer; });
+    var t0 = performance.now();
+    function frame(now) {
+      if (myGen !== gen) return;
+      var t = Math.min(1, (now - t0) / item.dur);
+      var e = t < .5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2;
+      var R = axisRot(item.axis, 90 * item.dir * e);
+      affected.forEach(function (c) { render(c, R); });
+      if (t < 1) { requestAnimationFrame(frame); return; }
+      var Rq = roundM(axisRot(item.axis, 90 * item.dir));
+      affected.forEach(function (c) {
+        var p = applyM(Rq, [c.x, c.y, c.z]).map(Math.round);
+        c.x = p[0]; c.y = p[1]; c.z = p[2];
+        c.m = roundM(mul(Rq, c.m));
+        render(c, null);
+      });
+      commit(item);
+      next();
+    }
+    requestAnimationFrame(frame);
+  }
+
+  function commit(item) {
+    if (item.src === 'user') {
+      history.push({ name: item.name, prime: item.prime });
+      moves++;
+    } else if (item.src === 'undo') {
+      history.pop();
+      moves = Math.max(0, moves - 1);
+    }
+    updateHud();
+    if (scrambled && item.src !== 'scramble' && isSolved()) celebrate();
+  }
+
+  // Solved when every sticker's current world direction equals its home direction.
+  // (Sufficient for any state reachable by legal turns; catches slice moves too.)
+  function isSolved() {
+    for (var i = 0; i < cubies.length; i++) {
+      var c = cubies[i], dirs = [];
+      if (c.ix) dirs.push([c.ix, 0, 0]);
+      if (c.iy) dirs.push([0, c.iy, 0]);
+      if (c.iz) dirs.push([0, 0, c.iz]);
+      for (var j = 0; j < dirs.length; j++) {
+        var d = dirs[j], w = applyM(c.m, d);
+        if (Math.round(w[0]) !== d[0] || Math.round(w[1]) !== d[1] || Math.round(w[2]) !== d[2]) return false;
+      }
+    }
+    return true;
+  }
+
+  function fmtTime(ms) {
+    var s = Math.floor(ms / 1000);
+    return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
+  }
+  function updateHud() { movesHud.textContent = moves; }
+  setInterval(function () {
+    if (timerStart !== null) elapsed = performance.now() - timerStart;
+    timeHud.textContent = fmtTime(elapsed);
+  }, 200);
+
+  function celebrate() {
+    scrambled = false;
+    if (timerStart !== null) { elapsed = performance.now() - timerStart; timerStart = null; }
+    banner.textContent = '🎉 Solved in ' + moves + ' move' + (moves === 1 ? '' : 's') +
+      (elapsed > 0 ? ' · ' + fmtTime(elapsed) : '') + '!';
+    banner.classList.add('show');
+    clearTimeout(celebrate.t);
+    celebrate.t = setTimeout(hideBanner, 6000);
+  }
+  function hideBanner() { banner.classList.remove('show'); }
+
+  function scramble() {
+    hideBanner();
+    scrambled = true;
+    history.length = 0; moves = 0;
+    timerStart = null; elapsed = 0;
+    updateHud();
+    var last = '';
+    for (var i = 0; i < 22; i++) {
+      var n;
+      do { n = OUTER[Math.floor(Math.random() * OUTER.length)]; } while (n === last);
+      last = n;
+      enqueue(n, Math.random() < .5, 110, 'scramble');
+    }
+  }
+
+  function reset() {
+    gen++; queue.length = 0; animating = false;
+    history.length = 0; moves = 0;
+    scrambled = false; timerStart = null; elapsed = 0;
+    hideBanner(); updateHud();
+    cubies.forEach(function (c) {
+      c.x = c.ix; c.y = c.iy; c.z = c.iz; c.m = I3.slice();
+      render(c, null);
+    });
+  }
+
+  function undo() {
+    if (animating || queue.length || !history.length) return;
+    var last = history[history.length - 1];
+    enqueue(last.name, !last.prime, 200, 'undo');
+  }
+
+  // ---- controls ----
+  var movesRow = document.getElementById('movesRow');
+  var primesRow = document.getElementById('primesRow');
+  ['U','D','L','R','F','B','M','E','S'].forEach(function (n) {
+    var slice = 'MES'.indexOf(n) !== -1;
+    var b = document.createElement('button');
+    b.textContent = n;
+    if (slice) b.className = 'slice';
+    b.title = 'Turn ' + n + ' clockwise';
+    b.onclick = function () { enqueue(n, false); };
+    movesRow.appendChild(b);
+    var bp = document.createElement('button');
+    bp.textContent = n + '′';
+    if (slice) bp.className = 'slice';
+    bp.title = 'Turn ' + n + ' counter-clockwise';
+    bp.onclick = function () { enqueue(n, true); };
+    primesRow.appendChild(bp);
+  });
+  document.getElementById('scramble').onclick = scramble;
+  document.getElementById('reset').onclick = reset;
+  document.getElementById('undo').onclick = undo;
+
+  document.addEventListener('keydown', function (ev) {
+    if (ev.metaKey || ev.ctrlKey) {
+      if (ev.key.toLowerCase() === 'z') { ev.preventDefault(); undo(); }
+      return;
+    }
+    if (ev.repeat) return;
+    var k = ev.key.toUpperCase();
+    if (MOVES[k]) { ev.preventDefault(); enqueue(k, ev.shiftKey); }
+    else if (ev.key === '+' || ev.key === '=') setZoom(zoom * 1.12);
+    else if (ev.key === '-' || ev.key === '_') setZoom(zoom / 1.12);
+  });
+
+  // ---- orbit + zoom ----
+  var rx = -28, ry = -38, zoom = 1, dragging = false, px = 0, py = 0;
+  function applyOrbit() {
+    cubeEl.style.transform = 'scale3d(' + zoom + ',' + zoom + ',' + zoom + ') ' +
+      'rotateX(' + rx + 'deg) rotateY(' + ry + 'deg)';
+  }
+  function setZoom(z) { zoom = Math.max(.5, Math.min(1.9, z)); applyOrbit(); }
+  applyOrbit();
+
+  viewport.addEventListener('pointerdown', function (ev) {
+    dragging = true; px = ev.clientX; py = ev.clientY;
+    viewport.classList.add('dragging');
+    viewport.setPointerCapture(ev.pointerId);
+  });
+  viewport.addEventListener('pointermove', function (ev) {
+    if (!dragging) return;
+    ry += (ev.clientX - px) * 0.4;
+    rx -= (ev.clientY - py) * 0.4;
+    rx = Math.max(-90, Math.min(90, rx));
+    px = ev.clientX; py = ev.clientY;
+    applyOrbit();
+  });
+  function endDrag() { dragging = false; viewport.classList.remove('dragging'); }
+  viewport.addEventListener('pointerup', endDrag);
+  viewport.addEventListener('pointercancel', endDrag);
+  viewport.addEventListener('wheel', function (ev) {
+    ev.preventDefault();
+    setZoom(zoom * Math.exp(-ev.deltaY * 0.0012));
+  }, { passive: false });
+
+  var resizeT;
+  addEventListener('resize', function () {
+    clearTimeout(resizeT);
+    resizeT = setTimeout(function () {
+      sizeCube();
+      cubies.forEach(function (c) { render(c, null); });
+    }, 120);
+  });
+
+  build();
+
+  // Test/debug hook (harmless in normal use)
+  window.__cube = {
+    isSolved: isSolved,
+    isBusy: function () { return animating || queue.length > 0; },
+    getMoves: function () { return moves; },
+    getHistoryLength: function () { return history.length; },
+    getOrbit: function () { return { rx: rx, ry: ry, zoom: zoom }; },
+    enqueue: function (n, p) { enqueue(n, p, 40); },
+    scramble: scramble, reset: reset, undo: undo
+  };
+})();
+</script>
+</body>
+</html>
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..3aa77db
--- /dev/null
+++ b/index.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>games.agentabrams.com</title>
+<style>
+  * { box-sizing: border-box; }
+  html, body { height: 100%; margin: 0; }
+  body {
+    display: flex; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+    background: #12141a; color: #e8e9ef; overflow: hidden;
+  }
+  /* ---- left panel ---- */
+  #panel {
+    width: 260px; min-width: 260px; height: 100%; display: flex; flex-direction: column;
+    background: linear-gradient(180deg, #1a1e28, #14171f);
+    border-right: 1px solid #2b3140;
+  }
+  #brand {
+    padding: 18px 16px 14px; border-bottom: 1px solid #2b3140;
+  }
+  #brand .dot { color: #7c5cff; }
+  #brand h1 {
+    margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 1.5px;
+  }
+  #brand .sub { font-size: 10px; opacity: .45; letter-spacing: 1px; margin-top: 4px; }
+  #list { flex: 1; overflow-y: auto; padding: 10px 8px; }
+  #list .dir { font-size: 10px; opacity: .4; letter-spacing: 1px; padding: 4px 8px 8px; }
+  .game {
+    display: block; width: 100%; text-align: left; cursor: pointer;
+    background: none; border: 1px solid transparent; border-radius: 8px;
+    color: #e8e9ef; font: inherit; padding: 9px 10px; margin-bottom: 2px;
+  }
+  .game:hover { background: #222839; }
+  .game.active { background: #2a3147; border-color: #3d4763; }
+  .game .fname { font-size: 13px; font-weight: 600; }
+  .game .fname::before { content: "▸ "; color: #7c5cff; }
+  .game.active .fname::before { content: "▾ "; }
+  .game .fdesc { font-size: 10.5px; opacity: .5; margin-top: 3px; line-height: 1.4;
+    display: none; }
+  .game.active .fdesc { display: block; }
+  #panel footer {
+    padding: 12px 16px; border-top: 1px solid #2b3140; font-size: 10px; opacity: .4;
+    letter-spacing: .5px; line-height: 1.6;
+  }
+  /* ---- right panel ---- */
+  #stage { flex: 1; height: 100%; position: relative; background: #0e1016; }
+  #frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
+  #empty {
+    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+    flex-direction: column; gap: 10px; opacity: .35; font-size: 13px; letter-spacing: 1px;
+  }
+  #empty .big { font-size: 40px; }
+  /* ---- mobile ---- */
+  @media (max-width: 700px) {
+    body { flex-direction: column; }
+    #panel { width: 100%; min-width: 0; height: auto; max-height: 45%; }
+    #panel footer { display: none; }
+  }
+</style>
+</head>
+<body>
+<aside id="panel">
+  <div id="brand">
+    <h1><span class="dot">●</span> AGENT ABRAMS ARCADE</h1>
+    <div class="sub">games.agentabrams.com</div>
+  </div>
+  <nav id="list"><div class="dir">~/games/</div></nav>
+  <footer>zero dependencies · pure browser<br>more games landing over time</footer>
+</aside>
+<main id="stage">
+  <iframe id="frame" title="game" hidden></iframe>
+  <div id="empty"><div class="big">🕹</div><div>select a game</div></div>
+</main>
+<script>
+(function () {
+  var list = document.getElementById('list');
+  var frame = document.getElementById('frame');
+  var empty = document.getElementById('empty');
+  var games = [], buttons = {};
+
+  function select(id, push) {
+    var g = games.find(function (x) { return x.id === id; });
+    if (!g) return;
+    Object.keys(buttons).forEach(function (k) { buttons[k].classList.toggle('active', k === id); });
+    frame.hidden = false; empty.style.display = 'none';
+    frame.src = g.path + 'index.html';
+    document.title = g.file + ' — games.agentabrams.com';
+    try { localStorage.setItem('aa-last-game', id); } catch (e) {}
+    if (push !== false) history.replaceState(null, '', '#' + id);
+  }
+
+  fetch('games.json').then(function (r) { return r.json(); }).then(function (data) {
+    games = data.games || [];
+    games.forEach(function (g) {
+      var b = document.createElement('button');
+      b.className = 'game';
+      b.innerHTML = '<div class="fname"></div><div class="fdesc"></div>';
+      b.querySelector('.fname').textContent = g.file;
+      b.querySelector('.fdesc').textContent = g.desc || '';
+      b.onclick = function () { select(g.id); };
+      list.appendChild(b);
+      buttons[g.id] = b;
+    });
+    var want = location.hash.replace('#', '');
+    if (!want) { try { want = localStorage.getItem('aa-last-game'); } catch (e) {} }
+    if (!want || !games.some(function (g) { return g.id === want; })) {
+      want = games.length ? games[0].id : null;
+    }
+    if (want) select(want, false);
+  });
+
+  addEventListener('hashchange', function () {
+    var id = location.hash.replace('#', '');
+    if (id) select(id, false);
+  });
+})();
+</script>
+</body>
+</html>
diff --git a/tests/e2e.mjs b/tests/e2e.mjs
new file mode 100644
index 0000000..8d56876
--- /dev/null
+++ b/tests/e2e.mjs
@@ -0,0 +1,70 @@
+// Headless E2E for the games hub. Needs a static server (fetch of games.json
+// fails on file://), so it spins one up on an ephemeral port.
+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' };
+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 url = `http://127.0.0.1:${server.address().port}/`;
+
+const browser = await chromium.launch();
+const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
+const errors = [];
+page.on('pageerror', e => errors.push('pageerror: ' + e.message));
+page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
+
+const results = [];
+const check = (name, ok) => { results.push({ name, ok }); console.log((ok ? 'PASS' : 'FAIL') + '  ' + name); };
+
+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('first game auto-selected', await page.locator('.game.active').count() === 1);
+
+const frame = page.frameLocator('#frame');
+await frame.locator('#scramble').waitFor({ timeout: 15000 });
+check('game iframe loads the cube', await frame.locator('.cubie').count() === 26);
+
+// game is actually playable inside the frame
+const gameFrame = page.frames().find(f => f.url().includes('rubiks-cube'));
+await gameFrame.evaluate(() => window.__cube.enqueue('U'));
+await gameFrame.waitForFunction(() => !window.__cube.isBusy());
+check('cube playable inside hub', (await gameFrame.evaluate(() => window.__cube.getMoves())) === 1);
+
+// deep link
+await page.goto(url + '#rubiks-cube');
+await page.waitForSelector('.game.active');
+check('hash deep-link selects game', await page.locator('.game.active').count() === 1);
+
+await page.screenshot({ path: path.join(root, 'tests', 'screenshot.png') });
+check('no console/page errors', errors.length === 0);
+if (errors.length) console.log(errors.join('\n'));
+
+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);

(oldest)  ·  back to Games Agentabrams  ·  deploy package: nginx conf + gated one-shot deploy.sh (DNS + 7d4ac89 →