[object Object]

← back to Games Agentabrams

auto-save: 2026-07-24T20:29:36 (10 files) — games/chomp/ games/gems/ games/hopper/ games/invaders/ games/missile/

423a750b39db7b62f5155f85737daea443297b0f · 2026-07-24 20:29:43 -0700 · Steve Abrams

Files touched

Diff

commit 423a750b39db7b62f5155f85737daea443297b0f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 24 20:29:43 2026 -0700

    auto-save: 2026-07-24T20:29:36 (10 files) — games/chomp/ games/gems/ games/hopper/ games/invaders/ games/missile/
---
 games/chomp/index.html    | 855 ++++++++++++++++++++++++++++++++++++++++++++
 games/gems/index.html     | 755 +++++++++++++++++++++++++++++++++++++++
 games/hopper/index.html   | 790 ++++++++++++++++++++++++++++++++++++++++
 games/invaders/index.html | 891 ++++++++++++++++++++++++++++++++++++++++++++++
 games/missile/index.html  | 886 +++++++++++++++++++++++++++++++++++++++++++++
 games/racer/index.html    | 882 +++++++++++++++++++++++++++++++++++++++++++++
 games/sokoban/index.html  | 712 ++++++++++++++++++++++++++++++++++++
 games/sudoku/index.html   | 799 +++++++++++++++++++++++++++++++++++++++++
 games/tron/index.html     | 454 +++++++++++++++++++++++
 games/wordle/index.html   | 477 +++++++++++++++++++++++++
 10 files changed, 7501 insertions(+)

