Agent 503: a GoldenEye-style spy shooter (rebuild guide) 04OCT2026

Three secret-base missions with objectives, guards and the classic guns

Posted by on 4th Oct 2026

My own first-person spy shooter in the spirit of GoldenEye 007 (N64, 1997). Play it at https://games.503mail.com/agent/ or pick it from the menu at https://games.503mail.com. This post is the complete rebuild guide; every block has a Copy button.

What's in it

There are three missions with objectives, like the original. Facility: find the keycard, plant an explosive on the gas tank, and get through the locked door to the exit. Bunker: wreck 3 computer consoles, steal the files, and escape. Archives: collect both documents, download at the main console, find the keycard, and reach the exit. Each mission unlocks the next. You get a silenced P9 pistol, a K5 SMG that guards drop, and a shotgun found in the Archives. Guards patrol, notice you when they see you or hear an unsilenced gun, chase you through doors (they find their way round the map), shoot back, and fall when hit. Hits can be headshots. Body armor soaks up damage before health. The watch screen shows objectives, time, kills, headshots and accuracy, and pauses the game. There are three difficulties, Agent, Secret Agent and Top Agent, which make the guards more accurate and harder hitting.

Controls

Keyboard and mouse: W A S D move, mouse or ◀ ▶ turn (click the picture first to use the mouse), click or Space fire, E use and open doors, 1 2 3 or Q change gun, R reload, Tab or P the watch screen, M sound. Touch: the left stick walks and strafes, dragging on the right turns, and there are FIRE, USE and GUN buttons and II for the watch. Aim help (on by default on touch) pulls shots onto a guard near the crosshair.

Rebuild, step by step

Agent 503 is one folder on the games site. Build the site first with Games site + Nibbles. That post creates CT 125 (nibbles), the menu, 05-push-site.sh and make-icons.ps1. The menu already has the Agent 503 tile.

1. Make the game's folder

Proxmox host shell (Proxmox web UI → pve → Shell):

mkdir -p /root/nibbles/agent

2. Create the game file

Paste this into the Proxmox host shell. It writes /root/nibbles/agent/index.html, the whole game in one file (HTML, CSS and JavaScript, no libraries):

cat > /root/nibbles/agent/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#000000">
<title>Agent 503</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<meta property="og:title" content="Agent 503">
<meta property="og:image" content="https://games.503mail.com/agent/icon-512.png">
<style>
  html,body{margin:0;height:100%;background:#000;overflow:hidden;font-family:"Lucida Console","Courier New",monospace}
  body{display:flex;align-items:center;justify-content:center}
  canvas{display:block;image-rendering:pixelated;image-rendering:crisp-edges;touch-action:none}
  #setup{position:fixed;inset:0;z-index:5;background:radial-gradient(rgba(40,35,10,.92),rgba(0,0,0,.96));color:#fff;display:flex;flex-direction:column;
    align-items:center;justify-content:center;gap:1.8vmin;font-size:clamp(12px,2.4vmin,20px);text-align:center;padding:16px;box-sizing:border-box;overflow:auto}
  #setup.hidden{display:none}
  #setup h1{margin:0;color:#e8c040;letter-spacing:.35em;font-size:1.9em;text-shadow:3px 3px #000}
  #setup .sub{color:#aaa;letter-spacing:.3em;margin-top:-1vmin}
  #setup .row{display:flex;gap:.7em;align-items:center;flex-wrap:wrap;justify-content:center}
  #setup button{font:inherit;cursor:pointer;border:0}
  #setup .pick button{background:#222;color:#fff;border:3px solid #555;padding:.35em .8em}
  #setup .pick button.on{border-color:#e8c040;color:#e8c040}
  #setup .pick button:disabled{opacity:.35;cursor:default}
  #setup #go{background:#a22;color:#fff;font-size:1.2em;padding:.4em 2em}
  #setup #brief{max-width:34em;color:#ddd;background:rgba(0,0,0,.4);border-left:4px solid #e8c040;padding:.6em 1em;text-align:left;line-height:1.5}
  #setup .help{color:#999;font-size:.82em;line-height:1.6}
  #setup kbd{color:#e8c040}
  #setup a{color:#5ff}
  .t{display:none;position:fixed;z-index:3;touch-action:none;user-select:none;-webkit-user-select:none}
  body.touch .t{display:block}
  #stick{left:3vmin;bottom:3vmin;width:32vmin;height:32vmin;border-radius:50%;background:rgba(255,255,255,.08);border:3px solid rgba(232,192,64,.6)}
  #knob{position:absolute;left:50%;top:50%;width:12vmin;height:12vmin;margin:-6vmin 0 0 -6vmin;border-radius:50%;background:rgba(232,192,64,.8);border:2px solid #000}
  #look{right:0;top:0;width:55vw;height:100vh;z-index:2}
  #btns{right:2vmin;bottom:3vmin;display:none;flex-direction:column;align-items:flex-end;gap:2vmin}
  body.touch #btns{display:flex}
  #btns .r{display:flex;gap:2vmin}
  #btns button{font:bold 3vmin "Lucida Console","Courier New",monospace;background:rgba(40,40,40,.8);color:#e8c040;border:3px solid #e8c040;border-radius:2vmin;padding:2vmin 2.6vmin;touch-action:none}
  #btns .fire{background:rgba(160,20,20,.85);color:#fff;border-color:#f66;font-size:4vmin;padding:4vmin 5vmin;border-radius:50%}
  #btns button.held{filter:brightness(1.6)}
  #pausebtn{right:2vmin;top:2vmin;font:bold 3vmin "Lucida Console",monospace;background:rgba(40,40,40,.8);color:#fff;border:3px solid #888;border-radius:2vmin;padding:1.5vmin 2.5vmin}
</style>
</head>
<body>
<canvas id="c" width="320" height="200"></canvas>
<div id="look" class="t" aria-label="Drag to look around"></div>
<div id="stick" class="t" aria-label="Move"><div id="knob"></div></div>
<div id="btns" class="t">
  <div class="r"><button data-a="gun">GUN</button><button data-a="use">USE</button></div>
  <button class="fire" data-a="fire">FIRE</button>
</div>
<button id="pausebtn" class="t" data-a="pause">II</button>
<div id="setup">
  <h1>AGENT 503</h1>
  <div class="sub">SECRET FACILITY MISSIONS</div>
  <div class="row pick" id="missions"></div>
  <div id="brief"></div>
  <div class="row pick" id="diffs"></div>
  <div class="row"><label><input type="checkbox" id="snd" checked> Sound</label>
    <label><input type="checkbox" id="aim"> Aim help</label></div>
  <button id="go">START MISSION</button>
  <div class="help">
    <kbd>W A S D</kbd> move &nbsp;·&nbsp; <kbd>mouse</kbd> or <kbd>&larr; &rarr;</kbd> turn &nbsp;·&nbsp; <kbd>click</kbd> / <kbd>Space</kbd> fire &nbsp;·&nbsp; <kbd>E</kbd> use / open doors<br>
    <kbd>1 2 3</kbd> or <kbd>Q</kbd> change gun &nbsp;·&nbsp; <kbd>R</kbd> reload &nbsp;·&nbsp; <kbd>Tab</kbd> or <kbd>P</kbd> watch (objectives + pause) &nbsp;·&nbsp; <kbd>M</kbd> sound<br>
    Click the picture to use the mouse for looking. Guards drop ammo. Body armor soaks up hits.
  </div>
  <a href="/">Other games</a>
</div>
<script>
(() => {
  const W = 320, H = 200;
  const cv = document.getElementById('c'), g = cv.getContext('2d');
  const img = g.createImageData(W, H), buf = new Uint32Array(img.data.buffer);
  const setupEl = document.getElementById('setup');
  const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
  if (isTouch) document.body.classList.add('touch');
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));

  let cfg = { sound:true, aim:isTouch, mission:0, diff:0, unlocked:1, best:{} };
  try { Object.assign(cfg, JSON.parse(localStorage.getItem('agent503') || '{}')); } catch (e) {}
  if (!cfg.best || typeof cfg.best !== 'object') cfg.best = {};
  const saveCfg = () => { try { localStorage.setItem('agent503', JSON.stringify(cfg)); } catch (e) {} };

  // ---- sound ----
  let actx = null, noiseBuf = null;
  function audio() {
    if (!cfg.sound) return null;
    if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } }
    if (actx.state === 'suspended') actx.resume();
    return actx;
  }
  function sweep(f0, f1, dur, type = 'square', vol = 0.04) {
    const a = audio(); if (!a) return;
    const t = a.currentTime, o = a.createOscillator(), v = a.createGain();
    o.type = type; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + dur);
    v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0005, t + dur);
    o.connect(v).connect(a.destination); o.start(t); o.stop(t + dur);
  }
  function noise(dur, vol, cutoff, type = 'lowpass', q = 1) {
    const a = audio(); if (!a) return;
    if (!noiseBuf) { noiseBuf = a.createBuffer(1, a.sampleRate, a.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; }
    const t = a.currentTime, s = a.createBufferSource(), f = a.createBiquadFilter(), v = a.createGain();
    s.buffer = noiseBuf; f.type = type; f.Q.value = q; f.frequency.setValueAtTime(cutoff, t); f.frequency.exponentialRampToValueAtTime(Math.max(40, cutoff / 8), t + dur);
    v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.001, t + dur);
    s.connect(f).connect(v).connect(a.destination); s.start(t, Math.random() * .5); s.stop(t + dur);
  }
  const SFX = {
    pistol: () => { noise(.12, .18, 2500, 'bandpass', 2); sweep(300, 120, .06, 'square', .02); },
    smg: () => noise(.09, .22, 3000),
    shotgun: () => { noise(.45, .45, 1800); sweep(90, 40, .25, 'square', .06); },
    guard: () => noise(.15, .14, 2200),
    empty: () => sweep(1500, 1400, .03, 'square', .03),
    reload: () => { sweep(500, 400, .05, 'square', .03); setTimeout(() => sweep(700, 900, .05, 'square', .03), 250); },
    hurt: () => sweep(260, 90, .25, 'sawtooth', .07),
    die: () => sweep(400, 70, .5, 'sawtooth', .05),
    alert: () => sweep(700, 1100, .12, 'square', .03),
    door: () => noise(.6, .06, 500),
    pick: () => [880, 1320].forEach((f, i) => setTimeout(() => sweep(f, f, .08, 'square', .04), i * 70)),
    obj: () => [523, 659, 784, 1047].forEach((f, i) => setTimeout(() => sweep(f, f, .12, 'square', .045), i * 110)),
    hit: () => sweep(1800, 1200, .04, 'square', .02),
  };

  // ---- textures: 64x64, made with canvas drawing then read back as pixels ----
  const TS = 64;
  function tex(fn, size = TS) {
    const c = document.createElement('canvas'); c.width = c.height = size; const x = c.getContext('2d'); fn(x, size);
    return new Uint32Array(x.getImageData(0, 0, size, size).data.buffer.slice(0));
  }
  function grain(x, n, a) { for (let i = 0; i < n; i++) { x.fillStyle = `rgba(${Math.random() < .5 ? '0,0,0' : '255,255,255'},${a * Math.random()})`; x.fillRect(Math.random() * 64, Math.random() * 64, 2, 2); } }
  const TEX = {
    '#': tex(x => { x.fillStyle = '#8a8a82'; x.fillRect(0, 0, 64, 64); grain(x, 500, .15); x.fillStyle = '#6a6a64'; x.fillRect(0, 31, 64, 2); x.fillRect(0, 63, 64, 1); x.fillRect(31, 0, 1, 32); x.fillRect(15, 32, 1, 32); x.fillRect(47, 32, 1, 32); x.fillStyle = '#5a5a40'; x.fillRect(0, 54, 64, 10); }),
    'D': tex(x => { x.fillStyle = '#7a8088'; x.fillRect(0, 0, 64, 64); grain(x, 200, .1); for (let i = 0; i < 8; i++) { x.fillStyle = i % 2 ? '#222' : '#e8c040'; x.beginPath(); x.moveTo(i * 8, 40); x.lineTo(i * 8 + 8, 40); x.lineTo(i * 8 + 4, 48); x.lineTo(i * 8 - 4, 48); x.fill(); }
      x.fillStyle = '#334'; x.fillRect(22, 10, 20, 14); x.fillStyle = '#6ac'; x.fillRect(24, 12, 16, 10); x.fillStyle = '#555'; x.fillRect(0, 0, 3, 64); x.fillRect(61, 0, 3, 64); }),
    'L': tex(x => { x.fillStyle = '#806868'; x.fillRect(0, 0, 64, 64); grain(x, 200, .1); for (let i = 0; i < 8; i++) { x.fillStyle = i % 2 ? '#222' : '#d33'; x.fillRect(i * 8, 40, 8, 8); }
      x.fillStyle = '#111'; x.fillRect(24, 12, 16, 18); x.fillStyle = '#f33'; x.fillRect(28, 16, 8, 4); x.fillStyle = '#e8c040'; x.font = 'bold 9px monospace'; x.fillText('KEY', 22, 58); x.fillStyle = '#555'; x.fillRect(0, 0, 3, 64); x.fillRect(61, 0, 3, 64); }),
    'C': tex(x => { x.fillStyle = '#8a6a3a'; x.fillRect(0, 0, 64, 64); grain(x, 300, .12); x.strokeStyle = '#5a4020'; x.lineWidth = 5; x.strokeRect(3, 3, 58, 58); x.beginPath(); x.moveTo(5, 5); x.lineTo(59, 59); x.moveTo(59, 5); x.lineTo(5, 59); x.stroke(); }),
    'T': tex(x => { x.fillStyle = '#3a3e44'; x.fillRect(0, 0, 64, 64); x.fillStyle = '#111'; x.fillRect(8, 8, 48, 26); x.fillStyle = '#2c4'; for (let i = 0; i < 6; i++) x.fillRect(11, 11 + i * 4, 10 + Math.random() * 30, 2);
      for (let i = 0; i < 10; i++) { x.fillStyle = ['#f33', '#3f3', '#ff3', '#3cf'][i % 4]; x.fillRect(8 + i * 5, 40, 3, 3); } x.fillStyle = '#222'; x.fillRect(6, 48, 52, 12); grain(x, 100, .1); }),
    'Tx': tex(x => { x.fillStyle = '#2a2a2e'; x.fillRect(0, 0, 64, 64); x.fillStyle = '#000'; x.fillRect(8, 8, 48, 26); x.strokeStyle = '#555'; x.beginPath(); x.moveTo(10, 10); x.lineTo(30, 30); x.lineTo(50, 14); x.stroke(); x.fillStyle = '#222'; x.fillRect(6, 48, 52, 12); x.fillStyle = 'rgba(0,0,0,.5)'; x.fillRect(20, 0, 30, 64); }),
    'g': tex(x => { x.fillStyle = '#555'; x.fillRect(0, 0, 64, 64); const gr = x.createLinearGradient(6, 0, 58, 0); gr.addColorStop(0, '#a80'); gr.addColorStop(.4, '#fd4'); gr.addColorStop(1, '#860'); x.fillStyle = gr; x.fillRect(6, 4, 52, 56);
      x.fillStyle = '#c00'; x.fillRect(6, 22, 52, 14); x.fillStyle = '#fff'; x.font = 'bold 12px monospace'; x.fillText('GAS', 21, 33); }),
    'gx': tex(x => { x.fillStyle = '#555'; x.fillRect(0, 0, 64, 64); const gr = x.createLinearGradient(6, 0, 58, 0); gr.addColorStop(0, '#a80'); gr.addColorStop(.4, '#fd4'); gr.addColorStop(1, '#860'); x.fillStyle = gr; x.fillRect(6, 4, 52, 56);
      x.fillStyle = '#c00'; x.fillRect(6, 22, 52, 14); x.fillStyle = '#fff'; x.font = 'bold 12px monospace'; x.fillText('GAS', 21, 33); x.fillStyle = '#333'; x.fillRect(20, 40, 24, 14); x.fillStyle = '#f00'; x.fillRect(36, 43, 5, 4); }),
  };
  // sprites: transparent background
  function guardFrame(kind) {
    return tex(x => {
      const legs = { stand: [0, 0], walk1: [-4, 3], walk2: [3, -4], shoot: [0, 0], hit: [0, 0] }[kind] || [0, 0];
      if (kind === 'dead') {
        x.fillStyle = 'rgba(0,0,0,.35)'; x.beginPath(); x.ellipse(32, 60, 26, 3, 0, 0, 7); x.fill();
        x.fillStyle = '#4a5a2a'; x.fillRect(12, 52, 34, 8); x.fillStyle = '#334018'; x.fillRect(40, 54, 12, 5); x.fillStyle = '#d8b090'; x.beginPath(); x.arc(10, 55, 4.5, 0, 7); x.fill();
        x.fillStyle = '#2a3018'; x.fillRect(5, 51, 8, 3); x.fillStyle = '#111'; x.fillRect(20, 49, 18, 2); return;
      }
      if (kind === 'fall') {
        x.fillStyle = '#4a5a2a'; x.save(); x.translate(32, 50); x.rotate(-1.0); x.fillRect(-7, -22, 14, 22); x.fillStyle = '#d8b090'; x.beginPath(); x.arc(0, -27, 5, 0, 7); x.fill(); x.restore();
        x.fillStyle = '#334018'; x.fillRect(26, 48, 6, 14); x.fillRect(34, 50, 6, 12); return;
      }
      x.fillStyle = 'rgba(0,0,0,.35)'; x.beginPath(); x.ellipse(32, 62, 12, 2, 0, 0, 7); x.fill();
      x.fillStyle = '#334018'; x.fillRect(25 + legs[0] * .3, 40, 6, 20 + legs[0]); x.fillRect(33 + legs[1] * .3, 40, 6, 20 + legs[1]);
      x.fillStyle = '#111'; x.fillRect(24 + legs[0] * .3, 58 + legs[0], 8, 4); x.fillRect(32 + legs[1] * .3, 58 + legs[1], 8, 4);
      x.fillStyle = kind === 'hit' ? '#7a8a4a' : '#4a5a2a'; x.fillRect(22, 18, 20, 24);
      x.fillStyle = '#3a4820'; x.fillRect(22, 36, 20, 3);
      x.fillStyle = '#d8b090'; x.beginPath(); x.arc(32, 12, 6, 0, 7); x.fill();
      x.fillStyle = '#2a3018'; x.beginPath(); x.ellipse(32, 7, 7.5, 4, 0, Math.PI, 0); x.fill(); x.fillRect(24, 6, 16, 2);
      x.fillStyle = '#000'; x.fillRect(29, 11, 2, 2); x.fillRect(34, 11, 2, 2);
      x.fillStyle = '#4a5a2a'; x.fillRect(18, 20, 5, 14); x.fillRect(41, 20, 5, 14);
      if (kind === 'shoot') {
        x.fillStyle = '#111'; x.fillRect(28, 24, 8, 16); x.fillStyle = '#222'; x.fillRect(29.5, 20, 5, 6);
        x.fillStyle = '#ff4'; x.beginPath(); x.arc(32, 22, 6, 0, 7); x.fill(); x.fillStyle = '#fff'; x.beginPath(); x.arc(32, 22, 2.5, 0, 7); x.fill();
      } else { x.fillStyle = '#111'; x.save(); x.translate(32, 30); x.rotate(-.6); x.fillRect(-14, -2, 28, 4); x.fillRect(-4, 2, 4, 6); x.restore(); }
      if (kind === 'hit') { x.fillStyle = '#a00'; x.fillRect(30, 26, 4, 4); }
    });
  }
  const GUARD = { stand: guardFrame('stand'), walk1: guardFrame('walk1'), walk2: guardFrame('walk2'), shoot: guardFrame('shoot'), hit: guardFrame('hit'), fall: guardFrame('fall'), dead: guardFrame('dead') };
  const ITEM = {
    K: tex(x => { x.fillStyle = 'rgba(0,0,0,.3)'; x.beginPath(); x.ellipse(32, 61, 10, 2, 0, 0, 7); x.fill(); x.fillStyle = '#e8c040'; x.fillRect(22, 46, 20, 13); x.fillStyle = '#333'; x.fillRect(24, 50, 16, 3); x.fillStyle = '#fff'; x.fillRect(25, 55, 6, 2); }),
    A: tex(x => { x.fillStyle = 'rgba(0,0,0,.3)'; x.beginPath(); x.ellipse(32, 61, 12, 2, 0, 0, 7); x.fill(); x.fillStyle = '#4a5a2a'; x.fillRect(20, 48, 24, 13); x.fillStyle = '#e8c040'; x.font = 'bold 8px monospace'; x.fillText('AMMO', 21, 57); }),
    R: tex(x => { x.fillStyle = 'rgba(0,0,0,.3)'; x.beginPath(); x.ellipse(32, 61, 12, 2, 0, 0, 7); x.fill(); x.fillStyle = '#2a4a8a'; x.beginPath(); x.moveTo(20, 40); x.lineTo(44, 40); x.lineTo(42, 61); x.lineTo(22, 61); x.closePath(); x.fill(); x.fillStyle = '#4a7ac8'; x.fillRect(24, 44, 16, 4); }),
    S: tex(x => { x.fillStyle = 'rgba(0,0,0,.3)'; x.beginPath(); x.ellipse(32, 61, 16, 2, 0, 0, 7); x.fill(); x.fillStyle = '#5a3a1a'; x.fillRect(12, 54, 14, 5); x.fillStyle = '#333'; x.fillRect(24, 54, 28, 3); x.fillRect(24, 57, 22, 2); }),
    F: tex(x => { x.fillStyle = 'rgba(0,0,0,.3)'; x.beginPath(); x.ellipse(32, 61, 12, 2, 0, 0, 7); x.fill(); x.fillStyle = '#c8a050'; x.fillRect(20, 47, 24, 13); x.fillStyle = '#a00'; x.fillRect(22, 50, 20, 3); x.fillStyle = '#fff'; x.font = 'bold 6px monospace'; x.fillText('TOP', 26, 58); }),
    a: tex(x => { x.fillStyle = '#222'; x.fillRect(22, 54, 20, 6); x.fillStyle = '#e8c040'; x.fillRect(24, 55, 4, 4); x.fillRect(30, 55, 4, 4); }),
    X: tex(x => { x.fillStyle = '#555'; x.fillRect(31, 22, 2, 40); x.fillStyle = '#063'; x.fillRect(14, 8, 36, 16); x.fillStyle = '#fff'; x.font = 'bold 11px monospace'; x.fillText('EXIT', 18, 20); }),
  };

  // floor and ceiling: a gradient that fades with distance
  const BG = new Uint32Array(W * H);
  const rgb = (r, gg, b) => (255 << 24) | (b << 16) | (gg << 8) | r;
  for (let y = 0; y < H; y++) {
    const k = Math.abs(y - H / 2) / (H / 2), c = y < H / 2 ? rgb(30 + 40 * k | 0, 30 + 40 * k | 0, 34 + 44 * k | 0) : rgb(40 + 60 * k | 0, 38 + 52 * k | 0, 30 + 36 * k | 0);
    BG.fill(c, y * W, y * W + W);
  }

  // ---- missions ----
  const MISSIONS = [
    { name: 'FACILITY', brief: 'Our agent is inside the chemical facility. Find the guard\'s KEYCARD, plant an explosive on the GAS TANK, then get through the locked door to the EXIT.',
      map: ['##########################', '#P...#.......#...........#', '#....#...G...#....G......#', '#....D.......D...........#', '#....#.......#....A......#', '##D###.......######D######', '#....#...R...#...........#',
        '#.G..####D####...C...C...#', '#....#.......#...........#', '#.K..D...G...D....G...g..#', '#....#.......#...........#', '######.......#####L#######', '#....#.......#...........#', '#.A..D...G...#.....G...X.#', '#....#.......#...........#', '##########################'],
      obj: [{ t: 'collect', what: 'K', text: 'Find the keycard' }, { t: 'use', what: 'g', text: 'Plant explosive on the gas tank' }, { t: 'exit', text: 'Reach the exit' }] },
    { name: 'BUNKER', brief: 'The bunker\'s computers control the missile network. Wreck all 3 COMPUTER CONSOLES (walk up and USE them), grab the SECRET FILES, and leave by the EXIT.',
      map: ['############################', '#P.......#.....T....#......#', '#........D..........D..G...#', '#..G.....#.....G....#......#', '####D#####..........###D####', '#........#####D######......#', '#..T.....#..........#..C...#', '#........D....G.....D......#',
        '#..G..A..#..........#...T..#', '#........#....R.....#......#', '#####D########D###########D#', '#......#.........#.........#', '#..F...D....G....D....G..X.#', '#......#.........#.........#', '############################'],
      obj: [{ t: 'use', what: 'T', count: 3, text: 'Wreck 3 computer consoles' }, { t: 'collect', what: 'F', text: 'Steal the secret files' }, { t: 'exit', text: 'Reach the exit' }] },
    { name: 'ARCHIVES', brief: 'The archives hold the plans for the satellite weapon. Collect BOTH DOCUMENTS, download the data at the MAIN CONSOLE, find the KEYCARD and escape through the locked door.',
      map: ['##############################', '#P....#........#.......#.....#', '#.....D...G....D...G...D..G..#', '#.....#........#.......#.....#', '###D###...S....###D#####..A..#', '#.....#........#.......#.....#', '#.G...####D#####...G...##D####', '#.....#........#.......#.....#',
        '#..F..D...G....D...T...D..G..#', '#.....#..C..C..#.......#.....#', '####D##........#########..R..#', '#.....#...G....#.......#.....#', '#..G..D........L...G...#..F..#', '#..K..#..A.....#.....X.#.....#', '##############################'],
      obj: [{ t: 'collect', what: 'F', count: 2, text: 'Collect both documents' }, { t: 'use', what: 'T', text: 'Download data at the main console' }, { t: 'exit', text: 'Reach the exit' }] },
  ];
  const DIFFS = [{ name: 'AGENT', acc: .45, dmg: 6, rate: 1.3 }, { name: 'SECRET AGENT', acc: .65, dmg: 9, rate: 1.0 }, { name: 'TOP AGENT', acc: .85, dmg: 13, rate: .75 }];
  const GUNS = [
    { name: 'P9 SILENCED', mag: 7, dmg: 38, rate: .3, spread: 2, loud: false, auto: false, pellets: 1, reload: 1.0, sfx: 'pistol' },
    { name: 'K5 SMG', mag: 30, dmg: 22, rate: .09, spread: 6, loud: true, auto: true, pellets: 1, reload: 1.2, sfx: 'smg' },
    { name: 'SHOTGUN', mag: 5, dmg: 17, rate: .75, spread: 16, loud: true, auto: false, pellets: 7, reload: 1.5, sfx: 'shotgun' },
  ];

  // ---- state ----
  let mode = 'setup', M, map, MW, MH, doors, used, guards, items, P, objs, stats, time = 0, msg = '', msgT = 0, flashT = 0, hasKey = false, collected = {}, resultText = '';
  const held = { fwd:false, back:false, left:false, right:false, sl:false, sr:false, fire:false };
  const stick = { x: 0, y: 0, on: false };
  const cell = (x, y) => (x < 0 || y < 0 || x >= MW || y >= MH) ? '#' : map[y][x];
  const isDoor = c => c === 'D' || c === 'L';
  const isWallTile = c => c === '#' || c === 'C' || c === 'T' || c === 'g' || isDoor(c);
  function solidAt(x, y) {
    const c = cell(x, y);
    if (isDoor(c)) return doors[x + ',' + y].open < .9;
    return isWallTile(c);
  }

  function startMission() {
    M = MISSIONS[cfg.mission];
    const rows = M.map, w = Math.max(...rows.map(r => r.length));
    map = rows.map(r => (r + '#'.repeat(w)).slice(0, w).split('')); MW = w; MH = rows.length;
    doors = {}; used = {}; guards = []; items = []; collected = {}; hasKey = false;
    P = { x: 1.5, y: 1.5, dx: 1, dy: 0, px: 0, py: .66, hp: 100, armor: 0, gun: 0, owned: [true, false, false], mag: [7, 0, 0], res: [28, 0, 0], fireT: 0, reloadT: 0, kick: 0, flash: 0, bob: 0 };
    for (let y = 0; y < MH; y++) for (let x = 0; x < MW; x++) {
      const c = map[y][x];
      if (c === 'P') { P.x = x + .5; P.y = y + .5; map[y][x] = '.'; }
      else if (c === 'G') { guards.push({ x: x + .5, y: y + .5, hp: 100, state: 'idle', t: Math.random() * 3, fireT: 1, anim: 0, shootT: 0, hitT: 0, lastX: 0, lastY: 0, path: null, pathT: 0, dieT: 0 }); map[y][x] = '.'; }
      else if ('KARSFX'.includes(c)) { items.push({ x: x + .5, y: y + .5, k: c }); map[y][x] = '.'; }
      else if (isDoor(c)) doors[x + ',' + y] = { open: 0, opening: false, wait: 0 };
    }
    // face down the first open corridor
    for (const [dx, dy] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) if (!solidAt(Math.floor(P.x + dx), Math.floor(P.y + dy))) { setDir(Math.atan2(dy, dx)); break; }
    objs = M.obj.map(o => ({ ...o, n: 0, done: false }));
    stats = { shots: 0, hits: 0, kills: 0, heads: 0, t: 0 };
    msg = `MISSION ${cfg.mission + 1}: ${M.name}  ·  ${isTouch ? 'II' : 'Tab'} = objectives`; msgT = 4;
    mode = 'play'; time = 0;
  }
  function setDir(a) { P.dx = Math.cos(a); P.dy = Math.sin(a); P.px = -P.dy * .66; P.py = P.dx * .66; }
  function turn(a) { setDir(Math.atan2(P.dy, P.dx) + a); }

  function objDone(t, what) {
    for (const o of objs) {
      if (o.done || o.t !== t || (what && o.what !== what)) continue;
      o.n++; if (o.n >= (o.count || 1)) { o.done = true; SFX.obj(); msg = 'OBJECTIVE COMPLETE: ' + o.text; msgT = 3; }
      else { SFX.pick(); msg = `${o.text}: ${o.n} of ${o.count}`; msgT = 2.5; }
      return true;
    }
    return false;
  }

  // ---- line of sight and guard paths ----
  function canSee(ax, ay, bx, by) {
    const d = Math.hypot(bx - ax, by - ay), n = Math.ceil(d / .2);
    for (let i = 1; i < n; i++) { const x = ax + (bx - ax) * i / n, y = ay + (by - ay) * i / n; if (solidAt(Math.floor(x), Math.floor(y))) return false; }
    return true;
  }
  function pathTo(sx, sy, tx, ty) {
    // breadth-first search over the grid (doors count as open, except locked ones)
    const key = (x, y) => y * MW + x, prev = new Int32Array(MW * MH).fill(-1), start = key(sx, sy), goal = key(tx, ty);
    const q = [start]; prev[start] = start;
    for (let i = 0; i < q.length; i++) {
      const c = q[i]; if (c === goal) break;
      const x = c % MW, y = c / MW | 0;
      for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
        const nx = x + dx, ny = y + dy, k = key(nx, ny), ch = cell(nx, ny);
        if (prev[k] >= 0 || ch === 'L' || (isWallTile(ch) && !isDoor(ch))) continue;
        prev[k] = c; q.push(k);
      }
    }
    if (prev[goal] < 0) return null;
    const out = []; for (let c = goal; c !== start; c = prev[c]) out.unshift([c % MW + .5, (c / MW | 0) + .5]);
    return out;
  }
  function openDoor(x, y) { const d = doors[x + ',' + y]; if (d && !d.opening && d.open < 1) { d.opening = true; SFX.door(); } if (d) d.wait = 3; }

  // ---- moving with collisions ----
  function move(e, mx, my, r = .22) {
    const nx = e.x + mx; if (!solidAt(Math.floor(nx + Math.sign(mx) * r), Math.floor(e.y - r)) && !solidAt(Math.floor(nx + Math.sign(mx) * r), Math.floor(e.y + r))) e.x = nx;
    const ny = e.y + my; if (!solidAt(Math.floor(e.x - r), Math.floor(ny + Math.sign(my) * r)) && !solidAt(Math.floor(e.x + r), Math.floor(ny + Math.sign(my) * r))) e.y = ny;
  }

  // ---- shooting ----
  let zbuf = new Float32Array(W);
  function project(sx, sy) {
    const rx = sx - P.x, ry = sy - P.y, inv = 1 / (P.px * P.dy - P.dx * P.py);
    const tx = inv * (P.dy * rx - P.dx * ry), ty = inv * (-P.py * rx + P.px * ry);
    return { tx, ty, sx: (W / 2) * (1 + tx / ty) };
  }
  function shoot() {
    const G0 = GUNS[P.gun];
    if (P.reloadT > 0 || P.fireT > 0) return;
    if (P.mag[P.gun] <= 0) { if (P.res[P.gun] > 0) reload(); else { SFX.empty(); P.fireT = .3; } return; }
    P.mag[P.gun]--; P.fireT = G0.rate; P.kick = 1; P.flash = .06; stats.shots++;
    SFX[G0.sfx]();
    if (G0.loud) for (const gd of guards) if (gd.hp > 0 && Math.hypot(gd.x - P.x, gd.y - P.y) < 11) alert(gd);
    // aim help: snap toward a guard close to the crosshair
    let aimX = W / 2;
    if (cfg.aim) {
      let best = 30;
      for (const gd of guards) { if (gd.hp <= 0) continue; const p = project(gd.x, gd.y); if (p.ty > .1 && Math.abs(p.sx - W / 2) < best && p.ty < zbuf[clamp(p.sx | 0, 0, W - 1)]) { best = Math.abs(p.sx - W / 2); aimX = p.sx; } }
    }
    let anyHit = false;
    for (let k = 0; k < G0.pellets; k++) {
      const ax = aimX + (Math.random() - .5) * G0.spread * 2;
      let hit = null, hd = Infinity;
      for (const gd of guards) {
        if (gd.hp <= 0) continue;
        const p = project(gd.x, gd.y); if (p.ty <= .1) continue;
        const half = Math.abs(H / p.ty) * .9 * .2;
        if (Math.abs(p.sx - ax) < half && p.ty < zbuf[clamp(ax | 0, 0, W - 1)] && p.ty < hd) { hd = p.ty; hit = gd; }
      }
      if (hit) {
        anyHit = true;
        const head = Math.random() < (P.gun === 2 ? .05 : .18);
        const dmg = head ? 999 : G0.dmg * (P.gun === 2 ? clamp(1.6 - hd / 5, .3, 1.4) : 1);
        damageGuard(hit, dmg, head);
      }
    }
    if (anyHit) { stats.hits++; SFX.hit(); }
  }
  function reload() {
    const G0 = GUNS[P.gun], need = G0.mag - P.mag[P.gun];
    if (need <= 0 || P.res[P.gun] <= 0 || P.reloadT > 0) return;
    P.reloadT = G0.reload; SFX.reload();
  }
  function damageGuard(gd, dmg, head) {
    gd.hp -= dmg; gd.hitT = .15; alert(gd);
    if (gd.hp <= 0) {
      gd.dieT = .45; stats.kills++; if (head) { stats.heads++; msg = 'HEADSHOT!'; msgT = 1; }
      SFX.die();
      if (Math.random() < .75) items.push({ x: gd.x, y: gd.y, k: 'a' });
    }
  }
  function alert(gd) { if (gd.state !== 'alert') { gd.state = 'alert'; gd.fireT = .5 + Math.random() * .6; if (Math.random() < .5) SFX.alert(); } gd.lastX = P.x; gd.lastY = P.y; }
  function hurt(n) {
    if (P.armor > 0) { const a = Math.min(P.armor, n); P.armor -= a; n -= a; }
    P.hp -= n; flashT = .25; SFX.hurt();
    if (P.hp <= 0) { P.hp = 0; resultText = 'MISSION FAILED'; mode = 'dead'; }
  }

  function useAction() {
    // look one step ahead for a door, console or gas tank
    for (const r of [.7, 1.2]) {
      const x = Math.floor(P.x + P.dx * r), y = Math.floor(P.y + P.dy * r), c = cell(x, y), k = x + ',' + y;
      if (isDoor(c)) {
        if (c === 'L' && !hasKey) { msg = 'LOCKED. You need a keycard.'; msgT = 2; SFX.empty(); return; }
        openDoor(x, y); return;
      }
      if ((c === 'T' || c === 'g') && !used[k]) { used[k] = true; if (!objDone('use', c)) { msg = c === 'T' ? 'Console wrecked.' : 'Explosive planted.'; msgT = 2; SFX.pick(); } return; }
    }
  }

  // ---- update ----
  function update(dt) {
    time += dt; msgT = Math.max(0, msgT - dt); flashT = Math.max(0, flashT - dt);
    if (mode !== 'play') return;
    stats.t += dt;
    // doors open, wait, then close if nobody is standing in them
    for (const k in doors) {
      const d = doors[k], [dx, dy] = k.split(',').map(Number);
      if (d.opening) { d.open = Math.min(1, d.open + dt * 1.8); if (d.open >= 1) d.opening = false; }
      else if (d.open > 0) {
        d.wait -= dt;
        const blocked = [P, ...guards.filter(gd => gd.hp > 0)].some(e => Math.floor(e.x) === dx && Math.floor(e.y) === dy);
        if (d.wait <= 0 && !blocked) d.open = Math.max(0, d.open - dt * 1.8);
      }
    }
    // player movement
    let f = (held.fwd ? 1 : 0) - (held.back ? 1 : 0), s = (held.sr ? 1 : 0) - (held.sl ? 1 : 0);
    if (stick.on) { f = -stick.y; s = stick.x; }
    if (held.left) turn(-2.6 * dt); if (held.right) turn(2.6 * dt);
    const sp = 3.2 * dt, mx = (P.dx * f - P.dy * s) * sp, my = (P.dy * f + P.dx * s) * sp;
    // walking into a door opens it
    if (f > 0) { const ax = Math.floor(P.x + P.dx * .6), ay = Math.floor(P.y + P.dy * .6), c = cell(ax, ay); if (c === 'D' || (c === 'L' && hasKey)) openDoor(ax, ay); }
    move(P, mx, my);
    if (f || s) P.bob += dt * 9;
    P.fireT -= dt; P.kick = Math.max(0, P.kick - dt * 6); P.flash = Math.max(0, P.flash - dt);
    if (P.reloadT > 0) {
      P.reloadT -= dt;
      if (P.reloadT <= 0) { const need = GUNS[P.gun].mag - P.mag[P.gun], take = Math.min(need, P.res[P.gun]); P.mag[P.gun] += take; P.res[P.gun] -= take; }
    }
    if (held.fire && (GUNS[P.gun].auto || !P.fireLatch)) { shoot(); P.fireLatch = true; }
    if (!held.fire) P.fireLatch = false;

    // pick-ups
    for (const it of items) {
      if (Math.hypot(it.x - P.x, it.y - P.y) > .5) continue;
      it.gone = true;
      if (it.k === 'K') { hasKey = true; objDone('collect', 'K') || (msg = 'Got the keycard.', msgT = 2); SFX.pick(); }
      else if (it.k === 'F') { objDone('collect', 'F') || (msg = 'Got the files.', msgT = 2); }
      else if (it.k === 'R') { if (P.armor >= 100) { it.gone = false; continue; } P.armor = Math.min(100, P.armor + 50); msg = 'BODY ARMOR'; msgT = 1.5; SFX.pick(); }
      else if (it.k === 'S') { P.owned[2] = true; P.res[2] += 10; if (!P.mag[2]) { P.mag[2] = 5; } P.gun = 2; msg = 'Picked up a SHOTGUN'; msgT = 2; SFX.pick(); }
      else if (it.k === 'A') { P.res[0] += 14; P.res[1] += 30; P.res[2] += 5; msg = 'AMMO'; msgT = 1.2; SFX.pick(); }
      else if (it.k === 'a') {
        if (!P.owned[1]) { P.owned[1] = true; P.mag[1] = 30; P.gun = P.gun === 2 ? 2 : 1; msg = 'Picked up a K5 SMG'; msgT = 2; } else { P.res[1] += 20; P.res[0] += 4; msg = '+20 SMG rounds'; msgT = 1; }
        SFX.pick();
      } else if (it.k === 'X') it.gone = false;
    }
    items = items.filter(i => !i.gone);

    // reaching the exit
    const ex = items.find(i => i.k === 'X');
    if (ex && Math.hypot(ex.x - P.x, ex.y - P.y) < .8) {
      if (objs.every(o => o.t === 'exit' || o.done)) { objs.forEach(o => o.done = true); finishMission(); return; }
      if (msgT <= 0) { msg = 'Finish your objectives first! (Tab/II shows them)'; msgT = 2.5; }
    }

    // guards
    const D = DIFFS[cfg.diff];
    for (const gd of guards) {
      if (gd.hp <= 0) { gd.dieT = Math.max(0, gd.dieT - dt); continue; }
      gd.hitT = Math.max(0, gd.hitT - dt); gd.shootT = Math.max(0, gd.shootT - dt);
      const dist = Math.hypot(P.x - gd.x, P.y - gd.y), sees = dist < 14 && canSee(gd.x, gd.y, P.x, P.y);
      if (sees && (gd.state === 'alert' || dist < 7 || Math.random() < dt * .5)) alert(gd);
      if (gd.state === 'alert') {
        if (sees) { gd.lastX = P.x; gd.lastY = P.y; gd.path = null; }
        // keep a little distance when there's a clear shot; otherwise close in
        let tx = gd.lastX, ty = gd.lastY;
        if (!sees) {
          gd.pathT -= dt;
          if (!gd.path || gd.pathT <= 0) { gd.path = pathTo(Math.floor(gd.x), Math.floor(gd.y), Math.floor(gd.lastX), Math.floor(gd.lastY)); gd.pathT = .6; }
          if (gd.path && gd.path.length) { [tx, ty] = gd.path[0]; if (Math.hypot(tx - gd.x, ty - gd.y) < .15) gd.path.shift(); }
          else if (Math.hypot(gd.lastX - gd.x, gd.lastY - gd.y) < .5) gd.state = 'search';
        }
        const want = sees ? (dist > 4 ? 1 : dist < 2 ? -.4 : 0) : 1;
        const dx = tx - gd.x, dy = ty - gd.y, l = Math.hypot(dx, dy) || 1;
        if (want && gd.shootT <= 0) {
          const nx = Math.floor(gd.x + dx / l * .6), ny = Math.floor(gd.y + dy / l * .6);
          if (cell(nx, ny) === 'D') openDoor(nx, ny);
          move(gd, dx / l * 1.7 * want * dt, dy / l * 1.7 * want * dt, .25); gd.anim += dt * 6;
        }
        gd.fireT -= dt;
        if (sees && gd.fireT <= 0) {
          gd.fireT = (.7 + Math.random() * .7) * D.rate; gd.shootT = .2; SFX.guard();
          const chance = D.acc * clamp(1.25 - dist / 10, .3, 1) * (held.fwd || held.back || stick.on ? .8 : 1);
          if (Math.random() < chance) hurt(D.dmg + Math.random() * D.dmg * .5 | 0);
          if (mode !== 'play') return;
        }
      } else if (gd.state === 'search') {
        gd.t -= dt; if (gd.t <= 0) { gd.t = 2 + Math.random() * 2; gd.state = 'idle'; }
      }
    }
  }
  function finishMission() {
    mode = 'won'; resultText = 'MISSION COMPLETE'; SFX.obj();
    const key = cfg.mission + '-' + cfg.diff, b = cfg.best[key];
    stats.newBest = !b || stats.t < b; if (stats.newBest) cfg.best[key] = stats.t;
    cfg.unlocked = Math.max(cfg.unlocked, Math.min(MISSIONS.length, cfg.mission + 2)); saveCfg();
  }

  // ---- drawing: classic ray casting, one column at a time ----
  function shade(c, k) { return (255 << 24) | (((c >> 16 & 255) * k) << 16) | (((c >> 8 & 255) * k) << 8) | ((c & 255) * k); }
  function render() {
    buf.set(BG);
    for (let x = 0; x < W; x++) {
      const cam = 2 * x / W - 1, rdx = P.dx + P.px * cam, rdy = P.dy + P.py * cam;
      let mx = Math.floor(P.x), my = Math.floor(P.y);
      const ddx = Math.abs(1 / rdx), ddy = Math.abs(1 / rdy), sx = rdx < 0 ? -1 : 1, sy = rdy < 0 ? -1 : 1;
      let sdx = (rdx < 0 ? P.x - mx : mx + 1 - P.x) * ddx, sdy = (rdy < 0 ? P.y - my : my + 1 - P.y) * ddy, side = 0, c = '#', perp = 30, wx = 0, open = 0;
      for (let i = 0; i < 64; i++) {
        if (sdx < sdy) { sdx += ddx; mx += sx; side = 0; } else { sdy += ddy; my += sy; side = 1; }
        c = cell(mx, my);
        if (!isWallTile(c)) continue;
        perp = side ? sdy - ddy : sdx - ddx;
        wx = side ? P.x + perp * rdx : P.y + perp * rdy; wx -= Math.floor(wx);
        if (isDoor(c)) { open = doors[mx + ',' + my].open; if (wx < open) continue; }
        break;
      }
      zbuf[x] = perp;
      const k = mx + ',' + my, T = (c === 'T' && used[k]) ? TEX.Tx : (c === 'g' && used[k]) ? TEX.gx : TEX[c] || TEX['#'];
      let tx = Math.floor((isDoor(c) ? wx - open : wx) * TS);
      if (!isDoor(c) && ((side === 0 && rdx > 0) || (side === 1 && rdy < 0))) tx = TS - 1 - tx;
      tx = clamp(tx, 0, TS - 1);
      const lh = H / perp, top = -lh / 2 + H / 2, y0 = Math.max(0, top | 0), y1 = Math.min(H, (lh / 2 + H / 2) | 0);
      const light = clamp(1.15 - perp / 12, .22, 1) * (side ? .78 : 1);
      for (let y = y0; y < y1; y++) { const ty = ((y - top) * TS / lh) | 0; buf[y * W + x] = shade(T[(ty & 63) * TS + tx], light); }
    }
    // sprites (guards, items), far to near
    const spr = [];
    for (const gd of guards) {
      let f;
      if (gd.hp <= 0) f = gd.dieT > 0 ? GUARD.fall : GUARD.dead;
      else if (gd.shootT > 0) f = GUARD.shoot; else if (gd.hitT > 0) f = GUARD.hit;
      else if (gd.state === 'alert') f = Math.floor(gd.anim) % 2 ? GUARD.walk1 : GUARD.walk2; else f = GUARD.stand;
      spr.push({ x: gd.x, y: gd.y, img: f, sc: .9 });
    }
    for (const it of items) spr.push({ x: it.x, y: it.y, img: ITEM[it.k], sc: it.k === 'X' ? 1 : .8 });
    for (const s of spr) s.d = (s.x - P.x) ** 2 + (s.y - P.y) ** 2;
    spr.sort((a, b) => b.d - a.d);
    for (const s of spr) {
      const p = project(s.x, s.y); if (p.ty <= .15) continue;
      const full = Math.abs(H / p.ty), sh = full * s.sc, bottom = H / 2 + full / 2, top = bottom - sh;
      const x0 = Math.round(p.sx - sh / 2), x1 = Math.round(p.sx + sh / 2);
      const light = clamp(1.15 - p.ty / 12, .25, 1);
      for (let x = Math.max(0, x0); x < Math.min(W, x1); x++) {
        if (p.ty >= zbuf[x]) continue;
        const tx = ((x - x0) * TS / sh) | 0;
        for (let y = Math.max(0, top | 0); y < Math.min(H, bottom | 0); y++) {
          const c = s.img[(((y - top) * TS / sh) | 0) * TS + tx];
          if ((c >>> 24) > 128) buf[y * W + x] = shade(c, light);
        }
      }
    }
    g.putImageData(img, 0, 0);
  }

  function txt(s, x, y, col, size = 8, align = 'left') {
    g.font = `bold ${size}px "Lucida Console","Courier New",monospace`; g.textAlign = align; g.textBaseline = 'middle';
    g.fillStyle = '#000'; g.fillText(s, x + 1, y + 1); g.fillStyle = col; g.fillText(s, x, y);
  }
  function drawGun() {
    const bob = Math.sin(P.bob) * 2, kick = P.kick * 8, rl = P.reloadT > 0 ? 25 : 0, x = W / 2 + 40 + bob, y = H - 10 + Math.abs(bob) + kick * .5 + rl;
    if (P.flash > 0) { g.fillStyle = P.gun === 0 ? 'rgba(255,240,180,.6)' : '#ffd040'; g.beginPath(); g.arc(x - 26, y - 62 + kick, P.gun === 0 ? 4 : 10, 0, 7); g.fill(); }
    if (P.gun === 0) {
      g.fillStyle = '#333'; g.fillRect(x - 30, y - 60 + kick, 8, 30); g.fillStyle = '#555'; g.fillRect(x - 32, y - 40 + kick, 14, 22); g.fillStyle = '#222'; g.fillRect(x - 29, y - 22 + kick, 10, 30);
      g.fillStyle = '#c99a70'; g.fillRect(x - 34, y - 12 + kick, 22, 20);
    } else if (P.gun === 1) {
      g.fillStyle = '#222'; g.fillRect(x - 30, y - 64 + kick, 8, 40); g.fillStyle = '#333'; g.fillRect(x - 36, y - 34 + kick, 22, 26); g.fillStyle = '#111'; g.fillRect(x - 30, y - 12 + kick, 10, 24);
      g.fillStyle = '#c99a70'; g.fillRect(x - 40, y - 4 + kick, 26, 16);
    } else {
      g.fillStyle = '#333'; g.fillRect(x - 32, y - 70 + kick, 6, 44); g.fillRect(x - 25, y - 70 + kick, 6, 44); g.fillStyle = '#5a3a1a'; g.fillRect(x - 36, y - 30 + kick, 22, 40);
      g.fillStyle = '#c99a70'; g.fillRect(x - 40, y - 2 + kick, 28, 14);
    }
  }
  function drawHud() {
    // crosshair
    g.fillStyle = 'rgba(255,60,60,.85)'; g.fillRect(W / 2 - 4, H / 2, 3, 1); g.fillRect(W / 2 + 2, H / 2, 3, 1); g.fillRect(W / 2, H / 2 - 4, 1, 3); g.fillRect(W / 2, H / 2 + 2, 1, 3);
    drawGun();
    // health (red) and armor (blue) bars, left; ammo, right
    g.fillStyle = 'rgba(0,0,0,.5)'; g.fillRect(4, H - 20, 64, 16);
    g.fillStyle = '#a00'; g.fillRect(6, H - 18, 60 * P.hp / 100, 5); g.fillStyle = '#36c'; g.fillRect(6, H - 11, 60 * P.armor / 100, 5);
    txt(hasKey ? 'KEY' : '', 72, H - 12, '#e8c040', 7);
    const G0 = GUNS[P.gun];
    txt(G0.name, W - 4, H - 20, '#ccc', 7, 'right');
    txt(P.reloadT > 0 ? 'RELOADING' : `${P.mag[P.gun]} / ${P.res[P.gun]}`, W - 4, H - 9, P.mag[P.gun] ? '#fff' : '#f55', 9, 'right');
    if (msgT > 0) { g.globalAlpha = Math.min(1, msgT); const sz = Math.min(9, W * 1.5 / msg.length); txt(msg, W / 2, 14, '#e8c040', sz, 'center'); g.globalAlpha = 1; }
    if (flashT > 0) { g.fillStyle = `rgba(200,0,0,${flashT * 1.6})`; g.fillRect(0, 0, W, H); }
  }
  function watch(title, extra = []) {
    // the "watch" screen: objectives and stats
    g.fillStyle = 'rgba(0,0,0,.75)'; g.fillRect(0, 0, W, H);
    g.fillStyle = '#1a1a14'; g.fillRect(30, 18, W - 60, H - 36); g.strokeStyle = '#e8c040'; g.lineWidth = 1; g.strokeRect(32.5, 20.5, W - 65, H - 41);
    txt(title, W / 2, 32, '#e8c040', 11, 'center');
    txt(MISSIONS[cfg.mission].name + ' · ' + DIFFS[cfg.diff].name, W / 2, 46, '#aaa', 7, 'center');
    objs.forEach((o, i) => txt(`${o.done ? '[X]' : '[ ]'} ${o.text}${o.count > 1 ? ` (${o.n}/${o.count})` : ''}`, 44, 62 + i * 12, o.done ? '#5f5' : '#fff', 8));
    const acc = stats.shots ? Math.round(stats.hits / stats.shots * 100) : 0;
    txt(`Time ${Math.floor(stats.t / 60)}:${String(Math.floor(stats.t % 60)).padStart(2, '0')}   Kills ${stats.kills}   Headshots ${stats.heads}   Accuracy ${acc}%`, W / 2, 112, '#ccc', 7, 'center');
    extra.forEach((s, i) => txt(s, W / 2, 128 + i * 12, i === 0 ? '#5f5' : '#fff', 8, 'center'));
  }

  let last = performance.now();
  function frame(now) {
    const dt = Math.min(.05, (now - last) / 1000); last = now;
    if (mode === 'play') update(dt); else time += dt;
    if (M) {
      render(); drawHud();
      const go = isTouch ? 'Tap to continue' : 'Click or press Space';
      if (mode === 'paused') watch('PAUSED', ['', isTouch ? 'Tap II to go on' : 'Press P or Tab to go on']);
      if (mode === 'won') watch(resultText, [stats.newBest ? 'NEW BEST TIME!' : '', go]);
      if (mode === 'dead') { g.fillStyle = 'rgba(120,0,0,.45)'; g.fillRect(0, 0, W, H); watch(resultText, ['', go]); }
    }
    requestAnimationFrame(frame);
  }

  // ---- controls ----
  function pause() { if (mode === 'play') { mode = 'paused'; for (const k in held) held[k] = false; stick.on = false; if (document.pointerLockElement) document.exitPointerLock(); } }
  function proceed() {
    if (mode === 'paused') { mode = 'play'; last = performance.now(); }
    else if (mode === 'won' || mode === 'dead') { if (mode === 'won' && cfg.mission < MISSIONS.length - 1) cfg.mission++; openSetup(); }
  }
  function nextGun() { for (let k = 1; k <= 3; k++) { const n = (P.gun + k) % 3; if (P.owned[n]) { selectGun(n); return; } } }
  function selectGun(n) { if (P.owned[n] && n !== P.gun) { P.gun = n; P.reloadT = 0; P.fireT = .3; SFX.reload(); } }
  const KEYS = { w:'fwd', ArrowUp:'fwd', s:'back', ArrowDown:'back', a:'sl', d:'sr', ArrowLeft:'left', ArrowRight:'right', ' ':'fire', Control:'fire' };
  addEventListener('keydown', e => {
    if (mode === 'setup') { if (e.key === 'Enter') startFromSetup(); return; }
    const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    if (KEYS[k] || k === 'Tab') e.preventDefault();
    if (k === 'm') { cfg.sound = !cfg.sound; saveCfg(); return; }
    if (mode === 'play') {
      if (KEYS[k]) held[KEYS[k]] = true;
      if (k === 'e') useAction();
      if (k === 'r') reload();
      if (k === 'q') nextGun();
      if (k >= '1' && k <= '3') selectGun(+k - 1);
      if (k === 'p' || k === 'Tab' || k === 'Escape') pause();
      return;
    }
    if (mode === 'paused' && (k === 'p' || k === 'Tab')) { proceed(); return; }
    if (!e.repeat && (k === ' ' || k === 'Enter')) proceed();
  });
  addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; if (KEYS[k]) held[KEYS[k]] = false; });
  addEventListener('blur', () => { for (const k in held) held[k] = false; stick.on = false; });
  // mouse: click to capture it for looking around, then click to fire
  cv.addEventListener('mousedown', e => {
    if (isTouch) return;
    if (mode !== 'play') { proceed(); return; }
    if (document.pointerLockElement !== cv) { cv.requestPointerLock && cv.requestPointerLock(); return; }
    held.fire = true;
  });
  addEventListener('mouseup', () => { if (!isTouch) held.fire = false; });
  addEventListener('mousemove', e => { if (document.pointerLockElement === cv && mode === 'play') turn(e.movementX * .0035); });
  // touch: left stick walks/strafes, dragging on the right turns, buttons fire / use / change gun
  const stickEl = document.getElementById('stick'), knob = document.getElementById('knob');
  function stickMove(e) {
    const b = stickEl.getBoundingClientRect(), r = b.width / 2;
    let x = (e.clientX - b.left - r) / r, y = (e.clientY - b.top - r) / r; const l = Math.hypot(x, y);
    if (l > 1) { x /= l; y /= l; }
    stick.x = Math.abs(x) < .2 ? 0 : x; stick.y = Math.abs(y) < .15 ? 0 : y; knob.style.transform = `translate(${x * r * .7}px,${y * r * .7}px)`;
  }
  stickEl.addEventListener('pointerdown', e => { e.preventDefault(); if (mode !== 'play') { proceed(); return; } stickEl.setPointerCapture(e.pointerId); stick.on = true; stickMove(e); });
  stickEl.addEventListener('pointermove', e => { if (stick.on) stickMove(e); });
  ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => stickEl.addEventListener(t, () => { stick.on = false; stick.x = stick.y = 0; knob.style.transform = ''; }));
  const look = document.getElementById('look'); let lookX = null;
  look.addEventListener('pointerdown', e => { e.preventDefault(); if (mode !== 'play') { proceed(); return; } look.setPointerCapture(e.pointerId); lookX = e.clientX; });
  look.addEventListener('pointermove', e => { if (lookX != null && mode === 'play') { turn((e.clientX - lookX) * .009); lookX = e.clientX; } });
  ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => look.addEventListener(t, () => { lookX = null; }));
  document.querySelectorAll('#btns button, #pausebtn').forEach(b => {
    const a = b.dataset.a;
    b.addEventListener('pointerdown', e => {
      e.preventDefault(); e.stopPropagation(); audio();
      if (mode !== 'play') { proceed(); return; }
      if (a === 'fire') { held.fire = true; b.classList.add('held'); b.setPointerCapture(e.pointerId); }
      else if (a === 'use') useAction(); else if (a === 'gun') nextGun(); else if (a === 'pause') pause();
    });
    ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => b.addEventListener(t, () => { if (a === 'fire') { held.fire = false; b.classList.remove('held'); } }));
  });
  document.addEventListener('visibilitychange', () => { if (document.hidden) pause(); });

  function fit() {
    const k = Math.min(innerWidth / W, innerHeight / H);
    cv.style.width = W * k + 'px'; cv.style.height = H * k + 'px';
  }
  addEventListener('resize', fit);

  // ---- setup screen ----
  function showSetup() {
    document.getElementById('missions').innerHTML = MISSIONS.map((m, i) => `<button data-i="${i}" class="${i === cfg.mission ? 'on' : ''}" ${i < cfg.unlocked ? '' : 'disabled'}>${i + 1}. ${m.name}${i < cfg.unlocked ? '' : ' 🔒'}</button>`).join('');
    document.getElementById('diffs').innerHTML = DIFFS.map((d, i) => `<button data-i="${i}" class="${i === cfg.diff ? 'on' : ''}">${d.name}</button>`).join('');
    const b = cfg.best[cfg.mission + '-' + cfg.diff];
    document.getElementById('brief').innerHTML = `<b>MISSION ${cfg.mission + 1}: ${MISSIONS[cfg.mission].name}</b><br>${MISSIONS[cfg.mission].brief}` + (b ? `<br><small>Best time: ${Math.floor(b / 60)}:${String(Math.floor(b % 60)).padStart(2, '0')}</small>` : '');
    document.querySelectorAll('#missions button').forEach(btn => btn.onclick = () => { cfg.mission = +btn.dataset.i; showSetup(); });
    document.querySelectorAll('#diffs button').forEach(btn => btn.onclick = () => { cfg.diff = +btn.dataset.i; showSetup(); });
  }
  function openSetup() {
    mode = 'setup'; if (document.pointerLockElement) document.exitPointerLock();
    cfg.mission = Math.min(cfg.mission, cfg.unlocked - 1);
    document.getElementById('snd').checked = cfg.sound; document.getElementById('aim').checked = cfg.aim;
    showSetup(); setupEl.classList.remove('hidden');
  }
  function startFromSetup() { saveCfg(); setupEl.classList.add('hidden'); audio(); startMission(); last = performance.now(); }
  document.getElementById('snd').addEventListener('change', e => { cfg.sound = e.target.checked; });
  document.getElementById('aim').addEventListener('change', e => { cfg.aim = e.target.checked; });
  document.getElementById('go').addEventListener('click', startFromSetup);

  fit();
  // the first mission's start room behind the title screen
  startMission(); mode = 'setup';
  openSetup();
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>
EOF