diff --git a/games/chomp/index.html b/games/chomp/index.html
new file mode 100644
index 0000000..e2d9abc
--- /dev/null
+++ b/games/chomp/index.html
@@ -0,0 +1,855 @@
+<!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 Chomp</title>
+<style>
+  :root {
+    --bg:#0a0e14; --grid:#070a10; --wall:#2563eb; --wall-glow:#60a5fa;
+    --player:#fde047; --pellet:#f5d7a1; --power:#fbbf24; --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,.78); 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: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,#facc15,#eab308); color:#1a1500; font-weight:700; font-size:14px;
+  }
+  .btns { position:absolute; top:8px; right:10px; display:flex; gap:6px; z-index:5; }
+  .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; }
+  /* D-pad */
+  #dpad {
+    display:grid; grid-template-columns:repeat(3,52px); grid-template-rows:repeat(3,52px);
+    gap:6px; margin-top:4px; touch-action:none;
+  }
+  #dpad button {
+    border:1px solid #223; border-radius:10px; background:#111926; color:#93c5fd;
+    font-size:20px; cursor:pointer; -webkit-user-select:none; user-select:none; touch-action:none;
+  }
+  #dpad button:active { background:#1b2740; }
+  #dpad .sp { visibility:hidden; }
+  @media (hover:hover) and (pointer:fine) { #dpad { display:none; } }
+</style>
+</head>
+<body>
+  <h1>Abrams Chomp</h1>
+  <div class="hud">
+    <span>Score <b id="score">0</b></span>
+    <span>Lives <b id="lives">3</b></span>
+    <span>Best <b id="best">0</b></span>
+    <span>Lvl <b id="level">1</b></span>
+  </div>
+  <div id="wrap">
+    <canvas id="c" width="448" height="496"></canvas>
+    <div class="btns"><button id="mute" title="Sound">&#128266;</button></div>
+    <div id="overlay" class="overlay">
+      <div class="tag">Neon Maze Chomper</div>
+      <div class="big" id="ovTitle">ABRAMS CHOMP</div>
+      <div class="sub" id="ovSub">Eat every pellet. Grab a power pellet to hunt the ghosts. Arrow keys / WASD or the D-pad.</div>
+      <button class="play" id="playBtn">PLAY</button>
+    </div>
+  </div>
+  <div id="dpad">
+    <button class="sp"></button><button data-dir="up">&#9650;</button><button class="sp"></button>
+    <button data-dir="left">&#9664;</button><button class="sp"></button><button data-dir="right">&#9654;</button>
+    <button class="sp"></button><button data-dir="down">&#9660;</button><button class="sp"></button>
+  </div>
+  <div class="hint">Arrow keys / WASD to steer &middot; P to pause</div>
+
+<script>
+(function () {
+  "use strict";
+
+  // ---- Maze layout (hand-authored ASCII) ----
+  // # wall, . pellet, o power pellet, space empty, - ghost house door, T tunnel row cell
+  // Grid is 28 wide x 31 tall (classic proportions).
+  var MAZE = [
+    "############################",
+    "#............##............#",
+    "#.####.#####.##.#####.####.#",
+    "#o####.#####.##.#####.####o#",
+    "#.####.#####.##.#####.####.#",
+    "#..........................#",
+    "#.####.##.########.##.####.#",
+    "#.####.##.########.##.####.#",
+    "#......##....##....##......#",
+    "######.##### ## #####.######",
+    "######.##### ## #####.######",
+    "######.##          ##.######",
+    "######.## ###--### ##.######",
+    "######.## #      # ##.######",
+    "T     .   #      #   .     T",
+    "######.## #      # ##.######",
+    "######.## ######## ##.######",
+    "######.##          ##.######",
+    "######.## ######## ##.######",
+    "######.## ######## ##.######",
+    "#............##............#",
+    "#.####.#####.##.#####.####.#",
+    "#.####.#####.##.#####.####.#",
+    "#o..##.......  .......##..o#",
+    "###.##.##.########.##.##.###",
+    "###.##.##.########.##.##.###",
+    "#......##....##....##......#",
+    "#.##########.##.##########.#",
+    "#.##########.##.##########.#",
+    "#..........................#",
+    "############################"
+  ];
+
+  var ROWS = MAZE.length, COLS = MAZE[0].length;
+  var TILE = 16;
+
+  var canvas = document.getElementById("c");
+  var ctx = canvas.getContext("2d");
+  canvas.width = COLS * TILE;   // 448
+  canvas.height = ROWS * TILE;  // 496
+
+  // ---- responsive scaling to fit iframe ----
+  function fit() {
+    try {
+      var wrap = document.getElementById("wrap");
+      var pad = 24;
+      var maxW = Math.max(160, (window.innerWidth || canvas.width) - pad);
+      var availH = (window.innerHeight || canvas.height) - 200; // leave room for hud/dpad
+      var maxH = Math.max(160, availH);
+      var scale = Math.min(maxW / canvas.width, maxH / canvas.height, 1.4);
+      if (!isFinite(scale) || scale <= 0) scale = 1;
+      canvas.style.width = Math.round(canvas.width * scale) + "px";
+      canvas.style.height = Math.round(canvas.height * scale) + "px";
+    } catch (e) { /* no-op */ }
+  }
+  window.addEventListener("resize", fit);
+
+  // ---- grid state ----
+  var grid = [];       // char per cell (walls/paths)
+  var pellets = [];     // 0 none, 1 pellet, 2 power
+  var pelletCount = 0;
+
+  function isWall(c, r) {
+    if (r < 0 || r >= ROWS) return true;
+    if (c < 0 || c >= COLS) return false; // horizontal tunnels are open
+    return grid[r][c] === "#";
+  }
+
+  function buildGrid() {
+    grid = [];
+    pellets = [];
+    pelletCount = 0;
+    for (var r = 0; r < ROWS; r++) {
+      var gr = [], pr = [];
+      var line = MAZE[r];
+      for (var c = 0; c < COLS; c++) {
+        var ch = line[c] || " ";
+        var pel = 0;
+        if (ch === ".") { pel = 1; pelletCount++; }
+        else if (ch === "o") { pel = 2; pelletCount++; }
+        gr.push(ch === "#" ? "#" : (ch === "-" ? "-" : " "));
+        pr.push(pel);
+      }
+      grid.push(gr);
+      pellets.push(pr);
+    }
+  }
+
+  // ---- entities ----
+  var DIRS = {
+    up:    { x: 0, y: -1 },
+    down:  { x: 0, y: 1 },
+    left:  { x: -1, y: 0 },
+    right: { x: 1, y: 0 },
+    none:  { x: 0, y: 0 }
+  };
+
+  var player = null;
+  var ghosts = [];
+  var GHOST_COLORS = ["#ef4444", "#f9a8d4", "#22d3ee", "#fb923c"];
+  var GHOST_NAMES = ["blinky", "pinky", "inky", "clyde"];
+
+  function makePlayer() {
+    return {
+      c: 13, r: 23,
+      px: 13 * TILE, py: 23 * TILE,
+      dir: "left", next: "left",
+      speed: 96,   // px / second
+      mouth: 0
+    };
+  }
+
+  // ghost home center (in tiles) and spawn positions inside the house
+  var HOME = { c: 13, r: 14 };
+  var GHOST_SPAWNS = [
+    { c: 13, r: 11 }, // starts outside on top
+    { c: 13, r: 14 },
+    { c: 12, r: 14 },
+    { c: 14, r: 14 }
+  ];
+
+  function makeGhost(i) {
+    var sp = GHOST_SPAWNS[i];
+    return {
+      name: GHOST_NAMES[i],
+      color: GHOST_COLORS[i],
+      c: sp.c, r: sp.r,
+      px: sp.c * TILE, py: sp.r * TILE,
+      dir: (i === 0 ? "left" : "up"),
+      speed: 84 + (level - 1) * 4,   // px / second, ramps with level
+      state: "scatter",     // scatter | chase | frightened | eaten
+      inHouse: i !== 0,
+      releaseAt: i * 2.2,   // seconds staggered release
+      scatterTarget: [
+        { c: COLS - 2, r: 0 }, { c: 1, r: 0 },
+        { c: COLS - 2, r: ROWS - 1 }, { c: 1, r: ROWS - 1 }
+      ][i]
+    };
+  }
+
+  // ---- game state ----
+  var state = "menu"; // menu | play | paused | dead | over | win
+  var score = 0, lives = 3, level = 1, best = 0;
+  var frightTimer = 0;    // seconds remaining of frightened mode
+  var ghostEatChain = 0;  // for 200/400/800/1600 scoring
+  var modeTimer = 0;      // scatter/chase alternation
+  var modePhase = 0;
+  var levelElapsed = 0;   // for staggered ghost release
+  var deathAnim = 0;
+  var readyTimer = 0;
+
+  var BEST_KEY = "abrams-chomp-best";
+  try {
+    var stored = localStorage.getItem(BEST_KEY);
+    if (stored != null && !isNaN(parseInt(stored, 10))) best = parseInt(stored, 10);
+  } catch (e) { best = 0; }
+
+  // scatter/chase schedule (seconds): scatter, chase, scatter, chase...
+  var MODE_SCHEDULE = [7, 20, 7, 20, 5, 20, 5, 99999];
+
+  // ---- audio (lazy, must never throw) ----
+  var audioCtx = null, muted = false;
+  function ensureAudio() {
+    if (audioCtx) return;
+    try {
+      var AC = window.AudioContext || window.webkitAudioContext;
+      if (AC) audioCtx = new AC();
+    } catch (e) { audioCtx = null; }
+  }
+  function blip(freq, dur, type, vol) {
+    if (muted || !audioCtx) return;
+    try {
+      var o = audioCtx.createOscillator();
+      var g = audioCtx.createGain();
+      o.type = type || "square";
+      o.frequency.value = freq;
+      g.gain.value = vol || 0.05;
+      o.connect(g); g.connect(audioCtx.destination);
+      var t = audioCtx.currentTime;
+      g.gain.setValueAtTime(g.gain.value, t);
+      g.gain.exponentialRampToValueAtTime(0.0001, t + (dur || 0.08));
+      o.start(t); o.stop(t + (dur || 0.08));
+    } catch (e) { /* ignore */ }
+  }
+
+  // ---- helpers ----
+  // c/r are the entity's CURRENT tile (the tile its top-left snaps to at a center).
+  // px/py are the top-left pixel; the entity is "centered" when px==c*TILE & py==r*TILE.
+  function canMove(c, r, dir, isGhost, allowDoor) {
+    var d = DIRS[dir];
+    var nc = c + d.x, nr = r + d.y;
+    if (nr < 0 || nr >= ROWS) return false;
+    // tunnel columns
+    if (nc < 0 || nc >= COLS) return true;
+    var cell = grid[nr][nc];
+    if (cell === "#") return false;
+    if (cell === "-") return !!allowDoor; // door only for ghosts entering/leaving
+    return true;
+  }
+
+  function opposite(dir) {
+    return { up: "down", down: "up", left: "right", right: "left", none: "none" }[dir];
+  }
+
+  // ---- input ----
+  var keymap = {
+    ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right",
+    w: "up", s: "down", a: "left", d: "right",
+    W: "up", S: "down", A: "left", D: "right"
+  };
+  window.addEventListener("keydown", function (e) {
+    var k = e.key;
+    if (k === " " || k === "ArrowUp" || k === "ArrowDown" || k === "ArrowLeft" || k === "ArrowRight") {
+      e.preventDefault();
+    }
+    if (k === "p" || k === "P") { togglePause(); return; }
+    if ((k === "Enter" || k === " ") && (state === "menu" || state === "over" || state === "win")) {
+      startGame(); return;
+    }
+    var dir = keymap[k];
+    if (dir && player) { player.next = dir; ensureAudio(); }
+  }, { passive: false });
+
+  function setDir(dir) {
+    if (player && (state === "play")) { player.next = dir; }
+    ensureAudio();
+  }
+  var dpad = document.getElementById("dpad");
+  if (dpad) {
+    dpad.querySelectorAll("button[data-dir]").forEach(function (b) {
+      var dir = b.getAttribute("data-dir");
+      b.addEventListener("touchstart", function (e) { e.preventDefault(); setDir(dir); }, { passive: false });
+      b.addEventListener("mousedown", function (e) { e.preventDefault(); setDir(dir); });
+    });
+  }
+
+  document.getElementById("mute").addEventListener("click", function () {
+    muted = !muted;
+    this.innerHTML = muted ? "&#128263;" : "&#128266;";
+    ensureAudio();
+  });
+
+  document.getElementById("playBtn").addEventListener("click", function () {
+    ensureAudio(); startGame();
+  });
+
+  function togglePause() {
+    if (state === "play") { state = "paused"; showOverlay("PAUSED", "Press P to resume.", false); }
+    else if (state === "paused") { hideOverlay(); state = "play"; }
+  }
+
+  // ---- overlay ----
+  var overlay = document.getElementById("overlay");
+  function showOverlay(title, sub, showBtn) {
+    document.getElementById("ovTitle").textContent = title;
+    document.getElementById("ovSub").textContent = sub;
+    document.getElementById("playBtn").style.display = showBtn ? "" : "none";
+    overlay.classList.remove("hide");
+  }
+  function hideOverlay() { overlay.classList.add("hide"); }
+
+  // ---- lifecycle ----
+  function resetPositions() {
+    player = makePlayer();
+    ghosts = [makeGhost(0), makeGhost(1), makeGhost(2), makeGhost(3)];
+    frightTimer = 0; ghostEatChain = 0;
+    modeTimer = 0; modePhase = 0; levelElapsed = 0;
+    readyTimer = 1.1;
+  }
+
+  function newLevel() {
+    buildGrid();
+    resetPositions();
+  }
+
+  function startGame() {
+    score = 0; lives = 3; level = 1;
+    buildGrid();
+    resetPositions();
+    state = "play";
+    hideOverlay();
+    updateHUD();
+  }
+
+  function loseLife() {
+    lives--;
+    updateHUD();
+    if (lives <= 0) {
+      state = "over";
+      if (score > best) {
+        best = score;
+        try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) {}
+      }
+      updateHUD();
+      showOverlay("GAME OVER", "Score " + score + "  ·  Best " + best + ". Press PLAY to try again.", true);
+    } else {
+      resetPositions();
+      state = "play";
+    }
+  }
+
+  function winLevel() {
+    level++;
+    if (score > best) {
+      best = score;
+      try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) {}
+    }
+    updateHUD();
+    newLevel();
+    state = "play";
+  }
+
+  function updateHUD() {
+    document.getElementById("score").textContent = score;
+    document.getElementById("lives").textContent = lives;
+    document.getElementById("best").textContent = best;
+    document.getElementById("level").textContent = level;
+  }
+
+  // ---- movement logic ----
+  // Move an entity `dist` pixels along its direction, snapping to tile centers so it
+  // can only turn / re-decide at a center, and never overshoots into a wall.
+  function moveEntity(ent, isGhost, dt) {
+    var sp = ent.speed;
+    if (isGhost && ent.state === "frightened") sp *= 0.62;
+    if (isGhost && ent.state === "eaten") sp *= 2.0;
+
+    // speed is in pixels/second; clamp dt so a stall can't teleport across the maze
+    var remaining = sp * Math.min(dt, 0.05);
+    var guard = 0;
+    while (remaining > 0.0001 && guard++ < 20) {
+      var atCenter = (ent.px === ent.c * TILE) && (ent.py === ent.r * TILE);
+
+      if (atCenter) {
+        // Decision point: choose direction.
+        if (!isGhost) {
+          if (player.next && canMove(ent.c, ent.r, player.next, false, false)) {
+            ent.dir = player.next;
+          }
+          if (!canMove(ent.c, ent.r, ent.dir, false, false)) {
+            ent.dir = "none";
+          }
+        } else {
+          chooseGhostDir(ent);
+        }
+      }
+
+      if (ent.dir === "none") break;
+
+      var d = DIRS[ent.dir];
+      // How far until the next tile boundary in this direction:
+      var targetC = ent.c + d.x, targetR = ent.r + d.y;
+
+      // If currently at center, verify we can enter the next tile.
+      if (atCenter) {
+        var allowDoor = isGhost && (ent.state === "eaten" || ent.inHouse);
+        if (!canMove(ent.c, ent.r, ent.dir, isGhost, allowDoor)) {
+          ent.dir = "none";
+          break;
+        }
+      }
+
+      var targetPx = targetC * TILE, targetPy = targetR * TILE;
+      var dxToTarget = targetPx - ent.px, dyToTarget = targetPy - ent.py;
+      var distToCenter = Math.abs(dxToTarget) + Math.abs(dyToTarget); // straight-line along one axis
+
+      if (distToCenter <= remaining) {
+        // arrive exactly at the next tile center
+        ent.px = targetPx; ent.py = targetPy;
+        ent.c = targetC; ent.r = targetR;
+        remaining -= distToCenter;
+        tunnelWrap(ent);
+      } else {
+        ent.px += d.x * remaining;
+        ent.py += d.y * remaining;
+        remaining = 0;
+      }
+    }
+  }
+
+  function tunnelWrap(ent) {
+    var w = COLS * TILE;
+    if (ent.c < 0) { ent.c = COLS - 1; ent.px = ent.c * TILE; }
+    else if (ent.c >= COLS) { ent.c = 0; ent.px = ent.c * TILE; }
+  }
+
+  function ghostTarget(g) {
+    if (g.state === "eaten") return { c: HOME.c, r: HOME.r - 3 }; // return to house entrance
+    if (g.state === "frightened") return null; // random
+    if (g.state === "scatter") return g.scatterTarget;
+
+    // chase — per-ghost AI
+    if (g.name === "blinky") {
+      return { c: player.c, r: player.r };
+    } else if (g.name === "pinky") {
+      var d = DIRS[player.dir];
+      return { c: player.c + d.x * 4, r: player.r + d.y * 4 };
+    } else if (g.name === "inky") {
+      var blinky = ghosts[0];
+      var d2 = DIRS[player.dir];
+      var ax = player.c + d2.x * 2, ay = player.r + d2.y * 2;
+      return { c: ax + (ax - blinky.c), r: ay + (ay - blinky.r) };
+    } else { // clyde
+      var dist = Math.abs(g.c - player.c) + Math.abs(g.r - player.r);
+      if (dist > 8) return { c: player.c, r: player.r };
+      return g.scatterTarget;
+    }
+  }
+
+  function chooseGhostDir(g) {
+    g.px = g.c * TILE; g.py = g.r * TILE;
+
+    // handle leaving / entering house
+    if (g.inHouse) {
+      // bob toward door and out
+      if (g.releaseAt <= levelElapsed) {
+        // move up out of the house through the door
+        var above = "up";
+        if (canMove(g.c, g.r, above, true, true)) {
+          // steer toward door column (13/14)
+          if (g.c < 13 && canMove(g.c, g.r, "right", true, true)) { g.dir = "right"; return; }
+          if (g.c > 14 && canMove(g.c, g.r, "left", true, true)) { g.dir = "left"; return; }
+          g.dir = "up";
+          if (g.r <= 11) { g.inHouse = false; }
+          return;
+        }
+      }
+      // idle bob
+      g.dir = (g.r <= 13) ? "down" : "up";
+      return;
+    }
+
+    if (g.state === "eaten" && g.c >= 12 && g.c <= 15 && g.r >= 11 && g.r <= 12) {
+      // reached house entrance: revive
+      g.state = (modePhase % 2 === 0) ? "scatter" : "chase";
+      g.inHouse = false;
+      g.speed = 84 + (level - 1) * 4;
+    }
+
+    var target = ghostTarget(g);
+    var options = ["up", "left", "down", "right"];
+    var best = null, bestDist = Infinity;
+    var back = opposite(g.dir);
+
+    // frightened: pick a random valid non-reverse direction
+    if (g.state === "frightened") {
+      var valid = [];
+      for (var i = 0; i < options.length; i++) {
+        var dir = options[i];
+        if (dir === back) continue;
+        if (canMove(g.c, g.r, dir, true, false)) valid.push(dir);
+      }
+      if (valid.length === 0) {
+        if (canMove(g.c, g.r, back, true, false)) g.dir = back;
+      } else {
+        g.dir = valid[(Math.random() * valid.length) | 0];
+      }
+      return;
+    }
+
+    for (var j = 0; j < options.length; j++) {
+      var dr = options[j];
+      if (dr === back) continue;
+      var allowDoor = (g.state === "eaten");
+      if (!canMove(g.c, g.r, dr, true, allowDoor)) continue;
+      var nd = DIRS[dr];
+      var nc = g.c + nd.x, nr = g.r + nd.y;
+      var dx = nc - target.c, dy = nr - target.r;
+      var dist = dx * dx + dy * dy;
+      // priority order up>left>down>right resolves ties naturally by iteration
+      if (dist < bestDist) { bestDist = dist; best = dr; }
+    }
+    if (best) g.dir = best;
+    else if (canMove(g.c, g.r, back, true, g.state === "eaten")) g.dir = back;
+  }
+
+  // ---- update ----
+  function update(dt) {
+    if (readyTimer > 0) {
+      readyTimer -= dt;
+      player.mouth += dt * 8;
+      return;
+    }
+
+    levelElapsed += dt;
+
+    // scatter/chase phase machine (only affects non-frightened, non-eaten)
+    if (frightTimer <= 0) {
+      modeTimer += dt;
+      if (modeTimer >= MODE_SCHEDULE[Math.min(modePhase, MODE_SCHEDULE.length - 1)]) {
+        modeTimer = 0; modePhase++;
+        var newMode = (modePhase % 2 === 0) ? "scatter" : "chase";
+        for (var i = 0; i < ghosts.length; i++) {
+          if (ghosts[i].state === "scatter" || ghosts[i].state === "chase") {
+            ghosts[i].state = newMode;
+            // reverse on mode switch (classic behavior)
+            ghosts[i].dir = opposite(ghosts[i].dir);
+          }
+        }
+      }
+    }
+
+    // fright timer
+    if (frightTimer > 0) {
+      frightTimer -= dt;
+      if (frightTimer <= 0) {
+        frightTimer = 0;
+        for (var g = 0; g < ghosts.length; g++) {
+          if (ghosts[g].state === "frightened") {
+            ghosts[g].state = (modePhase % 2 === 0) ? "scatter" : "chase";
+          }
+        }
+      }
+    }
+
+    // player move
+    moveEntity(player, false, dt);
+    player.mouth += dt * 10;
+
+    // eat pellet
+    if (player.c >= 0 && player.c < COLS && player.r >= 0 && player.r < ROWS) {
+      var p = pellets[player.r][player.c];
+      if (p === 1) {
+        pellets[player.r][player.c] = 0; pelletCount--; score += 10;
+        updateHUD(); blip(520, 0.04, "square", 0.03);
+      } else if (p === 2) {
+        pellets[player.r][player.c] = 0; pelletCount--; score += 50;
+        updateHUD();
+        // power!
+        frightTimer = Math.max(2.5, 7 - (level - 1) * 0.5);
+        ghostEatChain = 0;
+        for (var k = 0; k < ghosts.length; k++) {
+          if (ghosts[k].state === "scatter" || ghosts[k].state === "chase") {
+            ghosts[k].state = "frightened";
+            ghosts[k].dir = opposite(ghosts[k].dir);
+          }
+        }
+        blip(180, 0.2, "sawtooth", 0.05);
+      }
+    }
+
+    // ghosts move + collisions
+    for (var gi = 0; gi < ghosts.length; gi++) {
+      var gh = ghosts[gi];
+      moveEntity(gh, true, dt);
+      // collision (tile-level, tolerant)
+      var close = Math.abs(gh.px - player.px) < TILE * 0.7 && Math.abs(gh.py - player.py) < TILE * 0.7;
+      if (close) {
+        if (gh.state === "frightened") {
+          ghostEatChain++;
+          var pts = 200 * Math.pow(2, Math.min(ghostEatChain - 1, 3));
+          score += pts; updateHUD();
+          gh.state = "eaten"; gh.inHouse = false;
+          blip(880, 0.12, "square", 0.06);
+        } else if (gh.state !== "eaten") {
+          // player dies
+          blip(120, 0.5, "sawtooth", 0.06);
+          state = "dead"; deathAnim = 0;
+          return;
+        }
+      }
+    }
+
+    // win check
+    if (pelletCount <= 0) {
+      state = "win"; blip(660, 0.3, "triangle", 0.05);
+    }
+  }
+
+  function updateDeath(dt) {
+    deathAnim += dt;
+    if (deathAnim > 1.1) { loseLife(); }
+  }
+
+  function updateWin(dt) {
+    deathAnim += dt;
+    if (deathAnim > 0.9) { deathAnim = 0; winLevel(); }
+  }
+
+  // ---- draw ----
+  function draw() {
+    ctx.fillStyle = "#070a10";
+    ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+    // walls
+    ctx.lineWidth = 2;
+    for (var r = 0; r < ROWS; r++) {
+      for (var c = 0; c < COLS; c++) {
+        var cell = grid[r][c];
+        if (cell === "#") {
+          drawWall(c, r);
+        } else if (cell === "-") {
+          ctx.fillStyle = "#f9a8d4";
+          ctx.fillRect(c * TILE + 2, r * TILE + TILE / 2 - 1, TILE - 4, 2);
+        }
+      }
+    }
+
+    // pellets
+    for (var r2 = 0; r2 < ROWS; r2++) {
+      for (var c2 = 0; c2 < COLS; c2++) {
+        var pp = pellets[r2][c2];
+        if (pp === 1) {
+          ctx.fillStyle = "#f5d7a1";
+          ctx.beginPath();
+          ctx.arc(c2 * TILE + TILE / 2, r2 * TILE + TILE / 2, 2, 0, Math.PI * 2);
+          ctx.fill();
+        } else if (pp === 2) {
+          var pulse = 3 + Math.sin(Date.now() / 180) * 1.6;
+          ctx.fillStyle = "#fbbf24";
+          ctx.shadowColor = "#fbbf24"; ctx.shadowBlur = 8;
+          ctx.beginPath();
+          ctx.arc(c2 * TILE + TILE / 2, r2 * TILE + TILE / 2, pulse, 0, Math.PI * 2);
+          ctx.fill();
+          ctx.shadowBlur = 0;
+        }
+      }
+    }
+
+    // ghosts
+    for (var gi = 0; gi < ghosts.length; gi++) drawGhost(ghosts[gi]);
+
+    // player
+    drawPlayer();
+
+    // ready text
+    if (readyTimer > 0 && state === "play") {
+      ctx.fillStyle = "#fde047";
+      ctx.font = "bold 16px -apple-system, sans-serif";
+      ctx.textAlign = "center";
+      ctx.fillText("READY!", canvas.width / 2, 17 * TILE + 4);
+    }
+  }
+
+  function drawWall(c, r) {
+    var x = c * TILE, y = r * TILE;
+    ctx.fillStyle = "#0e2a5e";
+    // inset rounded block with neon border
+    ctx.strokeStyle = "#3b82f6";
+    ctx.shadowColor = "#60a5fa"; ctx.shadowBlur = 4;
+    ctx.strokeRect(x + 2.5, y + 2.5, TILE - 5, TILE - 5);
+    ctx.shadowBlur = 0;
+  }
+
+  function drawPlayer() {
+    var cx = player.px + TILE / 2, cy = player.py + TILE / 2;
+    var rad = TILE / 2 - 1.5;
+    var m = 0;
+    if (state === "dead") {
+      // shrink/fade mouth open
+      m = Math.min(Math.PI, deathAnim * Math.PI * 1.6);
+    } else {
+      m = (Math.abs(Math.sin(player.mouth)) * 0.28 + 0.06) * Math.PI;
+    }
+    var ang = { up: -Math.PI / 2, down: Math.PI / 2, left: Math.PI, right: 0, none: 0 }[player.dir] || 0;
+
+    ctx.save();
+    ctx.translate(cx, cy);
+    ctx.rotate(ang);
+    ctx.fillStyle = "#fde047";
+    ctx.shadowColor = "#fde047"; ctx.shadowBlur = 8;
+    ctx.beginPath();
+    ctx.moveTo(0, 0);
+    ctx.arc(0, 0, rad, m, Math.PI * 2 - m);
+    ctx.closePath();
+    ctx.fill();
+    ctx.shadowBlur = 0;
+    ctx.restore();
+  }
+
+  function drawGhost(g) {
+    var x = g.px, y = g.py;
+    var cx = x + TILE / 2, cy = y + TILE / 2;
+    var rad = TILE / 2 - 1.5;
+    var body;
+    var eyesVisible = true;
+
+    if (g.state === "eaten") {
+      body = null; // only eyes
+    } else if (g.state === "frightened") {
+      var blink = frightTimer < 2 && (Math.floor(frightTimer * 6) % 2 === 0);
+      body = blink ? "#e5e7eb" : "#1e3a8a";
+      eyesVisible = false;
+    } else {
+      body = g.color;
+    }
+
+    if (body) {
+      ctx.fillStyle = body;
+      ctx.shadowColor = body; ctx.shadowBlur = 6;
+      ctx.beginPath();
+      ctx.arc(cx, cy - 1, rad, Math.PI, 0);
+      ctx.lineTo(cx + rad, cy + rad - 1);
+      // wavy skirt
+      var feet = 3, step = (rad * 2) / feet;
+      for (var f = 0; f < feet; f++) {
+        var fx = cx + rad - step * f;
+        ctx.lineTo(fx - step / 2, cy + rad - 4);
+        ctx.lineTo(fx - step, cy + rad - 1);
+      }
+      ctx.closePath();
+      ctx.fill();
+      ctx.shadowBlur = 0;
+    }
+
+    // eyes
+    if (g.state === "frightened") {
+      // scared face
+      ctx.fillStyle = "#fde047";
+      ctx.fillRect(cx - 4, cy - 2, 2, 2);
+      ctx.fillRect(cx + 2, cy - 2, 2, 2);
+      ctx.strokeStyle = "#fde047"; ctx.lineWidth = 1;
+      ctx.beginPath();
+      ctx.moveTo(cx - 4, cy + 4);
+      ctx.lineTo(cx - 1, cy + 2);
+      ctx.lineTo(cx + 1, cy + 4);
+      ctx.lineTo(cx + 4, cy + 2);
+      ctx.stroke();
+    } else {
+      var d = DIRS[g.dir];
+      ctx.fillStyle = "#ffffff";
+      ctx.beginPath(); ctx.arc(cx - 3, cy - 1, 2.4, 0, Math.PI * 2); ctx.fill();
+      ctx.beginPath(); ctx.arc(cx + 3, cy - 1, 2.4, 0, Math.PI * 2); ctx.fill();
+      ctx.fillStyle = "#1e3a8a";
+      ctx.beginPath(); ctx.arc(cx - 3 + d.x * 1.2, cy - 1 + d.y * 1.2, 1.2, 0, Math.PI * 2); ctx.fill();
+      ctx.beginPath(); ctx.arc(cx + 3 + d.x * 1.2, cy - 1 + d.y * 1.2, 1.2, 0, Math.PI * 2); ctx.fill();
+    }
+  }
+
+  // ---- main loop ----
+  var last = 0;
+  function loop(ts) {
+    try {
+      if (!last) last = ts;
+      var dt = Math.min(0.05, (ts - last) / 1000);
+      last = ts;
+
+      if (state === "play") update(dt);
+      else if (state === "dead") updateDeath(dt);
+      else if (state === "win") updateWin(dt);
+
+      if (grid.length) draw();
+    } catch (e) {
+      // keep the loop alive; never let the iframe throw
+    }
+    requestAnimationFrame(loop);
+  }
+
+  // ---- boot ----
+  function boot() {
+    buildGrid();
+    resetPositions();
+    fit();
+    updateHUD();
+    state = "menu";
+    showOverlay("ABRAMS CHOMP",
+      "Eat every pellet. Grab a power pellet to hunt the ghosts. Arrow keys / WASD or the D-pad.", true);
+    requestAnimationFrame(loop);
+  }
+
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", boot);
+  } else {
+    boot();
+  }
+})();
+</script>
+</body>
+</html>
diff --git a/games/gems/index.html b/games/gems/index.html
new file mode 100644
index 0000000..64548fa
--- /dev/null
+++ b/games/gems/index.html
@@ -0,0 +1,755 @@
+<!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>NEON GEMS</title>
+<style>
+  :root{
+    --bg:#0a0e14;
+    --panel:#0f1622;
+    --line:#1c2735;
+    --ink:#e8f0ff;
+    --muted:#7f93ad;
+    --accent:#3df2ff;
+  }
+  *{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
+  html,body{ margin:0; padding:0; height:100%; }
+  body{
+    background:
+      radial-gradient(1200px 700px at 50% -10%, #12213a 0%, rgba(18,33,58,0) 60%),
+      var(--bg);
+    color:var(--ink);
+    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    touch-action:none;
+    overflow:hidden;
+    display:flex;
+    align-items:center;
+    justify-content:center;
+    min-height:100%;
+    user-select:none;
+    -webkit-user-select:none;
+  }
+  #wrap{
+    display:flex;
+    flex-direction:column;
+    align-items:center;
+    gap:14px;
+    padding:16px;
+    width:100%;
+    max-width:560px;
+  }
+  #title{
+    margin:0;
+    font-size:clamp(20px,6vw,34px);
+    font-weight:800;
+    letter-spacing:.42em;
+    text-transform:uppercase;
+    text-indent:.42em;
+    background:linear-gradient(90deg,#3df2ff,#8a7dff 45%,#ff5db8);
+    -webkit-background-clip:text;
+    background-clip:text;
+    color:transparent;
+    filter:drop-shadow(0 0 12px rgba(61,242,255,.35));
+    text-align:center;
+  }
+  #hud{
+    width:100%;
+    display:flex;
+    gap:10px;
+    justify-content:center;
+  }
+  .stat{
+    flex:1;
+    background:linear-gradient(180deg,var(--panel),#0b1220);
+    border:1px solid var(--line);
+    border-radius:12px;
+    padding:8px 10px;
+    text-align:center;
+    box-shadow:inset 0 1px 0 rgba(255,255,255,.03);
+  }
+  .stat .lbl{
+    font-size:10px;
+    letter-spacing:.22em;
+    text-transform:uppercase;
+    color:var(--muted);
+  }
+  .stat .val{
+    font-size:clamp(16px,5vw,24px);
+    font-weight:800;
+    margin-top:2px;
+    font-variant-numeric:tabular-nums;
+    color:var(--ink);
+  }
+  #boardWrap{
+    position:relative;
+    width:100%;
+    border-radius:16px;
+    background:linear-gradient(180deg,#0c1420,#080c13);
+    border:1px solid var(--line);
+    box-shadow:0 20px 60px rgba(0,0,0,.55), inset 0 0 60px rgba(61,242,255,.04);
+    padding:10px;
+    touch-action:none;
+  }
+  canvas{ display:block; width:100%; height:auto; border-radius:10px; touch-action:none; }
+  #foot{
+    display:flex;
+    gap:10px;
+    align-items:center;
+    justify-content:center;
+    width:100%;
+  }
+  button{
+    font-family:inherit;
+    font-weight:700;
+    letter-spacing:.16em;
+    text-transform:uppercase;
+    font-size:11px;
+    color:var(--ink);
+    background:linear-gradient(180deg,#132033,#0c1524);
+    border:1px solid var(--line);
+    border-radius:10px;
+    padding:9px 14px;
+    cursor:pointer;
+    transition:transform .08s ease, border-color .15s ease, box-shadow .15s ease;
+  }
+  button:hover{ border-color:var(--accent); box-shadow:0 0 14px rgba(61,242,255,.25); }
+  button:active{ transform:translateY(1px); }
+  #msg{
+    min-height:16px;
+    font-size:11px;
+    letter-spacing:.16em;
+    text-transform:uppercase;
+    color:var(--muted);
+    text-align:center;
+  }
+</style>
+</head>
+<body>
+  <div id="wrap">
+    <h1 id="title">Neon Gems</h1>
+    <div id="hud">
+      <div class="stat"><div class="lbl">Score</div><div class="val" id="scoreEl">0</div></div>
+      <div class="stat"><div class="lbl">Moves</div><div class="val" id="movesEl">30</div></div>
+      <div class="stat"><div class="lbl">Best</div><div class="val" id="bestEl">0</div></div>
+    </div>
+    <div id="boardWrap">
+      <canvas id="game"></canvas>
+    </div>
+    <div id="msg">Swap gems to match 3 or more</div>
+    <div id="foot">
+      <button id="soundBtn" type="button">Sound: On</button>
+      <button id="newBtn" type="button">New Game</button>
+    </div>
+  </div>
+
+<script>
+(function(){
+  "use strict";
+
+  function boot(){
+    var canvas = document.getElementById('game');
+    if(!canvas) return;
+    var ctx = canvas.getContext('2d');
+    if(!ctx) return;
+
+    var scoreEl = document.getElementById('scoreEl');
+    var movesEl = document.getElementById('movesEl');
+    var bestEl  = document.getElementById('bestEl');
+    var msgEl   = document.getElementById('msg');
+    var soundBtn= document.getElementById('soundBtn');
+    var newBtn  = document.getElementById('newBtn');
+    var boardWrap = document.getElementById('boardWrap');
+
+    var N = 8;              // grid size
+    var START_MOVES = 30;
+    var BEST_KEY = "abrams-gems-best";
+
+    // Gem palette: distinct saturated neon hues
+    var GEMS = [
+      { core:"#ff3d6e", glow:"#ff3d6e" }, // pink/red
+      { core:"#ffb43d", glow:"#ffb43d" }, // amber
+      { core:"#ffe93d", glow:"#ffe93d" }, // yellow
+      { core:"#4dff88", glow:"#4dff88" }, // green
+      { core:"#3df2ff", glow:"#3df2ff" }, // cyan
+      { core:"#7d8bff", glow:"#7d8bff" }, // indigo
+      { core:"#c96dff", glow:"#c96dff" }  // violet
+    ];
+    var NC = GEMS.length;
+
+    // DPR-aware sizing
+    var cell = 48;          // logical px per cell (set in resize)
+    var dpr = 1;
+
+    // Board state: grid[r][c] = color index (0..NC-1) or -1 empty
+    var grid = [];
+    var score = 0, moves = START_MOVES, best = 0;
+    var soundOn = true;
+
+    // Per-cell render offset (for fall/swap animation)
+    // offset[r][c] = {dx, dy} in logical pixels, animated to 0
+    var offX = [], offY = [];
+    // pop animation: pop[r][c] = scale factor while clearing (1 -> 0)
+    var popScale = [];
+    var spawnScale = []; // 0 -> 1 for newly spawned/fallen gems
+
+    var sel = null;        // {r,c} currently selected
+    var animating = false; // block input during resolve animations
+    var gameOver = false;
+
+    // ---------- storage ----------
+    function loadBest(){
+      try{
+        var v = localStorage.getItem(BEST_KEY);
+        best = v ? (parseInt(v,10)||0) : 0;
+      }catch(e){ best = 0; }
+    }
+    function saveBest(){
+      try{ localStorage.setItem(BEST_KEY, String(best)); }catch(e){}
+    }
+
+    // ---------- audio ----------
+    var audioCtx = null;
+    function ensureAudio(){
+      if(audioCtx) return;
+      try{
+        var AC = window.AudioContext || window.webkitAudioContext;
+        if(AC) audioCtx = new AC();
+      }catch(e){ audioCtx = null; }
+    }
+    function blip(freq, dur, type, vol){
+      if(!soundOn || !audioCtx) return;
+      try{
+        var t = audioCtx.currentTime;
+        var o = audioCtx.createOscillator();
+        var g = audioCtx.createGain();
+        o.type = type || "sine";
+        o.frequency.setValueAtTime(freq, t);
+        g.gain.setValueAtTime(0.0001, t);
+        g.gain.exponentialRampToValueAtTime(vol||0.14, t+0.01);
+        g.gain.exponentialRampToValueAtTime(0.0001, t+(dur||0.12));
+        o.connect(g); g.connect(audioCtx.destination);
+        o.start(t);
+        o.stop(t+(dur||0.12)+0.02);
+      }catch(e){}
+    }
+    function sfxSwap(){ blip(420,0.09,"triangle",0.10); }
+    function sfxBad(){ blip(160,0.14,"sawtooth",0.08); }
+    function sfxMatch(chain){ blip(520 + chain*90, 0.14, "square", 0.12); }
+    function sfxDrop(){ blip(300,0.06,"sine",0.06); }
+
+    // ---------- grid helpers ----------
+    function rndColor(){ return Math.floor(Math.random()*NC); }
+
+    function inBounds(r,c){ return r>=0 && r<N && c>=0 && c<N; }
+
+    // Build initial board with NO pre-existing match-3
+    function fillInitial(){
+      grid = [];
+      for(var r=0;r<N;r++){
+        grid[r] = [];
+        for(var c=0;c<N;c++){
+          var col;
+          var guard = 0;
+          do{
+            col = rndColor();
+            guard++;
+            // reject if it completes a horizontal or vertical run of 3
+          }while(guard<50 && (
+            (c>=2 && grid[r][c-1]===col && grid[r][c-2]===col) ||
+            (r>=2 && grid[r-1][c]===col && grid[r-2][c]===col)
+          ));
+          grid[r][c] = col;
+        }
+      }
+      // guarantee at least one valid move
+      if(!hasValidMove()){ shuffleBoard(); }
+    }
+
+    // find all matched cells (>=3 in row/col). returns 2D boolean.
+    function findMatches(){
+      var mark = [];
+      for(var r=0;r<N;r++){ mark[r]=[]; for(var c=0;c<N;c++) mark[r][c]=false; }
+      var r,c;
+      // horizontal
+      for(r=0;r<N;r++){
+        var run=1;
+        for(c=1;c<N;c++){
+          if(grid[r][c]!==-1 && grid[r][c]===grid[r][c-1]) run++;
+          else {
+            if(run>=3){ for(var k=0;k<run;k++) mark[r][c-1-k]=true; }
+            run=1;
+          }
+        }
+        if(run>=3){ for(var k2=0;k2<run;k2++) mark[r][N-1-k2]=true; }
+      }
+      // vertical
+      for(c=0;c<N;c++){
+        var run2=1;
+        for(r=1;r<N;r++){
+          if(grid[r][c]!==-1 && grid[r][c]===grid[r-1][c]) run2++;
+          else {
+            if(run2>=3){ for(var k3=0;k3<run2;k3++) mark[r-1-k3][c]=true; }
+            run2=1;
+          }
+        }
+        if(run2>=3){ for(var k4=0;k4<run2;k4++) mark[N-1-k4][c]=true; }
+      }
+      return mark;
+    }
+
+    function countMarked(mark){
+      var n=0;
+      for(var r=0;r<N;r++) for(var c=0;c<N;c++) if(mark[r][c]) n++;
+      return n;
+    }
+
+    // would swapping (r1,c1)<->(r2,c2) produce a match?
+    function swapProducesMatch(r1,c1,r2,c2){
+      var t = grid[r1][c1]; grid[r1][c1]=grid[r2][c2]; grid[r2][c2]=t;
+      var m = findMatches();
+      var ok = countMarked(m) > 0;
+      var t2 = grid[r1][c1]; grid[r1][c1]=grid[r2][c2]; grid[r2][c2]=t2;
+      return ok;
+    }
+
+    function hasValidMove(){
+      for(var r=0;r<N;r++){
+        for(var c=0;c<N;c++){
+          if(c<N-1 && swapProducesMatch(r,c,r,c+1)) return true;
+          if(r<N-1 && swapProducesMatch(r,c,r+1,c)) return true;
+        }
+      }
+      return false;
+    }
+
+    function shuffleBoard(){
+      var guard=0;
+      do{
+        // Fisher-Yates over flat colors
+        var flat=[];
+        for(var r=0;r<N;r++) for(var c=0;c<N;c++) flat.push(grid[r][c]);
+        for(var i=flat.length-1;i>0;i--){
+          var j=Math.floor(Math.random()*(i+1));
+          var tmp=flat[i]; flat[i]=flat[j]; flat[j]=tmp;
+        }
+        var idx=0;
+        for(var r2=0;r2<N;r2++) for(var c2=0;c2<N;c2++) grid[r2][c2]=flat[idx++];
+        guard++;
+      }while(guard<80 && (countMarked(findMatches())>0 || !hasValidMove()));
+    }
+
+    // ---------- animation arrays ----------
+    function resetAnimArrays(){
+      offX=[]; offY=[]; popScale=[]; spawnScale=[];
+      for(var r=0;r<N;r++){
+        offX[r]=[]; offY[r]=[]; popScale[r]=[]; spawnScale[r]=[];
+        for(var c=0;c<N;c++){ offX[r][c]=0; offY[r][c]=0; popScale[r][c]=1; spawnScale[r][c]=1; }
+      }
+    }
+
+    // ---------- new game ----------
+    function newGame(){
+      score=0; moves=START_MOVES; gameOver=false; sel=null; animating=false;
+      fillInitial();
+      resetAnimArrays();
+      updateHUD();
+      setMsg("Swap gems to match 3 or more");
+      draw();
+    }
+
+    // ---------- HUD ----------
+    function updateHUD(){
+      scoreEl.textContent = String(score);
+      movesEl.textContent = String(moves);
+      bestEl.textContent  = String(best);
+    }
+    function setMsg(t){ msgEl.textContent = t; }
+
+    // ---------- sizing ----------
+    function resize(){
+      // available width from wrapper (minus padding)
+      var wrapW = boardWrap.clientWidth - 20; // 10px padding each side
+      if(wrapW < 120) wrapW = 120;
+      // keep board square, cap so it fits typical iframe heights too
+      var maxByH = (window.innerHeight || 600) - 230;
+      var size = Math.min(wrapW, maxByH > 200 ? maxByH : wrapW);
+      size = Math.max(200, Math.min(size, 520));
+      cell = size / N;
+      dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));
+      canvas.width = Math.round(size * dpr);
+      canvas.height = Math.round(size * dpr);
+      canvas.style.width = size + "px";
+      canvas.style.height = size + "px";
+      draw();
+    }
+
+    // ---------- drawing ----------
+    function drawGem(cx, cy, rad, gi, alpha, scale){
+      if(gi<0) return;
+      var g = GEMS[gi];
+      var R = rad*scale;
+      if(R<=0.5) return;
+      ctx.save();
+      ctx.globalAlpha = alpha;
+      // outer glow
+      ctx.shadowColor = g.glow;
+      ctx.shadowBlur = R*0.9;
+      // body gradient
+      var grad = ctx.createRadialGradient(cx - R*0.3, cy - R*0.35, R*0.15, cx, cy, R);
+      grad.addColorStop(0, "#ffffff");
+      grad.addColorStop(0.18, g.core);
+      grad.addColorStop(1, shade(g.core, -0.45));
+      ctx.fillStyle = grad;
+      roundRectPath(cx-R, cy-R, R*2, R*2, R*0.42);
+      ctx.fill();
+      // inner rim
+      ctx.shadowBlur = 0;
+      ctx.lineWidth = Math.max(1, R*0.10);
+      ctx.strokeStyle = shade(g.core, 0.35);
+      ctx.globalAlpha = alpha*0.8;
+      roundRectPath(cx-R*0.92, cy-R*0.92, R*1.84, R*1.84, R*0.36);
+      ctx.stroke();
+      // sparkle highlight
+      ctx.globalAlpha = alpha*0.85;
+      ctx.fillStyle = "rgba(255,255,255,0.85)";
+      ctx.beginPath();
+      ctx.ellipse(cx - R*0.32, cy - R*0.38, R*0.22, R*0.13, -0.6, 0, Math.PI*2);
+      ctx.fill();
+      ctx.restore();
+    }
+
+    function roundRectPath(x,y,w,h,r){
+      if(r>w/2) r=w/2; if(r>h/2) r=h/2;
+      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();
+    }
+
+    // shade a hex color by amt (-1..1)
+    function shade(hex, amt){
+      var h = hex.replace('#','');
+      var r = parseInt(h.substring(0,2),16);
+      var g = parseInt(h.substring(2,4),16);
+      var b = parseInt(h.substring(4,6),16);
+      if(amt<0){
+        r = Math.round(r*(1+amt)); g = Math.round(g*(1+amt)); b = Math.round(b*(1+amt));
+      } else {
+        r = Math.round(r+(255-r)*amt); g = Math.round(g+(255-g)*amt); b = Math.round(b+(255-b)*amt);
+      }
+      r=Math.max(0,Math.min(255,r)); g=Math.max(0,Math.min(255,g)); b=Math.max(0,Math.min(255,b));
+      return "rgb("+r+","+g+","+b+")";
+    }
+
+    function draw(){
+      var W = canvas.width, H = canvas.height;
+      ctx.setTransform(dpr,0,0,dpr,0,0);
+      var size = W/dpr;
+      // bg
+      ctx.clearRect(0,0,size,size);
+      var bg = ctx.createLinearGradient(0,0,0,size);
+      bg.addColorStop(0,"#0b1220");
+      bg.addColorStop(1,"#070b11");
+      ctx.fillStyle = bg;
+      ctx.fillRect(0,0,size,size);
+
+      // grid cells backdrop
+      for(var r=0;r<N;r++){
+        for(var c=0;c<N;c++){
+          var x = c*cell, y = r*cell;
+          ctx.fillStyle = ((r+c)%2===0) ? "rgba(255,255,255,0.018)" : "rgba(255,255,255,0.006)";
+          ctx.fillRect(x,y,cell,cell);
+        }
+      }
+
+      // selection highlight
+      if(sel){
+        var sx = sel.c*cell, sy = sel.r*cell;
+        ctx.save();
+        ctx.strokeStyle = "#3df2ff";
+        ctx.shadowColor = "#3df2ff";
+        ctx.shadowBlur = 14;
+        ctx.lineWidth = 3;
+        roundRectPath(sx+3, sy+3, cell-6, cell-6, cell*0.2);
+        ctx.stroke();
+        ctx.restore();
+      }
+
+      // gems
+      var rad = cell*0.40;
+      for(var r2=0;r2<N;r2++){
+        for(var c2=0;c2<N;c2++){
+          var gi = grid[r2][c2];
+          if(gi<0) continue;
+          var cx = c2*cell + cell/2 + offX[r2][c2];
+          var cy = r2*cell + cell/2 + offY[r2][c2];
+          var ps = popScale[r2][c2];
+          var ss = spawnScale[r2][c2];
+          var sc = ps * ss;
+          if(sc<=0.02) continue;
+          drawGem(cx, cy, rad, gi, Math.min(1, sc<1?sc:1)*(ps<1?ps:1), sc);
+        }
+      }
+    }
+
+    // ---------- animation loop ----------
+    var rafId = null;
+    function animateOffsets(cb){
+      animating = true;
+      var speed = 0.22; // easing factor per frame
+      function step(){
+        var moving = false;
+        for(var r=0;r<N;r++){
+          for(var c=0;c<N;c++){
+            // ease offsets to 0
+            if(Math.abs(offX[r][c])>0.5){ offX[r][c]*= (1-speed); moving=true; } else offX[r][c]=0;
+            if(Math.abs(offY[r][c])>0.5){ offY[r][c]*= (1-speed); moving=true; } else offY[r][c]=0;
+            // spawn scale ease to 1
+            if(spawnScale[r][c] < 0.999){ spawnScale[r][c] += (1-spawnScale[r][c])*0.28; if(spawnScale[r][c]>0.995) spawnScale[r][c]=1; moving=true; }
+          }
+        }
+        draw();
+        if(moving){ rafId = requestAnimationFrame(step); }
+        else { animating=false; cb && cb(); }
+      }
+      rafId = requestAnimationFrame(step);
+    }
+
+    function animatePops(marks, cb){
+      animating = true;
+      function step(){
+        var moving=false;
+        for(var r=0;r<N;r++){
+          for(var c=0;c<N;c++){
+            if(marks[r][c] && popScale[r][c]>0.02){
+              popScale[r][c] -= 0.14;
+              if(popScale[r][c]<0) popScale[r][c]=0;
+              moving=true;
+            }
+          }
+        }
+        draw();
+        if(moving) rafId=requestAnimationFrame(step);
+        else { animating=false; cb && cb(); }
+      }
+      rafId=requestAnimationFrame(step);
+    }
+
+    // ---------- resolve cascades ----------
+    function resolveBoard(chain){
+      var marks = findMatches();
+      var n = countMarked(marks);
+      if(n===0){
+        // done cascading
+        animating=false;
+        checkEndAndShuffle();
+        draw();
+        return;
+      }
+      sfxMatch(chain||0);
+      // score: base 10 per gem, cascade multiplier
+      var mult = 1 + (chain||0)*0.5;
+      var gained = Math.round(n * 10 * mult);
+      score += gained;
+      if(score>best){ best=score; saveBest(); }
+      updateHUD();
+      if((chain||0)>0) setMsg("Combo x"+(chain+1)+"  +"+gained);
+      else setMsg("+"+gained);
+
+      // animate pop, then collapse + refill
+      animatePops(marks, function(){
+        // clear matched
+        for(var r=0;r<N;r++) for(var c=0;c<N;c++){ if(marks[r][c]){ grid[r][c]=-1; popScale[r][c]=1; } }
+        collapseAndRefill();
+        // animate the falls then resolve again (cascade)
+        animateOffsets(function(){
+          resolveBoard((chain||0)+1);
+        });
+      });
+    }
+
+    // gravity: pull down, spawn new at top; set offY so they animate falling
+    function collapseAndRefill(){
+      for(var c=0;c<N;c++){
+        // gather existing from bottom up
+        var writeR = N-1;
+        for(var r=N-1;r>=0;r--){
+          if(grid[r][c]!==-1){
+            if(writeR!==r){
+              grid[writeR][c]=grid[r][c];
+              grid[r][c]=-1;
+              // offset so it visually starts where it was, then eases to 0
+              offY[writeR][c] = (r-writeR)*cell;
+              spawnScale[writeR][c]=1;
+            }
+            writeR--;
+          }
+        }
+        // fill remaining top with new gems, dropping in from above
+        var spawnIdx = 0;
+        for(var rr=writeR; rr>=0; rr--){
+          grid[rr][c]=rndColor();
+          offY[rr][c] = -(cell*(spawnIdx+1)) - cell; // start above the board
+          spawnScale[rr][c]=0.35;
+          spawnIdx++;
+        }
+      }
+      sfxDrop();
+    }
+
+    function checkEndAndShuffle(){
+      if(!hasValidMove()){
+        setMsg("No moves — reshuffling…");
+        shuffleBoard();
+        resetAnimArrays();
+        draw();
+        // after shuffle there should be no immediate matches (guarded)
+      }
+      if(moves<=0){
+        gameOver=true;
+        setMsg("Out of moves — Score "+score+". Tap New Game.");
+      }
+    }
+
+    // ---------- input ----------
+    function cellFromEvent(clientX, clientY){
+      var rect = canvas.getBoundingClientRect();
+      var x = clientX - rect.left;
+      var y = clientY - rect.top;
+      // canvas CSS size maps to N*cell logical (cell already logical)
+      var px = x * (canvas.width/dpr) / rect.width;
+      var py = y * (canvas.height/dpr) / rect.height;
+      var c = Math.floor(px / cell);
+      var r = Math.floor(py / cell);
+      if(!inBounds(r,c)) return null;
+      return {r:r,c:c};
+    }
+
+    function tryMove(a, b){
+      // must be adjacent
+      var dr = Math.abs(a.r-b.r), dc = Math.abs(a.c-b.c);
+      if(dr+dc!==1) return false;
+      if(gameOver) return false;
+      sfxSwap();
+      // do swap
+      var t=grid[a.r][a.c]; grid[a.r][a.c]=grid[b.r][b.c]; grid[b.r][b.c]=t;
+      // set offsets for swap animation (each moves toward its new home)
+      offX[a.r][a.c] = (b.c-a.c)*cell; offY[a.r][a.c] = (b.r-a.r)*cell;
+      offX[b.r][b.c] = (a.c-b.c)*cell; offY[b.r][b.c] = (a.r-b.r)*cell;
+
+      var m = findMatches();
+      if(countMarked(m)===0){
+        // invalid: animate swap in, then swap back
+        sfxBad();
+        animateOffsets(function(){
+          var t2=grid[a.r][a.c]; grid[a.r][a.c]=grid[b.r][b.c]; grid[b.r][b.c]=t2;
+          offX[a.r][a.c]=(b.c-a.c)*cell; offY[a.r][a.c]=(b.r-a.r)*cell;
+          offX[b.r][b.c]=(a.c-b.c)*cell; offY[b.r][b.c]=(a.r-b.r)*cell;
+          animateOffsets(function(){ setMsg("No match there"); });
+        });
+        return true; // was a valid adjacent attempt (but no match)
+      }
+      // valid move
+      moves--;
+      updateHUD();
+      animateOffsets(function(){
+        resolveBoard(0);
+      });
+      return true;
+    }
+
+    var pointerDown = null; // {r,c,x,y}
+    function onDown(clientX, clientY){
+      ensureAudio();
+      if(animating || gameOver) return;
+      var cellPos = cellFromEvent(clientX, clientY);
+      if(!cellPos) return;
+      pointerDown = {r:cellPos.r, c:cellPos.c, x:clientX, y:clientY};
+      if(sel){
+        // second click: attempt swap or reselect
+        if(sel.r===cellPos.r && sel.c===cellPos.c){
+          sel=null; draw(); return;
+        }
+        var dr=Math.abs(sel.r-cellPos.r), dc=Math.abs(sel.c-cellPos.c);
+        if(dr+dc===1){
+          var a={r:sel.r,c:sel.c}; sel=null;
+          tryMove(a, cellPos);
+          draw();
+        } else {
+          sel={r:cellPos.r,c:cellPos.c}; draw();
+        }
+      } else {
+        sel={r:cellPos.r,c:cellPos.c}; draw();
+      }
+    }
+
+    function onUp(clientX, clientY){
+      if(!pointerDown) return;
+      if(animating || gameOver){ pointerDown=null; return; }
+      var dx = clientX - pointerDown.x;
+      var dy = clientY - pointerDown.y;
+      var absx=Math.abs(dx), absy=Math.abs(dy);
+      var threshold = cell*0.3;
+      if(Math.max(absx,absy) > threshold){
+        // treat as swipe from the down cell
+        var a={r:pointerDown.r, c:pointerDown.c};
+        var b;
+        if(absx>absy) b={r:a.r, c:a.c + (dx>0?1:-1)};
+        else b={r:a.r + (dy>0?1:-1), c:a.c};
+        if(inBounds(b.r,b.c)){
+          sel=null;
+          tryMove(a,b);
+          draw();
+        }
+      }
+      pointerDown=null;
+    }
+
+    // mouse
+    canvas.addEventListener('mousedown', function(e){ e.preventDefault(); onDown(e.clientX,e.clientY); });
+    window.addEventListener('mouseup', function(e){ onUp(e.clientX,e.clientY); });
+    // touch
+    canvas.addEventListener('touchstart', function(e){
+      if(e.touches.length){ e.preventDefault(); var t=e.touches[0]; onDown(t.clientX,t.clientY); }
+    }, {passive:false});
+    canvas.addEventListener('touchmove', function(e){ e.preventDefault(); }, {passive:false});
+    canvas.addEventListener('touchend', function(e){
+      e.preventDefault();
+      var t = (e.changedTouches && e.changedTouches.length) ? e.changedTouches[0] : null;
+      if(t) onUp(t.clientX,t.clientY);
+    }, {passive:false});
+
+    // buttons
+    soundBtn.addEventListener('click', function(){
+      soundOn = !soundOn;
+      soundBtn.textContent = "Sound: " + (soundOn?"On":"Off");
+      if(soundOn){ ensureAudio(); blip(660,0.08,"sine",0.1); }
+    });
+    newBtn.addEventListener('click', function(){ ensureAudio(); newGame(); });
+
+    // resize handling
+    var resizeTimer=null;
+    window.addEventListener('resize', function(){
+      clearTimeout(resizeTimer);
+      resizeTimer=setTimeout(resize, 60);
+    });
+
+    // ---------- init ----------
+    loadBest();
+    newGame();
+    resize();
+  }
+
+  if(document.readyState === "loading"){
+    document.addEventListener("DOMContentLoaded", boot);
+  } else {
+    boot();
+  }
+})();
+</script>
+</body>
+</html>
diff --git a/games/hopper/index.html b/games/hopper/index.html
new file mode 100644
index 0000000..d8ee5b4
--- /dev/null
+++ b/games/hopper/index.html
@@ -0,0 +1,790 @@
+<!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>HOPPER</title>
+<style>
+  html,body{margin:0;padding:0;height:100%;background:#0a0e14;overflow:hidden;}
+  body{
+    touch-action:none;
+    -webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
+    font-family:'Trebuchet MS','Segoe UI',system-ui,-apple-system,sans-serif;
+    display:flex;align-items:center;justify-content:center;
+    color:#cfe;
+  }
+  #wrap{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;}
+  canvas{display:block;background:#0a0e14;touch-action:none;image-rendering:optimizeQuality;}
+</style>
+</head>
+<body>
+<div id="wrap"><canvas id="c"></canvas></div>
+<script>
+(function(){
+  "use strict";
+
+  function boot(){
+    var canvas = document.getElementById('c');
+    if(!canvas){ return; }
+    var ctx = canvas.getContext('2d');
+    if(!ctx){ return; }
+
+    // ---------- Config ----------
+    var COLS = 13;          // grid columns
+    var ROWS = 15;          // grid rows (0 = home row, 14 = start row)
+    var BEST_KEY = "abrams-hopper-best";
+    var DPR = 1;
+    var cell = 40;          // px per cell (computed on resize)
+    var W = COLS*cell, H = ROWS*cell;
+    var offX = 0, offY = 0; // canvas draw offsets (letterbox)
+
+    // ---------- Audio (lazy) ----------
+    var actx = null;
+    function ensureAudio(){
+      if(actx) return;
+      try{
+        var AC = window.AudioContext || window.webkitAudioContext;
+        if(AC){ actx = new AC(); }
+      }catch(e){ actx = null; }
+    }
+    function blip(freq, dur, type, vol){
+      if(!actx) return;
+      try{
+        if(actx.state === "suspended"){ actx.resume(); }
+        var o = actx.createOscillator();
+        var g = actx.createGain();
+        o.type = type || "square";
+        o.frequency.value = freq;
+        g.gain.value = 0.0001;
+        o.connect(g); g.connect(actx.destination);
+        var t = actx.currentTime;
+        var v = (vol==null?0.12:vol);
+        g.gain.setValueAtTime(0.0001, t);
+        g.gain.exponentialRampToValueAtTime(v, t+0.01);
+        g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
+        o.start(t);
+        o.stop(t+dur+0.02);
+      }catch(e){}
+    }
+    function sHop(){ blip(660,0.08,"square",0.10); }
+    function sSplat(){ blip(120,0.28,"sawtooth",0.16); setTimeout(function(){blip(80,0.22,"sawtooth",0.14);},40); }
+    function sHome(){ blip(880,0.10,"triangle",0.14); setTimeout(function(){blip(1320,0.14,"triangle",0.12);},90); }
+    function sLevel(){ blip(523,0.10,"triangle",0.12); setTimeout(function(){blip(659,0.10,"triangle",0.12);},110); setTimeout(function(){blip(784,0.16,"triangle",0.12);},220); }
+
+    // ---------- Layout ----------
+    // Rows (top to bottom):
+    // 0        : HOME row (goal slots)
+    // 1..5     : RIVER lanes (logs/turtles)
+    // 6        : SAFE median
+    // 7..11    : ROAD lanes (cars)
+    // 12       : SAFE start-adjacent
+    // 13..14   : start area (grass)
+    var HOME_ROW = 0;
+    var RIVER_ROWS = [1,2,3,4,5];
+    var MEDIAN_ROW = 6;
+    var ROAD_ROWS = [7,8,9,10,11];
+    var SAFE_ROW = 12;
+    var START_ROW = 13;
+
+    // Home slots: positions (in columns) that must be filled
+    var HOME_SLOTS = []; // {col, filled}
+    function setupHomeSlots(){
+      HOME_SLOTS = [];
+      var cols = [1,4,6,8,11]; // 5 slots spread across
+      for(var i=0;i<cols.length;i++){
+        HOME_SLOTS.push({col:cols[i], filled:false});
+      }
+    }
+
+    // ---------- Entities ----------
+    var lanes = []; // active moving lanes: {row, type:'car'|'log', dir, speed, gap, len, color, items:[{x}]}
+    function buildLanes(level){
+      lanes = [];
+      var baseSpeed = 1.4 + (level-1)*0.28; // cells/sec baseline
+      // Road lanes (cars)
+      var carColors = ["#ff3b6b","#ffd23b","#3bff9d","#5b8cff","#ff8f3b"];
+      for(var r=0;r<ROAD_ROWS.length;r++){
+        var row = ROAD_ROWS[r];
+        var dir = (r%2===0)? 1 : -1;
+        var speed = baseSpeed*(0.75 + r*0.16) * (1 + (level-1)*0.05);
+        var len = 1 + (r%2); // 1 or 2 cells long
+        var gap = 3.2 + (r%3)*0.9;
+        lanes.push(makeLane(row,'car',dir,speed,gap,len,carColors[r%carColors.length]));
+      }
+      // River lanes (logs & turtles)
+      for(var rr=0;rr<RIVER_ROWS.length;rr++){
+        var rw = RIVER_ROWS[rr];
+        var d = (rr%2===0)? -1 : 1;
+        var sp = baseSpeed*(0.6 + rr*0.14) * (1 + (level-1)*0.05);
+        var isTurtle = (rr%2===1);
+        var llen = isTurtle ? (2 + (rr%2)) : (2 + (rr%3)); // 2..4
+        var lgap = 2.6 + (rr%2)*0.8;
+        var lane = makeLane(rw, 'log', d, sp, lgap, llen, isTurtle?"#2ee6c8":"#c8975a");
+        lane.turtle = isTurtle;
+        lanes.push(lane);
+      }
+    }
+    function makeLane(row,type,dir,speed,gap,len,color){
+      var items = [];
+      var span = len + gap;
+      // fill across a wider range so wrap looks continuous
+      var total = Math.ceil((COLS + span*2)/span);
+      var startX = -span;
+      for(var i=0;i<total;i++){
+        items.push({ x: startX + i*span + (Math.random()*0.4-0.2) });
+      }
+      return {row:row,type:type,dir:dir,speed:speed,gap:gap,len:len,color:color,items:items,span:span};
+    }
+
+    // ---------- Player ----------
+    var player = {col:6, row:START_ROW, px:6, py:START_ROW, riding:null, alive:true};
+    function resetPlayerPos(){
+      player.col = 6; player.row = START_ROW;
+      player.px = 6; player.py = START_ROW;
+      player.riding = null; player.alive = true;
+    }
+
+    // ---------- Game State ----------
+    var state = "title"; // title | play | dead | over | win
+    var level = 1;
+    var lives = 3;
+    var score = 0;
+    var best = 0;
+    try{ var b = localStorage.getItem(BEST_KEY); if(b){ best = parseInt(b,10)||0; } }catch(e){}
+    var timeMax = 30;      // seconds per crossing
+    var timeLeft = timeMax;
+    var deadTimer = 0;
+    var farthestRow = START_ROW;
+
+    function startGame(){
+      level = 1; lives = 3; score = 0;
+      setupHomeSlots();
+      buildLanes(level);
+      resetPlayerPos();
+      timeMax = 30; timeLeft = timeMax;
+      farthestRow = START_ROW;
+      state = "play";
+    }
+    function nextLevel(){
+      level++;
+      setupHomeSlots();
+      buildLanes(level);
+      resetPlayerPos();
+      timeMax = Math.max(18, 30 - (level-1)*2);
+      timeLeft = timeMax;
+      farthestRow = START_ROW;
+      score += 500; // level bonus
+      state = "play";
+      sLevel();
+    }
+    function loseLife(){
+      lives--;
+      sSplat();
+      if(lives<=0){
+        commitBest();
+        state = "over";
+      }else{
+        state = "dead";
+        deadTimer = 0.9;
+      }
+    }
+    function commitBest(){
+      if(score>best){
+        best = score;
+        try{ localStorage.setItem(BEST_KEY, String(best)); }catch(e){}
+      }
+    }
+
+    // ---------- Movement ----------
+    function tryMove(dc,dr){
+      if(state!=="play" || !player.alive) return;
+      var nc = player.col + dc;
+      var nr = player.row + dr;
+      if(nc<0||nc>=COLS||nr<0||nr>ROWS-1) return;
+      // Landing on home row: must hit an empty slot
+      if(nr===HOME_ROW){
+        var slot = null;
+        for(var i=0;i<HOME_SLOTS.length;i++){
+          if(!HOME_SLOTS[i].filled && Math.abs(HOME_SLOTS[i].col - nc) <= 0){
+            slot = HOME_SLOTS[i]; break;
+          }
+        }
+        // allow snapping to nearest empty slot within 1 col
+        if(!slot){
+          for(var j=0;j<HOME_SLOTS.length;j++){
+            if(!HOME_SLOTS[j].filled && Math.abs(HOME_SLOTS[j].col - nc) === 1){
+              slot = HOME_SLOTS[j]; break;
+            }
+          }
+        }
+        if(slot){
+          slot.filled = true;
+          score += 100 + Math.round(timeLeft*10);
+          sHome();
+          // Check all filled
+          var allFilled = true;
+          for(var k=0;k<HOME_SLOTS.length;k++){ if(!HOME_SLOTS[k].filled){ allFilled=false; break; } }
+          if(allFilled){
+            nextLevel();
+          }else{
+            resetPlayerPos();
+            timeLeft = timeMax;
+            farthestRow = START_ROW;
+          }
+          return;
+        }else{
+          // hit the wall between slots => death
+          player.col = nc; player.row = nr;
+          player.px = nc; player.py = nr;
+          player.alive = false;
+          loseLife();
+          return;
+        }
+      }
+      player.col = nc; player.row = nr;
+      player.px = nc; player.py = nr;
+      player.riding = null;
+      // forward progress score
+      if(nr < farthestRow){
+        farthestRow = nr;
+        score += 10;
+      }
+      sHop();
+    }
+
+    // ---------- Update ----------
+    function update(dt){
+      if(state==="dead"){
+        deadTimer -= dt;
+        if(deadTimer<=0){
+          resetPlayerPos();
+          timeLeft = timeMax;
+          farthestRow = START_ROW;
+          state = "play";
+        }
+        return;
+      }
+      if(state!=="play") return;
+
+      // move lanes
+      for(var i=0;i<lanes.length;i++){
+        var ln = lanes[i];
+        var d = ln.dir * ln.speed * dt;
+        for(var m=0;m<ln.items.length;m++){
+          ln.items[m].x += d;
+          // wrap
+          if(ln.dir>0 && ln.items[m].x > COLS+1){
+            ln.items[m].x -= ln.span * ln.items.length;
+          }else if(ln.dir<0 && ln.items[m].x < -ln.len-1){
+            ln.items[m].x += ln.span * ln.items.length;
+          }
+        }
+      }
+
+      // timer
+      timeLeft -= dt;
+      if(timeLeft<=0){
+        timeLeft = 0;
+        player.alive = false;
+        loseLife();
+        return;
+      }
+
+      // River carry / drown logic
+      var row = player.row;
+      var inRiver = (RIVER_ROWS.indexOf(row) !== -1);
+      if(inRiver){
+        var onLog = null;
+        for(var L=0;L<lanes.length;L++){
+          if(lanes[L].row!==row) continue;
+          var lane = lanes[L];
+          for(var it=0; it<lane.items.length; it++){
+            var x0 = lane.items[it].x;
+            var x1 = x0 + lane.len;
+            // player center on this log?
+            if(player.px+0.5 >= x0-0.05 && player.px+0.5 <= x1+0.05){
+              onLog = {lane:lane, item:lane.items[it]};
+              break;
+            }
+          }
+          if(onLog) break;
+        }
+        if(onLog){
+          // carried by log
+          player.px += onLog.lane.dir * onLog.lane.speed * dt;
+          player.riding = onLog;
+          // carried off screen => drown
+          if(player.px < -0.6 || player.px > COLS-0.4){
+            player.alive = false;
+            loseLife();
+            return;
+          }
+          player.col = Math.round(player.px);
+        }else{
+          // in water, no log => drown
+          player.alive = false;
+          loseLife();
+          return;
+        }
+      }else{
+        player.riding = null;
+        player.px = player.col;
+      }
+
+      // Road collision
+      if(ROAD_ROWS.indexOf(row) !== -1){
+        for(var c=0;c<lanes.length;c++){
+          if(lanes[c].row!==row) continue;
+          var cl = lanes[c];
+          for(var ci=0; ci<cl.items.length; ci++){
+            var cx0 = cl.items[ci].x;
+            var cx1 = cx0 + cl.len;
+            var pcx = player.col + 0.5;
+            if(pcx > cx0 && pcx < cx1){
+              player.alive = false;
+              loseLife();
+              return;
+            }
+          }
+        }
+      }
+    }
+
+    // ---------- Render ----------
+    function drawRoundRect(x,y,w,h,r){
+      var rr = Math.min(r, w/2, h/2);
+      ctx.beginPath();
+      ctx.moveTo(x+rr,y);
+      ctx.arcTo(x+w,y,x+w,y+h,rr);
+      ctx.arcTo(x+w,y+h,x,y+h,rr);
+      ctx.arcTo(x,y+h,x,y,rr);
+      ctx.arcTo(x,y,x+w,y,rr);
+      ctx.closePath();
+    }
+
+    function render(){
+      ctx.save();
+      ctx.setTransform(DPR,0,0,DPR,0,0);
+      ctx.clearRect(0,0,canvas.width,canvas.height);
+      // full bg
+      ctx.fillStyle = "#0a0e14";
+      ctx.fillRect(0,0,canvas.width/DPR,canvas.height/DPR);
+      ctx.translate(offX, offY);
+
+      // playfield bg
+      ctx.fillStyle = "#080b11";
+      ctx.fillRect(0,0,W,H);
+
+      // ----- row backgrounds -----
+      // home band
+      ctx.fillStyle = "#0d1a14";
+      ctx.fillRect(0, HOME_ROW*cell, W, cell);
+      // river band
+      ctx.fillStyle = "#071726";
+      ctx.fillRect(0, RIVER_ROWS[0]*cell, W, RIVER_ROWS.length*cell);
+      // subtle water lines
+      ctx.strokeStyle = "rgba(60,140,220,0.12)";
+      ctx.lineWidth = 1;
+      for(var rl=0; rl<RIVER_ROWS.length; rl++){
+        var ry = (RIVER_ROWS[rl])*cell + cell*0.5;
+        ctx.beginPath(); ctx.moveTo(0,ry); ctx.lineTo(W,ry); ctx.stroke();
+      }
+      // median
+      ctx.fillStyle = "#12241a";
+      ctx.fillRect(0, MEDIAN_ROW*cell, W, cell);
+      // road band
+      ctx.fillStyle = "#0c0f16";
+      ctx.fillRect(0, ROAD_ROWS[0]*cell, W, ROAD_ROWS.length*cell);
+      // lane dashes
+      ctx.strokeStyle = "rgba(255,255,255,0.10)";
+      ctx.setLineDash([cell*0.35, cell*0.3]);
+      ctx.lineWidth = 2;
+      for(var rd=0; rd<ROAD_ROWS.length; rd++){
+        var yy = (ROAD_ROWS[rd])*cell + cell;
+        if(rd<ROAD_ROWS.length){
+          ctx.beginPath(); ctx.moveTo(0,yy); ctx.lineTo(W,yy); ctx.stroke();
+        }
+      }
+      ctx.setLineDash([]);
+      // safe & start grass
+      ctx.fillStyle = "#0e1f16";
+      ctx.fillRect(0, SAFE_ROW*cell, W, (ROWS-SAFE_ROW)*cell);
+
+      // neon band edges
+      ctx.strokeStyle = "rgba(46,230,200,0.25)";
+      ctx.lineWidth = 2;
+      ctx.strokeRect(1,1,W-2,H-2);
+
+      // ----- home slots -----
+      for(var hs=0; hs<HOME_SLOTS.length; hs++){
+        var slot = HOME_SLOTS[hs];
+        var sx = slot.col*cell;
+        var sy = HOME_ROW*cell;
+        ctx.save();
+        if(slot.filled){
+          ctx.shadowColor = "#2ee6c8"; ctx.shadowBlur = 14;
+          ctx.fillStyle = "#2ee6c8";
+          drawRoundRect(sx+cell*0.16, sy+cell*0.16, cell*0.68, cell*0.68, 8);
+          ctx.fill();
+          ctx.shadowBlur = 0;
+          ctx.fillStyle = "#04120e";
+          ctx.fillRect(sx+cell*0.42, sy+cell*0.28, cell*0.16, cell*0.34);
+        }else{
+          ctx.strokeStyle = "rgba(120,255,220,0.55)";
+          ctx.lineWidth = 2;
+          drawRoundRect(sx+cell*0.14, sy+cell*0.14, cell*0.72, cell*0.72, 8);
+          ctx.stroke();
+        }
+        ctx.restore();
+      }
+
+      // ----- lanes -----
+      for(var i=0;i<lanes.length;i++){
+        var ln = lanes[i];
+        var y = ln.row*cell;
+        for(var m=0;m<ln.items.length;m++){
+          var ix = ln.items[m].x*cell;
+          if(ix > W+cell || ix < -ln.len*cell-cell) continue;
+          if(ln.type==='car'){
+            ctx.save();
+            ctx.shadowColor = ln.color; ctx.shadowBlur = 12;
+            ctx.fillStyle = ln.color;
+            drawRoundRect(ix+3, y+cell*0.18, ln.len*cell-6, cell*0.64, 7);
+            ctx.fill();
+            ctx.shadowBlur = 0;
+            // windows
+            ctx.fillStyle = "rgba(10,14,20,0.6)";
+            ctx.fillRect(ix+ (ln.dir>0? ln.len*cell-16 : 8), y+cell*0.28, 8, cell*0.44);
+            ctx.restore();
+          }else{
+            // log or turtle
+            ctx.save();
+            if(ln.turtle){
+              ctx.shadowColor = ln.color; ctx.shadowBlur = 10;
+              ctx.fillStyle = ln.color;
+              for(var t=0;t<ln.len;t++){
+                ctx.beginPath();
+                ctx.ellipse(ix + t*cell + cell*0.5, y+cell*0.5, cell*0.34, cell*0.30, 0, 0, Math.PI*2);
+                ctx.fill();
+              }
+              ctx.shadowBlur=0;
+              ctx.fillStyle="rgba(4,30,26,0.5)";
+              for(var t2=0;t2<ln.len;t2++){
+                ctx.beginPath();
+                ctx.ellipse(ix + t2*cell + cell*0.5, y+cell*0.5, cell*0.16, cell*0.14, 0, 0, Math.PI*2);
+                ctx.fill();
+              }
+            }else{
+              ctx.shadowColor = "rgba(200,150,90,0.5)"; ctx.shadowBlur = 8;
+              ctx.fillStyle = ln.color;
+              drawRoundRect(ix+2, y+cell*0.20, ln.len*cell-4, cell*0.60, 9);
+              ctx.fill();
+              ctx.shadowBlur=0;
+              ctx.strokeStyle="rgba(80,50,20,0.5)";
+              ctx.lineWidth=1;
+              for(var g=1;g<ln.len;g++){
+                ctx.beginPath();
+                ctx.moveTo(ix+g*cell, y+cell*0.22);
+                ctx.lineTo(ix+g*cell, y+cell*0.78);
+                ctx.stroke();
+              }
+            }
+            ctx.restore();
+          }
+        }
+      }
+
+      // ----- player -----
+      if(state!=="over" && state!=="title"){
+        var pxPix = player.px*cell;
+        var pyPix = player.row*cell;
+        ctx.save();
+        var glow = player.alive ? "#7dffb0" : "#ff5b6b";
+        ctx.shadowColor = glow; ctx.shadowBlur = 18;
+        ctx.fillStyle = glow;
+        // body
+        drawRoundRect(pxPix+cell*0.18, pyPix+cell*0.18, cell*0.64, cell*0.64, 10);
+        ctx.fill();
+        ctx.shadowBlur = 0;
+        // eyes
+        ctx.fillStyle = "#04120e";
+        ctx.beginPath();
+        ctx.arc(pxPix+cell*0.38, pyPix+cell*0.40, cell*0.07, 0, Math.PI*2);
+        ctx.arc(pxPix+cell*0.62, pyPix+cell*0.40, cell*0.07, 0, Math.PI*2);
+        ctx.fill();
+        ctx.restore();
+      }
+
+      // ----- HUD -----
+      drawHUD();
+
+      // overlays
+      if(state==="title") drawTitle();
+      else if(state==="over") drawOver();
+
+      ctx.restore();
+    }
+
+    function drawHUD(){
+      var pad = 6;
+      ctx.save();
+      ctx.textBaseline = "top";
+      // score
+      var fs = Math.max(11, Math.round(cell*0.32));
+      ctx.font = "bold "+fs+"px 'Trebuchet MS',sans-serif";
+      ctx.fillStyle = "#8ff";
+      ctx.textAlign = "left";
+      ctx.fillText("SCORE "+score, pad+2, pad);
+      ctx.fillStyle = "#8fa";
+      ctx.textAlign = "center";
+      ctx.fillText("BEST "+best, W/2, pad);
+      // lives
+      ctx.textAlign = "right";
+      ctx.fillStyle = "#ff7d9c";
+      var lifeStr = "";
+      for(var i=0;i<lives;i++){ lifeStr += "♥"; }
+      ctx.fillText("LV"+level+"  "+lifeStr, W-pad-2, pad);
+
+      // timer bar
+      if(state==="play" || state==="dead"){
+        var barY = HOME_ROW*cell + cell + 3;
+        var barH = 5;
+        var frac = Math.max(0, timeLeft/timeMax);
+        ctx.fillStyle = "rgba(255,255,255,0.08)";
+        ctx.fillRect(6, barY, W-12, barH);
+        var col = frac>0.5? "#2ee6c8" : (frac>0.25? "#ffd23b" : "#ff3b6b");
+        ctx.shadowColor = col; ctx.shadowBlur = 8;
+        ctx.fillStyle = col;
+        ctx.fillRect(6, barY, (W-12)*frac, barH);
+        ctx.shadowBlur = 0;
+      }
+      ctx.restore();
+    }
+
+    function neonTitleText(txt, cx, cy, size, color){
+      ctx.save();
+      ctx.textAlign = "center";
+      ctx.textBaseline = "middle";
+      ctx.font = "bold "+size+"px 'Trebuchet MS',sans-serif";
+      // letter-spacing manual
+      var ls = size*0.16;
+      var chars = txt.split("");
+      var total = 0;
+      for(var i=0;i<chars.length;i++){ total += ctx.measureText(chars[i]).width + ls; }
+      total -= ls;
+      var x = cx - total/2;
+      ctx.shadowColor = color; ctx.shadowBlur = 22;
+      ctx.fillStyle = color;
+      for(var j=0;j<chars.length;j++){
+        var w = ctx.measureText(chars[j]).width;
+        ctx.fillText(chars[j], x + w/2, cy);
+        x += w + ls;
+      }
+      ctx.restore();
+    }
+
+    function drawTitle(){
+      ctx.save();
+      ctx.fillStyle = "rgba(4,7,12,0.82)";
+      ctx.fillRect(0,0,W,H);
+      neonTitleText("HOPPER", W/2, H*0.30, Math.round(cell*1.05), "#2ee6c8");
+      ctx.textAlign = "center";
+      ctx.fillStyle = "#bfe";
+      var s = Math.max(12, Math.round(cell*0.34));
+      ctx.font = s+"px 'Trebuchet MS',sans-serif";
+      ctx.fillText("Cross the road & river", W/2, H*0.44);
+      ctx.fillText("Fill all home slots", W/2, H*0.50);
+      ctx.fillStyle = "#8fa";
+      ctx.font = "bold "+s+"px 'Trebuchet MS',sans-serif";
+      ctx.fillText("ARROWS / WASD to hop", W/2, H*0.60);
+      // pulsing prompt
+      var a = 0.55 + 0.45*Math.sin(Date.now()/300);
+      ctx.fillStyle = "rgba(125,255,176,"+a.toFixed(3)+")";
+      ctx.font = "bold "+Math.round(cell*0.42)+"px 'Trebuchet MS',sans-serif";
+      ctx.fillText("TAP / PRESS TO START", W/2, H*0.72);
+      ctx.restore();
+    }
+
+    function drawOver(){
+      ctx.save();
+      ctx.fillStyle = "rgba(12,4,8,0.85)";
+      ctx.fillRect(0,0,W,H);
+      neonTitleText("GAME OVER", W/2, H*0.34, Math.round(cell*0.82), "#ff3b6b");
+      ctx.textAlign="center";
+      var s = Math.max(12, Math.round(cell*0.4));
+      ctx.font = "bold "+s+"px 'Trebuchet MS',sans-serif";
+      ctx.fillStyle = "#8ff";
+      ctx.fillText("SCORE "+score, W/2, H*0.48);
+      ctx.fillStyle = "#8fa";
+      ctx.fillText("BEST "+best, W/2, H*0.55);
+      var a = 0.55 + 0.45*Math.sin(Date.now()/300);
+      ctx.fillStyle = "rgba(125,255,176,"+a.toFixed(3)+")";
+      ctx.font = "bold "+Math.round(cell*0.42)+"px 'Trebuchet MS',sans-serif";
+      ctx.fillText("TAP / PRESS TO RETRY", W/2, H*0.68);
+      ctx.restore();
+    }
+
+    // ---------- D-pad (touch) ----------
+    var dpad = null; // {cx,cy,r} in canvas coords
+    function computeDpad(){
+      // place bottom-left area over start grass
+      var r = cell*1.1;
+      dpad = { cx: r*1.15, cy: H - r*1.15, r: r };
+    }
+    function drawDpad(){
+      if(!dpad) return;
+      if(state!=="play" && state!=="dead") return;
+      ctx.save();
+      ctx.translate(offX, offY);
+      ctx.globalAlpha = 0.5;
+      var d = dpad;
+      var arm = d.r*0.62, w = d.r*0.42;
+      ctx.fillStyle = "rgba(46,230,200,0.14)";
+      ctx.strokeStyle = "rgba(46,230,200,0.55)";
+      ctx.lineWidth = 2;
+      function armRect(ang){
+        ctx.save();
+        ctx.translate(d.cx,d.cy);
+        ctx.rotate(ang);
+        drawRoundRect(w*0.2, -w/2, arm, w, 6);
+        ctx.fill(); ctx.stroke();
+        // arrow tip
+        ctx.fillStyle="rgba(180,255,235,0.8)";
+        ctx.beginPath();
+        ctx.moveTo(arm+w*0.2, 0);
+        ctx.lineTo(arm-w*0.05, -w*0.28);
+        ctx.lineTo(arm-w*0.05, w*0.28);
+        ctx.closePath();
+        ctx.fill();
+        ctx.fillStyle="rgba(46,230,200,0.14)";
+        ctx.restore();
+      }
+      armRect(-Math.PI/2); // up
+      armRect(Math.PI/2);  // down
+      armRect(Math.PI);    // left
+      armRect(0);          // right
+      ctx.restore();
+    }
+    function dpadHit(x,y){
+      if(!dpad) return null;
+      var dx = x - dpad.cx, dy = y - dpad.cy;
+      var dist = Math.sqrt(dx*dx+dy*dy);
+      if(dist > dpad.r*1.35) return null;
+      if(dist < dpad.r*0.18) return null;
+      if(Math.abs(dx) > Math.abs(dy)){
+        return dx>0 ? "right" : "left";
+      }else{
+        return dy>0 ? "down" : "up";
+      }
+    }
+
+    // ---------- Input ----------
+    function handleDir(dir){
+      if(dir==="up") tryMove(0,-1);
+      else if(dir==="down") tryMove(0,1);
+      else if(dir==="left") tryMove(-1,0);
+      else if(dir==="right") tryMove(1,0);
+    }
+
+    function onKey(e){
+      var k = e.key;
+      var used = false;
+      if(k==="ArrowUp"||k==="w"||k==="W"){ used=true; act("up"); }
+      else if(k==="ArrowDown"||k==="s"||k==="S"){ used=true; act("down"); }
+      else if(k==="ArrowLeft"||k==="a"||k==="A"){ used=true; act("left"); }
+      else if(k==="ArrowRight"||k==="d"||k==="D"){ used=true; act("right"); }
+      else if(k===" "||k==="Enter"){ used=true; act("start"); }
+      if(used){ e.preventDefault(); }
+    }
+    function act(dir){
+      ensureAudio();
+      if(state==="title"){ startGame(); return; }
+      if(state==="over"){ startGame(); return; }
+      handleDir(dir);
+    }
+
+    window.addEventListener("keydown", onKey, {passive:false});
+
+    // touch / pointer
+    function canvasPoint(e){
+      var rect = canvas.getBoundingClientRect();
+      var cx = (e.clientX - rect.left) - offX;
+      var cy = (e.clientY - rect.top) - offY;
+      return {x:cx, y:cy};
+    }
+    function onPointerDown(e){
+      e.preventDefault();
+      ensureAudio();
+      if(state==="title"||state==="over"){ startGame(); return; }
+      var p = canvasPoint(e);
+      var hit = dpadHit(p.x, p.y);
+      if(hit){ handleDir(hit); return; }
+      // swipe-region fallback: tap relative to player => hop toward tap
+      var pcx = (player.px+0.5)*cell;
+      var pcy = (player.row+0.5)*cell;
+      var dx = p.x - pcx, dy = p.y - pcy;
+      if(Math.abs(dx)<cell*0.4 && Math.abs(dy)<cell*0.4){ tryMove(0,-1); return; }
+      if(Math.abs(dx) > Math.abs(dy)){ handleDir(dx>0?"right":"left"); }
+      else{ handleDir(dy>0?"down":"up"); }
+    }
+    canvas.addEventListener("pointerdown", onPointerDown, {passive:false});
+    canvas.addEventListener("touchstart", function(e){ e.preventDefault(); }, {passive:false});
+
+    // ---------- Resize ----------
+    function resize(){
+      var wrap = document.getElementById("wrap");
+      var availW = wrap.clientWidth || window.innerWidth;
+      var availH = wrap.clientHeight || window.innerHeight;
+      if(availW<=0) availW = window.innerWidth;
+      if(availH<=0) availH = window.innerHeight;
+      DPR = Math.min(window.devicePixelRatio||1, 2);
+
+      // choose cell to fit both dims
+      var cw = Math.floor(availW / COLS);
+      var ch = Math.floor(availH / ROWS);
+      cell = Math.max(12, Math.min(cw, ch));
+      W = COLS*cell; H = ROWS*cell;
+
+      canvas.width = Math.floor(availW*DPR);
+      canvas.height = Math.floor(availH*DPR);
+      canvas.style.width = availW+"px";
+      canvas.style.height = availH+"px";
+
+      offX = Math.floor((availW - W)/2);
+      offY = Math.floor((availH - H)/2);
+
+      computeDpad();
+
+      // rebuild lane item x extents not needed (grid-relative), but ensure lanes exist
+      if(lanes.length===0 && state!=="title"){ buildLanes(level); }
+    }
+    window.addEventListener("resize", resize);
+
+    // ---------- Loop ----------
+    var last = 0;
+    function frame(ts){
+      if(!last) last = ts;
+      var dt = (ts - last)/1000;
+      last = ts;
+      if(dt>0.1) dt = 0.1; // clamp
+      update(dt);
+      render();
+      drawDpad();
+      requestAnimationFrame(frame);
+    }
+
+    // ---------- Init ----------
+    setupHomeSlots();
+    resize();
+    requestAnimationFrame(frame);
+  }
+
+  if(document.readyState === "loading"){
+    document.addEventListener("DOMContentLoaded", boot);
+  }else{
+    boot();
+  }
+})();
+</script>
+</body>
+</html>
diff --git a/games/invaders/index.html b/games/invaders/index.html
new file mode 100644
index 0000000..0b75e8c
--- /dev/null
+++ b/games/invaders/index.html
@@ -0,0 +1,891 @@
+<!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>NEON INVADERS</title>
+<style>
+  * { margin: 0; padding: 0; box-sizing: border-box; }
+  html, body {
+    width: 100%; height: 100%;
+    background: #05070c;
+    overflow: hidden;
+    touch-action: none;
+    -webkit-user-select: none; user-select: none;
+    -webkit-tap-highlight-color: transparent;
+    font-family: "Courier New", ui-monospace, monospace;
+  }
+  #wrap {
+    position: fixed; inset: 0;
+    display: flex; flex-direction: column;
+    align-items: center; justify-content: center;
+    background:
+      radial-gradient(ellipse at 50% 30%, #101a2b 0%, #0a0e14 55%, #05070c 100%);
+  }
+  #title {
+    color: #39ff14;
+    font-weight: bold;
+    letter-spacing: 0.42em;
+    text-transform: uppercase;
+    font-size: clamp(14px, 3.6vw, 26px);
+    text-shadow: 0 0 8px #39ff14, 0 0 18px rgba(57,255,20,.5);
+    padding: 8px 0 6px;
+    text-align: center;
+  }
+  #stage {
+    position: relative;
+    border: 1px solid rgba(57,255,20,.28);
+    box-shadow: 0 0 22px rgba(57,255,20,.15), inset 0 0 40px rgba(10,20,40,.6);
+    background: #060a12;
+    border-radius: 6px;
+    overflow: hidden;
+  }
+  canvas { display: block; touch-action: none; }
+  #touch {
+    position: absolute; inset: 0;
+    display: none;
+    pointer-events: none;
+  }
+  .tbtn {
+    position: absolute;
+    pointer-events: auto;
+    display: flex; align-items: center; justify-content: center;
+    color: #7ffcff;
+    border: 1px solid rgba(127,252,255,.4);
+    background: rgba(10,20,35,.35);
+    border-radius: 12px;
+    font-size: 26px; font-weight: bold;
+    text-shadow: 0 0 6px #7ffcff;
+    backdrop-filter: blur(2px);
+    user-select: none;
+  }
+  .tbtn:active { background: rgba(127,252,255,.22); }
+  #bLeft  { left: 12px;  bottom: 14px; width: 62px; height: 62px; }
+  #bRight { left: 88px;  bottom: 14px; width: 62px; height: 62px; }
+  #bFire  { right: 14px; bottom: 14px; width: 78px; height: 78px;
+            color:#ff4fd8; border-color:rgba(255,79,216,.5); text-shadow:0 0 6px #ff4fd8; }
+  #hint {
+    color: rgba(160,200,220,.55);
+    font-size: clamp(9px, 1.9vw, 12px);
+    letter-spacing: .12em;
+    padding: 8px 6px 10px;
+    text-align: center;
+  }
+</style>
+</head>
+<body>
+  <div id="wrap">
+    <div id="title">Neon Invaders</div>
+    <div id="stage">
+      <canvas id="c"></canvas>
+      <div id="touch">
+        <div class="tbtn" id="bLeft">&#9664;</div>
+        <div class="tbtn" id="bRight">&#9654;</div>
+        <div class="tbtn" id="bFire">FIRE</div>
+      </div>
+    </div>
+    <div id="hint">&larr; &rarr; / A D MOVE &nbsp;&bull;&nbsp; SPACE FIRE &nbsp;&bull;&nbsp; P PAUSE</div>
+  </div>
+
+<script>
+(function () {
+  "use strict";
+
+  var BEST_KEY = "abrams-invaders-best";
+
+  function boot() {
+    var canvas = document.getElementById("c");
+    var ctx = canvas.getContext("2d");
+    var stage = document.getElementById("stage");
+    var touchLayer = document.getElementById("touch");
+
+    // Logical resolution (game world). Canvas is scaled to fit.
+    var W = 480, H = 640;
+    var DPR = 1;
+
+    function fitCanvas() {
+      // Available space inside the wrap, minus title/hint chrome.
+      var availW = Math.min(window.innerWidth - 12, 520);
+      var availH = window.innerHeight - 120;
+      if (availW < 200) availW = 200;
+      if (availH < 300) availH = 300;
+      var scale = Math.min(availW / W, availH / H);
+      if (!isFinite(scale) || scale <= 0) scale = 1;
+      var cssW = Math.round(W * scale);
+      var cssH = Math.round(H * scale);
+      DPR = Math.min(window.devicePixelRatio || 1, 2);
+      canvas.style.width = cssW + "px";
+      canvas.style.height = cssH + "px";
+      canvas.width = Math.round(W * DPR);
+      canvas.height = Math.round(H * DPR);
+      stage.style.width = cssW + "px";
+      stage.style.height = cssH + "px";
+      ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+    }
+
+    // ---- Audio (lazy) ----
+    var actx = null, muted = false;
+    function ensureAudio() {
+      if (actx || muted) return;
+      try {
+        var AC = window.AudioContext || window.webkitAudioContext;
+        if (!AC) { muted = true; return; }
+        actx = new AC();
+      } catch (e) { muted = true; actx = null; }
+    }
+    function blip(type, freq, dur, vol) {
+      if (!actx) return;
+      try {
+        if (actx.state === "suspended") { actx.resume().catch(function(){}); }
+        var o = actx.createOscillator();
+        var g = actx.createGain();
+        o.type = type;
+        o.frequency.value = freq;
+        var t = actx.currentTime;
+        g.gain.setValueAtTime(0.0001, t);
+        g.gain.exponentialRampToValueAtTime(vol, t + 0.008);
+        g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+        o.connect(g); g.connect(actx.destination);
+        o.start(t); o.stop(t + dur + 0.02);
+      } catch (e) { /* never throw from audio */ }
+    }
+    function sShoot()  { blip("square", 880, 0.09, 0.05); }
+    function sHit()    { blip("triangle", 320, 0.10, 0.06); }
+    function sBoom()   { blip("sawtooth", 120, 0.32, 0.08); }
+    function sUfo()    { blip("sine", 1200, 0.14, 0.05); }
+    function sStep(i)  { blip("square", 200 + (i % 4) * 30, 0.05, 0.03); }
+
+    // ---- Game state ----
+    var STATE = { MENU: 0, PLAY: 1, WAVE: 2, OVER: 3, PAUSED: 4 };
+    var state = STATE.MENU;
+
+    var best = 0;
+    try {
+      var stored = localStorage.getItem(BEST_KEY);
+      if (stored != null) { var n = parseInt(stored, 10); if (!isNaN(n)) best = n; }
+    } catch (e) { best = 0; }
+
+    var score = 0, lives = 3, wave = 1;
+
+    var player, bullets, bombs, invaders, bunkers, ufo, particles;
+    var invDir, invStepTimer, invStepInterval, invDropPending;
+    var fireCooldown, ufoTimer, stepIndex;
+    var flashTimer = 0;
+
+    var keys = {};
+    var touch = { left: false, right: false, fire: false };
+
+    // ---- Entity setup ----
+    function makePlayer() {
+      return { x: W / 2, y: H - 46, w: 40, h: 18, speed: 260, cool: 0 };
+    }
+
+    function makeBunkers() {
+      var arr = [];
+      var count = 4;
+      var bw = 58, bh = 40;
+      var gap = (W - count * bw) / (count + 1);
+      var by = H - 130;
+      for (var b = 0; b < count; b++) {
+        var bx = gap + b * (bw + gap);
+        var cellsX = 7, cellsY = 5;
+        var cw = bw / cellsX, ch = bh / cellsY;
+        var cells = [];
+        for (var yy = 0; yy < cellsY; yy++) {
+          for (var xx = 0; xx < cellsX; xx++) {
+            // carve an arch shape
+            var isNotch = (yy >= 3) && (xx >= 2 && xx <= 4);
+            if (isNotch) continue;
+            cells.push({
+              x: bx + xx * cw, y: by + yy * ch,
+              w: cw, h: ch, hp: 3
+            });
+          }
+        }
+        arr.push({ cells: cells });
+      }
+      return arr;
+    }
+
+    function buildInvaders() {
+      invaders = [];
+      var cols = 8, rows = 5;
+      var cellW = 44, cellH = 34;
+      var gridW = cols * cellW;
+      var startX = (W - gridW) / 2 + cellW / 2;
+      var startY = 70;
+      for (var r = 0; r < rows; r++) {
+        for (var col = 0; col < cols; col++) {
+          var type = r === 0 ? 2 : (r < 3 ? 1 : 0);
+          invaders.push({
+            x: startX + col * cellW,
+            y: startY + r * cellH,
+            w: 28, h: 20,
+            type: type,
+            alive: true,
+            row: r, col: col
+          });
+        }
+      }
+      invDir = 1;
+      invStepTimer = 0;
+      invStepInterval = Math.max(0.18, 0.75 - (wave - 1) * 0.06);
+      invDropPending = false;
+      stepIndex = 0;
+    }
+
+    function startGame() {
+      score = 0; lives = 3; wave = 1;
+      player = makePlayer();
+      bullets = []; bombs = []; particles = [];
+      bunkers = makeBunkers();
+      ufo = null;
+      fireCooldown = 0;
+      ufoTimer = rand(8, 16);
+      buildInvaders();
+      state = STATE.PLAY;
+    }
+
+    function nextWave() {
+      wave++;
+      player.x = W / 2;
+      bullets = []; bombs = [];
+      bunkers = makeBunkers();
+      ufo = null;
+      ufoTimer = rand(8, 16);
+      buildInvaders();
+      state = STATE.PLAY;
+      flashTimer = 0.6;
+    }
+
+    function rand(a, b) { return a + Math.random() * (b - a); }
+
+    // ---- Particles ----
+    function burst(x, y, color, n) {
+      for (var i = 0; i < n; i++) {
+        var a = Math.random() * Math.PI * 2;
+        var sp = rand(30, 160);
+        particles.push({
+          x: x, y: y,
+          vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
+          life: rand(0.3, 0.7), max: 0.7, color: color
+        });
+      }
+    }
+
+    // ---- Collision ----
+    function overlap(ax, ay, aw, ah, bx, by, bw, bh) {
+      return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
+    }
+
+    // ---- Update ----
+    function update(dt) {
+      if (state !== STATE.PLAY) {
+        // still animate particles on OVER for a moment
+        updateParticles(dt);
+        return;
+      }
+
+      if (flashTimer > 0) flashTimer -= dt;
+
+      // player movement
+      var mv = 0;
+      if (keys.left || touch.left) mv -= 1;
+      if (keys.right || touch.right) mv += 1;
+      player.x += mv * player.speed * dt;
+      var half = player.w / 2;
+      if (player.x < half) player.x = half;
+      if (player.x > W - half) player.x = W - half;
+
+      // firing
+      if (player.cool > 0) player.cool -= dt;
+      if ((keys.fire || touch.fire) && player.cool <= 0) {
+        bullets.push({ x: player.x, y: player.y - 12, w: 3, h: 14, vy: -520 });
+        player.cool = 0.34;
+        sShoot();
+      }
+
+      // bullets
+      for (var i = bullets.length - 1; i >= 0; i--) {
+        var bl = bullets[i];
+        bl.y += bl.vy * dt;
+        if (bl.y + bl.h < 0) { bullets.splice(i, 1); continue; }
+      }
+
+      // invader movement (grid step)
+      updateInvaders(dt);
+
+      // bombs (from invaders)
+      for (var j = bombs.length - 1; j >= 0; j--) {
+        var bo = bombs[j];
+        bo.y += bo.vy * dt;
+        bo.t = (bo.t || 0) + dt;
+        if (bo.y > H) { bombs.splice(j, 1); continue; }
+      }
+
+      // UFO
+      updateUfo(dt);
+
+      // collisions
+      handleCollisions();
+
+      updateParticles(dt);
+
+      // win condition
+      var anyAlive = false;
+      for (var k = 0; k < invaders.length; k++) { if (invaders[k].alive) { anyAlive = true; break; } }
+      if (!anyAlive) {
+        state = STATE.WAVE;
+        waveDelay = 1.4;
+      }
+    }
+
+    var waveDelay = 0;
+
+    function updateInvaders(dt) {
+      var aliveList = [];
+      var minX = 1e9, maxX = -1e9, maxY = -1e9;
+      for (var i = 0; i < invaders.length; i++) {
+        var inv = invaders[i];
+        if (!inv.alive) continue;
+        aliveList.push(inv);
+        if (inv.x - inv.w / 2 < minX) minX = inv.x - inv.w / 2;
+        if (inv.x + inv.w / 2 > maxX) maxX = inv.x + inv.w / 2;
+        if (inv.y + inv.h / 2 > maxY) maxY = inv.y + inv.h / 2;
+      }
+      if (aliveList.length === 0) return;
+
+      // Speed scales up as fewer remain.
+      var frac = aliveList.length / invaders.length;
+      var interval = invStepInterval * (0.35 + 0.65 * frac);
+      invStepTimer += dt;
+      if (invStepTimer >= interval) {
+        invStepTimer = 0;
+        stepIndex++;
+        sStep(stepIndex);
+
+        var stepX = 12;
+        var willHit = (invDir > 0 && maxX + stepX >= W - 6) ||
+                      (invDir < 0 && minX - stepX <= 6);
+        if (willHit) {
+          invDir *= -1;
+          for (var d = 0; d < aliveList.length; d++) aliveList[d].y += 18;
+        } else {
+          for (var m = 0; m < aliveList.length; m++) aliveList[m].x += stepX * invDir;
+        }
+
+        // reached player level -> game over
+        for (var q = 0; q < aliveList.length; q++) {
+          if (aliveList[q].y + aliveList[q].h / 2 >= player.y - 6) {
+            loseAllLives();
+            return;
+          }
+        }
+      }
+
+      // random bomb drops from bottom-most invaders per column
+      var dropChance = (0.35 + wave * 0.12) * dt;
+      if (Math.random() < dropChance && aliveList.length) {
+        // find a random column's lowest alive invader
+        var shooter = aliveList[(Math.random() * aliveList.length) | 0];
+        // ensure it's the lowest in its column
+        var lowest = shooter;
+        for (var c = 0; c < aliveList.length; c++) {
+          if (aliveList[c].col === shooter.col && aliveList[c].y > lowest.y) lowest = aliveList[c];
+        }
+        bombs.push({ x: lowest.x, y: lowest.y + 12, w: 5, h: 12, vy: rand(170, 210) + wave * 8, t: 0 });
+      }
+    }
+
+    function updateUfo(dt) {
+      if (ufo) {
+        ufo.x += ufo.vx * dt;
+        if ((ufo.vx > 0 && ufo.x > W + 40) || (ufo.vx < 0 && ufo.x < -40)) {
+          ufo = null;
+        }
+      } else {
+        ufoTimer -= dt;
+        if (ufoTimer <= 0) {
+          var fromLeft = Math.random() < 0.5;
+          ufo = {
+            x: fromLeft ? -30 : W + 30,
+            y: 42, w: 40, h: 16,
+            vx: fromLeft ? 90 : -90,
+            value: [50, 100, 150, 300][(Math.random() * 4) | 0]
+          };
+          ufoTimer = rand(12, 22);
+        }
+      }
+    }
+
+    function updateParticles(dt) {
+      for (var i = particles.length - 1; i >= 0; i--) {
+        var p = particles[i];
+        p.life -= dt;
+        if (p.life <= 0) { particles.splice(i, 1); continue; }
+        p.x += p.vx * dt;
+        p.y += p.vy * dt;
+        p.vy += 120 * dt;
+      }
+    }
+
+    function damageBunkerAt(x, y, w, h) {
+      for (var b = 0; b < bunkers.length; b++) {
+        var cells = bunkers[b].cells;
+        for (var i = cells.length - 1; i >= 0; i--) {
+          var cl = cells[i];
+          if (overlap(x, y, w, h, cl.x, cl.y, cl.w, cl.h)) {
+            cl.hp--;
+            burst(cl.x + cl.w / 2, cl.y + cl.h / 2, "#39ff14", 4);
+            if (cl.hp <= 0) cells.splice(i, 1);
+            return true;
+          }
+        }
+      }
+      return false;
+    }
+
+    function handleCollisions() {
+      // player bullets
+      for (var i = bullets.length - 1; i >= 0; i--) {
+        var bl = bullets[i];
+        var bx = bl.x - bl.w / 2, by = bl.y;
+
+        // bunkers
+        if (damageBunkerAt(bx, by, bl.w, bl.h)) { bullets.splice(i, 1); continue; }
+
+        // ufo
+        if (ufo && overlap(bx, by, bl.w, bl.h, ufo.x - ufo.w / 2, ufo.y - ufo.h / 2, ufo.w, ufo.h)) {
+          addScore(ufo.value);
+          burst(ufo.x, ufo.y, "#ff4fd8", 22);
+          sUfo();
+          ufo = null;
+          bullets.splice(i, 1);
+          continue;
+        }
+
+        // invaders
+        var hitInv = false;
+        for (var k = 0; k < invaders.length; k++) {
+          var inv = invaders[k];
+          if (!inv.alive) continue;
+          if (overlap(bx, by, bl.w, bl.h, inv.x - inv.w / 2, inv.y - inv.h / 2, inv.w, inv.h)) {
+            inv.alive = false;
+            addScore((inv.type + 1) * 10);
+            burst(inv.x, inv.y, invColor(inv.type), 16);
+            sHit();
+            hitInv = true;
+            break;
+          }
+        }
+        if (hitInv) { bullets.splice(i, 1); continue; }
+      }
+
+      // enemy bombs
+      for (var j = bombs.length - 1; j >= 0; j--) {
+        var bo = bombs[j];
+        var ox = bo.x - bo.w / 2, oy = bo.y;
+
+        if (damageBunkerAt(ox, oy, bo.w, bo.h)) { bombs.splice(j, 1); continue; }
+
+        if (overlap(ox, oy, bo.w, bo.h, player.x - player.w / 2, player.y - player.h / 2, player.w, player.h)) {
+          bombs.splice(j, 1);
+          playerHit();
+          continue;
+        }
+      }
+    }
+
+    function invColor(type) {
+      return type === 2 ? "#ff4fd8" : (type === 1 ? "#7ffcff" : "#39ff14");
+    }
+
+    function addScore(v) {
+      score += v;
+      if (score > best) {
+        best = score;
+        try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* ignore */ }
+      }
+    }
+
+    function playerHit() {
+      lives--;
+      burst(player.x, player.y, "#ffd23f", 26);
+      sBoom();
+      flashTimer = 0.35;
+      if (lives <= 0) {
+        gameOver();
+      } else {
+        player.x = W / 2;
+        player.cool = 0.6;
+      }
+    }
+
+    function loseAllLives() {
+      lives = 0;
+      burst(player.x, player.y, "#ffd23f", 30);
+      sBoom();
+      gameOver();
+    }
+
+    function gameOver() {
+      state = STATE.OVER;
+    }
+
+    // ---- Render ----
+    function draw() {
+      ctx.clearRect(0, 0, W, H);
+
+      // starfield backdrop
+      drawStars();
+
+      if (flashTimer > 0 && (state === STATE.PLAY || state === STATE.WAVE)) {
+        ctx.fillStyle = "rgba(255,210,63," + Math.min(0.25, flashTimer * 0.4) + ")";
+        ctx.fillRect(0, 0, W, H);
+      }
+
+      if (state === STATE.MENU) {
+        drawMenu();
+        drawParticles();
+        return;
+      }
+
+      // bunkers
+      for (var b = 0; b < bunkers.length; b++) {
+        var cells = bunkers[b].cells;
+        for (var i = 0; i < cells.length; i++) {
+          var cl = cells[i];
+          var a = cl.hp / 3;
+          ctx.fillStyle = "rgba(57,255,20," + (0.35 + a * 0.55) + ")";
+          ctx.fillRect(cl.x + 0.5, cl.y + 0.5, cl.w - 1, cl.h - 1);
+        }
+      }
+
+      // invaders
+      for (var k = 0; k < invaders.length; k++) {
+        if (invaders[k].alive) drawInvader(invaders[k]);
+      }
+
+      // ufo
+      if (ufo) drawUfo(ufo);
+
+      // bullets
+      ctx.save();
+      ctx.shadowBlur = 8; ctx.shadowColor = "#ffffff";
+      ctx.fillStyle = "#ffffff";
+      for (var m = 0; m < bullets.length; m++) {
+        var bl = bullets[m];
+        ctx.fillRect(bl.x - bl.w / 2, bl.y, bl.w, bl.h);
+      }
+      ctx.restore();
+
+      // bombs
+      ctx.save();
+      ctx.shadowBlur = 8; ctx.shadowColor = "#ff4fd8";
+      for (var n = 0; n < bombs.length; n++) {
+        var bo = bombs[n];
+        ctx.fillStyle = ((bo.t * 20) | 0) % 2 ? "#ff4fd8" : "#ffb0ec";
+        ctx.fillRect(bo.x - bo.w / 2, bo.y, bo.w, bo.h);
+      }
+      ctx.restore();
+
+      // player
+      if (state === STATE.PLAY || state === STATE.WAVE || state === STATE.PAUSED) drawPlayer();
+
+      drawParticles();
+      drawHUD();
+
+      if (state === STATE.OVER) drawGameOver();
+      if (state === STATE.PAUSED) drawPaused();
+      if (state === STATE.WAVE) drawWaveBanner();
+    }
+
+    var stars = [];
+    function initStars() {
+      stars = [];
+      for (var i = 0; i < 60; i++) {
+        stars.push({ x: Math.random() * W, y: Math.random() * H, s: Math.random() * 1.6 + 0.4, t: Math.random() * 6.28 });
+      }
+    }
+    function drawStars() {
+      for (var i = 0; i < stars.length; i++) {
+        var st = stars[i];
+        var tw = 0.4 + 0.6 * Math.abs(Math.sin(st.t + performance.now() / 900));
+        ctx.fillStyle = "rgba(120,180,220," + (tw * 0.5) + ")";
+        ctx.fillRect(st.x, st.y, st.s, st.s);
+      }
+    }
+
+    function drawPlayer() {
+      var px = player.x, py = player.y, w = player.w, h = player.h;
+      ctx.save();
+      ctx.shadowBlur = 12; ctx.shadowColor = "#39ff14";
+      ctx.fillStyle = "#39ff14";
+      // cannon body
+      ctx.beginPath();
+      ctx.moveTo(px - w / 2, py + h / 2);
+      ctx.lineTo(px + w / 2, py + h / 2);
+      ctx.lineTo(px + w / 2, py + h / 2 - 6);
+      ctx.lineTo(px + 8, py + h / 2 - 6);
+      ctx.lineTo(px + 4, py - h / 2 + 2);
+      ctx.lineTo(px - 4, py - h / 2 + 2);
+      ctx.lineTo(px - 8, py + h / 2 - 6);
+      ctx.lineTo(px - w / 2, py + h / 2 - 6);
+      ctx.closePath();
+      ctx.fill();
+      ctx.fillRect(px - 2, py - h / 2 - 4, 4, 6);
+      ctx.restore();
+    }
+
+    function drawInvader(inv) {
+      var x = inv.x - inv.w / 2, y = inv.y - inv.h / 2, w = inv.w, h = inv.h;
+      var col = invColor(inv.type);
+      var frame = stepIndex % 2;
+      ctx.save();
+      ctx.shadowBlur = 8; ctx.shadowColor = col;
+      ctx.fillStyle = col;
+      var px = w / 8, py = h / 5;
+      // simple 8x5 sprite bitmaps
+      var A = [
+        [0,0,1,0,0,1,0,0],
+        [0,1,1,1,1,1,1,0],
+        [1,1,0,1,1,0,1,1],
+        [1,1,1,1,1,1,1,1],
+        frame ? [0,1,0,0,0,0,1,0] : [1,0,1,0,0,1,0,1]
+      ];
+      for (var ry = 0; ry < 5; ry++) {
+        for (var rx = 0; rx < 8; rx++) {
+          if (A[ry][rx]) ctx.fillRect(x + rx * px, y + ry * py, px + 0.5, py + 0.5);
+        }
+      }
+      ctx.restore();
+    }
+
+    function drawUfo(u) {
+      ctx.save();
+      ctx.shadowBlur = 12; ctx.shadowColor = "#ff4fd8";
+      ctx.fillStyle = "#ff4fd8";
+      ctx.beginPath();
+      ctx.ellipse(u.x, u.y, u.w / 2, u.h / 2, 0, 0, Math.PI * 2);
+      ctx.fill();
+      ctx.fillStyle = "#ffd23f";
+      ctx.beginPath();
+      ctx.ellipse(u.x, u.y - 3, u.w / 4, u.h / 3, 0, Math.PI, 0);
+      ctx.fill();
+      ctx.restore();
+    }
+
+    function drawParticles() {
+      for (var i = 0; i < particles.length; i++) {
+        var p = particles[i];
+        var a = Math.max(0, p.life / p.max);
+        ctx.globalAlpha = a;
+        ctx.fillStyle = p.color;
+        ctx.fillRect(p.x - 1.5, p.y - 1.5, 3, 3);
+      }
+      ctx.globalAlpha = 1;
+    }
+
+    function drawHUD() {
+      ctx.save();
+      ctx.font = "bold 15px 'Courier New', monospace";
+      ctx.textBaseline = "top";
+      ctx.fillStyle = "#39ff14";
+      ctx.shadowBlur = 6; ctx.shadowColor = "#39ff14";
+      ctx.textAlign = "left";
+      ctx.fillText("SCORE " + pad(score, 5), 12, 10);
+      ctx.textAlign = "center";
+      ctx.fillStyle = "#7ffcff"; ctx.shadowColor = "#7ffcff";
+      ctx.fillText("WAVE " + wave, W / 2, 10);
+      ctx.textAlign = "right";
+      ctx.fillStyle = "#ffd23f"; ctx.shadowColor = "#ffd23f";
+      ctx.fillText("BEST " + pad(best, 5), W - 12, 10);
+      ctx.restore();
+
+      // lives
+      for (var i = 0; i < lives; i++) {
+        var lx = 20 + i * 26, ly = H - 22;
+        ctx.save();
+        ctx.shadowBlur = 6; ctx.shadowColor = "#39ff14";
+        ctx.fillStyle = "#39ff14";
+        ctx.beginPath();
+        ctx.moveTo(lx - 8, ly + 6);
+        ctx.lineTo(lx + 8, ly + 6);
+        ctx.lineTo(lx + 4, ly - 4);
+        ctx.lineTo(lx - 4, ly - 4);
+        ctx.closePath();
+        ctx.fill();
+        ctx.fillRect(lx - 1, ly - 8, 2, 4);
+        ctx.restore();
+      }
+    }
+
+    function drawMenu() {
+      centerText("NEON INVADERS", W / 2, H / 2 - 70, "bold 34px 'Courier New', monospace", "#39ff14", 8, 0.12);
+      centerText("DEFEND THE GRID", W / 2, H / 2 - 30, "14px 'Courier New', monospace", "#7ffcff", 4, 0.3);
+      var blink = (Math.floor(performance.now() / 500) % 2) === 0;
+      if (blink) centerText("PRESS SPACE / TAP TO START", W / 2, H / 2 + 30, "bold 15px 'Courier New', monospace", "#ffd23f", 6, 0.1);
+      centerText("BEST  " + pad(best, 5), W / 2, H / 2 + 80, "14px 'Courier New', monospace", "#ff4fd8", 4, 0.2);
+    }
+
+    function drawGameOver() {
+      ctx.fillStyle = "rgba(5,7,12,.6)";
+      ctx.fillRect(0, 0, W, H);
+      centerText("GAME OVER", W / 2, H / 2 - 50, "bold 34px 'Courier New', monospace", "#ff4fd8", 10, 0.1);
+      centerText("SCORE  " + pad(score, 5), W / 2, H / 2, "16px 'Courier New', monospace", "#39ff14", 5, 0.15);
+      centerText("BEST   " + pad(best, 5), W / 2, H / 2 + 26, "16px 'Courier New', monospace", "#ffd23f", 5, 0.15);
+      var blink = (Math.floor(performance.now() / 500) % 2) === 0;
+      if (blink) centerText("PRESS SPACE / TAP TO RESTART", W / 2, H / 2 + 70, "bold 14px 'Courier New', monospace", "#7ffcff", 5, 0.08);
+    }
+
+    function drawPaused() {
+      ctx.fillStyle = "rgba(5,7,12,.55)";
+      ctx.fillRect(0, 0, W, H);
+      centerText("PAUSED", W / 2, H / 2 - 10, "bold 30px 'Courier New', monospace", "#7ffcff", 8, 0.2);
+      centerText("PRESS P TO RESUME", W / 2, H / 2 + 30, "13px 'Courier New', monospace", "#ffd23f", 4, 0.1);
+    }
+
+    function drawWaveBanner() {
+      centerText("WAVE " + wave + " CLEARED", W / 2, H / 2 - 10, "bold 26px 'Courier New', monospace", "#39ff14", 8, 0.14);
+    }
+
+    function centerText(txt, x, y, font, color, glow, spacing) {
+      ctx.save();
+      ctx.font = font;
+      ctx.textAlign = "center";
+      ctx.textBaseline = "middle";
+      ctx.shadowBlur = glow || 0;
+      ctx.shadowColor = color;
+      ctx.fillStyle = color;
+      if (spacing) {
+        // manual letter spacing
+        var total = 0, i;
+        var widths = [];
+        for (i = 0; i < txt.length; i++) {
+          var wch = ctx.measureText(txt[i]).width + spacing * 20;
+          widths.push(wch); total += wch;
+        }
+        var cx = x - total / 2;
+        ctx.textAlign = "left";
+        for (i = 0; i < txt.length; i++) {
+          ctx.fillText(txt[i], cx, y);
+          cx += widths[i];
+        }
+      } else {
+        ctx.fillText(txt, x, y);
+      }
+      ctx.restore();
+    }
+
+    function pad(n, len) {
+      var s = String(Math.max(0, n | 0));
+      while (s.length < len) s = "0" + s;
+      return s;
+    }
+
+    // ---- Input ----
+    function onKeyDown(e) {
+      var k = e.key;
+      if (k === "ArrowLeft" || k === "a" || k === "A") { keys.left = true; e.preventDefault(); }
+      else if (k === "ArrowRight" || k === "d" || k === "D") { keys.right = true; e.preventDefault(); }
+      else if (k === " " || k === "Spacebar" || k === "ArrowUp") {
+        keys.fire = true; e.preventDefault();
+        ensureAudio();
+        if (state === STATE.MENU || state === STATE.OVER) startGame();
+      }
+      else if (k === "p" || k === "P") {
+        if (state === STATE.PLAY) state = STATE.PAUSED;
+        else if (state === STATE.PAUSED) state = STATE.PLAY;
+      }
+      else if (k === "m" || k === "M") { muted = !muted; }
+    }
+    function onKeyUp(e) {
+      var k = e.key;
+      if (k === "ArrowLeft" || k === "a" || k === "A") keys.left = false;
+      else if (k === "ArrowRight" || k === "d" || k === "D") keys.right = false;
+      else if (k === " " || k === "Spacebar" || k === "ArrowUp") keys.fire = false;
+    }
+
+    window.addEventListener("keydown", onKeyDown, { passive: false });
+    window.addEventListener("keyup", onKeyUp);
+
+    // Touch controls
+    function bindTouch(el, key) {
+      function down(ev) {
+        ev.preventDefault();
+        ensureAudio();
+        touch[key] = true;
+        if (key === "fire" && (state === STATE.MENU || state === STATE.OVER)) startGame();
+      }
+      function up(ev) { ev.preventDefault(); touch[key] = false; }
+      el.addEventListener("touchstart", down, { passive: false });
+      el.addEventListener("touchend", up, { passive: false });
+      el.addEventListener("touchcancel", up, { passive: false });
+      el.addEventListener("mousedown", down);
+      el.addEventListener("mouseup", up);
+      el.addEventListener("mouseleave", up);
+    }
+    bindTouch(document.getElementById("bLeft"), "left");
+    bindTouch(document.getElementById("bRight"), "right");
+    bindTouch(document.getElementById("bFire"), "fire");
+
+    // Tap on canvas also starts / fires on touch devices
+    canvas.addEventListener("touchstart", function (ev) {
+      ev.preventDefault();
+      ensureAudio();
+      if (state === STATE.MENU || state === STATE.OVER) startGame();
+    }, { passive: false });
+    canvas.addEventListener("mousedown", function () {
+      ensureAudio();
+      if (state === STATE.MENU || state === STATE.OVER) startGame();
+    });
+
+    // Show touch controls when a touch is detected
+    function enableTouchUI() {
+      touchLayer.style.display = "block";
+      window.removeEventListener("touchstart", enableTouchUI);
+    }
+    window.addEventListener("touchstart", enableTouchUI, { passive: true });
+    if (("ontouchstart" in window) || (navigator.maxTouchPoints > 0)) {
+      touchLayer.style.display = "block";
+    }
+
+    window.addEventListener("resize", fitCanvas);
+
+    // ---- Loop ----
+    var last = performance.now();
+    function frame(now) {
+      var dt = (now - last) / 1000;
+      last = now;
+      if (dt > 0.05) dt = 0.05; // clamp for tab-switch spikes
+
+      if (state === STATE.WAVE) {
+        updateParticles(dt);
+        waveDelay -= dt;
+        if (waveDelay <= 0) nextWave();
+      } else {
+        update(dt);
+      }
+
+      draw();
+      requestAnimationFrame(frame);
+    }
+
+    // ---- Init ----
+    initStars();
+    fitCanvas();
+    // initialize placeholder arrays for menu particle draws
+    particles = [];
+    bullets = []; bombs = []; invaders = []; bunkers = []; player = makePlayer();
+    requestAnimationFrame(frame);
+  }
+
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", boot);
+  } else {
+    boot();
+  }
+})();
+</script>
+</body>
+</html>
diff --git a/games/missile/index.html b/games/missile/index.html
new file mode 100644
index 0000000..a8a3c16
--- /dev/null
+++ b/games/missile/index.html
@@ -0,0 +1,886 @@
+<!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>MISSILE COMMAND</title>
+<style>
+  html, body {
+    margin: 0;
+    padding: 0;
+    width: 100%;
+    height: 100%;
+    background: #0a0e14;
+    overflow: hidden;
+    touch-action: none;
+    -webkit-user-select: none;
+    user-select: none;
+    -webkit-tap-highlight-color: transparent;
+  }
+  #wrap {
+    position: fixed;
+    inset: 0;
+    display: block;
+    background: #0a0e14;
+  }
+  canvas {
+    display: block;
+    width: 100%;
+    height: 100%;
+    background: #0a0e14;
+    cursor: crosshair;
+  }
+</style>
+</head>
+<body>
+<div id="wrap">
+  <canvas id="game"></canvas>
+</div>
+<script>
+(function () {
+  "use strict";
+
+  function boot() {
+    var canvas = document.getElementById("game");
+    if (!canvas || !canvas.getContext) return;
+    var ctx = canvas.getContext("2d");
+    if (!ctx) return;
+
+    var BEST_KEY = "abrams-missile-best";
+    var best = 0;
+    try {
+      var stored = localStorage.getItem(BEST_KEY);
+      if (stored !== null) { var n = parseInt(stored, 10); if (!isNaN(n)) best = n; }
+    } catch (e) { best = 0; }
+
+    // Logical design resolution; we scale to fit the iframe.
+    var W = 900, H = 600;
+    var dpr = 1;
+
+    function resize() {
+      var cw = window.innerWidth || document.documentElement.clientWidth || W;
+      var ch = window.innerHeight || document.documentElement.clientHeight || H;
+      dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
+      canvas.width = Math.max(1, Math.floor(cw * dpr));
+      canvas.height = Math.max(1, Math.floor(ch * dpr));
+      canvas.style.width = cw + "px";
+      canvas.style.height = ch + "px";
+      W = cw;
+      H = ch;
+    }
+
+    window.addEventListener("resize", resize, { passive: true });
+    resize();
+
+    // ---- Audio (lazy) ----
+    var audioCtx = null;
+    var audioOK = true;
+    function ensureAudio() {
+      if (audioCtx || !audioOK) return;
+      try {
+        var AC = window.AudioContext || window.webkitAudioContext;
+        if (!AC) { audioOK = false; return; }
+        audioCtx = new AC();
+      } catch (e) { audioOK = false; audioCtx = null; }
+    }
+    function blip(type, freq, dur, vol) {
+      if (!audioOK) return;
+      try {
+        ensureAudio();
+        if (!audioCtx) return;
+        if (audioCtx.state === "suspended" && audioCtx.resume) { audioCtx.resume(); }
+        var o = audioCtx.createOscillator();
+        var g = audioCtx.createGain();
+        o.type = type;
+        o.frequency.setValueAtTime(freq, audioCtx.currentTime);
+        g.gain.setValueAtTime(vol, audioCtx.currentTime);
+        g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
+        o.connect(g);
+        g.connect(audioCtx.destination);
+        o.start();
+        o.stop(audioCtx.currentTime + dur + 0.02);
+      } catch (e) { /* never throw */ }
+    }
+    function sfxLaunch() { blip("square", 660, 0.12, 0.06); }
+    function sfxExplode() {
+      blip("sawtooth", 180, 0.30, 0.09);
+      blip("triangle", 90, 0.35, 0.06);
+    }
+    function sfxCityHit() { blip("sawtooth", 70, 0.5, 0.12); }
+
+    // ---- Game state ----
+    var STATE_START = 0, STATE_PLAY = 1, STATE_WAVEEND = 2, STATE_OVER = 3;
+    var state = STATE_START;
+
+    var cities = [];      // {x, alive}
+    var bases = [];       // {x, ammo, maxAmmo}
+    var enemies = [];     // incoming missiles
+    var friendlies = [];  // counter-missiles
+    var blasts = [];      // explosions
+    var particles = [];
+
+    var wave = 1;
+    var score = 0;
+    var waveEnemiesTotal = 0;
+    var waveEnemiesSpawned = 0;
+    var spawnTimer = 0;
+    var spawnInterval = 1.2;
+    var waveEndTimer = 0;
+    var bonusStep = 0;
+    var bonusCityIndex = 0;
+    var bonusAmmoBase = 0;
+    var bonusAmmoLeft = 0;
+    var flashTimer = 0;
+
+    var GROUND_H = 70; // reserved from bottom in logical coords (relative to H)
+
+    function groundY() { return H - 64; }
+
+    function cityBaseCount() { return 6; }
+
+    function setupField() {
+      cities = [];
+      bases = [];
+      var n = cityBaseCount();
+      // 3 bases + 6 cities arranged: base, 2 cities, base, 2 cities, base? classic: bases at far L, mid, far R
+      // We'll spread bases and cities across width.
+      var margin = W * 0.06;
+      var usable = W - margin * 2;
+      // base positions: left, center, right
+      var basePositions = [0.5 / 10, 5 / 10, 9.5 / 10];
+      // city positions between bases
+      var cityFractions = [1.7 / 10, 2.6 / 10, 3.5 / 10, 6.5 / 10, 7.4 / 10, 8.3 / 10];
+      for (var i = 0; i < basePositions.length; i++) {
+        bases.push({ x: margin + basePositions[i] * usable, ammo: 10, maxAmmo: 10 });
+      }
+      for (var j = 0; j < cityFractions.length; j++) {
+        cities.push({ x: margin + cityFractions[j] * usable, alive: true });
+      }
+    }
+
+    function repositionField() {
+      // Recompute x positions on resize while preserving alive/ammo.
+      var margin = W * 0.06;
+      var usable = W - margin * 2;
+      var basePositions = [0.5 / 10, 5 / 10, 9.5 / 10];
+      var cityFractions = [1.7 / 10, 2.6 / 10, 3.5 / 10, 6.5 / 10, 7.4 / 10, 8.3 / 10];
+      for (var i = 0; i < bases.length && i < basePositions.length; i++) {
+        bases[i].x = margin + basePositions[i] * usable;
+      }
+      for (var j = 0; j < cities.length && j < cityFractions.length; j++) {
+        cities[j].x = margin + cityFractions[j] * usable;
+      }
+    }
+
+    function startWave(w) {
+      wave = w;
+      enemies = [];
+      friendlies = [];
+      blasts = [];
+      particles = [];
+      waveEnemiesTotal = 8 + Math.floor(w * 2.5);
+      waveEnemiesSpawned = 0;
+      spawnTimer = 0.5;
+      spawnInterval = Math.max(0.35, 1.3 - w * 0.07);
+      // refill ammo
+      for (var i = 0; i < bases.length; i++) {
+        bases[i].ammo = bases[i].maxAmmo;
+      }
+      state = STATE_PLAY;
+    }
+
+    function newGame() {
+      score = 0;
+      wave = 1;
+      setupField();
+      startWave(1);
+    }
+
+    function aliveCities() {
+      var c = 0;
+      for (var i = 0; i < cities.length; i++) if (cities[i].alive) c++;
+      return c;
+    }
+
+    function enemySpeed() {
+      return (28 + wave * 5) * (H / 600);
+    }
+
+    function spawnEnemy() {
+      var speed = enemySpeed() * (0.8 + Math.random() * 0.5);
+      var sx = Math.random() * W;
+      // target a city or base
+      var targets = [];
+      for (var i = 0; i < cities.length; i++) if (cities[i].alive) targets.push(cities[i].x);
+      for (var j = 0; j < bases.length; j++) targets.push(bases[j].x);
+      if (targets.length === 0) targets = [W / 2];
+      var tx = targets[Math.floor(Math.random() * targets.length)] + (Math.random() - 0.5) * 40;
+      var ty = groundY();
+      var canSplit = wave >= 3 && Math.random() < Math.min(0.5, 0.15 + wave * 0.03);
+      var smart = wave >= 5 && Math.random() < Math.min(0.4, (wave - 4) * 0.06);
+      enemies.push({
+        x: sx, y: -10,
+        tx: tx, ty: ty,
+        sx: sx, sy: -10,
+        speed: speed,
+        split: canSplit,
+        splitY: H * (0.35 + Math.random() * 0.2),
+        smart: smart,
+        trail: [],
+        dead: false
+      });
+    }
+
+    function spawnSplit(e) {
+      var count = 2 + (Math.random() < 0.3 ? 1 : 0);
+      for (var i = 0; i < count; i++) {
+        var speed = e.speed * (0.9 + Math.random() * 0.3);
+        var targets = [];
+        for (var c = 0; c < cities.length; c++) if (cities[c].alive) targets.push(cities[c].x);
+        for (var b = 0; b < bases.length; b++) targets.push(bases[b].x);
+        if (targets.length === 0) targets = [W / 2];
+        var tx = targets[Math.floor(Math.random() * targets.length)] + (Math.random() - 0.5) * 60;
+        enemies.push({
+          x: e.x, y: e.y,
+          tx: tx, ty: groundY(),
+          sx: e.x, sy: e.y,
+          speed: speed,
+          split: false,
+          splitY: 0,
+          smart: false,
+          trail: [],
+          dead: false
+        });
+      }
+    }
+
+    function launchAt(px, py) {
+      // choose nearest base with ammo
+      var best_i = -1, bestD = Infinity;
+      for (var i = 0; i < bases.length; i++) {
+        if (bases[i].ammo <= 0) continue;
+        var d = Math.abs(bases[i].x - px);
+        if (d < bestD) { bestD = d; best_i = i; }
+      }
+      if (best_i < 0) return; // no ammo anywhere
+      var b = bases[best_i];
+      b.ammo--;
+      var ox = b.x;
+      var oy = groundY();
+      var dx = px - ox, dy = py - oy;
+      var dist = Math.sqrt(dx * dx + dy * dy) || 1;
+      var spd = 620 * (H / 600);
+      friendlies.push({
+        x: ox, y: oy,
+        vx: dx / dist * spd,
+        vy: dy / dist * spd,
+        tx: px, ty: py,
+        trail: [],
+        dead: false
+      });
+      sfxLaunch();
+    }
+
+    function addBlast(x, y, maxR) {
+      blasts.push({ x: x, y: y, r: 4, maxR: maxR, growing: true, life: 0 });
+      sfxExplode();
+    }
+
+    function addParticles(x, y, color, n) {
+      for (var i = 0; i < n; i++) {
+        var a = Math.random() * Math.PI * 2;
+        var s = 30 + Math.random() * 120;
+        particles.push({
+          x: x, y: y,
+          vx: Math.cos(a) * s,
+          vy: Math.sin(a) * s - 30,
+          life: 0.5 + Math.random() * 0.6,
+          maxLife: 1,
+          color: color
+        });
+      }
+    }
+
+    function destroyCity(c) {
+      if (!c.alive) return;
+      c.alive = false;
+      addParticles(c.x, groundY() - 8, "#ff5a7a", 26);
+      flashTimer = 0.35;
+      sfxCityHit();
+    }
+
+    // ---- Input ----
+    function pointerToLogical(clientX, clientY) {
+      var rect = canvas.getBoundingClientRect();
+      var x = (clientX - rect.left) / rect.width * W;
+      var y = (clientY - rect.top) / rect.height * H;
+      return { x: x, y: y };
+    }
+
+    function handleFire(clientX, clientY) {
+      ensureAudio();
+      if (state === STATE_START) { newGame(); return; }
+      if (state === STATE_OVER) { newGame(); return; }
+      if (state === STATE_WAVEEND) { return; }
+      if (state !== STATE_PLAY) return;
+      var p = pointerToLogical(clientX, clientY);
+      if (p.y > groundY() - 6) p.y = groundY() - 6;
+      launchAt(p.x, p.y);
+    }
+
+    canvas.addEventListener("mousedown", function (e) {
+      e.preventDefault();
+      handleFire(e.clientX, e.clientY);
+    });
+
+    canvas.addEventListener("touchstart", function (e) {
+      e.preventDefault();
+      if (e.changedTouches && e.changedTouches.length) {
+        var t = e.changedTouches[0];
+        handleFire(t.clientX, t.clientY);
+      }
+    }, { passive: false });
+
+    window.addEventListener("keydown", function (e) {
+      if (e.key === " " || e.key === "Enter") {
+        if (state === STATE_START || state === STATE_OVER) newGame();
+      }
+    });
+
+    // ---- Update ----
+    function update(dt) {
+      flashTimer = Math.max(0, flashTimer - dt);
+
+      if (state === STATE_PLAY) {
+        // spawn
+        if (waveEnemiesSpawned < waveEnemiesTotal) {
+          spawnTimer -= dt;
+          if (spawnTimer <= 0) {
+            spawnEnemy();
+            waveEnemiesSpawned++;
+            spawnTimer = spawnInterval * (0.6 + Math.random() * 0.8);
+          }
+        }
+      }
+
+      // update enemies
+      var gy = groundY();
+      for (var i = enemies.length - 1; i >= 0; i--) {
+        var e = enemies[i];
+        if (e.dead) { enemies.splice(i, 1); continue; }
+        var dx = e.tx - e.x, dy = e.ty - e.y;
+        var dd = Math.sqrt(dx * dx + dy * dy) || 1;
+        var step = e.speed * dt;
+        // smart bombs slightly weave / retarget toward alive cities
+        e.x += dx / dd * step;
+        e.y += dy / dd * step;
+
+        e.trail.push({ x: e.x, y: e.y });
+        if (e.trail.length > 16) e.trail.shift();
+
+        // split
+        if (e.split && e.y >= e.splitY) {
+          e.split = false;
+          spawnSplit(e);
+        }
+
+        if (e.y >= e.ty - 2 || e.y >= gy) {
+          // hit ground: destroy nearest city/base if near
+          e.dead = true;
+          addParticles(e.x, gy - 4, "#ff8a5a", 10);
+          // check city hits
+          for (var c = 0; c < cities.length; c++) {
+            if (cities[c].alive && Math.abs(cities[c].x - e.x) < W * 0.035) {
+              destroyCity(cities[c]);
+            }
+          }
+          for (var b = 0; b < bases.length; b++) {
+            if (Math.abs(bases[b].x - e.x) < W * 0.03) {
+              bases[b].ammo = 0; // knocked out for the wave visually
+            }
+          }
+          enemies.splice(i, 1);
+        }
+      }
+
+      // update friendlies
+      for (var f = friendlies.length - 1; f >= 0; f--) {
+        var fm = friendlies[f];
+        if (fm.dead) { friendlies.splice(f, 1); continue; }
+        fm.x += fm.vx * dt;
+        fm.y += fm.vy * dt;
+        fm.trail.push({ x: fm.x, y: fm.y });
+        if (fm.trail.length > 22) fm.trail.shift();
+        var ddx = fm.tx - fm.x, ddy = fm.ty - fm.y;
+        // reached target when passing it
+        if (ddx * fm.vx + ddy * fm.vy <= 0) {
+          fm.dead = true;
+          addBlast(fm.tx, fm.ty, Math.max(46, W * 0.06));
+          friendlies.splice(f, 1);
+        }
+      }
+
+      // update blasts
+      for (var bl = blasts.length - 1; bl >= 0; bl--) {
+        var B = blasts[bl];
+        B.life += dt;
+        if (B.growing) {
+          B.r += (B.maxR) * dt * 3.2;
+          if (B.r >= B.maxR) { B.r = B.maxR; B.growing = false; }
+        } else {
+          B.r -= B.maxR * dt * 1.9;
+          if (B.r <= 0) { blasts.splice(bl, 1); continue; }
+        }
+        // destroy enemies inside blast
+        for (var ei = enemies.length - 1; ei >= 0; ei--) {
+          var en = enemies[ei];
+          var edx = en.x - B.x, edy = en.y - B.y;
+          if (edx * edx + edy * edy <= B.r * B.r) {
+            en.dead = true;
+            score += 25;
+            addParticles(en.x, en.y, "#7dffea", 8);
+            enemies.splice(ei, 1);
+          }
+        }
+      }
+
+      // update particles
+      for (var p = particles.length - 1; p >= 0; p--) {
+        var pt = particles[p];
+        pt.life -= dt;
+        if (pt.life <= 0) { particles.splice(p, 1); continue; }
+        pt.vy += 140 * dt;
+        pt.x += pt.vx * dt;
+        pt.y += pt.vy * dt;
+      }
+
+      // check wave end / game over
+      if (state === STATE_PLAY) {
+        if (aliveCities() === 0) {
+          // let remaining blasts fade a touch, then game over
+          state = STATE_OVER;
+          if (score > best) {
+            best = score;
+            try { localStorage.setItem(BEST_KEY, String(best)); } catch (e2) {}
+          }
+        } else if (waveEnemiesSpawned >= waveEnemiesTotal && enemies.length === 0 && friendlies.length === 0) {
+          // wave clear -> bonus phase
+          state = STATE_WAVEEND;
+          waveEndTimer = 0;
+          bonusStep = 0;
+          bonusCityIndex = 0;
+          bonusAmmoLeft = 0;
+          for (var bb = 0; bb < bases.length; bb++) bonusAmmoLeft += bases[bb].ammo;
+          bonusAmmoBase = bonusAmmoLeft;
+        }
+      }
+
+      if (state === STATE_WAVEEND) {
+        waveEndTimer += dt;
+        // tally city bonus (100 each) then ammo bonus (10 each)
+        // simple timed tally for feel
+        if (bonusStep === 0 && waveEndTimer > 0.4) {
+          bonusStep = 1;
+        } else if (bonusStep === 1) {
+          if (waveEndTimer > 0.5 + bonusCityIndex * 0.12) {
+            if (bonusCityIndex < cities.length) {
+              if (cities[bonusCityIndex].alive) { score += 100; blip("sine", 880, 0.08, 0.05); }
+              bonusCityIndex++;
+            } else {
+              bonusStep = 2;
+              waveEndTimer = 0;
+            }
+          }
+        } else if (bonusStep === 2) {
+          if (waveEndTimer > 0.05 && bonusAmmoLeft > 0) {
+            if (waveEndTimer > 0.02) {
+              score += 5;
+              bonusAmmoLeft--;
+              waveEndTimer = 0;
+              blip("sine", 1200, 0.04, 0.04);
+            }
+          } else if (bonusAmmoLeft <= 0) {
+            bonusStep = 3;
+            waveEndTimer = 0;
+          }
+        } else if (bonusStep === 3) {
+          if (waveEndTimer > 1.0) {
+            startWave(wave + 1);
+          }
+        }
+      }
+    }
+
+    // ---- Draw ----
+    var starField = null;
+    function buildStars() {
+      starField = [];
+      var n = 70;
+      for (var i = 0; i < n; i++) {
+        starField.push({
+          x: Math.random(),
+          y: Math.random() * 0.75,
+          s: Math.random() * 1.6 + 0.3,
+          tw: Math.random() * Math.PI * 2
+        });
+      }
+    }
+    buildStars();
+
+    var tGlobal = 0;
+
+    function drawBackground() {
+      // gradient sky
+      var g = ctx.createLinearGradient(0, 0, 0, H);
+      g.addColorStop(0, "#0a0e14");
+      g.addColorStop(0.6, "#0b1220");
+      g.addColorStop(1, "#0d1a2b");
+      ctx.fillStyle = g;
+      ctx.fillRect(0, 0, W, H);
+
+      // stars
+      for (var i = 0; i < starField.length; i++) {
+        var st = starField[i];
+        var a = 0.4 + 0.5 * Math.abs(Math.sin(tGlobal * 1.5 + st.tw));
+        ctx.fillStyle = "rgba(180,220,255," + a.toFixed(3) + ")";
+        ctx.fillRect(st.x * W, st.y * H, st.s, st.s);
+      }
+    }
+
+    function drawGround() {
+      var gy = groundY();
+      // ground glow line
+      ctx.save();
+      ctx.strokeStyle = "#1de9b6";
+      ctx.shadowColor = "#1de9b6";
+      ctx.shadowBlur = 14;
+      ctx.lineWidth = 2;
+      ctx.beginPath();
+      ctx.moveTo(0, gy + 10);
+      ctx.lineTo(W, gy + 10);
+      ctx.stroke();
+      ctx.restore();
+
+      // ground fill
+      ctx.fillStyle = "#08131c";
+      ctx.fillRect(0, gy + 10, W, H - (gy + 10));
+    }
+
+    function drawCity(c) {
+      var gy = groundY();
+      var cw = W * 0.05;
+      var bx = c.x - cw / 2;
+      if (!c.alive) {
+        // rubble
+        ctx.save();
+        ctx.fillStyle = "#20303c";
+        for (var r = 0; r < 4; r++) {
+          var rw = cw * (0.2 + Math.random() * 0.1);
+          ctx.fillRect(bx + r * (cw / 4), gy - 4 - Math.random() * 5, rw, 5 + Math.random() * 4);
+        }
+        ctx.restore();
+        return;
+      }
+      ctx.save();
+      ctx.shadowColor = "#36e0ff";
+      ctx.shadowBlur = 12;
+      // skyline: a few towers
+      var towers = [
+        { w: cw * 0.20, h: 20 },
+        { w: cw * 0.16, h: 32 },
+        { w: cw * 0.22, h: 26 },
+        { w: cw * 0.16, h: 16 }
+      ];
+      var tx = bx;
+      var hue = "#2ad4ff";
+      for (var t = 0; t < towers.length; t++) {
+        var tw = towers[t].w;
+        var th = towers[t].h * (H / 600);
+        ctx.fillStyle = hue;
+        ctx.globalAlpha = 0.9;
+        ctx.fillRect(tx, gy - th, tw, th);
+        tx += tw + cw * 0.02;
+      }
+      ctx.globalAlpha = 1;
+      ctx.restore();
+      // little windows
+      ctx.fillStyle = "rgba(255,255,180,0.5)";
+      var wx = bx + 3;
+      for (var wxi = 0; wxi < 5; wxi++) {
+        ctx.fillRect(wx + wxi * (cw / 6), gy - 10, 1.5, 1.5);
+      }
+    }
+
+    function drawBase(b) {
+      var gy = groundY();
+      ctx.save();
+      var lowAmmo = b.ammo <= 0;
+      var col = lowAmmo ? "#5a6b78" : "#ffd23f";
+      ctx.shadowColor = col;
+      ctx.shadowBlur = lowAmmo ? 0 : 14;
+      ctx.fillStyle = col;
+      // triangular battery
+      ctx.beginPath();
+      var bw = W * 0.03;
+      ctx.moveTo(b.x - bw, gy + 8);
+      ctx.lineTo(b.x + bw, gy + 8);
+      ctx.lineTo(b.x, gy - 16);
+      ctx.closePath();
+      ctx.fill();
+      ctx.restore();
+
+      // ammo count
+      ctx.fillStyle = lowAmmo ? "#7a8792" : "#fff3c4";
+      ctx.font = "bold " + Math.round(11 * (H / 600) + 6) + "px monospace";
+      ctx.textAlign = "center";
+      ctx.textBaseline = "top";
+      ctx.fillText(String(b.ammo), b.x, gy + 12);
+    }
+
+    function drawEnemies() {
+      for (var i = 0; i < enemies.length; i++) {
+        var e = enemies[i];
+        // trail
+        ctx.save();
+        var col = e.smart ? "#ff4de0" : "#ff5a5a";
+        ctx.strokeStyle = col;
+        ctx.shadowColor = col;
+        ctx.shadowBlur = 8;
+        ctx.lineWidth = 1.6;
+        ctx.beginPath();
+        ctx.moveTo(e.sx, e.sy);
+        for (var t = 0; t < e.trail.length; t++) {
+          ctx.lineTo(e.trail[t].x, e.trail[t].y);
+        }
+        ctx.lineTo(e.x, e.y);
+        ctx.stroke();
+        // head
+        ctx.fillStyle = "#fff";
+        ctx.beginPath();
+        ctx.arc(e.x, e.y, e.smart ? 3.4 : 2.6, 0, Math.PI * 2);
+        ctx.fill();
+        ctx.restore();
+      }
+    }
+
+    function drawFriendlies() {
+      for (var i = 0; i < friendlies.length; i++) {
+        var f = friendlies[i];
+        ctx.save();
+        ctx.strokeStyle = "#7dffea";
+        ctx.shadowColor = "#7dffea";
+        ctx.shadowBlur = 10;
+        ctx.lineWidth = 1.8;
+        ctx.beginPath();
+        if (f.trail.length) {
+          ctx.moveTo(f.trail[0].x, f.trail[0].y);
+          for (var t = 1; t < f.trail.length; t++) ctx.lineTo(f.trail[t].x, f.trail[t].y);
+          ctx.lineTo(f.x, f.y);
+        }
+        ctx.stroke();
+        // target marker
+        ctx.strokeStyle = "rgba(125,255,234,0.5)";
+        ctx.shadowBlur = 0;
+        ctx.lineWidth = 1;
+        ctx.beginPath();
+        ctx.moveTo(f.tx - 5, f.ty); ctx.lineTo(f.tx + 5, f.ty);
+        ctx.moveTo(f.tx, f.ty - 5); ctx.lineTo(f.tx, f.ty + 5);
+        ctx.stroke();
+        ctx.restore();
+      }
+    }
+
+    function drawBlasts() {
+      for (var i = 0; i < blasts.length; i++) {
+        var B = blasts[i];
+        var hueT = (tGlobal * 6 + i) % 3;
+        var color = hueT < 1 ? "#fff6a8" : (hueT < 2 ? "#7dffea" : "#ff8ad4");
+        ctx.save();
+        ctx.globalCompositeOperation = "lighter";
+        // outer ring
+        ctx.strokeStyle = color;
+        ctx.shadowColor = color;
+        ctx.shadowBlur = 20;
+        ctx.lineWidth = 3;
+        ctx.beginPath();
+        ctx.arc(B.x, B.y, B.r, 0, Math.PI * 2);
+        ctx.stroke();
+        // inner fill
+        var grad = ctx.createRadialGradient(B.x, B.y, 0, B.x, B.y, B.r);
+        grad.addColorStop(0, "rgba(255,255,255,0.5)");
+        grad.addColorStop(0.5, "rgba(125,255,234,0.25)");
+        grad.addColorStop(1, "rgba(125,255,234,0)");
+        ctx.fillStyle = grad;
+        ctx.beginPath();
+        ctx.arc(B.x, B.y, B.r, 0, Math.PI * 2);
+        ctx.fill();
+        ctx.restore();
+      }
+    }
+
+    function drawParticles() {
+      for (var i = 0; i < particles.length; i++) {
+        var p = particles[i];
+        var a = Math.max(0, Math.min(1, p.life));
+        ctx.save();
+        ctx.globalAlpha = a;
+        ctx.fillStyle = p.color;
+        ctx.shadowColor = p.color;
+        ctx.shadowBlur = 6;
+        ctx.fillRect(p.x, p.y, 2.2, 2.2);
+        ctx.restore();
+      }
+    }
+
+    function drawHUD() {
+      ctx.save();
+      ctx.textBaseline = "top";
+      var fs = Math.round(14 * (H / 600) + 6);
+      ctx.font = "bold " + fs + "px monospace";
+      ctx.textAlign = "left";
+      ctx.fillStyle = "#7dffea";
+      ctx.shadowColor = "#7dffea";
+      ctx.shadowBlur = 8;
+      ctx.fillText("SCORE " + score, 14, 12);
+      ctx.textAlign = "center";
+      ctx.fillStyle = "#ffd23f";
+      ctx.shadowColor = "#ffd23f";
+      ctx.fillText("WAVE " + wave, W / 2, 12);
+      ctx.textAlign = "right";
+      ctx.fillStyle = "#ff8ad4";
+      ctx.shadowColor = "#ff8ad4";
+      ctx.fillText("BEST " + best, W - 14, 12);
+      ctx.restore();
+    }
+
+    function drawTitleText(lines, subLines) {
+      ctx.save();
+      ctx.textAlign = "center";
+      ctx.textBaseline = "middle";
+      var cy = H * 0.4;
+      var big = Math.round(Math.min(W * 0.075, 54));
+      ctx.font = "bold " + big + "px monospace";
+      // letter-spaced uppercase title with glow
+      for (var li = 0; li < lines.length; li++) {
+        var txt = lines[li];
+        ctx.fillStyle = "#7dffea";
+        ctx.shadowColor = "#36e0ff";
+        ctx.shadowBlur = 24;
+        drawSpaced(txt.toUpperCase(), W / 2, cy + li * (big * 1.2), big * 0.18);
+      }
+      ctx.shadowBlur = 0;
+      var sm = Math.round(Math.min(W * 0.03, 18));
+      ctx.font = "bold " + sm + "px monospace";
+      ctx.fillStyle = "#ffd23f";
+      var sy = cy + lines.length * (big * 1.2) + 24;
+      for (var si = 0; si < subLines.length; si++) {
+        ctx.shadowColor = "#ffd23f";
+        ctx.shadowBlur = 10;
+        ctx.fillText(subLines[si], W / 2, sy + si * (sm * 1.6));
+      }
+      ctx.restore();
+    }
+
+    function drawSpaced(text, cx, cy, extra) {
+      // measure with tracking
+      var widths = [];
+      var total = 0;
+      for (var i = 0; i < text.length; i++) {
+        var w = ctx.measureText(text[i]).width;
+        widths.push(w);
+        total += w + (i < text.length - 1 ? extra : 0);
+      }
+      var x = cx - total / 2;
+      for (var j = 0; j < text.length; j++) {
+        ctx.textAlign = "left";
+        ctx.fillText(text[j], x, cy);
+        x += widths[j] + extra;
+      }
+      ctx.textAlign = "center";
+    }
+
+    function draw() {
+      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+      drawBackground();
+
+      // field
+      var i;
+      for (i = 0; i < cities.length; i++) drawCity(cities[i]);
+      for (i = 0; i < bases.length; i++) drawBase(bases[i]);
+      drawGround();
+
+      drawEnemies();
+      drawFriendlies();
+      drawBlasts();
+      drawParticles();
+
+      // city-hit flash
+      if (flashTimer > 0) {
+        ctx.save();
+        ctx.fillStyle = "rgba(255,60,110," + (flashTimer * 0.6).toFixed(3) + ")";
+        ctx.fillRect(0, 0, W, H);
+        ctx.restore();
+      }
+
+      drawHUD();
+
+      if (state === STATE_START) {
+        drawTitleText(["Missile", "Command"], ["TAP / CLICK TO DEFEND", "PROTECT YOUR 6 CITIES"]);
+      } else if (state === STATE_OVER) {
+        drawTitleText(["Game", "Over"], ["SCORE " + score, "TAP TO PLAY AGAIN"]);
+      } else if (state === STATE_WAVEEND) {
+        ctx.save();
+        ctx.textAlign = "center";
+        ctx.textBaseline = "middle";
+        var big = Math.round(Math.min(W * 0.05, 34));
+        ctx.font = "bold " + big + "px monospace";
+        ctx.fillStyle = "#ffd23f";
+        ctx.shadowColor = "#ffd23f";
+        ctx.shadowBlur = 18;
+        drawSpaced("WAVE " + wave + " CLEAR", W / 2, H * 0.4, big * 0.14);
+        ctx.shadowBlur = 8;
+        var sm = Math.round(Math.min(W * 0.028, 16));
+        ctx.font = "bold " + sm + "px monospace";
+        ctx.fillStyle = "#7dffea";
+        ctx.shadowColor = "#7dffea";
+        ctx.fillText("BONUS TALLY...", W / 2, H * 0.4 + big + 20);
+        ctx.restore();
+      }
+    }
+
+    // ---- Loop ----
+    var last = 0;
+    var running = true;
+    function frame(now) {
+      if (!running) return;
+      if (!last) last = now;
+      var dt = (now - last) / 1000;
+      last = now;
+      if (dt > 0.05) dt = 0.05; // clamp
+      tGlobal += dt;
+
+      // keep field positions in sync with size
+      repositionField();
+
+      try {
+        update(dt);
+        draw();
+      } catch (err) {
+        // never let a frame throw a pageerror
+        running = false;
+      }
+      window.requestAnimationFrame(frame);
+    }
+
+    window.requestAnimationFrame(frame);
+  }
+
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", boot);
+  } else {
+    boot();
+  }
+})();
+</script>
+</body>
+</html>
diff --git a/games/racer/index.html b/games/racer/index.html
new file mode 100644
index 0000000..84c5673
--- /dev/null
+++ b/games/racer/index.html
@@ -0,0 +1,882 @@
+<!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>NEON DRIFTER</title>
+<style>
+  :root {
+    --bg: #0a0e14;
+    --neon: #ff2fd0;
+    --cyan: #22e7ff;
+    --amber: #ffd23f;
+    --edge: #7a3cff;
+  }
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body {
+    width: 100%; height: 100%;
+    background: var(--bg);
+    overflow: hidden;
+    touch-action: none;
+    -webkit-user-select: none;
+    user-select: none;
+    -webkit-tap-highlight-color: transparent;
+    font-family: "Segoe UI", Helvetica, Arial, sans-serif;
+  }
+  #wrap {
+    position: fixed;
+    inset: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: radial-gradient(ellipse at 50% 120%, #16203a 0%, #0a0e14 60%, #05070c 100%);
+  }
+  canvas { display: block; touch-action: none; }
+
+  /* Touch controls */
+  .touch {
+    position: fixed;
+    bottom: 0;
+    width: 40%;
+    height: 45%;
+    max-height: 320px;
+    z-index: 20;
+    display: none;
+    align-items: flex-end;
+    justify-content: center;
+    padding-bottom: 22px;
+    -webkit-user-select: none;
+    user-select: none;
+  }
+  .touch.left { left: 0; }
+  .touch.right { right: 0; }
+  .touch .btn {
+    width: 92px; height: 92px;
+    border-radius: 50%;
+    border: 2px solid rgba(34,231,255,0.55);
+    background: rgba(34,231,255,0.08);
+    color: #cffcff;
+    font-size: 40px;
+    line-height: 88px;
+    text-align: center;
+    box-shadow: 0 0 18px rgba(34,231,255,0.35), inset 0 0 14px rgba(34,231,255,0.15);
+    backdrop-filter: blur(2px);
+  }
+  .touch.active .btn {
+    background: rgba(255,47,208,0.22);
+    border-color: rgba(255,47,208,0.8);
+    box-shadow: 0 0 24px rgba(255,47,208,0.6), inset 0 0 16px rgba(255,47,208,0.25);
+  }
+  @media (pointer: coarse) {
+    .touch { display: flex; }
+  }
+</style>
+</head>
+<body>
+  <div id="wrap">
+    <canvas id="game"></canvas>
+  </div>
+  <div class="touch left" id="touchLeft"><div class="btn">&#8592;</div></div>
+  <div class="touch right" id="touchRight"><div class="btn">&#8594;</div></div>
+
+<script>
+(function () {
+  "use strict";
+
+  function boot() {
+    var canvas = document.getElementById("game");
+    if (!canvas) return;
+    var ctx = canvas.getContext("2d");
+    if (!ctx) return;
+
+    var BEST_KEY = "abrams-racer-best";
+
+    // ---- logical dimensions (portrait road) ----
+    var W = 480, H = 800;   // logical
+    var DPR = 1;
+
+    function fitCanvas() {
+      var vw = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 480);
+      var vh = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 800);
+      var targetAR = W / H;
+      var winAR = vw / vh;
+      var cssW, cssH;
+      if (winAR > targetAR) { cssH = vh; cssW = vh * targetAR; }
+      else { cssW = vw; cssH = vw / targetAR; }
+      DPR = Math.min(window.devicePixelRatio || 1, 2);
+      canvas.style.width = cssW + "px";
+      canvas.style.height = cssH + "px";
+      canvas.width = Math.round(W * DPR);
+      canvas.height = Math.round(H * DPR);
+      ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+    }
+    window.addEventListener("resize", fitCanvas, { passive: true });
+    window.addEventListener("orientationchange", fitCanvas, { passive: true });
+    fitCanvas();
+
+    // ---- road geometry ----
+    var LANES = 4;
+    var ROAD_MARGIN = 56;              // side margin to road edge
+    var roadLeft = ROAD_MARGIN;
+    var roadRight = W - ROAD_MARGIN;
+    var roadW = roadRight - roadLeft;
+    var laneW = roadW / LANES;
+
+    function laneCenter(i) { return roadLeft + laneW * (i + 0.5); }
+
+    // ---- game state ----
+    var STATE_MENU = 0, STATE_PLAY = 1, STATE_OVER = 2;
+    var state = STATE_MENU;
+
+    var best = 0;
+    try {
+      var stored = localStorage.getItem(BEST_KEY);
+      if (stored != null) { var b = parseFloat(stored); if (isFinite(b)) best = b; }
+    } catch (e) {}
+
+    var player, traffic, pickups, particles;
+    var dashOffset, scroll, distance, score, speed, baseSpeed, elapsed, spawnTimer, pickTimer;
+    var boost, shakeMag, newBest;
+
+    function resetGame() {
+      player = {
+        x: laneCenter(1),
+        y: H - 150,
+        w: 46, h: 84,
+        vx: 0,
+        color: "#22e7ff"
+      };
+      traffic = [];
+      pickups = [];
+      particles = [];
+      dashOffset = 0;
+      scroll = 0;
+      distance = 0;
+      score = 0;
+      baseSpeed = 260;      // px/sec world scroll
+      speed = baseSpeed;
+      elapsed = 0;
+      spawnTimer = 0.6;
+      pickTimer = 3.0;
+      boost = 0;
+      shakeMag = 0;
+      newBest = false;
+    }
+    resetGame();
+
+    // ---- input ----
+    var keys = { left: false, right: false, up: false, down: false };
+    var touchL = false, touchR = false;
+
+    function onKeyDown(e) {
+      var k = e.key;
+      var handled = true;
+      if (k === "ArrowLeft" || k === "a" || k === "A") keys.left = true;
+      else if (k === "ArrowRight" || k === "d" || k === "D") keys.right = true;
+      else if (k === "ArrowUp" || k === "w" || k === "W") keys.up = true;
+      else if (k === "ArrowDown" || k === "s" || k === "S") keys.down = true;
+      else if (k === " " || k === "Enter") {
+        if (state === STATE_MENU) startPlay();
+        else if (state === STATE_OVER) { resetGame(); startPlay(); }
+      } else handled = false;
+
+      if (handled) {
+        // resume/init audio on first gesture
+        ensureAudio();
+        if (state === STATE_MENU && (k === "ArrowLeft" || k === "ArrowRight" || k === "a" || k === "A" || k === "d" || k === "D")) {
+          startPlay();
+        }
+        e.preventDefault();
+      }
+    }
+    function onKeyUp(e) {
+      var k = e.key;
+      if (k === "ArrowLeft" || k === "a" || k === "A") keys.left = false;
+      else if (k === "ArrowRight" || k === "d" || k === "D") keys.right = false;
+      else if (k === "ArrowUp" || k === "w" || k === "W") keys.up = false;
+      else if (k === "ArrowDown" || k === "s" || k === "S") keys.down = false;
+    }
+    window.addEventListener("keydown", onKeyDown, { passive: false });
+    window.addEventListener("keyup", onKeyUp, { passive: false });
+
+    // touch buttons
+    function bindTouch(el, setter) {
+      if (!el) return;
+      function down(e) {
+        e.preventDefault();
+        ensureAudio();
+        el.classList.add("active");
+        setter(true);
+        if (state === STATE_MENU) startPlay();
+        else if (state === STATE_OVER) { resetGame(); startPlay(); }
+      }
+      function up(e) {
+        e.preventDefault();
+        el.classList.remove("active");
+        setter(false);
+      }
+      el.addEventListener("touchstart", down, { passive: false });
+      el.addEventListener("touchend", up, { passive: false });
+      el.addEventListener("touchcancel", up, { passive: false });
+      el.addEventListener("mousedown", down);
+      window.addEventListener("mouseup", up);
+    }
+    bindTouch(document.getElementById("touchLeft"), function (v) { touchL = v; });
+    bindTouch(document.getElementById("touchRight"), function (v) { touchR = v; });
+
+    // tap canvas to start/restart
+    canvas.addEventListener("pointerdown", function (e) {
+      ensureAudio();
+      if (state === STATE_MENU) { e.preventDefault(); startPlay(); }
+      else if (state === STATE_OVER) { e.preventDefault(); resetGame(); startPlay(); }
+    });
+
+    // prevent scroll gestures
+    document.addEventListener("touchmove", function (e) { e.preventDefault(); }, { passive: false });
+
+    // ---- audio (lazy) ----
+    var actx = null, engineOsc = null, engineGain = null, engineFilter = null;
+    function ensureAudio() {
+      try {
+        if (!actx) {
+          var AC = window.AudioContext || window.webkitAudioContext;
+          if (!AC) return;
+          actx = new AC();
+          engineGain = actx.createGain();
+          engineGain.gain.value = 0.0;
+          engineFilter = actx.createBiquadFilter();
+          engineFilter.type = "lowpass";
+          engineFilter.frequency.value = 700;
+          engineOsc = actx.createOscillator();
+          engineOsc.type = "sawtooth";
+          engineOsc.frequency.value = 70;
+          engineOsc.connect(engineFilter);
+          engineFilter.connect(engineGain);
+          engineGain.connect(actx.destination);
+          engineOsc.start();
+        }
+        if (actx.state === "suspended") actx.resume();
+      } catch (e) { actx = null; }
+    }
+    function blip(freq, dur, type, vol) {
+      if (!actx) return;
+      try {
+        var o = actx.createOscillator();
+        var g = actx.createGain();
+        o.type = type || "square";
+        o.frequency.value = freq;
+        g.gain.value = 0.0001;
+        o.connect(g); g.connect(actx.destination);
+        var t = actx.currentTime;
+        g.gain.setValueAtTime(0.0001, t);
+        g.gain.exponentialRampToValueAtTime(vol || 0.15, t + 0.01);
+        g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+        o.start(t);
+        o.stop(t + dur + 0.02);
+      } catch (e) {}
+    }
+    function crashSound() {
+      if (!actx) return;
+      try {
+        var o = actx.createOscillator();
+        var g = actx.createGain();
+        o.type = "sawtooth";
+        o.frequency.setValueAtTime(220, actx.currentTime);
+        o.frequency.exponentialRampToValueAtTime(40, actx.currentTime + 0.45);
+        g.gain.setValueAtTime(0.3, actx.currentTime);
+        g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + 0.5);
+        o.connect(g); g.connect(actx.destination);
+        o.start();
+        o.stop(actx.currentTime + 0.55);
+      } catch (e) {}
+    }
+    function updateEngine() {
+      if (!actx || !engineGain || !engineOsc) return;
+      try {
+        var target = state === STATE_PLAY ? 0.05 : 0.0;
+        engineGain.gain.setTargetAtTime(target, actx.currentTime, 0.1);
+        var f = 55 + (speed - baseSpeed) * 0.06 + (boost > 0 ? 40 : 0);
+        engineOsc.frequency.setTargetAtTime(f, actx.currentTime, 0.08);
+        engineFilter.frequency.setTargetAtTime(600 + (speed - 200) * 1.2, actx.currentTime, 0.1);
+      } catch (e) {}
+    }
+
+    function startPlay() {
+      if (state === STATE_PLAY) return;
+      state = STATE_PLAY;
+      ensureAudio();
+      blip(660, 0.08, "square", 0.12);
+    }
+
+    // ---- spawning ----
+    var TRAFFIC_COLORS = ["#ff2fd0", "#ffd23f", "#9d5bff", "#ff5a3c", "#3cff8a"];
+
+    function spawnTraffic() {
+      var lane = Math.floor(Math.random() * LANES);
+      var w = 46, h = 84;
+      // occasionally a wider slow truck
+      if (Math.random() < 0.18) { w = 54; h = 110; }
+      var carSpeed = 60 + Math.random() * 120; // slower than world; downward relative
+      traffic.push({
+        lane: lane,
+        x: laneCenter(lane),
+        y: -h - 20,
+        w: w, h: h,
+        rel: carSpeed,
+        color: TRAFFIC_COLORS[(Math.random() * TRAFFIC_COLORS.length) | 0]
+      });
+    }
+
+    function spawnPickup() {
+      var lane = Math.floor(Math.random() * LANES);
+      var type = Math.random() < 0.4 ? "boost" : "coin";
+      pickups.push({
+        x: laneCenter(lane),
+        y: -30,
+        r: 14,
+        type: type,
+        t: 0
+      });
+    }
+
+    function laneOccupiedNear(lane, y, minGap) {
+      for (var i = 0; i < traffic.length; i++) {
+        var t = traffic[i];
+        if (t.lane === lane && Math.abs(t.y - y) < minGap) return true;
+      }
+      return false;
+    }
+
+    // ---- particles ----
+    function addParticles(x, y, color, n, spread) {
+      for (var i = 0; i < n; i++) {
+        particles.push({
+          x: x, y: y,
+          vx: (Math.random() - 0.5) * spread,
+          vy: (Math.random() - 0.5) * spread - 30,
+          life: 0.4 + Math.random() * 0.5,
+          age: 0,
+          color: color,
+          size: 2 + Math.random() * 3
+        });
+      }
+    }
+
+    // ---- collision ----
+    function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
+      return Math.abs(ax - bx) * 2 < (aw + bw) && Math.abs(ay - by) * 2 < (ah + bh);
+    }
+
+    // ---- update ----
+    function update(dt) {
+      if (state === STATE_PLAY) {
+        elapsed += dt;
+        // speed ramps over time
+        baseSpeed = 260 + elapsed * 9;
+        if (baseSpeed > 720) baseSpeed = 720;
+        var boostFactor = boost > 0 ? 1.55 : 1;
+        // throttle (up/down) small modifier
+        var throttle = 1;
+        if (keys.up) throttle = 1.18;
+        else if (keys.down) throttle = 0.72;
+        speed = baseSpeed * boostFactor * throttle;
+
+        if (boost > 0) boost -= dt;
+
+        // steering
+        var steerLeft = keys.left || touchL;
+        var steerRight = keys.right || touchR;
+        var accel = 900;
+        if (steerLeft && !steerRight) player.vx -= accel * dt;
+        else if (steerRight && !steerLeft) player.vx += accel * dt;
+        else player.vx *= Math.pow(0.001, dt); // strong damping when no input
+        // clamp lateral speed
+        var maxVx = 420;
+        if (player.vx > maxVx) player.vx = maxVx;
+        if (player.vx < -maxVx) player.vx = -maxVx;
+        player.x += player.vx * dt;
+        // road clamp
+        var half = player.w / 2 + 4;
+        if (player.x < roadLeft + half) { player.x = roadLeft + half; player.vx = 0; }
+        if (player.x > roadRight - half) { player.x = roadRight - half; player.vx = 0; }
+
+        // scroll + distance
+        scroll += speed * dt;
+        dashOffset = (dashOffset + speed * dt) % 80;
+        distance += speed * dt * 0.02; // meters-ish
+        score += speed * dt * 0.05;
+
+        // spawn traffic
+        spawnTimer -= dt;
+        var spawnEvery = Math.max(0.35, 1.1 - elapsed * 0.01);
+        if (spawnTimer <= 0) {
+          spawnTimer = spawnEvery * (0.6 + Math.random() * 0.8);
+          // try a lane that isn't crowded near top
+          var tries = 3;
+          while (tries-- > 0) {
+            var lane = Math.floor(Math.random() * LANES);
+            if (!laneOccupiedNear(lane, -60, 160)) {
+              var w = 46, h = 84;
+              if (Math.random() < 0.18) { w = 54; h = 110; }
+              traffic.push({
+                lane: lane, x: laneCenter(lane), y: -h - 20,
+                w: w, h: h, rel: 60 + Math.random() * 120,
+                color: TRAFFIC_COLORS[(Math.random() * TRAFFIC_COLORS.length) | 0]
+              });
+              break;
+            }
+          }
+        }
+
+        // spawn pickups
+        pickTimer -= dt;
+        if (pickTimer <= 0) {
+          pickTimer = 3.5 + Math.random() * 3.5;
+          var plane = Math.floor(Math.random() * LANES);
+          if (!laneOccupiedNear(plane, -40, 120)) {
+            pickups.push({
+              x: laneCenter(plane), y: -30, r: 14,
+              type: Math.random() < 0.4 ? "boost" : "coin", t: 0
+            });
+          }
+        }
+
+        // move traffic (world scrolls down; traffic moves down toward player)
+        for (var i = traffic.length - 1; i >= 0; i--) {
+          var t = traffic[i];
+          t.y += (speed - t.rel) * dt;   // relative to player
+          // engine exhaust trail
+          if (Math.random() < 0.3) addParticles(t.x, t.y - t.h / 2, "rgba(120,140,180,0.4)", 1, 20);
+          if (t.y - t.h > H + 40) { traffic.splice(i, 1); continue; }
+          // collision
+          if (rectsOverlap(player.x, player.y, player.w - 8, player.h - 10, t.x, t.y, t.w - 8, t.h - 10)) {
+            gameOver();
+            return;
+          }
+        }
+
+        // move pickups
+        for (var j = pickups.length - 1; j >= 0; j--) {
+          var p = pickups[j];
+          p.y += speed * dt;
+          p.t += dt;
+          if (p.y - p.r > H + 40) { pickups.splice(j, 1); continue; }
+          var dx = p.x - player.x, dy = p.y - player.y;
+          if (Math.abs(dx) < (player.w / 2 + p.r) && Math.abs(dy) < (player.h / 2 + p.r)) {
+            if (p.type === "boost") {
+              boost = 2.2;
+              score += 50;
+              addParticles(p.x, p.y, "#22e7ff", 18, 220);
+              blip(880, 0.14, "sawtooth", 0.16);
+              blip(1320, 0.14, "sawtooth", 0.1);
+            } else {
+              score += 100;
+              addParticles(p.x, p.y, "#ffd23f", 14, 180);
+              blip(1046, 0.09, "square", 0.14);
+            }
+            pickups.splice(j, 1);
+          }
+        }
+
+        // player exhaust / speed particles
+        if (Math.random() < (boost > 0 ? 0.9 : 0.4)) {
+          addParticles(player.x + (Math.random() - 0.5) * 18, player.y + player.h / 2,
+            boost > 0 ? "#22e7ff" : "rgba(255,120,220,0.6)", 1, 40);
+        }
+      }
+
+      // particles always update
+      for (var k = particles.length - 1; k >= 0; k--) {
+        var pt = particles[k];
+        pt.age += dt;
+        if (pt.age >= pt.life) { particles.splice(k, 1); continue; }
+        pt.x += pt.vx * dt;
+        pt.y += pt.vy * dt;
+        pt.vy += 60 * dt;
+      }
+
+      // shake decay
+      if (shakeMag > 0) { shakeMag -= dt * 40; if (shakeMag < 0) shakeMag = 0; }
+
+      updateEngine();
+    }
+
+    function gameOver() {
+      state = STATE_OVER;
+      shakeMag = 18;
+      addParticles(player.x, player.y, "#ff5a3c", 40, 400);
+      addParticles(player.x, player.y, "#ffd23f", 30, 320);
+      crashSound();
+      if (distance > best) {
+        best = distance;
+        newBest = true;
+        try { localStorage.setItem(BEST_KEY, String(Math.floor(best))); } catch (e) {}
+      }
+    }
+
+    // ---- rendering ----
+    function drawCar(x, y, w, h, color, isPlayer) {
+      ctx.save();
+      ctx.translate(x, y);
+      // glow
+      ctx.shadowColor = color;
+      ctx.shadowBlur = isPlayer ? 22 : 14;
+      // body
+      ctx.fillStyle = color;
+      roundRect(-w / 2, -h / 2, w, h, 10);
+      ctx.fill();
+      ctx.shadowBlur = 0;
+      // cockpit / windows
+      ctx.fillStyle = "rgba(6,10,20,0.85)";
+      roundRect(-w / 2 + 7, -h / 2 + 12, w - 14, h * 0.28, 6);
+      ctx.fill();
+      roundRect(-w / 2 + 7, h / 2 - h * 0.32, w - 14, h * 0.20, 6);
+      ctx.fill();
+      // center stripe
+      ctx.fillStyle = "rgba(255,255,255,0.18)";
+      ctx.fillRect(-2, -h / 2 + 6, 4, h - 12);
+      // headlights/taillights
+      if (isPlayer) {
+        ctx.fillStyle = "#fff6c8";
+        ctx.fillRect(-w / 2 + 5, -h / 2 + 2, 8, 5);
+        ctx.fillRect(w / 2 - 13, -h / 2 + 2, 8, 5);
+      } else {
+        ctx.fillStyle = "#ff3b3b";
+        ctx.shadowColor = "#ff3b3b"; ctx.shadowBlur = 8;
+        ctx.fillRect(-w / 2 + 5, h / 2 - 7, 8, 5);
+        ctx.fillRect(w / 2 - 13, h / 2 - 7, 8, 5);
+        ctx.shadowBlur = 0;
+      }
+      ctx.restore();
+    }
+
+    function roundRect(x, y, w, h, r) {
+      if (r > w / 2) r = w / 2;
+      if (r > h / 2) r = h / 2;
+      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 draw() {
+      ctx.save();
+      // screen shake
+      if (shakeMag > 0) {
+        ctx.translate((Math.random() - 0.5) * shakeMag, (Math.random() - 0.5) * shakeMag);
+      }
+
+      // background
+      ctx.fillStyle = "#05070c";
+      ctx.fillRect(-40, -40, W + 80, H + 80);
+
+      // side "grass"/gradient synth glow bands
+      var g = ctx.createLinearGradient(0, 0, 0, H);
+      g.addColorStop(0, "#0c1226");
+      g.addColorStop(1, "#141a33");
+      ctx.fillStyle = g;
+      ctx.fillRect(-40, -40, W + 80, H + 80);
+
+      // road surface
+      var rg = ctx.createLinearGradient(0, 0, 0, H);
+      rg.addColorStop(0, "#12141f");
+      rg.addColorStop(1, "#1a1d2e");
+      ctx.fillStyle = rg;
+      ctx.fillRect(roadLeft, -40, roadW, H + 80);
+
+      // glowing road edges
+      drawGlowLine(roadLeft, 0, roadLeft, H, "#ff2fd0", 4);
+      drawGlowLine(roadRight, 0, roadRight, H, "#22e7ff", 4);
+
+      // moving dashed lane markers
+      ctx.save();
+      ctx.lineWidth = 5;
+      ctx.setLineDash([32, 48]);
+      ctx.lineDashOffset = -dashOffset;
+      ctx.strokeStyle = "rgba(180,200,255,0.55)";
+      ctx.shadowColor = "rgba(120,150,255,0.5)";
+      ctx.shadowBlur = 8;
+      for (var li = 1; li < LANES; li++) {
+        var lx = roadLeft + laneW * li;
+        ctx.beginPath();
+        ctx.moveTo(lx, -40);
+        ctx.lineTo(lx, H + 40);
+        ctx.stroke();
+      }
+      ctx.restore();
+
+      // horizon glow lines (perspective illusion) faint moving cross-lines
+      ctx.save();
+      ctx.strokeStyle = "rgba(122,60,255,0.10)";
+      ctx.lineWidth = 2;
+      for (var hy = 0; hy < H + 80; hy += 80) {
+        var yy = (hy - (dashOffset % 80));
+        ctx.beginPath();
+        ctx.moveTo(roadLeft, yy);
+        ctx.lineTo(roadRight, yy);
+        ctx.stroke();
+      }
+      ctx.restore();
+
+      // pickups
+      for (var i = 0; i < pickups.length; i++) {
+        var p = pickups[i];
+        var pulse = 1 + Math.sin(p.t * 8) * 0.12;
+        ctx.save();
+        ctx.translate(p.x, p.y);
+        ctx.scale(pulse, pulse);
+        if (p.type === "boost") {
+          ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 18;
+          ctx.fillStyle = "#22e7ff";
+          // lightning bolt
+          ctx.beginPath();
+          ctx.moveTo(-4, -14); ctx.lineTo(6, -2); ctx.lineTo(0, -2);
+          ctx.lineTo(4, 14); ctx.lineTo(-6, 0); ctx.lineTo(0, 0);
+          ctx.closePath();
+          ctx.fill();
+        } else {
+          ctx.shadowColor = "#ffd23f"; ctx.shadowBlur = 16;
+          ctx.fillStyle = "#ffd23f";
+          ctx.beginPath();
+          ctx.arc(0, 0, 12, 0, Math.PI * 2);
+          ctx.fill();
+          ctx.fillStyle = "rgba(120,80,0,0.55)";
+          ctx.font = "bold 15px sans-serif";
+          ctx.textAlign = "center";
+          ctx.textBaseline = "middle";
+          ctx.fillText("$", 0, 1);
+        }
+        ctx.restore();
+      }
+
+      // traffic
+      for (var t = 0; t < traffic.length; t++) {
+        var c = traffic[t];
+        drawCar(c.x, c.y, c.w, c.h, c.color, false);
+      }
+
+      // particles
+      for (var pp = 0; pp < particles.length; pp++) {
+        var pt = particles[pp];
+        var a = 1 - pt.age / pt.life;
+        ctx.globalAlpha = a < 0 ? 0 : a;
+        ctx.fillStyle = pt.color;
+        ctx.fillRect(pt.x - pt.size / 2, pt.y - pt.size / 2, pt.size, pt.size);
+      }
+      ctx.globalAlpha = 1;
+
+      // player
+      if (state !== STATE_OVER || (Math.floor(elapsed * 10) % 2 === 0)) {
+        // boost aura
+        if (boost > 0) {
+          ctx.save();
+          ctx.globalAlpha = 0.5 + Math.sin(elapsed * 30) * 0.2;
+          ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 30;
+          ctx.fillStyle = "rgba(34,231,255,0.15)";
+          roundRect(player.x - player.w / 2 - 6, player.y - player.h / 2 - 6, player.w + 12, player.h + 12, 12);
+          ctx.fill();
+          ctx.restore();
+        }
+        drawCar(player.x, player.y, player.w, player.h, player.color, true);
+      }
+
+      ctx.restore(); // shake
+
+      // HUD
+      drawHUD();
+
+      // overlays
+      if (state === STATE_MENU) drawMenu();
+      else if (state === STATE_OVER) drawGameOver();
+    }
+
+    function drawGlowLine(x1, y1, x2, y2, color, width) {
+      ctx.save();
+      ctx.strokeStyle = color;
+      ctx.lineWidth = width;
+      ctx.shadowColor = color;
+      ctx.shadowBlur = 16;
+      ctx.beginPath();
+      ctx.moveTo(x1, y1);
+      ctx.lineTo(x2, y2);
+      ctx.stroke();
+      ctx.restore();
+    }
+
+    function drawHUD() {
+      ctx.save();
+      ctx.textBaseline = "top";
+      // distance (big)
+      ctx.textAlign = "left";
+      ctx.fillStyle = "rgba(180,220,255,0.55)";
+      ctx.font = "600 13px 'Segoe UI', sans-serif";
+      ctx.fillText("DISTANCE", 18, 18);
+      ctx.fillStyle = "#e8f6ff";
+      ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 12;
+      ctx.font = "800 30px 'Segoe UI', sans-serif";
+      ctx.fillText(Math.floor(distance) + " m", 18, 32);
+      ctx.shadowBlur = 0;
+
+      // score (right)
+      ctx.textAlign = "right";
+      ctx.fillStyle = "rgba(255,180,240,0.55)";
+      ctx.font = "600 13px 'Segoe UI', sans-serif";
+      ctx.fillText("SCORE", W - 18, 18);
+      ctx.fillStyle = "#ffe3f8";
+      ctx.shadowColor = "#ff2fd0"; ctx.shadowBlur = 12;
+      ctx.font = "800 30px 'Segoe UI', sans-serif";
+      ctx.fillText(String(Math.floor(score)), W - 18, 32);
+      ctx.shadowBlur = 0;
+
+      // best (top center)
+      ctx.textAlign = "center";
+      ctx.fillStyle = "rgba(255,210,63,0.75)";
+      ctx.font = "700 13px 'Segoe UI', sans-serif";
+      ctx.fillText("BEST  " + Math.floor(best) + " m", W / 2, 22);
+
+      // speedometer bar
+      var spNorm = Math.min(1, (speed - 200) / 560);
+      var barW = 160, barH = 8, barX = W / 2 - barW / 2, barY = 44;
+      ctx.fillStyle = "rgba(255,255,255,0.08)";
+      roundRect(barX, barY, barW, barH, 4); ctx.fill();
+      var sg = ctx.createLinearGradient(barX, 0, barX + barW, 0);
+      sg.addColorStop(0, "#22e7ff");
+      sg.addColorStop(0.6, "#ff2fd0");
+      sg.addColorStop(1, "#ff5a3c");
+      ctx.fillStyle = sg;
+      roundRect(barX, barY, Math.max(6, barW * spNorm), barH, 4); ctx.fill();
+
+      if (boost > 0) {
+        ctx.textAlign = "center";
+        ctx.fillStyle = "#22e7ff";
+        ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 14;
+        ctx.font = "800 16px 'Segoe UI', sans-serif";
+        ctx.fillText("⚡ BOOST", W / 2, 58);
+        ctx.shadowBlur = 0;
+      }
+      ctx.restore();
+    }
+
+    function drawTitle(cx, cy) {
+      ctx.save();
+      ctx.textAlign = "center";
+      ctx.textBaseline = "middle";
+      // "NEON DRIFTER" uppercase, letter-spaced
+      var title = "N E O N   D R I F T E R";
+      ctx.font = "800 34px 'Segoe UI', sans-serif";
+      ctx.fillStyle = "#22e7ff";
+      ctx.shadowColor = "#ff2fd0"; ctx.shadowBlur = 24;
+      ctx.fillText(title, cx, cy);
+      ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 18;
+      ctx.fillStyle = "#ff2fd0";
+      ctx.fillText(title, cx + 1.5, cy + 1.5);
+      ctx.restore();
+    }
+
+    function overlayPanel() {
+      ctx.save();
+      ctx.fillStyle = "rgba(5,7,12,0.72)";
+      ctx.fillRect(0, 0, W, H);
+      ctx.restore();
+    }
+
+    function drawMenu() {
+      overlayPanel();
+      drawTitle(W / 2, H * 0.32);
+      ctx.save();
+      ctx.textAlign = "center";
+      ctx.textBaseline = "middle";
+      ctx.fillStyle = "rgba(200,225,255,0.85)";
+      ctx.font = "600 17px 'Segoe UI', sans-serif";
+      ctx.fillText("Weave through neon traffic. Don't crash.", W / 2, H * 0.44);
+
+      var pulse = 0.6 + Math.sin(elapsed * 4) * 0.4;
+      ctx.globalAlpha = pulse;
+      ctx.fillStyle = "#ffd23f";
+      ctx.shadowColor = "#ffd23f"; ctx.shadowBlur = 14;
+      ctx.font = "800 22px 'Segoe UI', sans-serif";
+      ctx.fillText("TAP / PRESS  ← →  TO START", W / 2, H * 0.55);
+      ctx.globalAlpha = 1;
+      ctx.shadowBlur = 0;
+
+      ctx.fillStyle = "rgba(160,190,230,0.6)";
+      ctx.font = "500 14px 'Segoe UI', sans-serif";
+      ctx.fillText("Arrows / A-D steer  •  Up/Down throttle", W / 2, H * 0.63);
+      ctx.fillText("⚡ = boost   $ = coin", W / 2, H * 0.67);
+      ctx.fillText("BEST  " + Math.floor(best) + " m", W / 2, H * 0.73);
+      ctx.restore();
+    }
+
+    function drawGameOver() {
+      overlayPanel();
+      ctx.save();
+      ctx.textAlign = "center";
+      ctx.textBaseline = "middle";
+
+      ctx.fillStyle = "#ff5a3c";
+      ctx.shadowColor = "#ff2fd0"; ctx.shadowBlur = 22;
+      ctx.font = "800 44px 'Segoe UI', sans-serif";
+      ctx.fillText("CRASHED", W / 2, H * 0.30);
+      ctx.shadowBlur = 0;
+
+      if (newBest) {
+        ctx.fillStyle = "#ffd23f";
+        ctx.shadowColor = "#ffd23f"; ctx.shadowBlur = 16;
+        ctx.font = "800 22px 'Segoe UI', sans-serif";
+        ctx.fillText("★ NEW BEST ★", W / 2, H * 0.40);
+        ctx.shadowBlur = 0;
+      }
+
+      ctx.fillStyle = "#e8f6ff";
+      ctx.font = "700 26px 'Segoe UI', sans-serif";
+      ctx.fillText(Math.floor(distance) + " m", W / 2, H * 0.47);
+      ctx.fillStyle = "rgba(200,225,255,0.7)";
+      ctx.font = "600 16px 'Segoe UI', sans-serif";
+      ctx.fillText("SCORE  " + Math.floor(score), W / 2, H * 0.52);
+      ctx.fillText("BEST  " + Math.floor(best) + " m", W / 2, H * 0.56);
+
+      var pulse = 0.6 + Math.sin(elapsed * 5) * 0.4;
+      ctx.globalAlpha = pulse;
+      ctx.fillStyle = "#22e7ff";
+      ctx.shadowColor = "#22e7ff"; ctx.shadowBlur = 14;
+      ctx.font = "800 20px 'Segoe UI', sans-serif";
+      ctx.fillText("TAP / PRESS SPACE TO RACE AGAIN", W / 2, H * 0.66);
+      ctx.globalAlpha = 1;
+      ctx.restore();
+    }
+
+    // ---- main loop ----
+    var lastT = 0;
+    function frame(ts) {
+      if (!lastT) lastT = ts;
+      var dt = (ts - lastT) / 1000;
+      lastT = ts;
+      if (dt > 0.05) dt = 0.05; // clamp big gaps
+      if (dt < 0) dt = 0;
+
+      // on game over let elapsed keep advancing for pulsing text
+      if (state === STATE_OVER || state === STATE_MENU) elapsed += dt;
+
+      try {
+        update(dt);
+        draw();
+      } catch (e) {
+        // never throw out of RAF
+      }
+      requestAnimationFrame(frame);
+    }
+    requestAnimationFrame(frame);
+  }
+
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", boot);
+  } else {
+    boot();
+  }
+})();
+</script>
+</body>
+</html>
diff --git a/games/sokoban/index.html b/games/sokoban/index.html
new file mode 100644
index 0000000..88a4a2b
--- /dev/null
+++ b/games/sokoban/index.html
@@ -0,0 +1,712 @@
+<!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>NEON SOKOBAN</title>
+<style>
+  :root{
+    --bg:#0a0e14;
+    --panel:#0f1622;
+    --wall:#1b2a44;
+    --wall-glow:#2f6bff;
+    --floor:#0d131e;
+    --crate:#f0a500;
+    --crate-done:#2ee6a6;
+    --target:#ff3d81;
+    --player:#41d6ff;
+    --ink:#cfe3ff;
+    --ink-dim:#6f86a8;
+  }
+  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
+  html,body{margin:0;padding:0;height:100%;}
+  body{
+    background:var(--bg);
+    color:var(--ink);
+    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    display:flex;flex-direction:column;align-items:center;
+    min-height:100%;
+    touch-action:none;
+    overflow:hidden;
+    user-select:none;-webkit-user-select:none;
+  }
+  #app{
+    width:100%;max-width:640px;
+    display:flex;flex-direction:column;
+    height:100vh;height:100dvh;
+    padding:8px;
+  }
+  header{flex:0 0 auto;text-align:center;padding:2px 0 6px;}
+  h1{
+    margin:0;
+    font-size:clamp(16px,5vw,26px);
+    font-weight:800;
+    letter-spacing:0.42em;
+    text-transform:uppercase;
+    color:var(--player);
+    text-shadow:0 0 8px rgba(65,214,255,.55),0 0 22px rgba(65,214,255,.25);
+    padding-left:0.42em;
+  }
+  .bar{
+    display:flex;flex-wrap:wrap;gap:6px;align-items:center;justify-content:center;
+    flex:0 0 auto;margin:2px 0 6px;
+  }
+  select,button{
+    font-family:inherit;font-size:13px;
+    background:var(--panel);color:var(--ink);
+    border:1px solid #24344f;border-radius:8px;
+    padding:7px 10px;cursor:pointer;
+    transition:border-color .12s,background .12s,transform .05s;
+  }
+  button:hover,select:hover{border-color:var(--wall-glow);}
+  button:active{transform:translateY(1px);}
+  .stats{
+    display:flex;gap:14px;justify-content:center;flex-wrap:wrap;
+    font-size:12px;letter-spacing:.08em;color:var(--ink-dim);
+    text-transform:uppercase;flex:0 0 auto;margin-bottom:4px;
+  }
+  .stats b{color:var(--ink);font-weight:700;}
+  .stats .solved{color:var(--crate-done);}
+  #boardWrap{
+    flex:1 1 auto;
+    display:flex;align-items:center;justify-content:center;
+    min-height:0;position:relative;
+  }
+  canvas{
+    display:block;
+    image-rendering:auto;
+    border-radius:10px;
+    background:var(--floor);
+    box-shadow:0 0 0 1px #1a2740, 0 8px 30px rgba(0,0,0,.5);
+    touch-action:none;
+  }
+  #win{
+    position:absolute;inset:0;display:none;
+    align-items:center;justify-content:center;flex-direction:column;
+    background:rgba(6,10,16,.82);border-radius:10px;text-align:center;
+    gap:12px;padding:16px;
+  }
+  #win.show{display:flex;}
+  #win .big{
+    font-size:clamp(22px,7vw,40px);font-weight:800;letter-spacing:.3em;
+    color:var(--crate-done);text-shadow:0 0 16px rgba(46,230,166,.6);
+    text-transform:uppercase;padding-left:.3em;
+  }
+  #win .sub{font-size:13px;color:var(--ink);letter-spacing:.06em;}
+  #controls{
+    flex:0 0 auto;
+    display:flex;align-items:center;justify-content:space-between;
+    gap:10px;padding:8px 4px 12px;
+  }
+  #dpad{
+    display:grid;
+    grid-template-columns:repeat(3,54px);
+    grid-template-rows:repeat(3,54px);
+    gap:6px;
+  }
+  #dpad button{
+    font-size:22px;padding:0;border-radius:12px;
+    display:flex;align-items:center;justify-content:center;
+    color:var(--player);background:#101a2b;border-color:#22344f;
+  }
+  #dpad button:active{background:#16233a;box-shadow:0 0 12px rgba(65,214,255,.35) inset;}
+  .du{grid-column:2;grid-row:1;}
+  .dl{grid-column:1;grid-row:2;}
+  .dr{grid-column:3;grid-row:2;}
+  .dd{grid-column:2;grid-row:3;}
+  #dpad .ctr{grid-column:2;grid-row:2;visibility:hidden;}
+  #sideBtns{display:flex;flex-direction:column;gap:8px;}
+  #sideBtns button{min-width:88px;font-weight:700;letter-spacing:.08em;}
+  #btnUndo{color:var(--crate);border-color:#3a2f12;}
+  #btnReset{color:var(--target);border-color:#3a1424;}
+  .hint{font-size:10px;color:var(--ink-dim);letter-spacing:.06em;text-align:center;flex:0 0 auto;padding-bottom:4px;}
+  @media (max-height:620px){
+    #dpad{grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,46px);}
+    h1{font-size:16px;}
+  }
+</style>
+</head>
+<body>
+<div id="app">
+  <header><h1>Neon Sokoban</h1></header>
+  <div class="bar">
+    <select id="levelSelect" aria-label="Select level"></select>
+    <button id="btnPrev">◀ Prev</button>
+    <button id="btnNext">Next ▶</button>
+  </div>
+  <div class="stats">
+    <span>Level <b id="statLevel">1</b></span>
+    <span>Moves <b id="statMoves">0</b></span>
+    <span>Pushes <b id="statPushes">0</b></span>
+    <span>Best <b id="statBest">—</b></span>
+    <span class="solved" id="statSolved"></span>
+  </div>
+  <div id="boardWrap">
+    <canvas id="board"></canvas>
+    <div id="win">
+      <div class="big">Solved</div>
+      <div class="sub" id="winSub"></div>
+      <button id="btnWinNext">Next Level ▶</button>
+    </div>
+  </div>
+  <div id="controls">
+    <div id="dpad">
+      <button class="du" data-dir="up" aria-label="Up">▲</button>
+      <button class="dl" data-dir="left" aria-label="Left">◀</button>
+      <span class="ctr"></span>
+      <button class="dr" data-dir="right" aria-label="Right">▶</button>
+      <button class="dd" data-dir="down" aria-label="Down">▼</button>
+    </div>
+    <div id="sideBtns">
+      <button id="btnUndo">↶ UNDO</button>
+      <button id="btnReset">⟳ RESET</button>
+    </div>
+  </div>
+  <div class="hint">Arrows / WASD move · U undo · R reset</div>
+</div>
+<script>
+(function(){
+  "use strict";
+
+  // ---- Level data (ASCII). # wall, space floor, . target, $ crate, * crate-on-target, @ player, + player-on-target ----
+  var LEVELS = [
+    { name:"Warmup", grid:[
+      "#####",
+      "#@$.#",
+      "#####"
+    ]},
+    { name:"Corner", grid:[
+      "######",
+      "#.   #",
+      "# $$ #",
+      "# $. #",
+      "#. @ #",
+      "######"
+    ]},
+    { name:"Twist", grid:[
+      "  #####",
+      "###   #",
+      "#.@$  #",
+      "### $.#",
+      "#.##$ #",
+      "# # . ##",
+      "#$ *$$.#",
+      "#   .  #",
+      "########"
+    ]},
+    { name:"Detour", grid:[
+      "########",
+      "#      #",
+      "# .$.  #",
+      "# $@$  #",
+      "# .$.  #",
+      "#      #",
+      "########"
+    ]},
+    { name:"Chamber", grid:[
+      "#######",
+      "#  .  #",
+      "# $#$ #",
+      "#. @ .#",
+      "# $#$ #",
+      "#  .  #",
+      "#######"
+    ]},
+    { name:"Zigzag", grid:[
+      "########",
+      "#      #",
+      "# $ $  #",
+      "#.##.# #",
+      "# $ @  #",
+      "#.## # #",
+      "#      #",
+      "########"
+    ]},
+    { name:"Lockbox", grid:[
+      "#########",
+      "#   #   #",
+      "# $ # . #",
+      "# $ #.. #",
+      "# @ $   #",
+      "#   #   #",
+      "#########"
+    ]},
+    { name:"Gauntlet", grid:[
+      "########",
+      "#. #   #",
+      "#. $$@ #",
+      "#. $ $ #",
+      "#.     #",
+      "#      #",
+      "########"
+    ]},
+    { name:"Spiral", grid:[
+      "########",
+      "#. .  .#",
+      "# $$$  #",
+      "#  @   #",
+      "# $$$  #",
+      "#. .  .#",
+      "########"
+    ]},
+    { name:"Vault", grid:[
+      "#########",
+      "#  ...  #",
+      "#  $$$  #",
+      "#  $@   #",
+      "#  $$   #",
+      "#  ...  #",
+      "#########"
+    ]}
+  ];
+
+  var STORE_KEY = "abrams-sokoban";
+
+  // ---- Tile constants ----
+  var WALL=1, FLOOR=0, TARGET=2;
+
+  // ---- State ----
+  var state = null; // { statics, w, h, crates(Set of index), player, targets(Set), moves, pushes, history[] }
+  var current = 0;
+  var progress = loadProgress();
+
+  var canvas = document.getElementById("board");
+  var ctx = canvas.getContext("2d");
+  var cell = 32;
+
+  function loadProgress(){
+    try{
+      var raw = localStorage.getItem(STORE_KEY);
+      if(!raw) return {};
+      var p = JSON.parse(raw);
+      return (p && typeof p === "object") ? p : {};
+    }catch(e){ return {}; }
+  }
+  function saveProgress(){
+    try{ localStorage.setItem(STORE_KEY, JSON.stringify(progress)); }catch(e){}
+  }
+
+  function idx(x,y,w){ return y*w + x; }
+
+  // Parse a level into a fresh state object
+  function buildState(levelIndex){
+    var grid = LEVELS[levelIndex].grid;
+    var h = grid.length;
+    var w = 0;
+    for(var i=0;i<h;i++){ if(grid[i].length>w) w = grid[i].length; }
+    var statics = new Array(w*h); // WALL / FLOOR / TARGET
+    var crates = {}; // index -> true
+    var targets = {};
+    var player = 0;
+    for(var y=0;y<h;y++){
+      var row = grid[y];
+      for(var x=0;x<w;x++){
+        var ch = x < row.length ? row[x] : " ";
+        var index = idx(x,y,w);
+        var isTarget=false, isCrate=false, isPlayer=false, isWall=false;
+        switch(ch){
+          case "#": isWall=true; break;
+          case ".": isTarget=true; break;
+          case "$": isCrate=true; break;
+          case "*": isCrate=true; isTarget=true; break;
+          case "@": isPlayer=true; break;
+          case "+": isPlayer=true; isTarget=true; break;
+          default: break; // space => floor
+        }
+        statics[index] = isWall ? WALL : (isTarget ? TARGET : FLOOR);
+        if(isTarget) targets[index]=true;
+        if(isCrate) crates[index]=true;
+        if(isPlayer) player=index;
+      }
+    }
+    return {
+      w:w, h:h, statics:statics,
+      crates:crates, targets:targets,
+      player:player, moves:0, pushes:0,
+      history:[]
+    };
+  }
+
+  var DIRS = {
+    up:   {dx:0, dy:-1},
+    down: {dx:0, dy:1},
+    left: {dx:-1,dy:0},
+    right:{dx:1, dy:0}
+  };
+
+  function isWall(s,index){ return s.statics[index] === WALL; }
+
+  function tryMove(dir){
+    if(!state) return;
+    var d = DIRS[dir];
+    if(!d) return;
+    var s = state;
+    var w = s.w;
+    var px = s.player % w, py = (s.player - (s.player%w))/w;
+    var nx = px + d.dx, ny = py + d.dy;
+    if(nx<0||ny<0||nx>=w||ny>=s.h) return;
+    var nIndex = idx(nx,ny,w);
+    if(isWall(s,nIndex)) return;
+
+    var pushed = false;
+    if(s.crates[nIndex]){
+      // crate ahead — check the cell beyond
+      var bx = nx + d.dx, by = ny + d.dy;
+      if(bx<0||by<0||bx>=w||by>=s.h) return;
+      var bIndex = idx(bx,by,w);
+      if(isWall(s,bIndex) || s.crates[bIndex]) return; // blocked
+      // move crate
+      delete s.crates[nIndex];
+      s.crates[bIndex] = true;
+      pushed = true;
+    }
+    // record history for undo
+    s.history.push({ from:s.player, to:nIndex, pushed:pushed });
+    s.player = nIndex;
+    s.moves++;
+    if(pushed) s.pushes++;
+    render();
+    checkWin();
+  }
+
+  function undo(){
+    if(!state || state.history.length===0) return;
+    var s = state;
+    var last = s.history.pop();
+    // player moves back
+    s.player = last.from;
+    if(s.moves>0) s.moves--;
+    if(last.pushed){
+      // crate was pushed from 'to' to beyond; revert it
+      var w = s.w;
+      var tx = last.to % w, ty = (last.to - (last.to%w))/w;
+      var dx = tx - (last.from % w);
+      var dy = ty - ((last.from - (last.from%w))/w);
+      var bIndex = idx(tx+dx, ty+dy, w);
+      delete s.crates[bIndex];
+      s.crates[last.to] = true;
+      if(s.pushes>0) s.pushes--;
+    }
+    hideWin();
+    render();
+  }
+
+  function reset(){
+    state = buildState(current);
+    hideWin();
+    layout();
+    render();
+    updateStats();
+  }
+
+  function isSolved(s){
+    for(var t in s.targets){
+      if(!s.crates[t]) return false;
+    }
+    return true;
+  }
+
+  function checkWin(){
+    updateStats();
+    if(state && isSolved(state)){
+      recordSolve();
+      showWin();
+    }
+  }
+
+  function recordSolve(){
+    var key = String(current);
+    var rec = progress[key] || {};
+    rec.solved = true;
+    if(typeof rec.best !== "number" || state.moves < rec.best){
+      rec.best = state.moves;
+    }
+    progress[key] = rec;
+    saveProgress();
+    buildLevelSelect();
+    updateStats();
+  }
+
+  // ---- Rendering ----
+  function layout(){
+    if(!state) return;
+    var wrap = document.getElementById("boardWrap");
+    var availW = wrap.clientWidth - 4;
+    var availH = wrap.clientHeight - 4;
+    if(availW<=0) availW = 300;
+    if(availH<=0) availH = 300;
+    var c = Math.floor(Math.min(availW/state.w, availH/state.h));
+    c = Math.max(14, Math.min(c, 64));
+    cell = c;
+    canvas.width = state.w * cell;
+    canvas.height = state.h * cell;
+    canvas.style.width = canvas.width + "px";
+    canvas.style.height = canvas.height + "px";
+  }
+
+  function render(){
+    if(!state) return;
+    var s = state, w=s.w, h=s.h;
+    ctx.clearRect(0,0,canvas.width,canvas.height);
+    // floor bg
+    ctx.fillStyle = "#0d131e";
+    ctx.fillRect(0,0,canvas.width,canvas.height);
+
+    for(var y=0;y<h;y++){
+      for(var x=0;x<w;x++){
+        var index = idx(x,y,w);
+        var st = s.statics[index];
+        var px = x*cell, py=y*cell;
+        if(st===WALL){
+          drawWall(px,py);
+        } else {
+          drawFloor(px,py);
+          if(st===TARGET){ drawTarget(px,py); }
+        }
+      }
+    }
+    // crates
+    for(var ci in s.crates){
+      var i = +ci;
+      var cx = (i % w)*cell, cy = ((i-(i%w))/w)*cell;
+      var onTarget = s.statics[i]===TARGET;
+      drawCrate(cx,cy,onTarget);
+    }
+    // player
+    var pi = s.player;
+    var ppx = (pi % w)*cell, ppy = ((pi-(pi%w))/w)*cell;
+    drawPlayer(ppx,ppy);
+  }
+
+  function drawFloor(px,py){
+    ctx.fillStyle = "#0d131e";
+    ctx.fillRect(px,py,cell,cell);
+    ctx.strokeStyle = "rgba(40,60,95,.28)";
+    ctx.lineWidth = 1;
+    ctx.strokeRect(px+0.5,py+0.5,cell-1,cell-1);
+  }
+  function drawWall(px,py){
+    var g = ctx.createLinearGradient(px,py,px,py+cell);
+    g.addColorStop(0,"#20335a");
+    g.addColorStop(1,"#152340");
+    ctx.fillStyle = g;
+    ctx.fillRect(px,py,cell,cell);
+    ctx.strokeStyle = "rgba(47,107,255,.55)";
+    ctx.lineWidth = Math.max(1, cell*0.06);
+    ctx.strokeRect(px+ctx.lineWidth/2, py+ctx.lineWidth/2, cell-ctx.lineWidth, cell-ctx.lineWidth);
+  }
+  function drawTarget(px,py){
+    var cx = px+cell/2, cy=py+cell/2;
+    var r = cell*0.18;
+    ctx.save();
+    ctx.shadowColor = "rgba(255,61,129,.85)";
+    ctx.shadowBlur = cell*0.4;
+    ctx.strokeStyle = "#ff3d81";
+    ctx.lineWidth = Math.max(1.5, cell*0.07);
+    ctx.beginPath();
+    ctx.arc(cx,cy,r,0,Math.PI*2);
+    ctx.stroke();
+    ctx.restore();
+  }
+  function drawCrate(px,py,onTarget){
+    var pad = cell*0.12;
+    var x=px+pad, y=py+pad, sz=cell-pad*2;
+    var col = onTarget ? "#2ee6a6" : "#f0a500";
+    var glow = onTarget ? "rgba(46,230,166,.75)" : "rgba(240,165,0,.55)";
+    ctx.save();
+    ctx.shadowColor = glow;
+    ctx.shadowBlur = onTarget ? cell*0.5 : cell*0.28;
+    var g = ctx.createLinearGradient(x,y,x,y+sz);
+    g.addColorStop(0, onTarget ? "#3af0b4" : "#ffbe2e");
+    g.addColorStop(1, col);
+    ctx.fillStyle = g;
+    roundRect(x,y,sz,sz,cell*0.12);
+    ctx.fill();
+    ctx.restore();
+    // inner cross detail
+    ctx.strokeStyle = onTarget ? "rgba(6,30,22,.55)" : "rgba(60,40,0,.5)";
+    ctx.lineWidth = Math.max(1, cell*0.04);
+    ctx.beginPath();
+    ctx.moveTo(x,y); ctx.lineTo(x+sz,y+sz);
+    ctx.moveTo(x+sz,y); ctx.lineTo(x,y+sz);
+    ctx.stroke();
+  }
+  function drawPlayer(px,py){
+    var cx=px+cell/2, cy=py+cell/2;
+    var r = cell*0.32;
+    ctx.save();
+    ctx.shadowColor = "rgba(65,214,255,.85)";
+    ctx.shadowBlur = cell*0.45;
+    var g = ctx.createRadialGradient(cx,cy-r*0.3,r*0.2,cx,cy,r);
+    g.addColorStop(0,"#bff2ff");
+    g.addColorStop(1,"#41d6ff");
+    ctx.fillStyle = g;
+    ctx.beginPath();
+    ctx.arc(cx,cy,r,0,Math.PI*2);
+    ctx.fill();
+    ctx.restore();
+    ctx.strokeStyle = "rgba(10,40,55,.6)";
+    ctx.lineWidth = Math.max(1, cell*0.04);
+    ctx.beginPath();
+    ctx.arc(cx,cy,r,0,Math.PI*2);
+    ctx.stroke();
+  }
+  function roundRect(x,y,w,h,r){
+    r = Math.min(r, w/2, h/2);
+    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();
+  }
+
+  // ---- UI ----
+  function updateStats(){
+    document.getElementById("statLevel").textContent = String(current+1);
+    document.getElementById("statMoves").textContent = state ? String(state.moves) : "0";
+    document.getElementById("statPushes").textContent = state ? String(state.pushes) : "0";
+    var rec = progress[String(current)];
+    document.getElementById("statBest").textContent = (rec && typeof rec.best==="number") ? String(rec.best) : "—";
+    var solvedCount = 0;
+    for(var k in progress){ if(progress[k] && progress[k].solved) solvedCount++; }
+    document.getElementById("statSolved").textContent = solvedCount>0 ? ("✓ " + solvedCount + "/" + LEVELS.length + " solved") : "";
+  }
+
+  function buildLevelSelect(){
+    var sel = document.getElementById("levelSelect");
+    var prev = sel.value;
+    sel.innerHTML = "";
+    for(var i=0;i<LEVELS.length;i++){
+      var opt = document.createElement("option");
+      opt.value = String(i);
+      var rec = progress[String(i)];
+      var tag = (rec && rec.solved) ? " ✓" : "";
+      var best = (rec && typeof rec.best==="number") ? (" · " + rec.best) : "";
+      opt.textContent = (i+1) + ". " + LEVELS[i].name + tag + best;
+      sel.appendChild(opt);
+    }
+    sel.value = String(current);
+    if(prev==="" ){ /* noop */ }
+  }
+
+  function loadLevel(i){
+    if(i<0) i=0;
+    if(i>=LEVELS.length) i=LEVELS.length-1;
+    current = i;
+    document.getElementById("levelSelect").value = String(current);
+    reset();
+  }
+
+  function showWin(){
+    var el = document.getElementById("win");
+    var sub = document.getElementById("winSub");
+    var rec = progress[String(current)] || {};
+    var bestTxt = (typeof rec.best==="number") ? (" · Best " + rec.best) : "";
+    sub.textContent = state.moves + " moves · " + state.pushes + " pushes" + bestTxt;
+    var nextBtn = document.getElementById("btnWinNext");
+    nextBtn.style.display = (current < LEVELS.length-1) ? "" : "none";
+    el.classList.add("show");
+  }
+  function hideWin(){ document.getElementById("win").classList.remove("show"); }
+
+  // ---- Input ----
+  var KEYMAP = {
+    "ArrowUp":"up","ArrowDown":"down","ArrowLeft":"left","ArrowRight":"right",
+    "w":"up","s":"down","a":"left","d":"right",
+    "W":"up","S":"down","A":"left","D":"right"
+  };
+  window.addEventListener("keydown", function(e){
+    var k = e.key;
+    if(KEYMAP[k]){
+      e.preventDefault();
+      tryMove(KEYMAP[k]);
+      return;
+    }
+    if(k==="u"||k==="U"){ e.preventDefault(); undo(); return; }
+    if(k==="r"||k==="R"){ e.preventDefault(); reset(); return; }
+    if(k==="n"||k==="N"){ e.preventDefault(); loadLevel(current+1); return; }
+  }, {passive:false});
+
+  // Prevent arrow/space page scroll fallback
+  window.addEventListener("keydown", function(e){
+    if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," "].indexOf(e.key) !== -1){
+      e.preventDefault();
+    }
+  }, {passive:false});
+
+  function bindTap(el, fn){
+    if(!el) return;
+    el.addEventListener("click", function(e){ e.preventDefault(); fn(); });
+    el.addEventListener("touchstart", function(e){ e.preventDefault(); fn(); }, {passive:false});
+  }
+
+  var dpadBtns = document.querySelectorAll("#dpad button[data-dir]");
+  for(var i=0;i<dpadBtns.length;i++){
+    (function(btn){
+      var dir = btn.getAttribute("data-dir");
+      btn.addEventListener("click", function(e){ e.preventDefault(); tryMove(dir); });
+      btn.addEventListener("touchstart", function(e){ e.preventDefault(); tryMove(dir); }, {passive:false});
+    })(dpadBtns[i]);
+  }
+
+  bindTap(document.getElementById("btnUndo"), undo);
+  bindTap(document.getElementById("btnReset"), reset);
+  bindTap(document.getElementById("btnPrev"), function(){ loadLevel(current-1); });
+  bindTap(document.getElementById("btnNext"), function(){ loadLevel(current+1); });
+  bindTap(document.getElementById("btnWinNext"), function(){ loadLevel(current+1); });
+
+  document.getElementById("levelSelect").addEventListener("change", function(e){
+    loadLevel(parseInt(e.target.value,10)||0);
+  });
+
+  // Swipe on canvas
+  var touchStart = null;
+  canvas.addEventListener("touchstart", function(e){
+    if(e.touches.length===1){
+      touchStart = {x:e.touches[0].clientX, y:e.touches[0].clientY};
+    }
+  }, {passive:true});
+  canvas.addEventListener("touchend", function(e){
+    if(!touchStart) return;
+    var t = e.changedTouches[0];
+    var dx = t.clientX - touchStart.x;
+    var dy = t.clientY - touchStart.y;
+    touchStart = null;
+    var ax = Math.abs(dx), ay = Math.abs(dy);
+    if(Math.max(ax,ay) < 22) return;
+    if(ax>ay){ tryMove(dx>0?"right":"left"); }
+    else{ tryMove(dy>0?"down":"up"); }
+  }, {passive:true});
+
+  var resizeTimer = null;
+  window.addEventListener("resize", function(){
+    if(resizeTimer) clearTimeout(resizeTimer);
+    resizeTimer = setTimeout(function(){ layout(); render(); }, 60);
+  });
+
+  // ---- Init (guarded until DOM ready) ----
+  function init(){
+    buildLevelSelect();
+    // start at first unsolved level, else level 0
+    var start = 0;
+    for(var i=0;i<LEVELS.length;i++){
+      var rec = progress[String(i)];
+      if(!rec || !rec.solved){ start = i; break; }
+    }
+    current = start;
+    loadLevel(current);
+    // second layout pass after fonts/flex settle
+    setTimeout(function(){ layout(); render(); }, 50);
+  }
+
+  if(document.readyState === "loading"){
+    document.addEventListener("DOMContentLoaded", init);
+  } else {
+    init();
+  }
+})();
+</script>
+</body>
+</html>
diff --git a/games/sudoku/index.html b/games/sudoku/index.html
new file mode 100644
index 0000000..8c0de66
--- /dev/null
+++ b/games/sudoku/index.html
@@ -0,0 +1,799 @@
+<!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>NEON SUDOKU</title>
+<style>
+  :root{
+    --bg:#0a0e14;
+    --panel:#0f151f;
+    --grid-line:#1c2a3a;
+    --grid-line-strong:#2fd2ff;
+    --given:#e6f4ff;
+    --entry:#38f0c0;
+    --note:#5f7a92;
+    --conflict:#ff3b5c;
+    --accent:#2fd2ff;
+    --sel:#12303f;
+    --sel-peer:#0e1f2b;
+    --sameval:#123448;
+  }
+  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
+  html,body{margin:0;padding:0;height:100%;}
+  body{
+    background:var(--bg);
+    color:#cfe6f5;
+    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    display:flex;
+    flex-direction:column;
+    align-items:center;
+    justify-content:flex-start;
+    min-height:100%;
+    padding:12px 10px 24px;
+    touch-action:manipulation;
+    overflow-x:hidden;
+  }
+  h1{
+    margin:4px 0 10px;
+    font-size:clamp(18px,5vw,30px);
+    font-weight:800;
+    letter-spacing:.34em;
+    text-transform:uppercase;
+    color:#eaf7ff;
+    text-shadow:0 0 8px rgba(47,210,255,.6),0 0 22px rgba(47,210,255,.25);
+    text-align:center;
+  }
+  .hud{
+    display:flex;
+    flex-wrap:wrap;
+    gap:8px 14px;
+    align-items:center;
+    justify-content:center;
+    margin-bottom:12px;
+    width:100%;
+    max-width:560px;
+  }
+  .hud .item{
+    display:flex;
+    flex-direction:column;
+    align-items:center;
+    line-height:1.1;
+  }
+  .hud .label{
+    font-size:10px;
+    letter-spacing:.18em;
+    text-transform:uppercase;
+    color:#5f7a92;
+  }
+  .hud .val{
+    font-size:16px;
+    font-weight:700;
+    font-variant-numeric:tabular-nums;
+    color:var(--accent);
+    text-shadow:0 0 8px rgba(47,210,255,.4);
+  }
+  .controls{
+    display:flex;
+    flex-wrap:wrap;
+    gap:8px;
+    justify-content:center;
+    margin-bottom:14px;
+    width:100%;
+    max-width:560px;
+  }
+  select,button{
+    font-family:inherit;
+    background:var(--panel);
+    color:#cfe6f5;
+    border:1px solid var(--grid-line);
+    border-radius:8px;
+    padding:9px 12px;
+    font-size:13px;
+    letter-spacing:.05em;
+    cursor:pointer;
+    transition:border-color .15s,box-shadow .15s,background .15s;
+  }
+  select:focus,button:focus{outline:none;border-color:var(--accent);}
+  button:hover,select:hover{border-color:var(--accent);}
+  button:active{transform:translateY(1px);}
+  button.primary{
+    border-color:var(--accent);
+    color:#eaf7ff;
+    box-shadow:0 0 10px rgba(47,210,255,.25) inset;
+  }
+  button.toggled{
+    background:#123448;
+    border-color:var(--entry);
+    color:var(--entry);
+    box-shadow:0 0 10px rgba(56,240,192,.3) inset;
+  }
+  #boardWrap{
+    width:min(94vw,520px);
+    aspect-ratio:1/1;
+    max-height:60vh;
+    margin-bottom:16px;
+  }
+  #board{
+    width:100%;
+    height:100%;
+    display:grid;
+    grid-template-columns:repeat(9,1fr);
+    grid-template-rows:repeat(9,1fr);
+    background:var(--grid-line-strong);
+    border:2px solid var(--grid-line-strong);
+    border-radius:6px;
+    gap:1px;
+    box-shadow:0 0 22px rgba(47,210,255,.25);
+    overflow:hidden;
+  }
+  .cell{
+    position:relative;
+    background:var(--panel);
+    display:flex;
+    align-items:center;
+    justify-content:center;
+    font-size:clamp(16px,5vw,28px);
+    font-weight:600;
+    color:var(--entry);
+    cursor:pointer;
+    user-select:none;
+    font-variant-numeric:tabular-nums;
+  }
+  .cell.given{color:var(--given);font-weight:700;}
+  /* thick 3x3 box borders using margins */
+  .cell.br{margin-right:1px;box-shadow:2px 0 0 0 var(--grid-line-strong);}
+  .cell.bb{margin-bottom:1px;box-shadow:0 2px 0 0 var(--grid-line-strong);}
+  .cell.br.bb{box-shadow:2px 0 0 0 var(--grid-line-strong),0 2px 0 0 var(--grid-line-strong);}
+  .cell.peer{background:var(--sel-peer);}
+  .cell.sameval{background:var(--sameval);}
+  .cell.selected{background:var(--sel);box-shadow:inset 0 0 0 2px var(--accent);}
+  .cell.selected.br,.cell.selected.bb,.cell.selected.br.bb{box-shadow:inset 0 0 0 2px var(--accent);}
+  .cell.conflict{color:var(--conflict);text-shadow:0 0 10px rgba(255,59,92,.7);}
+  .cell.conflict.given{color:var(--conflict);}
+  .notes{
+    position:absolute;
+    inset:2px;
+    display:grid;
+    grid-template-columns:repeat(3,1fr);
+    grid-template-rows:repeat(3,1fr);
+    pointer-events:none;
+  }
+  .notes span{
+    display:flex;
+    align-items:center;
+    justify-content:center;
+    font-size:clamp(7px,2vw,11px);
+    font-weight:600;
+    color:var(--note);
+    line-height:1;
+  }
+  .pad{
+    display:grid;
+    grid-template-columns:repeat(5,1fr);
+    gap:8px;
+    width:min(94vw,520px);
+    margin-bottom:10px;
+  }
+  .pad button{
+    padding:14px 0;
+    font-size:20px;
+    font-weight:700;
+    border-radius:10px;
+  }
+  .pad .num{color:#eaf7ff;}
+  .pad .fn{font-size:12px;letter-spacing:.08em;}
+  .msg{
+    min-height:22px;
+    font-size:14px;
+    letter-spacing:.1em;
+    text-transform:uppercase;
+    color:var(--entry);
+    text-shadow:0 0 10px rgba(56,240,192,.5);
+    text-align:center;
+    font-weight:700;
+  }
+  .best{font-size:11px;color:#5f7a92;letter-spacing:.12em;text-transform:uppercase;text-align:center;margin-top:2px;}
+  @media (max-width:360px){
+    h1{letter-spacing:.2em;}
+    .pad button{padding:11px 0;font-size:17px;}
+  }
+</style>
+</head>
+<body>
+  <h1>Neon Sudoku</h1>
+
+  <div class="hud">
+    <div class="item"><span class="label">Time</span><span class="val" id="timer">00:00</span></div>
+    <div class="item"><span class="label">Difficulty</span><span class="val" id="diffLbl">Easy</span></div>
+    <div class="item"><span class="label">Mode</span><span class="val" id="modeLbl">Fill</span></div>
+  </div>
+
+  <div class="controls">
+    <select id="diffSel" aria-label="Difficulty">
+      <option value="easy">Easy</option>
+      <option value="medium">Medium</option>
+      <option value="hard">Hard</option>
+    </select>
+    <button class="primary" id="newBtn">New Puzzle</button>
+    <button id="notesBtn">Notes: Off</button>
+  </div>
+
+  <div id="boardWrap"><div id="board" aria-label="Sudoku board"></div></div>
+
+  <div class="pad" id="pad"></div>
+
+  <div class="msg" id="msg"></div>
+  <div class="best" id="best"></div>
+
+<script>
+(function(){
+  "use strict";
+
+  var STORE_KEY = "abrams-sudoku";
+  var SIZE = 9;
+
+  // ---------- utilities ----------
+  function shuffled(arr){
+    var a = arr.slice();
+    for (var i = a.length - 1; i > 0; i--){
+      var j = Math.floor(Math.random() * (i + 1));
+      var t = a[i]; a[i] = a[j]; a[j] = t;
+    }
+    return a;
+  }
+
+  // ---------- solution generator (bounded backtracking) ----------
+  // Returns a full valid 9x9 grid (array of 81 ints 1-9), or null on failure.
+  function generateSolution(){
+    var grid = new Array(81).fill(0);
+    var steps = { count: 0 };
+    var MAX_STEPS = 600000; // hard cap so we can never spin forever
+
+    function fits(idx, val){
+      var r = (idx / 9) | 0, c = idx % 9;
+      var br = r - r % 3, bc = c - c % 3;
+      for (var i = 0; i < 9; i++){
+        if (grid[r*9 + i] === val) return false;
+        if (grid[i*9 + c] === val) return false;
+      }
+      for (var rr = 0; rr < 3; rr++){
+        for (var cc = 0; cc < 3; cc++){
+          if (grid[(br+rr)*9 + (bc+cc)] === val) return false;
+        }
+      }
+      return true;
+    }
+
+    function solve(pos){
+      if (steps.count++ > MAX_STEPS) return false;
+      if (pos >= 81) return true;
+      if (grid[pos] !== 0) return solve(pos + 1);
+      var nums = shuffled([1,2,3,4,5,6,7,8,9]);
+      for (var k = 0; k < 9; k++){
+        var v = nums[k];
+        if (fits(pos, v)){
+          grid[pos] = v;
+          if (solve(pos + 1)) return true;
+          grid[pos] = 0;
+        }
+        if (steps.count > MAX_STEPS) return false;
+      }
+      return false;
+    }
+
+    if (solve(0)) return grid;
+    return null;
+  }
+
+  // ---------- solver: count solutions up to a limit (for uniqueness) ----------
+  function countSolutions(puzzle, limit){
+    var grid = puzzle.slice();
+    var steps = { count: 0 };
+    var MAX_STEPS = 400000;
+    var found = 0;
+
+    function fits(idx, val){
+      var r = (idx / 9) | 0, c = idx % 9;
+      var br = r - r % 3, bc = c - c % 3;
+      for (var i = 0; i < 9; i++){
+        if (grid[r*9 + i] === val) return false;
+        if (grid[i*9 + c] === val) return false;
+      }
+      for (var rr = 0; rr < 3; rr++){
+        for (var cc = 0; cc < 3; cc++){
+          if (grid[(br+rr)*9 + (bc+cc)] === val) return false;
+        }
+      }
+      return true;
+    }
+
+    // find the empty cell with fewest candidates (MRV) to keep it fast/bounded
+    function pickCell(){
+      var best = -1, bestCount = 10;
+      for (var idx = 0; idx < 81; idx++){
+        if (grid[idx] !== 0) continue;
+        var cnt = 0;
+        for (var v = 1; v <= 9; v++){ if (fits(idx, v)) cnt++; }
+        if (cnt < bestCount){ bestCount = cnt; best = idx; if (cnt <= 1) break; }
+      }
+      return best;
+    }
+
+    function solve(){
+      if (steps.count++ > MAX_STEPS) return true; // bail -> treat as "give up", stops recursion
+      var idx = pickCell();
+      if (idx === -1){ found++; return found >= limit; }
+      for (var v = 1; v <= 9; v++){
+        if (fits(idx, v)){
+          grid[idx] = v;
+          if (solve()) { grid[idx] = 0; return true; }
+          grid[idx] = 0;
+        }
+        if (steps.count > MAX_STEPS) return true;
+      }
+      return false;
+    }
+
+    solve();
+    return found;
+  }
+
+  // ---------- build a puzzle at difficulty ----------
+  var DIFF = {
+    easy:   { clues: 40 },
+    medium: { clues: 32 },
+    hard:   { clues: 26 }
+  };
+
+  // Make puzzle: start from full solution, remove cells (symmetric) while
+  // keeping a unique solution, bounded by attempts. Falls back gracefully.
+  function makePuzzle(difficulty){
+    var solution = null;
+    // bounded retries to obtain a solution
+    for (var g = 0; g < 8 && !solution; g++){
+      solution = generateSolution();
+    }
+    if (!solution){
+      // absolute fallback: a hard-coded valid solved grid
+      solution = FALLBACK_SOLUTION.slice();
+    }
+
+    var targetClues = (DIFF[difficulty] || DIFF.easy).clues;
+    var puzzle = solution.slice();
+
+    var order = shuffled((function(){ var a=[]; for(var i=0;i<81;i++) a.push(i); return a; })());
+    var removed = 0;
+    var toRemove = 81 - targetClues;
+    var attempts = 0;
+    var MAX_ATTEMPTS = 200; // hard cap on removal attempts
+
+    for (var oi = 0; oi < order.length && removed < toRemove && attempts < MAX_ATTEMPTS; oi++){
+      var idx = order[oi];
+      if (puzzle[idx] === 0) continue;
+      attempts++;
+      var saved = puzzle[idx];
+      puzzle[idx] = 0;
+      // keep unique: count solutions up to 2
+      var cnt = countSolutions(puzzle, 2);
+      if (cnt !== 1){
+        puzzle[idx] = saved; // revert, not removable
+      } else {
+        removed++;
+      }
+    }
+
+    return { puzzle: puzzle, solution: solution };
+  }
+
+  // A known-good completed grid used only if generation ever fails entirely.
+  var FALLBACK_SOLUTION = [
+    5,3,4, 6,7,8, 9,1,2,
+    6,7,2, 1,9,5, 3,4,8,
+    1,9,8, 3,4,2, 5,6,7,
+    8,5,9, 7,6,1, 4,2,3,
+    4,2,6, 8,5,3, 7,9,1,
+    7,1,3, 9,2,4, 8,5,6,
+    9,6,1, 5,3,7, 2,8,4,
+    2,8,7, 4,1,9, 6,3,5,
+    3,4,5, 2,8,6, 1,7,9
+  ];
+
+  // ---------- game state ----------
+  var state = {
+    puzzle: [],       // starting clues (0 = empty)
+    solution: [],     // full solution
+    values: [],       // current user values (includes givens)
+    notes: [],        // array of Set-like objects per cell
+    given: [],        // boolean per cell
+    selected: -1,
+    notesMode: false,
+    difficulty: "easy",
+    startTime: 0,
+    elapsed: 0,
+    running: false,
+    solved: false
+  };
+
+  var timerHandle = null;
+
+  // ---------- DOM refs ----------
+  var boardEl = document.getElementById("board");
+  var padEl = document.getElementById("pad");
+  var timerEl = document.getElementById("timer");
+  var diffLblEl = document.getElementById("diffLbl");
+  var modeLblEl = document.getElementById("modeLbl");
+  var diffSelEl = document.getElementById("diffSel");
+  var newBtn = document.getElementById("newBtn");
+  var notesBtn = document.getElementById("notesBtn");
+  var msgEl = document.getElementById("msg");
+  var bestEl = document.getElementById("best");
+
+  var cellEls = [];
+
+  function buildBoard(){
+    boardEl.innerHTML = "";
+    cellEls = [];
+    for (var idx = 0; idx < 81; idx++){
+      var r = (idx / 9) | 0, c = idx % 9;
+      var cell = document.createElement("div");
+      cell.className = "cell";
+      if (c === 2 || c === 5) cell.classList.add("br");
+      if (r === 2 || r === 5) cell.classList.add("bb");
+      cell.setAttribute("data-idx", String(idx));
+      (function(i){
+        cell.addEventListener("click", function(){ selectCell(i); });
+      })(idx);
+      var notes = document.createElement("div");
+      notes.className = "notes";
+      for (var n = 1; n <= 9; n++){
+        var sp = document.createElement("span");
+        sp.setAttribute("data-n", String(n));
+        notes.appendChild(sp);
+      }
+      cell.appendChild(notes);
+      boardEl.appendChild(cell);
+      cellEls.push(cell);
+    }
+  }
+
+  function buildPad(){
+    padEl.innerHTML = "";
+    for (var n = 1; n <= 9; n++){
+      var b = document.createElement("button");
+      b.className = "num";
+      b.textContent = String(n);
+      (function(v){ b.addEventListener("click", function(){ inputNumber(v); }); })(n);
+      padEl.appendChild(b);
+    }
+    var eraseB = document.createElement("button");
+    eraseB.className = "fn";
+    eraseB.textContent = "ERASE";
+    eraseB.addEventListener("click", function(){ inputNumber(0); });
+    padEl.appendChild(eraseB);
+  }
+
+  // ---------- timer ----------
+  function fmt(sec){
+    var m = Math.floor(sec / 60), s = sec % 60;
+    return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
+  }
+  function startTimer(){
+    stopTimer();
+    state.startTime = Date.now() - state.elapsed * 1000;
+    state.running = true;
+    timerHandle = setInterval(function(){
+      if (!state.running) return;
+      state.elapsed = Math.floor((Date.now() - state.startTime) / 1000);
+      timerEl.textContent = fmt(state.elapsed);
+    }, 250);
+  }
+  function stopTimer(){
+    state.running = false;
+    if (timerHandle){ clearInterval(timerHandle); timerHandle = null; }
+  }
+
+  // ---------- best times ----------
+  function loadBest(){
+    try {
+      var raw = localStorage.getItem(STORE_KEY);
+      if (!raw) return {};
+      var obj = JSON.parse(raw);
+      return (obj && typeof obj === "object") ? obj : {};
+    } catch (e) { return {}; }
+  }
+  function saveBest(obj){
+    try { localStorage.setItem(STORE_KEY, JSON.stringify(obj)); } catch (e) {}
+  }
+  function showBest(){
+    var b = loadBest();
+    var t = b[state.difficulty];
+    if (typeof t === "number"){
+      bestEl.textContent = "Best (" + state.difficulty + "): " + fmt(t);
+    } else {
+      bestEl.textContent = "Best (" + state.difficulty + "): --:--";
+    }
+  }
+  function recordBest(){
+    var b = loadBest();
+    var cur = b[state.difficulty];
+    if (typeof cur !== "number" || state.elapsed < cur){
+      b[state.difficulty] = state.elapsed;
+      saveBest(b);
+      return true;
+    }
+    return false;
+  }
+
+  // ---------- conflict detection ----------
+  function conflictSet(){
+    var conflicts = {};
+    // rows, cols, boxes
+    function check(indices){
+      var seen = {};
+      for (var i = 0; i < indices.length; i++){
+        var v = state.values[indices[i]];
+        if (v === 0) continue;
+        if (seen[v] === undefined) seen[v] = [indices[i]];
+        else seen[v].push(indices[i]);
+      }
+      for (var val in seen){
+        if (seen[val].length > 1){
+          for (var j = 0; j < seen[val].length; j++){
+            conflicts[seen[val][j]] = true;
+          }
+        }
+      }
+    }
+    var r, c, i;
+    for (r = 0; r < 9; r++){
+      var row = [];
+      for (c = 0; c < 9; c++) row.push(r*9 + c);
+      check(row);
+    }
+    for (c = 0; c < 9; c++){
+      var col = [];
+      for (r = 0; r < 9; r++) col.push(r*9 + c);
+      check(col);
+    }
+    for (var br = 0; br < 3; br++){
+      for (var bc = 0; bc < 3; bc++){
+        var box = [];
+        for (var rr = 0; rr < 3; rr++)
+          for (var cc = 0; cc < 3; cc++)
+            box.push((br*3+rr)*9 + (bc*3+cc));
+        check(box);
+      }
+    }
+    return conflicts;
+  }
+
+  // ---------- rendering ----------
+  function render(){
+    var conflicts = conflictSet();
+    var selVal = state.selected >= 0 ? state.values[state.selected] : 0;
+    var selR = state.selected >= 0 ? (state.selected / 9) | 0 : -1;
+    var selC = state.selected >= 0 ? state.selected % 9 : -1;
+
+    for (var idx = 0; idx < 81; idx++){
+      var cell = cellEls[idx];
+      var v = state.values[idx];
+      // reset classes but keep border classes + base
+      cell.classList.remove("given","conflict","selected","peer","sameval");
+
+      if (state.given[idx]) cell.classList.add("given");
+
+      var notesEl = cell.querySelector(".notes");
+      var textNode = cell.childNodes[cell.childNodes.length - 1];
+      // We store text in a dedicated text node: rebuild it
+      // Clear existing value text node (first non-notes child)
+      // Simpler: set cell's own text via a data span
+      // Remove any prior value text nodes:
+      for (var k = cell.childNodes.length - 1; k >= 0; k--){
+        var ch = cell.childNodes[k];
+        if (ch.nodeType === 3) cell.removeChild(ch);
+      }
+
+      if (v !== 0){
+        notesEl.style.display = "none";
+        cell.insertBefore(document.createTextNode(String(v)), notesEl);
+      } else {
+        notesEl.style.display = "";
+        var set = state.notes[idx];
+        var spans = notesEl.querySelectorAll("span");
+        for (var s = 0; s < spans.length; s++){
+          var n = s + 1;
+          spans[s].textContent = (set && set[n]) ? String(n) : "";
+        }
+      }
+
+      if (conflicts[idx]) cell.classList.add("conflict");
+
+      // highlighting relative to selection
+      if (state.selected >= 0 && idx !== state.selected){
+        var r = (idx / 9) | 0, c = idx % 9;
+        var sameBox = ((r - r%3) === (selR - selR%3)) && ((c - c%3) === (selC - selC%3));
+        if (r === selR || c === selC || sameBox) cell.classList.add("peer");
+        if (selVal !== 0 && v === selVal) cell.classList.add("sameval");
+      }
+    }
+
+    if (state.selected >= 0){
+      cellEls[state.selected].classList.add("selected");
+    }
+  }
+
+  function selectCell(idx){
+    state.selected = idx;
+    render();
+  }
+
+  // ---------- input ----------
+  function inputNumber(v){
+    if (state.selected < 0 || state.solved) return;
+    var idx = state.selected;
+    if (state.given[idx]) return;
+
+    if (v === 0){
+      // erase
+      state.values[idx] = 0;
+      state.notes[idx] = {};
+      render();
+      return;
+    }
+
+    if (state.notesMode){
+      if (state.values[idx] !== 0) state.values[idx] = 0;
+      if (!state.notes[idx]) state.notes[idx] = {};
+      state.notes[idx][v] = !state.notes[idx][v];
+      render();
+    } else {
+      state.values[idx] = (state.values[idx] === v) ? 0 : v;
+      state.notes[idx] = {};
+      render();
+      checkSolved();
+    }
+  }
+
+  function checkSolved(){
+    // must be fully filled
+    for (var i = 0; i < 81; i++){
+      if (state.values[i] === 0) return;
+    }
+    // must match solution (guarantees valid & correct)
+    for (var j = 0; j < 81; j++){
+      if (state.values[j] !== state.solution[j]) return;
+    }
+    // solved!
+    state.solved = true;
+    stopTimer();
+    var isBest = recordBest();
+    showBest();
+    msgEl.textContent = isBest ? "Solved! New best time!" : "Solved!";
+  }
+
+  // ---------- new game ----------
+  function newGame(){
+    msgEl.textContent = "Generating...";
+    // defer so the "Generating..." paints before the (bounded) work
+    setTimeout(function(){
+      try {
+        var built = makePuzzle(state.difficulty);
+        state.puzzle = built.puzzle.slice();
+        state.solution = built.solution.slice();
+        state.values = built.puzzle.slice();
+        state.given = [];
+        state.notes = [];
+        for (var i = 0; i < 81; i++){
+          state.given.push(built.puzzle[i] !== 0);
+          state.notes.push({});
+        }
+        state.selected = -1;
+        state.solved = false;
+        state.elapsed = 0;
+        timerEl.textContent = "00:00";
+        msgEl.textContent = "";
+        render();
+        startTimer();
+        showBest();
+      } catch (e) {
+        // never throw to the page; fail to a trivial fallback
+        msgEl.textContent = "";
+        var sol = FALLBACK_SOLUTION.slice();
+        var pz = sol.slice();
+        // blank out a handful of cells deterministically
+        var blanks = [0,10,20,30,40,50,60,70,80];
+        for (var b = 0; b < blanks.length; b++) pz[blanks[b]] = 0;
+        state.puzzle = pz.slice();
+        state.solution = sol.slice();
+        state.values = pz.slice();
+        state.given = [];
+        state.notes = [];
+        for (var k = 0; k < 81; k++){ state.given.push(pz[k] !== 0); state.notes.push({}); }
+        state.selected = -1;
+        state.solved = false;
+        state.elapsed = 0;
+        timerEl.textContent = "00:00";
+        render();
+        startTimer();
+        showBest();
+      }
+    }, 20);
+  }
+
+  // ---------- controls ----------
+  function setNotesMode(on){
+    state.notesMode = on;
+    notesBtn.textContent = "Notes: " + (on ? "On" : "Off");
+    notesBtn.classList.toggle("toggled", on);
+    modeLblEl.textContent = on ? "Notes" : "Fill";
+  }
+
+  function moveSelection(dr, dc){
+    var idx = state.selected;
+    if (idx < 0){ selectCell(0); return; }
+    var r = (idx / 9) | 0, c = idx % 9;
+    r = Math.max(0, Math.min(8, r + dr));
+    c = Math.max(0, Math.min(8, c + dc));
+    selectCell(r*9 + c);
+  }
+
+  function onKey(e){
+    var key = e.key;
+    if (key >= "1" && key <= "9"){
+      inputNumber(parseInt(key, 10));
+      e.preventDefault();
+      return;
+    }
+    if (key === "0" || key === "Backspace" || key === "Delete"){
+      inputNumber(0);
+      e.preventDefault();
+      return;
+    }
+    if (key === "ArrowUp"){ moveSelection(-1, 0); e.preventDefault(); return; }
+    if (key === "ArrowDown"){ moveSelection(1, 0); e.preventDefault(); return; }
+    if (key === "ArrowLeft"){ moveSelection(0, -1); e.preventDefault(); return; }
+    if (key === "ArrowRight"){ moveSelection(0, 1); e.preventDefault(); return; }
+    if (key === "n" || key === "N"){ setNotesMode(!state.notesMode); e.preventDefault(); return; }
+  }
+
+  // ---------- init ----------
+  function init(){
+    buildBoard();
+    buildPad();
+
+    diffSelEl.addEventListener("change", function(){
+      state.difficulty = diffSelEl.value;
+      diffLblEl.textContent = diffSelEl.options[diffSelEl.selectedIndex].text;
+      newGame();
+    });
+    newBtn.addEventListener("click", function(){ newGame(); });
+    notesBtn.addEventListener("click", function(){ setNotesMode(!state.notesMode); });
+    document.addEventListener("keydown", onKey);
+
+    // pause timer when tab hidden, resume on return
+    document.addEventListener("visibilitychange", function(){
+      if (document.hidden){
+        state.running = false;
+      } else if (!state.solved && state.values.length){
+        state.startTime = Date.now() - state.elapsed * 1000;
+        state.running = true;
+      }
+    });
+
+    state.difficulty = diffSelEl.value;
+    diffLblEl.textContent = diffSelEl.options[diffSelEl.selectedIndex].text;
+    setNotesMode(false);
+    showBest();
+    newGame();
+  }
+
+  if (document.readyState === "loading"){
+    document.addEventListener("DOMContentLoaded", init);
+  } else {
+    init();
+  }
+})();
+</script>
+</body>
+</html>
diff --git a/games/tron/index.html b/games/tron/index.html
new file mode 100644
index 0000000..ad192f3
--- /dev/null
+++ b/games/tron/index.html
@@ -0,0 +1,454 @@
+<!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 Tron</title>
+<style>
+  :root {
+    --bg:#0a0e14; --arena:#0b1018; --grid:#122130; --gridGlow:#0e3a4a;
+    --p1:#22d3ee; --p1glow:#0891b2; --p2:#f59e0b; --p2glow:#b45309;
+    --ink:#e5e7eb; --dim:#8aa0b2;
+  }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; }
+  body {
+    background:radial-gradient(1200px 700px at 50% -12%, #10202e, var(--bg));
+    color:var(--ink); font:14px/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;
+    -webkit-user-select:none; user-select:none;
+  }
+  h1 {
+    font-size:15px; letter-spacing:.42em; text-transform:uppercase; font-weight:700;
+    color:#e0f7ff; text-shadow:0 0 12px rgba(34,211,238,.55), 0 0 30px rgba(34,211,238,.25);
+    padding-left:.42em;
+  }
+  .hud {
+    display:flex; gap:16px; align-items:center; font-variant-numeric:tabular-nums;
+    font-size:12px; color:var(--dim); flex-wrap:wrap; justify-content:center;
+  }
+  .hud .pill { display:flex; align-items:center; gap:6px; padding:3px 10px; border-radius:999px;
+    border:1px solid #1c2a38; background:rgba(12,18,26,.6); }
+  .hud b { color:var(--ink); font-weight:700; }
+  .dot { width:9px; height:9px; border-radius:50%; }
+  .dot.p1 { background:var(--p1); box-shadow:0 0 8px var(--p1); }
+  .dot.p2 { background:var(--p2); box-shadow:0 0 8px var(--p2); }
+  .streak b { color:#fde68a; }
+  #wrap { position:relative; line-height:0; }
+  canvas {
+    background:var(--arena); border-radius:14px; display:block;
+    box-shadow:0 0 0 1px #16354a, 0 0 40px rgba(34,211,238,.12), 0 14px 50px rgba(0,0,0,.6);
+    max-width:96vw; max-height:70vh; touch-action:none;
+  }
+  .overlay {
+    position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
+    gap:10px; text-align:center; background:rgba(7,11,17,.78); border-radius:14px; backdrop-filter:blur(2px);
+    padding:16px;
+  }
+  .overlay.hide { display:none; }
+  .tag { font-size:11px; letter-spacing:.28em; text-transform:uppercase; color:var(--p1);
+    text-shadow:0 0 10px rgba(34,211,238,.5); }
+  .big { font-size:24px; font-weight:800; letter-spacing:.04em; }
+  .big.p1c { color:#67e8f9; text-shadow:0 0 16px rgba(34,211,238,.5); }
+  .big.p2c { color:#fbbf24; text-shadow:0 0 16px rgba(245,158,11,.5); }
+  .sub { font-size:12.5px; color:var(--dim); max-width:300px; }
+  button.play {
+    margin-top:4px; padding:11px 26px; border:0; border-radius:999px; cursor:pointer;
+    background:linear-gradient(180deg,#22d3ee,#0891b2); color:#031419; font-weight:800; font-size:14px;
+    letter-spacing:.06em; text-transform:uppercase; box-shadow:0 6px 20px rgba(34,211,238,.35);
+  }
+  .btns { position:absolute; top:8px; right:10px; display:flex; gap:6px; z-index:3; }
+  .btns button { width:32px; height:32px; border-radius:9px; border:1px solid #21384a;
+    background:rgba(15,24,34,.85); color:#bfe6f2; cursor:pointer; font-size:15px; }
+  .hint { font-size:11px; color:#5b6673; }
+  /* touch turn buttons */
+  .turns { display:none; gap:14px; margin-top:2px; }
+  .turns button {
+    width:74px; height:52px; border-radius:14px; border:1px solid #21384a;
+    background:rgba(15,24,34,.9); color:var(--p1); font-size:24px; cursor:pointer;
+    box-shadow:0 0 14px rgba(34,211,238,.15);
+  }
+  .turns button:active { background:rgba(34,211,238,.18); }
+  body.touch .turns { display:flex; }
+  body.touch .hint { display:none; }
+</style>
+</head>
+<body>
+  <h1>Abrams Tron</h1>
+  <div class="hud">
+    <span class="pill"><i class="dot p1"></i>YOU <b id="s1">0</b></span>
+    <span class="pill"><i class="dot p2"></i>CPU <b id="s2">0</b></span>
+    <span class="pill">Round <b id="round">1</b>/<b id="rounds">5</b></span>
+    <span class="pill streak">Streak <b id="streak">0</b> · Best <b id="best">0</b></span>
+  </div>
+  <div id="wrap">
+    <canvas id="c" width="480" height="480"></canvas>
+    <div class="btns">
+      <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 Tron</div>
+      <div class="sub" id="ovSub">Ride the cyan cycle. Steer to box in the CPU and avoid every trail. First to 3 rounds wins the match.</div>
+      <button class="play" id="playBtn">Play</button>
+      <div class="hint">Arrows / WASD to turn · P pause</div>
+    </div>
+  </div>
+  <div class="turns">
+    <button id="tLeft" aria-label="Turn left">↺</button>
+    <button id="tRight" aria-label="Turn right">↻</button>
+  </div>
+
+<script>
+(function () {
+  "use strict";
+  function boot() {
+    var cv = document.getElementById('c');
+    if (!cv || !cv.getContext) return;
+    var ctx = cv.getContext('2d');
+
+    // ---- config ----
+    var COLS = 60, ROWS = 60;      // logical grid
+    var CELL = cv.width / COLS;    // pixel size per cell
+    var WIN_ROUNDS = 3;            // first to N rounds wins match
+    var MAX_ROUNDS = 5;
+    var TICK_MS = 55;              // movement cadence
+
+    var DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
+    var OPP = { up:'down', down:'up', left:'right', right:'left' };
+
+    var best = 0;
+    try { best = parseInt(localStorage.getItem('abrams-tron-best') || '0', 10) || 0; } catch (e) {}
+
+    // ---- state ----
+    var grid;                 // occupancy: 0 empty, 1 player, 2 ai
+    var player, ai;
+    var score1 = 0, score2 = 0, roundNo = 1, streak = 0;
+    var state = 'ready';      // ready | play | paused | roundover | matchover
+    var raf = null, acc = 0, last = 0;
+    var flash = 0, flashOwner = 0;
+
+    var $ = function (id) { return document.getElementById(id); };
+    var ov = $('ov');
+
+    function newCycle(col, row, dir, id) {
+      return { x:col, y:row, dir:dir, next:dir, id:id, alive:true, trail:[{x:col,y:row}] };
+    }
+
+    function resetRound() {
+      grid = new Array(COLS * ROWS);
+      for (var i = 0; i < grid.length; i++) grid[i] = 0;
+      // player left, heading right; ai right, heading left
+      var py = Math.floor(ROWS / 2);
+      player = newCycle(6, py, 'right', 1);
+      ai = newCycle(COLS - 7, py, 'left', 2);
+      grid[idx(player.x, player.y)] = 1;
+      grid[idx(ai.x, ai.y)] = 2;
+      acc = 0; flash = 0;
+    }
+
+    function idx(x, y) { return y * COLS + x; }
+    function inb(x, y) { return x >= 0 && y >= 0 && x < COLS && y < ROWS; }
+    function free(x, y) { return inb(x, y) && grid[idx(x, y)] === 0; }
+
+    // queue a turn (ignore reversals)
+    function turn(dir) {
+      if (state !== 'play') return;
+      if (dir === OPP[player.dir]) return;
+      player.next = dir;
+    }
+    // relative left/right turn for touch
+    function turnRel(rel) {
+      var order = ['up','right','down','left'];
+      var i = order.indexOf(player.dir);
+      var ni = (i + (rel > 0 ? 1 : 3)) % 4;
+      turn(order[ni]);
+    }
+
+    // ---- AI ----
+    // count how many free cells reachable within `limit` steps (flood) from a start cell,
+    // treating a hypothetical occupied set. Cheap space estimate to avoid trapping self.
+    function floodSpace(sx, sy, limit) {
+      if (!free(sx, sy)) return 0;
+      var seen = {};
+      var q = [[sx, sy]];
+      seen[idx(sx, sy)] = 1;
+      var count = 0, head = 0;
+      while (head < q.length && count < limit) {
+        var c = q[head++]; count++;
+        var nx, ny, dd = [[1,0],[-1,0],[0,1],[0,-1]];
+        for (var k = 0; k < 4; k++) {
+          nx = c[0] + dd[k][0]; ny = c[1] + dd[k][1];
+          var id2 = idx(nx, ny);
+          if (free(nx, ny) && !seen[id2]) { seen[id2] = 1; q.push([nx, ny]); }
+        }
+      }
+      return count;
+    }
+
+    function aiChoose() {
+      var options = ['up','down','left','right'];
+      var cur = ai.dir;
+      var best2 = null, bestScore = -1e9;
+      for (var i = 0; i < options.length; i++) {
+        var d = options[i];
+        if (d === OPP[cur]) continue;              // no reversing
+        var nx = ai.x + DIRS[d].x, ny = ai.y + DIRS[d].y;
+        if (!free(nx, ny)) continue;               // immediate death
+        // lookahead: space available after this move
+        grid[idx(nx, ny)] = 2;                     // tentatively occupy
+        var space = floodSpace(nx, ny, 260);
+        grid[idx(nx, ny)] = 0;                     // undo
+
+        var sc = space;
+        // prefer keeping straight a bit (smoother), small bonus
+        if (d === cur) sc += 6;
+        // aggression: bias toward cutting the player off — move that reduces
+        // player's open space is favored, but not at cost of our own survival.
+        var pdist = Math.abs(nx - player.x) + Math.abs(ny - player.y);
+        if (space > 40) sc += (30 - Math.min(pdist, 30)) * 0.6; // close in when safe
+        // tiny randomness to avoid deterministic loops
+        sc += Math.random() * 2;
+
+        if (sc > bestScore) { bestScore = sc; best2 = d; }
+      }
+      if (best2) ai.next = best2;
+      // if nothing safe, keep current dir (will crash — fair)
+    }
+
+    // ---- step ----
+    function step() {
+      aiChoose();
+      player.dir = player.next;
+      ai.dir = ai.next;
+
+      var p1x = player.x + DIRS[player.dir].x, p1y = player.y + DIRS[player.dir].y;
+      var a1x = ai.x + DIRS[ai.dir].x,       a1y = ai.y + DIRS[ai.dir].y;
+
+      var pDead = !free(p1x, p1y);
+      var aDead = !free(a1x, a1y);
+      // head-on into same cell = both dead
+      if (p1x === a1x && p1y === a1y) { pDead = true; aDead = true; }
+
+      if (!pDead) { grid[idx(p1x, p1y)] = 1; player.x = p1x; player.y = p1y; player.trail.push({x:p1x,y:p1y}); }
+      if (!aDead) { grid[idx(a1x, a1y)] = 2; ai.x = a1x; ai.y = a1y; ai.trail.push({x:a1x,y:a1y}); }
+
+      if (pDead || aDead) {
+        player.alive = !pDead; ai.alive = !aDead;
+        endRound(pDead, aDead);
+      }
+    }
+
+    function endRound(pDead, aDead) {
+      var winner; // 1 player, 2 ai, 0 draw
+      if (pDead && aDead) winner = 0;
+      else if (pDead) winner = 2;
+      else winner = 1;
+
+      if (winner === 1) { score1++; streak++; if (streak > best) { best = streak; saveBest(); } }
+      else if (winner === 2) { score2++; streak = 0; }
+      // draw: no score, no streak change
+
+      flash = 1; flashOwner = winner;
+      render();
+
+      var matchDone = score1 >= WIN_ROUNDS || score2 >= WIN_ROUNDS || roundNo >= MAX_ROUNDS;
+      if (matchDone) {
+        state = 'matchover';
+        var youWin = score1 > score2;
+        showOverlay(
+          youWin ? 'Match Won' : (score1 === score2 ? 'Match Draw' : 'Match Lost'),
+          youWin ? 'DERES.' : (score1 === score2 ? 'DEADLOCK.' : 'GAME OVER'),
+          'Final ' + score1 + ' – ' + score2 + '.  Best win streak ' + best + '.',
+          'Play Again', youWin ? 'p1c' : 'p2c'
+        );
+      } else {
+        state = 'roundover';
+        var msg = winner === 1 ? 'You boxed in the CPU.' : winner === 2 ? 'The CPU cut you off.' : 'Both cycles crashed.';
+        showOverlay(
+          'Round ' + roundNo + ' — ' + (winner === 1 ? 'You' : winner === 2 ? 'CPU' : 'Draw'),
+          winner === 1 ? 'ROUND WON' : winner === 2 ? 'ROUND LOST' : 'DRAW',
+          msg + '  ' + score1 + ' – ' + score2,
+          'Next Round', winner === 1 ? 'p1c' : winner === 2 ? 'p2c' : ''
+        );
+      }
+      updateHud();
+    }
+
+    function saveBest() { try { localStorage.setItem('abrams-tron-best', String(best)); } catch (e) {} }
+
+    function nextRound() {
+      roundNo++;
+      resetRound();
+      state = 'play';
+      hideOverlay();
+      updateHud();
+    }
+
+    function restartMatch() {
+      score1 = 0; score2 = 0; roundNo = 1; streak = 0;
+      resetRound();
+      state = 'play';
+      hideOverlay();
+      updateHud();
+    }
+
+    // ---- rendering ----
+    function drawGrid() {
+      ctx.fillStyle = '#0b1018';
+      ctx.fillRect(0, 0, cv.width, cv.height);
+      ctx.strokeStyle = 'rgba(20,52,72,.55)';
+      ctx.lineWidth = 1;
+      ctx.beginPath();
+      for (var x = 0; x <= COLS; x += 3) {
+        var px = Math.round(x * CELL) + 0.5;
+        ctx.moveTo(px, 0); ctx.lineTo(px, cv.height);
+      }
+      for (var y = 0; y <= ROWS; y += 3) {
+        var py = Math.round(y * CELL) + 0.5;
+        ctx.moveTo(0, py); ctx.lineTo(cv.width, py);
+      }
+      ctx.stroke();
+      // border glow
+      ctx.strokeStyle = 'rgba(34,211,238,.45)';
+      ctx.lineWidth = 2;
+      ctx.strokeRect(1, 1, cv.width - 2, cv.height - 2);
+    }
+
+    function drawTrail(cyc, color, glow) {
+      var t = cyc.trail;
+      ctx.save();
+      ctx.shadowColor = glow; ctx.shadowBlur = 8;
+      ctx.fillStyle = color;
+      for (var i = 0; i < t.length; i++) {
+        ctx.fillRect(t[i].x * CELL, t[i].y * CELL, CELL, CELL);
+      }
+      ctx.restore();
+      // head brighter
+      var h = t[t.length - 1];
+      ctx.save();
+      ctx.shadowColor = glow; ctx.shadowBlur = 16;
+      ctx.fillStyle = '#ffffff';
+      ctx.globalAlpha = 0.9;
+      ctx.fillRect(h.x * CELL, h.y * CELL, CELL, CELL);
+      ctx.restore();
+    }
+
+    function render() {
+      drawGrid();
+      drawTrail(ai, '#f59e0b', '#b45309');
+      drawTrail(player, '#22d3ee', '#0891b2');
+      if (flash > 0) {
+        var c = flashOwner === 1 ? 'rgba(34,211,238,' : flashOwner === 2 ? 'rgba(245,158,11,' : 'rgba(200,210,220,';
+        ctx.fillStyle = c + (0.18 * flash) + ')';
+        ctx.fillRect(0, 0, cv.width, cv.height);
+      }
+    }
+
+    // ---- loop ----
+    function frame(ts) {
+      raf = requestAnimationFrame(frame);
+      if (!last) last = ts;
+      var dt = ts - last; last = ts;
+      if (flash > 0) flash = Math.max(0, flash - dt / 400);
+
+      if (state === 'play') {
+        acc += dt;
+        while (acc >= TICK_MS && state === 'play') {
+          acc -= TICK_MS;
+          step();
+        }
+        render();
+      } else if (flash > 0) {
+        render();
+      }
+    }
+
+    // ---- overlay / hud ----
+    function showOverlay(tag, title, sub, btn, cls) {
+      $('ovTag').textContent = tag;
+      var t = $('ovTitle');
+      t.textContent = title;
+      t.className = 'big' + (cls ? ' ' + cls : '');
+      $('ovSub').textContent = sub;
+      $('playBtn').textContent = btn;
+      ov.classList.remove('hide');
+    }
+    function hideOverlay() { ov.classList.add('hide'); }
+
+    function updateHud() {
+      $('s1').textContent = score1;
+      $('s2').textContent = score2;
+      $('round').textContent = roundNo;
+      $('rounds').textContent = MAX_ROUNDS;
+      $('streak').textContent = streak;
+      $('best').textContent = best;
+    }
+
+    // ---- input ----
+    function onPlayBtn() {
+      if (state === 'ready' || state === 'matchover') restartMatch();
+      else if (state === 'roundover') nextRound();
+    }
+
+    var keyMap = {
+      ArrowUp:'up', ArrowDown:'down', ArrowLeft:'left', ArrowRight:'right',
+      w:'up', s:'down', a:'left', d:'right', W:'up', S:'down', A:'left', D:'right'
+    };
+    window.addEventListener('keydown', function (e) {
+      if (e.key === 'p' || e.key === 'P') { togglePause(); return; }
+      if (e.key === 'Enter' || e.key === ' ') {
+        if (state === 'ready' || state === 'roundover' || state === 'matchover') { e.preventDefault(); onPlayBtn(); }
+        return;
+      }
+      var d = keyMap[e.key];
+      if (d) {
+        // prevent page scroll for arrows/space
+        if (e.key.indexOf('Arrow') === 0) e.preventDefault();
+        turn(d);
+      }
+    }, { passive:false });
+
+    function togglePause() {
+      if (state === 'play') { state = 'paused'; showOverlay('Paused', 'PAUSED', 'Press P or Play to resume.', 'Resume', ''); }
+      else if (state === 'paused') { state = 'play'; hideOverlay(); }
+    }
+
+    $('playBtn').addEventListener('click', function () {
+      if (state === 'paused') { state = 'play'; hideOverlay(); return; }
+      onPlayBtn();
+    });
+    $('pauseBtn').addEventListener('click', togglePause);
+
+    // touch turn buttons
+    $('tLeft').addEventListener('click', function () { turnRel(-1); });
+    $('tRight').addEventListener('click', function () { turnRel(1); });
+
+    // tap-left-half / tap-right-half on canvas for touch
+    cv.addEventListener('pointerdown', function (e) {
+      if (e.pointerType === 'mouse') return; // mouse uses buttons/keys
+      var r = cv.getBoundingClientRect();
+      var rel = (e.clientX - r.left) < r.width / 2 ? -1 : 1;
+      turnRel(rel);
+      e.preventDefault();
+    }, { passive:false });
+
+    // detect touch to reveal on-screen controls
+    function markTouch() { document.body.classList.add('touch'); window.removeEventListener('touchstart', markTouch); }
+    window.addEventListener('touchstart', markTouch, { passive:true });
+    if (('ontouchstart' in window) || (navigator.maxTouchPoints > 0)) document.body.classList.add('touch');
+
+    // ---- init ----
+    resetRound();
+    updateHud();
+    render();
+    raf = requestAnimationFrame(frame);
+  }
+
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+  else boot();
+})();
+</script>
+</body>
+</html>
diff --git a/games/wordle/index.html b/games/wordle/index.html
new file mode 100644
index 0000000..7044fe6
--- /dev/null
+++ b/games/wordle/index.html
@@ -0,0 +1,477 @@
+<!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 Wordle</title>
+<style>
+  :root {
+    --bg:#0a0e14; --ink:#e5e7eb; --muted:#8b97a7;
+    --tile:#141b25; --tile-border:#2a3441;
+    --green:#2fbf71; --yellow:#e0b23a; --gray:#3a4655;
+    --key:#28323f; --key-ink:#dbe3ec;
+    --neon:#7dd3fc;
+  }
+  * { box-sizing:border-box; margin:0; padding:0; -webkit-tap-highlight-color:transparent; }
+  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;
+    min-height:100%; gap:10px; padding:12px 10px; overscroll-behavior:none;
+    touch-action:manipulation; user-select:none; -webkit-user-select:none;
+  }
+  h1 {
+    font-size:15px; letter-spacing:.34em; text-transform:uppercase;
+    color:var(--neon); font-weight:700; text-align:center;
+    text-shadow:0 0 12px rgba(125,211,252,.35);
+  }
+  .hud { display:flex; gap:8px; align-items:stretch; flex-wrap:wrap; justify-content:center; }
+  .stat { background:var(--tile); border:1px solid var(--tile-border); border-radius:10px; padding:5px 10px; text-align:center; min-width:58px; }
+  .stat .k { font-size:9px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
+  .stat .v { font-size:17px; font-weight:700; font-variant-numeric:tabular-nums; }
+  .btn {
+    padding:7px 14px; border-radius:10px; border:1px solid var(--tile-border);
+    background:var(--tile); color:#cbd5e1; cursor:pointer; font-size:13px; font-weight:600;
+  }
+  .btn:active { transform:scale(.96); }
+
+  #board { display:grid; grid-template-rows:repeat(6,1fr); gap:6px; margin-top:2px; }
+  .brow { display:grid; grid-template-columns:repeat(5,1fr); gap:6px; }
+  .tile {
+    width:52px; height:52px; border:2px solid var(--tile-border);
+    border-radius:8px; display:flex; align-items:center; justify-content:center;
+    font-weight:800; font-size:26px; text-transform:uppercase; color:var(--ink);
+    background:transparent; transition:transform .08s ease, border-color .12s ease;
+  }
+  .tile.filled { border-color:#4a586b; }
+  .tile.pop { animation:pop .12s ease; }
+  @keyframes pop { 0%{transform:scale(1)} 50%{transform:scale(1.12)} 100%{transform:scale(1)} }
+  .tile.flip { animation:flip .5s ease forwards; }
+  @keyframes flip {
+    0% { transform:rotateX(0); }
+    45% { transform:rotateX(90deg); }
+    55% { transform:rotateX(90deg); }
+    100% { transform:rotateX(0); }
+  }
+  .tile.g { background:var(--green); border-color:var(--green); color:#062015; box-shadow:0 0 14px rgba(47,191,113,.4); }
+  .tile.y { background:var(--yellow); border-color:var(--yellow); color:#241a03; box-shadow:0 0 14px rgba(224,178,58,.35); }
+  .tile.x { background:var(--gray); border-color:var(--gray); color:#cdd6e0; }
+  .brow.shake { animation:shake .4s ease; }
+  @keyframes shake {
+    0%,100%{transform:translateX(0)} 20%{transform:translateX(-7px)}
+    40%{transform:translateX(7px)} 60%{transform:translateX(-5px)} 80%{transform:translateX(5px)}
+  }
+
+  #msg { height:20px; font-size:13px; font-weight:600; color:var(--neon); letter-spacing:.02em; text-align:center; }
+
+  #kb { display:flex; flex-direction:column; gap:6px; width:100%; max-width:500px; margin-top:2px; }
+  .krow { display:flex; gap:5px; justify-content:center; }
+  .key {
+    flex:1 1 auto; min-width:0; height:50px; border-radius:7px; border:0;
+    background:var(--key); color:var(--key-ink); font-weight:700; font-size:14px;
+    text-transform:uppercase; cursor:pointer; display:flex; align-items:center; justify-content:center;
+    transition:transform .06s ease, background .12s ease;
+  }
+  .key.wide { flex:1.5 1 auto; font-size:11px; letter-spacing:.04em; }
+  .key:active { transform:scale(.93); }
+  .key.g { background:var(--green); color:#062015; }
+  .key.y { background:var(--yellow); color:#241a03; }
+  .key.x { background:var(--gray); color:#9aa6b3; }
+
+  .overlay {
+    position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
+    gap:10px; text-align:center; background:rgba(6,10,16,.86); backdrop-filter:blur(3px); z-index:10; padding:20px;
+  }
+  .overlay.hide { display:none; }
+  .tag { font-size:11px; color:var(--neon); letter-spacing:.24em; text-transform:uppercase; }
+  .big { font-size:26px; font-weight:800; }
+  .word { font-size:20px; font-weight:800; letter-spacing:.18em; text-transform:uppercase; color:var(--green); }
+  .sub { font-size:13px; color:var(--muted); max-width:280px; }
+  button.play { margin-top:6px; padding:11px 26px; border:0; border-radius:999px; cursor:pointer;
+    background:linear-gradient(180deg,#7dd3fc,#38bdf8); color:#03202e; font-weight:800; font-size:14px; }
+  .ostats { display:flex; gap:14px; margin-top:6px; }
+  .ostats .k { font-size:9px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); }
+  .ostats .v { font-size:20px; font-weight:800; }
+
+  @media (max-width:380px){
+    .tile{ width:46px; height:46px; font-size:22px; }
+    .key{ height:46px; }
+    h1{ letter-spacing:.24em; }
+  }
+</style>
+</head>
+<body>
+  <h1>Abrams Wordle</h1>
+  <div class="hud">
+    <div class="stat"><div class="k">Played</div><div class="v" id="sPlayed">0</div></div>
+    <div class="stat"><div class="k">Win %</div><div class="v" id="sWin">0</div></div>
+    <div class="stat"><div class="k">Streak</div><div class="v" id="sStreak">0</div></div>
+    <div class="stat"><div class="k">Max</div><div class="v" id="sMax">0</div></div>
+    <button class="btn" id="newBtn">New</button>
+  </div>
+  <div id="board"></div>
+  <div id="msg"></div>
+  <div id="kb"></div>
+
+  <div class="overlay hide" id="ov">
+    <div class="tag" id="ovTag">Result</div>
+    <div class="big" id="ovTitle"></div>
+    <div class="word" id="ovWord"></div>
+    <div class="sub" id="ovSub"></div>
+    <div class="ostats">
+      <div><div class="k">Played</div><div class="v" id="oPlayed">0</div></div>
+      <div><div class="k">Win %</div><div class="v" id="oWin">0</div></div>
+      <div><div class="k">Streak</div><div class="v" id="oStreak">0</div></div>
+      <div><div class="k">Max</div><div class="v" id="oMax">0</div></div>
+    </div>
+    <button class="play" id="playBtn">New game</button>
+  </div>
+
+<script>
+(function () {
+  "use strict";
+
+  // ---- Answer words (common 5-letter solutions) ----
+  var ANSWERS = ["about","above","abuse","actor","acute","admit","adopt","adult","after","again","agent","agree","ahead","alarm","album","alert","alike","alive","allow","alone","along","alter","among","anger","angle","angry","apart","apple","apply","arena","argue","arise","array","aside","asset","audio","audit","avoid","award","aware","badly","baker","bases","basic","beach","began","begin","being","below","bench","billy","birth","black","blame","blank","blast","blind","block","blood","board","boost","booth","bound","brain","brand","brass","brave","bread","break","breed","brief","bring","broad","broke","brown","brush","build","built","buyer","cable","calif","carry","catch","cause","chain","chair","chaos","charm","chart","chase","cheap","check","chest","chief","child","china","chose","civil","claim","class","clean","clear","click","climb","clock","close","cloud","coach","coast","could","count","court","cover","craft","crash","crazy","cream","crime","cross","crowd","crown","crude","curve","cycle","daily","dance","dated","dealt","death","debut","delay","depth","doing","doubt","dozen","draft","drama","drank","drawn","dream","dress","drill","drink","drive","drove","dying","eager","early","earth","eight","elite","empty","enemy","enjoy","enter","entry","equal","error","event","every","exact","exist","extra","faith","false","fault","favor","fence","fever","fewer","field","fifth","fifty","fight","final","first","fixed","flame","flash","fleet","floor","fluid","focus","force","forth","forty","forum","found","frame","frank","fraud","fresh","front","fruit","fully","funny","giant","given","glass","globe","going","grace","grade","grain","grand","grant","grass","grave","great","green","gross","group","grown","guard","guess","guest","guide","happy","harsh","heart","heavy","hence","henry","horse","hotel","house","human","ideal","image","index","inner","input","issue","japan","jimmy","joint","jones","judge","known","label","large","laser","later","laugh","layer","learn","lease","least","leave","legal","level","lewis","light","limit","links","lives","local","logic","loose","lower","lucky","lunch","lying","magic","major","maker","march","maria","match","maybe","mayor","meant","medal","media","metal","meter","might","minor","minus","mixed","model","money","month","moral","motor","mount","mouse","mouth","moved","movie","music","needs","never","newly","night","noise","north","noted","novel","nurse","occur","ocean","offer","often","order","other","ought","paint","panel","paper","party","peace","penny","peter","phase","phone","photo","piano","piece","pilot","pitch","place","plain","plane","plant","plate","point","pound","power","press","price","pride","prime","print","prior","prize","proof","proud","prove","queen","quick","quiet","quite","radio","raise","range","rapid","ratio","reach","ready","realm","rebel","refer","relax","reply","right","rigid","rival","river","robin","roger","roman","rough","round","route","royal","rural","scale","scene","scope","score","sense","serve","seven","shall","shape","share","sharp","sheet","shelf","shell","shift","shine","shirt","shock","shoot","short","shown","sight","silly","since","sixth","sixty","sized","skill","sleep","slide","small","smart","smile","smith","smoke","snake","snowy","solid","solve","sorry","sound","south","space","spare","speak","speed","spend","spent","split","spoke","sport","staff","stage","stake","stand","start","state","steam","steel","steep","steer","stern","stick","still","stock","stone","stood","store","storm","story","strip","stuck","study","stuff","style","sugar","suite","super","sweet","table","taken","taste","taxes","teach","teeth","terry","texas","thank","theft","their","theme","there","these","thick","thing","think","third","those","three","threw","throw","thumb","tiger","tight","timer","title","today","topic","total","touch","tough","tower","track","trade","trail","train","treat","trend","trial","tribe","trick","tried","tries","truck","truly","trunk","trust","truth","twice","under","undue","union","unity","until","upper","upset","urban","usage","usual","valid","value","video","virus","visit","vital","voice","waste","watch","water","wheel","where","which","while","white","whole","whose","woman","women","world","worry","worse","worst","worth","would","wound","write","wrong","wrote","yield","young","youth","zebra","zesty","chalk","chart","chord","churn","cider","cliff","cloak","clone","clown","coral","couch","cough","cramp","crank","crate","crept","crisp","crook","daisy","dandy","dodge","donor","drift","drown","dwarf","ebony","elbow","ember","evoke","exile","fable","fairy","fancy","feast","ferry","fetch","fiber","fjord","flask","flint","float","flock","flora","flour","flung","folly","forge","frost","gauge","gecko","ghost","gland","gleam","glide","glint","gloom","glory","gloss","grasp","grill","grind","groan","grove","gruff","habit","haste","hazel","hedge","hinge","hoard","hobby","honey","humid","hunch","hutch","icing","igloo","ivory","jelly","jewel","jolly","joust","kiosk","kneel","knock","lapse","larva","latch","lemon","liver","llama","lobby","lodge","loyal","lyric","manor","maple","marsh","medic","melon","mercy","merge","merit","mirth","mocha","moist","mound","mourn","mucky","mummy","nasty","navel","nerve","niche","noble","nudge","oaken","olive","onset","opera","orbit","organ","otter","ozone","pansy","parka","pasta","patch","pause","peach","pearl","pecan","perch","petal","piety","pixel","plaid","pluck","plumb","plume","plush","poise","polar","porch","posse","pouch","prank","prawn","preen","prism","probe","prong","prune","pupil","quail","quake","qualm","quart","quash","quell","query","quest","quill","quilt","quota","quote","rabid","ranch","raven","razor","realm","rerun","resin","rhino","ridge","rinse","risky","roast","robot","rogue","rowdy","ruddy","rugby","salsa","salty","sandy","sassy","sauna","savor","scald","scalp","scarf","scoff","scold","scoop","scorn","scout","scowl","scrap","scrub","seize","serum","sever","sewer","shack","shade","shady","shaft","shale","shame","shard","shawl","shear","sheen","sheep","sheer","shied","shiny","shoal","shone","shore","shorn","showy","shrub","shrug","shuck","siege","sieve","skate","skimp","skunk","slack","slain","slang","slant","slash","slate","sleek","sleet","slept","slick","slime","sling","slink","slope","slosh","sloth","slump","slung","slunk","slush","smash","smear","smelt","smirk","snack","snail","snare","sneak","sneer","snort","snout","snowy","soggy","solar","sonic","sooty","sound","spade","spank","spawn","spear","speck","spell","spice","spicy","spied","spike","spiky","spine","spiny","spire","spite","splat","spoof","spool","spoon","spore","sprig","spurn","spurt","squad","squat","squid","stack","stage","staid","stain","stair","stalk","stall","stamp","stank","stare","stash","steak","stead","steed","stink","stint","stirs","stoke","stole","stomp","stony","stool","stoop","stork","stout","stove","stown","strap","straw","stray","strum","strut","stump","stung","stunk","stunt","suave","surge","swamp","swash","swath","swear","sweat","swell","swept","swift","swine","swing","swirl","swoon","swoop","sword","swore","sworn","swung","syrup","tabby","tacit","taffy","tally","talon","tango","tardy","tarot","taunt","tawny","tease","tepid","thick","thigh","thong","thorn","those","throb","throw","thump","tibia","tidal","tiger","tilde","timid","tipsy","toast","today","toddy","token","tonal","tonic","topaz","torch","torso","total","touch","towel","tower","toxic","toxin","trace","tract","trail","tramp","trash","trawl","tread","treat","tress","triad","trill","tripe","troll","troop","trout","trove","truce","tulip","tumor","tunic","turbo","tutor","tweak","tweed","twine","twirl","twist","udder","ulcer","ultra","umbra","uncle","undid","unfit","unify","unlit","unmet","untie","upend","upper","usher","utter","vague","valor","vapor","vault","vegan","venom","verse","verve","vigor","villa","viper","vivid","vixen","vodka","vogue","vouch","vowel","wafer","wager","wagon","waist","waltz","waste","weary","weave","wedge","weedy","weigh","weird","whale","wharf","wheat","which","whiff","whine","whirl","whisk","white","whoop","whorl","widow","wield","wince","windy","wiser","witch","wooly","woozy","world","wrath","wreak","wreck","wrest","wring","wrist","wrote","yacht","yearn","yeast","yield","yodel","yokel","young","yummy","zebra","zesty","zonal"];
+
+  // ---- Additional allowed guesses (common valid words, not usually answers) ----
+  var EXTRA = ["aback","abbey","abbot","abhor","abide","abled","abode","aboil","abort","about","abyss","ached","aches","acids","acres","acrid","addle","adept","adieu","adobe","affix","afire","afoot","afoul","agape","agile","aging","agora","ahold","aisle","alarm","alder","alert","algae","alias","alibi","alien","align","alkyl","allay","alley","allot","aloft","aloof","aloud","altar","amass","amaze","amber","amble","amend","amiss","amity","ample","amply","amuse","angel","anime","anion","ankle","annex","annoy","antic","antsy","aorta","aphid","aptly","arbor","ardor","areas","arena","argon","aroma","arose","arrow","arson","ascot","ashen","ashes","aspen","aspic","assay","aster","astir","atlas","atoll","atone","attic","augur","aunts","avail","avast","avert","avian","await","awake","aware","awash","awful","awoke","axial","axiom","axion","azure","bacon","badge","badly","bagel","baggy","baked","baker","baler","balls","balmy","banal","banjo","barge","baron","basil","basin","baste","bathe","baton","batty","bawdy","bayou","beach","beady","beard","beast","befit","began","begat","beget","begin","begun","being","belch","belie","belle","belly","below","bench","beret","berry","berth","beset","betel","bevel","bezel","bicep","biddy","bigot","bilge","billy","binge","biome","birch","birth","bison","bitty","black","blade","blame","bland","blank","blare","blast","blaze","bleak","bleat","bleed","bleep","blend","bless","blimp","blind","blink","bliss","blitz","bloat","block","bloke","blond","blood","bloom","blown","bluff","blunt","blurb","blurt","blush","board","boast","bobby","boded","bogey","boggy","bongo","bonus","booby","boost","booth","booty","booze","borax","borne","bosom","bossy","botch","bough","boule","bound","bowel","boxer","brace","braid","brain","brake","brand","brash","brass","brave","bravo","brawl","brawn","bread","break","breed","briar","bribe","brick","bride","brine","bring","brink","briny","brisk","broad","broil","broke","brood","brook","broom","broth","brown","brunt","brush","brute","buddy","budge","buggy","bugle","build","built","bulge","bulky","bully","bunch","bunny","burly","burnt","burst","bushy","butte","buxom","buyer","bylaw","cabal","cabby","cabin","cable","cacao","cache","cacti","caddy","cadet","cagey","cairn","camel","cameo","canal","candy","canny","canoe","canon","caper","caput","carat","cargo","carol","carry","carve","caste","catch","cater","catty","caulk","cause","cavil","cease","cedar","cello","chafe","chaff","chain","chair","chalk","champ","chant","chaos","chard","charm","chart","chase","chasm","cheap","cheat","check","cheek","cheer","chess","chest","chick","chide","chief","child","chili","chill","chime","china","chirp","chock","choir","choke","chord","chore","chose","chuck","chump","chunk","churn","chute","cider","cigar","cinch","circa","civet","civic","civil","clack","claim","clamp","clang","clank","clash","clasp","class","clean","clear","cleat","cleft","clerk","click","cliff","climb","cling","clink","cloak","clock","clone","close","cloth","cloud","clout","clove","clown","cluck","clued","clump","clung","coach","coast","cobra","cocoa","colon","color","comet","comfy","comic","comma","conch","condo","conic","copse","coral","corny","couch","cough","could","count","coupe","court","coven","cover","covet","covey","cower","coyly","crack","craft","cramp","crane","crank","crash","crass","crate","crave","crawl","craze","crazy","creak","cream","credo","creed","creek","creep","creme","crepe","crept","cress","crest","crick","cried","crier","crime","crimp","crisp","croak","crock","crony","crook","croon","cross","croup","crowd","crown","crude","cruel","crumb","crump","crush","crust","crypt","cubic","cumin","curio","curly","curry","curse","curve","curvy","cutie","cyber","cycle","cynic","daddy","daily","dairy","daisy","dally","dance","dandy","datum","daunt","dealt","death","debit","debug","debut","decal","decay","decor","decoy","decry","defer","deign","deity","delta","delve","demon","demur","denim","dense","depot","depth","derby","deter","detox","devil","diary","dicey","digit","dilly","dimly","diner","dingo","dingy","diode","dirge","dirty","disco","ditch","ditto","ditty","diver","dizzy","dodge","dodgy","dogma","doing","dolly","donor","donut","dopey","doubt","dough","dowdy","dowel","downy","dowry","dozen","draft","drain","drake","drama","drank","drape","drawl","drawn","dread","dream","dress","dried","drier","drift","drill","drink","drive","droit","droll","drone","drool","droop","dross","drove","drown","druid","drunk","dryer","dryly","duchy","dully","dummy","dumpy","dunce","dusky","dusty","dutch","duvet","dwarf","dwell","dwelt","dying","eager","eagle","early","earth","easel","eaten","eater","ebony","eclat","edict","edify","eerie","egret","eight","eject","eking","elate","elbow","elder","elect","elegy","elfin","elide","elite","elope","elude","email","embed","ember","emcee","emote","empty","enact","endow","enema","enemy","enjoy","ennui","ensue","enter","entry","envoy","epoch","epoxy","equal","equip","erase","erect","erode","error","erupt","essay","ester","ether","ethic","ethos","etude","evade","event","every","evict","evoke","exact","exalt","excel","exert","exile","exist","expel","extol","extra","exult","fable","facet","faint","fairy","faith","false","fancy","farce","fatal","fatty","fault","fauna","favor","feast","fecal","feign","fella","felon","femur","fence","feral","ferry","fetal","fetch","fetid","fever","fewer","fiber","fibre","ficus","field","fiend","fiery","fifth","fifty","fight","filet","filly","filmy","filth","final","finch","finer","first","fishy","fixer","fizzy","fjord","flack","flail","flair","flake","flaky","flame","flank","flare","flash","flask","fleck","fleet","flesh","flick","flier","fling","flint","flirt","float","flock","flood","floor","flora","floss","flour","flout","flown","fluff","fluid","fluke","flume","flung","flush","flute","foamy","focal","focus","foggy","foist","folio","folly","foray","force","forge","forgo","forte","forth","forty","forum","found","foyer","frail","frame","frank","fraud","freak","freed","freer","fresh","friar","fried","frill","frisk","fritz","frock","frond","front","frost","froth","frown","froze","fruit","fudge","fugue","fully","fumes","fungi","funky","funny","furor","furry","fussy","fuzzy","gaffe","gaily","gamer","gamma","gamut","gassy","gaudy","gauge","gaunt","gauze","gavel","gawky","gayer","gayly","gazer","gecko","geeky","geese","genie","genre","ghost","ghoul","giant","giddy","gipsy","girly","girth","given","giver","gizmo","glade","gland","glare","glass","glaze","gleam","glean","glide","glint","gloat","globe","gloom","glory","gloss","glove","glyph","gnash","gnome","godly","going","golem","golly","gonad","goner","goody","gooey","goofy","goose","gorge","gouge","gourd","grace","grade","graft","grail","grain","grand","grant","grape","graph","grasp","grass","grate","grave","gravy","graze","great","greed","green","greet","grief","grill","grime","grimy","grind","gripe","groan","groin","groom","grope","gross","group","grout","grove","growl","grown","gruel","gruff","grunt","guard","guava","guess","guest","guide","guild","guile","guilt","guise","gulch","gully","gumbo","gummy","guppy","gusto","gusty","gypsy","habit","hairy","halve","handy","happy","hardy","harem","harpy","harsh","haste","hasty","hatch","hater","haunt","haute","haven","havoc","hazel","heady","heard","heart","heath","heave","heavy","hedge","hefty","heist","helix","hello","hence","heron","hilly","hinge","hippo","hippy","hitch","hoard","hobby","hoist","holly","homer","honey","honor","horde","horny","horse","hotel","hotly","hound","house","hovel","hover","howdy","human","humid","humor","humph","humus","hunch","hunky","hurry","husky","hussy","hutch","hydro","hyena","hymen","hyper","icily","icing","ideal","idiom","idiot","idler","idyll","igloo","iliac","image","imbue","impel","imply","inane","inbox","incur","index","inept","inert","infer","ingot","inlay","inlet","inner","input","inter","intro","ionic","irate","irony","islet","issue","itchy","ivory","jaunt","jazzy","jelly","jerky","jetty","jewel","jiffy","joint","joist","joker","jolly","joust","judge","juice","juicy","jumbo","jumpy","junta","junto","juror","kappa","karma","kayak","kebab","khaki","kinky","kiosk","kitty","knack","knave","knead","kneed","kneel","knelt","knife","knock","knoll","known","koala","krill","label","labor","laden","ladle","lager","lance","lanky","lapel","lapse","larva","laser","lasso","latch","later","lathe","laugh","layer","leach","leafy","leaky","leant","leapt","learn","lease","leash","least","leave","ledge","leech","leery","lefty","legal","leggy","lemon","lemur","leper","level","lever","libel","liege","light","liken","lilac","limbo","limit","linen","liner","lingo","lipid","lithe","liver","livid","llama","loamy","loath","lobby","local","locus","lodge","lofty","logic","login","loopy","loose","lorry","loser","louse","lousy","lover","lower","lowly","loyal","lucid","lucky","lumen","lumpy","lunar","lunch","lunge","lupus","lurch","lurid","lusty","lying","lymph","lynch","lyric","macaw","macho","macro","madam","madly","mafia","magic","magma","maize","major","maker","mambo","mamma","mammy","manga","mange","mango","mangy","mania","manic","manly","manor","maple","march","marge","marry","marsh","mason","masse","match","matey","mauve","maxim","maybe","mayor","mealy","meant","meaty","mecca","medal","media","medic","melee","melon","mercy","merge","merit","merry","metal","meter","metro","micro","midge","midst","might","milky","mimic","mince","miner","minim","minor","minty","minus","mirth","miser","missy","mocha","modal","model","modem","mogul","moist","molar","moldy","money","month","moody","moose","moral","moron","morph","mossy","motel","motif","motor","motto","moult","mound","mount","mourn","mouse","mousy","mouth","moved","mover","movie","mower","mucky","mucus","muddy","mulch","mummy","munch","mural","murky","mushy","music","musky","musty","myrrh","nadir","naive","nanny","nasal","nasty","natal","naval","navel","needy","neigh","nerdy","nerve","never","newer","newly","nicer","niche","niece","night","ninja","ninny","ninth","noble","nobly","noise","noisy","nomad","noose","north","nosey","notch","noted","novel","nudge","nurse","nutty","nylon","nymph","oaken","obese","occur","ocean","octal","octet","odder","oddly","offal","offer","often","olden","older","olive","ombre","omega","onion","onset","opera","opine","opium","optic","orbit","order","organ","other","otter","ought","ounce","outdo","outer","outgo","ovary","ovate","overt","ovine","ovoid","owing","owner","oxide","ozone","paddy","pagan","paint","paler","palsy","panel","panic","pansy","paper","parer","parka","parry","parse","party","pasta","paste","pasty","patch","patio","patsy","patty","pause","payee","payer","peace","peach","pearl","pecan","pedal","penal","pence","penne","penny","perch","peril","perky","pesky","pesto","petal","petty","phase","phone","phony","photo","piano","picky","piece","piety","piggy","pilot","pinch","piney","pinky","pinto","piper","pique","pitch","pithy","pivot","pixel","pixie","pizza","place","plaid","plain","plait","plane","plank","plant","plate","plaza","plead","pleat","plied","plier","pluck","plumb","plume","plump","plunk","plush","poesy","point","poise","poker","polar","polka","polyp","pooch","poppy","porch","poser","posit","posse","pouch","pound","pouty","power","prank","prawn","preen","press","preen","price","prick","pride","pried","prime","primo","primp","print","prior","prism","privy","prize","probe","prone","prong","proof","prose","proud","prove","prowl","proxy","prude","prune","psalm","pubic","pudgy","puffy","pulpy","pulse","punch","pupal","pupil","puppy","puree","purer","purge","purse","pushy","putty","pygmy","quack","quail","quake","qualm","quark","quart","quash","quasi","queen","queer","quell","query","quest","queue","quick","quiet","quill","quilt","quirk","quite","quota","quote","quoth","rabbi","rabid","racer","radar","radii","radio","rainy","raise","rajah","rally","ralph","ramen","ranch","randy","range","rapid","rarer","raspy","ratio","ratty","raven","rayon","razor","reach","react","ready","realm","rearm","rebar","rebel","rebus","rebut","recap","recur","recut","reedy","refer","refit","regal","rehab","reign","relax","relay","relic","remit","renal","renew","repay","repel","reply","rerun","reset","resin","retch","retro","retry","reuse","revel","revue","rhino","rhyme","rider","ridge","rifle","right","rigid","rigor","rinse","ripen","riper","risen","riser","risky","rival","river","rivet","roach","roast","robin","robot","rocky","rodeo","roger","rogue","roomy","roost","rotor","rouge","rough","round","rouse","route","rowdy","royal","ruddy","ruder","rugby","ruler","rumba","rumor","rupee","rural","rusty","sadly","safer","saint","salad","sally","salon","salsa","salty","salve","salvo","sandy","saner","sappy","sassy","satin","satyr","sauce","saucy","sauna","saute","savor","savoy","savvy","scald","scale","scalp","scaly","scamp","scant","scare","scarf","scary","scene","scent","scion","scoff","scold","scone","scoop","scope","score","scorn","scour","scout","scowl","scram","scrap","scree","screw","scrub","scrum","scuba","sedan","seedy","segue","seize","semen","sense","sepia","serif","serum","serve","setup","seven","sever","sewer","shack","shade","shady","shaft","shake","shaky","shale","shall","shalt","shame","shank","shape","shard","share","shark","sharp","shave","shawl","shear","sheen","sheep","sheer","sheet","sheik","shelf","shell","shied","shift","shine","shiny","shire","shirk","shirt","shoal","shock","shone","shook","shoot","shore","shorn","short","shout","shove","shown","showy","shrew","shrub","shrug","shuck","shunt","shush","shyly","siege","sieve","sight","sigma","silky","silly","since","sinew","singe","siren","sissy","sixth","sixty","skate","skier","skiff","skill","skimp","skirt","skulk","skull","skunk","slack","slain","slang","slant","slash","slate","slave","sleek","sleep","sleet","slept","slice","slick","slide","slime","slimy","sling","slink","sloop","slope","slosh","sloth","slump","slung","slunk","slurp","slush","slyly","smack","small","smart","smash","smear","smell","smelt","smile","smirk","smite","smith","smock","smoke","smoky","smote","snack","snail","snake","snaky","snare","snarl","sneak","sneer","snide","sniff","snipe","snoop","snore","snort","snout","snowy","snuck","snuff","soapy","sober","soggy","solar","solid","solve","sonar","sonic","sooth","sooty","sorry","sound","south","sowed","space","spade","spank","spare","spark","spasm","spawn","speak","spear","speck","speed","spell","spelt","spend","spent","sperm","spice","spicy","spied","spiel","spike","spiky","spill","spilt","spine","spiny","spire","spite","splat","split","spoil","spoke","spoof","spook","spool","spoon","spore","sport","spout","spray","spree","sprig","spume","spunk","spurn","spurt","squad","squat","squib","squid","stack","staff","stage","staid","stain","stair","stake","stale","stalk","stall","stamp","stand","stank","stare","stark","start","stash","state","stave","stead","steak","steal","steam","steed","steel","steep","steer","stein","stern","stick","stiff","still","stilt","sting","stink","stint","stock","stoic","stoke","stole","stomp","stone","stony","stood","stool","stoop","store","stork","storm","story","stout","stove","strap","straw","stray","strip","strut","stuck","study","stuff","stump","stung","stunk","stunt","style","suave","sugar","suing","suite","sulky","sully","sumac","sunny","super","surer","surge","surly","sushi","swami","swamp","swash","swath","swear","sweat","sweep","sweet","swell","swept","swift","swill","swine","swing","swirl","swish","swoon","swoop","sword","swore","sworn","swung","synod","syrup","tabby","table","taboo","tacit","tacky","taffy","taint","taken","taker","tally","talon","tamer","tango","tangy","taper","tapir","tardy","tarot","taste","tasty","tatty","taunt","tawny","teach","teary","tease","teddy","teeth","tempo","tenet","tenor","tense","tenth","tepee","tepid","terra","terse","testy","thank","theft","their","theme","there","these","theta","thick","thief","thigh","thing","think","third","thong","thorn","those","three","threw","throb","throw","thrum","thumb","thump","thyme","tiara","tibia","tidal","tiger","tight","tilde","timer","timid","tipsy","titan","tithe","title","toast","today","toddy","token","tonal","tonga","tonic","tooth","topaz","topic","torch","torso","torus","total","totem","touch","tough","towel","tower","toxic","toxin","trace","track","tract","trade","trail","train","trait","tramp","trash","trawl","tread","treat","trend","triad","trial","tribe","trice","trick","tried","tripe","trite","troll","troop","trope","trout","trove","truce","truck","truer","truly","trump","trunk","truss","trust","truth","tryst","tubal","tuber","tulip","tulle","tumor","tunic","turbo","tutor","twang","tweak","tweed","tweet","twice","twine","twirl","twist","twixt","tying","udder","ulcer","ultra","umbra","uncle","uncut","under","undid","undue","unfed","unfit","unify","union","unite","unity","unlit","unmet","unset","untie","until","unwed","unzip","upend","upper","upset","urban","urine","usage","usher","using","usual","usurp","utile","utter","vague","valet","valid","valor","value","valve","vapid","vapor","vault","vaunt","vegan","venom","venue","verge","verse","verso","verve","vicar","video","vigil","vigor","villa","vinyl","viola","viper","viral","virus","visit","visor","vista","vital","vivid","vixen","vocal","vodka","vogue","voice","voila","vomit","voter","vouch","vowel","vying","wacky","wafer","wager","wagon","waist","waive","waltz","warty","waste","watch","water","waver","waxen","weary","weave","wedge","weedy","weigh","weird","welch","welsh","whack","whale","wharf","wheat","wheel","whelp","where","which","whiff","while","whine","whiny","whirl","whisk","white","whole","whoop","whorl","whose","widen","wider","widow","width","wield","wight","willy","wimpy","wince","winch","windy","wiser","wispy","witch","witty","woken","woman","women","woody","wooer","wooly","woozy","wordy","world","worry","worse","worst","worth","would","wound","woven","wrack","wrath","wreak","wreck","wrest","wring","wrist","write","wrong","wrote","wrung","wryly","yacht","yearn","yeast","yield","yodel","yokel","young","youth","zebra","zesty","zonal"];
+
+  // Build validity set (deduped, 5-letter, lowercase)
+  var VALID = {};
+  var i, w;
+  var ans = [];
+  var seen = {};
+  for (i = 0; i < ANSWERS.length; i++) {
+    w = String(ANSWERS[i]).toLowerCase();
+    if (w.length === 5 && /^[a-z]{5}$/.test(w)) {
+      VALID[w] = 1;
+      if (!seen[w]) { seen[w] = 1; ans.push(w); }
+    }
+  }
+  for (i = 0; i < EXTRA.length; i++) {
+    w = String(EXTRA[i]).toLowerCase();
+    if (w.length === 5 && /^[a-z]{5}$/.test(w)) VALID[w] = 1;
+  }
+  var ANSWER_POOL = ans;
+
+  // ---- State ----
+  var ROWS = 6, COLS = 5;
+  var STORE_KEY = "abrams-wordle-stats";
+  var target = "", row = 0, col = 0, cur = "", over = false, busy = false;
+  var boardEl, msgEl, kbEl;
+  var tiles = [];     // tiles[r][c]
+  var keyEls = {};    // letter -> key element
+  var keyState = {};  // letter -> best status 0 none, 1 x, 2 y, 3 g
+
+  function loadStats() {
+    var s = { played:0, wins:0, streak:0, max:0 };
+    try {
+      var raw = localStorage.getItem(STORE_KEY);
+      if (raw) {
+        var p = JSON.parse(raw);
+        if (p && typeof p === "object") {
+          s.played = p.played|0; s.wins = p.wins|0;
+          s.streak = p.streak|0; s.max = p.max|0;
+        }
+      }
+    } catch (e) {}
+    return s;
+  }
+  function saveStats(s) {
+    try { localStorage.setItem(STORE_KEY, JSON.stringify(s)); } catch (e) {}
+  }
+  var stats = loadStats();
+
+  function renderStats() {
+    var pct = stats.played ? Math.round(stats.wins / stats.played * 100) : 0;
+    setText("sPlayed", stats.played);
+    setText("sWin", pct);
+    setText("sStreak", stats.streak);
+    setText("sMax", stats.max);
+    setText("oPlayed", stats.played);
+    setText("oWin", pct);
+    setText("oStreak", stats.streak);
+    setText("oMax", stats.max);
+  }
+  function setText(id, v) {
+    var el = document.getElementById(id);
+    if (el) el.textContent = String(v);
+  }
+
+  function buildBoard() {
+    boardEl.innerHTML = "";
+    tiles = [];
+    for (var r = 0; r < ROWS; r++) {
+      var rowEl = document.createElement("div");
+      rowEl.className = "brow";
+      var rowTiles = [];
+      for (var c = 0; c < COLS; c++) {
+        var t = document.createElement("div");
+        t.className = "tile";
+        rowEl.appendChild(t);
+        rowTiles.push(t);
+      }
+      boardEl.appendChild(rowEl);
+      tiles.push(rowTiles);
+    }
+  }
+
+  var KB_ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm"];
+  function buildKeyboard() {
+    kbEl.innerHTML = "";
+    keyEls = {};
+    for (var r = 0; r < KB_ROWS.length; r++) {
+      var rowEl = document.createElement("div");
+      rowEl.className = "krow";
+      if (r === 2) {
+        rowEl.appendChild(makeKey("ENTER", "enter", true));
+      }
+      var letters = KB_ROWS[r];
+      for (var c = 0; c < letters.length; c++) {
+        var ch = letters.charAt(c);
+        var k = makeKey(ch, ch, false);
+        keyEls[ch] = k;
+        rowEl.appendChild(k);
+      }
+      if (r === 2) {
+        rowEl.appendChild(makeKey("DEL", "backspace", true));
+      }
+      kbEl.appendChild(rowEl);
+    }
+  }
+  function makeKey(label, action, wide) {
+    var b = document.createElement("button");
+    b.className = "key" + (wide ? " wide" : "");
+    b.textContent = label;
+    b.setAttribute("data-action", action);
+    b.setAttribute("type", "button");
+    return b;
+  }
+
+  function newGame() {
+    target = ANSWER_POOL[Math.floor(Math.random() * ANSWER_POOL.length)];
+    row = 0; col = 0; cur = ""; over = false; busy = false;
+    keyState = {};
+    buildBoard();
+    for (var k in keyEls) {
+      if (keyEls.hasOwnProperty(k)) keyEls[k].className = "key";
+    }
+    setMsg("");
+    hideOverlay();
+  }
+
+  function setMsg(m) { if (msgEl) msgEl.textContent = m || ""; }
+
+  function flashMsg(m) {
+    setMsg(m);
+    if (flashMsg._t) clearTimeout(flashMsg._t);
+    flashMsg._t = setTimeout(function () { setMsg(""); }, 1400);
+  }
+
+  function addLetter(ch) {
+    if (over || busy) return;
+    if (cur.length >= COLS) return;
+    var t = tiles[row][cur.length];
+    t.textContent = ch;
+    t.classList.add("filled", "pop");
+    (function (tt) { setTimeout(function () { tt.classList.remove("pop"); }, 130); })(t);
+    cur += ch;
+  }
+
+  function delLetter() {
+    if (over || busy) return;
+    if (cur.length === 0) return;
+    cur = cur.slice(0, -1);
+    var t = tiles[row][cur.length];
+    t.textContent = "";
+    t.classList.remove("filled");
+  }
+
+  function shakeRow() {
+    var rowEl = boardEl.children[row];
+    if (!rowEl) return;
+    rowEl.classList.add("shake");
+    setTimeout(function () { rowEl.classList.remove("shake"); }, 420);
+  }
+
+  function scoreGuess(guess, answer) {
+    // returns array of "g"/"y"/"x"
+    var res = ["x","x","x","x","x"];
+    var counts = {};
+    var a, gch;
+    for (var j = 0; j < COLS; j++) {
+      a = answer.charAt(j);
+      counts[a] = (counts[a] || 0) + 1;
+    }
+    for (j = 0; j < COLS; j++) {
+      if (guess.charAt(j) === answer.charAt(j)) {
+        res[j] = "g";
+        counts[guess.charAt(j)]--;
+      }
+    }
+    for (j = 0; j < COLS; j++) {
+      if (res[j] === "g") continue;
+      gch = guess.charAt(j);
+      if (counts[gch] > 0) {
+        res[j] = "y";
+        counts[gch]--;
+      }
+    }
+    return res;
+  }
+
+  var RANK = { "x":1, "y":2, "g":3 };
+  function updateKey(ch, st) {
+    var prev = keyState[ch] || 0;
+    if (RANK[st] > prev) {
+      keyState[ch] = RANK[st];
+      var el = keyEls[ch];
+      if (el) {
+        el.classList.remove("g", "y", "x");
+        el.classList.add(st);
+      }
+    }
+  }
+
+  function submit() {
+    if (over || busy) return;
+    if (cur.length < COLS) { flashMsg("Not enough letters"); shakeRow(); return; }
+    var guess = cur;
+    if (!VALID[guess]) { flashMsg("Not in word list"); shakeRow(); return; }
+
+    busy = true;
+    var res = scoreGuess(guess, target);
+    var rowTiles = tiles[row];
+    var thisRow = row;
+
+    for (var c = 0; c < COLS; c++) {
+      (function (c) {
+        setTimeout(function () {
+          var t = rowTiles[c];
+          t.classList.add("flip");
+          setTimeout(function () {
+            t.classList.add(res[c]);
+            updateKey(guess.charAt(c), res[c]);
+          }, 250);
+          setTimeout(function () { t.classList.remove("flip"); }, 500);
+        }, c * 180);
+      })(c);
+    }
+
+    var total = (COLS - 1) * 180 + 520;
+    setTimeout(function () {
+      finishRow(guess, res, thisRow);
+    }, total);
+  }
+
+  function finishRow(guess, res, guessRow) {
+    var won = true;
+    for (var c = 0; c < COLS; c++) { if (res[c] !== "g") { won = false; break; } }
+
+    if (won) {
+      over = true; busy = false;
+      recordResult(true);
+      showOverlay(true, guessRow + 1);
+      return;
+    }
+
+    row++;
+    cur = "";
+    if (row >= ROWS) {
+      over = true; busy = false;
+      recordResult(false);
+      showOverlay(false, 0);
+    } else {
+      busy = false;
+    }
+  }
+
+  function recordResult(won) {
+    stats.played++;
+    if (won) {
+      stats.wins++;
+      stats.streak++;
+      if (stats.streak > stats.max) stats.max = stats.streak;
+    } else {
+      stats.streak = 0;
+    }
+    saveStats(stats);
+    renderStats();
+  }
+
+  var WIN_MSGS = ["Genius","Magnificent","Impressive","Splendid","Great","Phew"];
+  function showOverlay(won, guessNum) {
+    var ov = document.getElementById("ov");
+    setText("ovTag", won ? "You win" : "Out of guesses");
+    setText("ovTitle", won ? (WIN_MSGS[Math.min(guessNum - 1, WIN_MSGS.length - 1)] || "Nice") : "So close");
+    setText("ovWord", target);
+    setText("ovSub", won
+      ? ("Solved in " + guessNum + (guessNum === 1 ? " guess" : " guesses"))
+      : "Better luck next round");
+    renderStats();
+    ov.classList.remove("hide");
+  }
+  function hideOverlay() {
+    var ov = document.getElementById("ov");
+    ov.classList.add("hide");
+  }
+
+  // ---- Input ----
+  function handleAction(action) {
+    if (action === "enter") submit();
+    else if (action === "backspace") delLetter();
+    else if (action.length === 1 && action >= "a" && action <= "z") addLetter(action);
+  }
+
+  function onKeydown(e) {
+    if (e.ctrlKey || e.metaKey || e.altKey) return;
+    var key = e.key;
+    if (key === "Enter") { e.preventDefault(); handleAction("enter"); }
+    else if (key === "Backspace") { e.preventDefault(); handleAction("backspace"); }
+    else if (key && key.length === 1) {
+      var lc = key.toLowerCase();
+      if (lc >= "a" && lc <= "z") { e.preventDefault(); handleAction(lc); }
+    }
+  }
+
+  function onKbClick(e) {
+    var el = e.target;
+    while (el && el !== kbEl && !el.getAttribute) el = el.parentNode;
+    if (!el || el === kbEl) return;
+    var action = el.getAttribute && el.getAttribute("data-action");
+    if (action) {
+      e.preventDefault();
+      handleAction(action);
+    }
+  }
+
+  function init() {
+    boardEl = document.getElementById("board");
+    msgEl = document.getElementById("msg");
+    kbEl = document.getElementById("kb");
+
+    buildBoard();
+    buildKeyboard();
+    renderStats();
+    newGame();
+
+    document.addEventListener("keydown", onKeydown);
+    kbEl.addEventListener("click", onKbClick);
+
+    var newBtn = document.getElementById("newBtn");
+    if (newBtn) newBtn.addEventListener("click", function () { newGame(); });
+    var playBtn = document.getElementById("playBtn");
+    if (playBtn) playBtn.addEventListener("click", function () { newGame(); });
+  }
+
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", init);
+  } else {
+    init();
+  }
+})();
+</script>
+</body>
+</html>

← f4e6a87 Fix stale e2e: decouple cube checks from list order (deep-li  ·  back to Games Agentabrams  ·  games.aa: upgrade Emoji Memory to the richer free-roster bui 36c7d11 →