3. Copy the icons

make-icons.ps1 (from the games site post) draws this game's icons into a agent subfolder next to itself. On your Windows PC, in PowerShell, in that folder (e.g. C:\nibbles):

scp -o BatchMode=yes agent\icon-512.png agent\icon-192.png agent\apple-touch-icon.png agent\favicon-32.png proxmox:/root/nibbles/agent/

4. Push it into CT 125

Proxmox host shell. It ends with a listing of /var/www/nibbles, which should include a agent folder:

bash /root/nibbles/05-push-site.sh

5. Check it

Proxmox host shell. Should print 200:

pct exec 125 -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/agent/

Then open https://games.503mail.com/agent/ in a browser.

Updating it later

Edit index.html, paste the new version over /root/nibbles/agent/index.html with the block from step 2, and run step 4 again. No restart is needed; the pages are served with no-cache. A browser that has the page open just needs a reload.

Troubleshooting

The game runs entirely in the browser, so the server's only job is handing out the page.

  • The page doesn't load (or shows a 404): that's the server side. Run the check in step 5. If it doesn't print 200, see "Troubleshooting: where the logs are" in the games site post for the Caddy logs on CT 125 (nibbles) and CT 108 (Caddy reverse proxy).
  • The page loads but the game misbehaves: open the browser's developer console. On a PC press F12, then the Console tab. Red lines are real errors; a 404 for /favicon.ico is harmless (browsers ask for it on their own, and the games use PNG icons instead).
  • Settings or best scores look wrong: they're kept in the browser's localStorage under 'agent503'. Clearing the site's data in the browser resets them; nothing is stored on the server.

Notes

  • How the 3D works: classic ray casting, like the early-90s shooters. For each of the 320 columns of the picture, a ray is stepped through the map grid until it hits a wall, and the wall slice is drawn at a height based on the distance. Wall textures and guard pictures are drawn by code when the page loads.
  • The maps are text: each mission is a few lines (# wall, D door, L locked door, G guard, K keycard, T console, g gas tank, X exit). I checked every map with a search from the start square: each objective can be reached, and the exit is only reachable through the locked door where that's intended.
  • Strafing bug: the first version mixed up the sideways direction, so stepping sideways moved diagonally. It was fixed before release.