← DOOM results

DOOM

Ornith 1.5 35B A3B · full

Initial view of DOOM

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 42,116 bytes · SHA-256 5fa489be82e0
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HAZARD: Sector 7 — Retro FPS</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    width: 100%; height: 100%;
    background: #000;
    overflow: hidden;
    font-family: 'Courier New', monospace;
    cursor: default;
    user-select: none;
  }
  #wrap {
    position: relative;
    width: 100vw; height: 100vh;
    display: flex; align-items: center; justify-content: center;
  }
  canvas#game {
    background: #000;
    image-rendering: pixelated;
    image-rendering: crisp-edges;
    box-shadow: 0 0 40px rgba(0,0,0,0.9), inset 0 0 20px rgba(0,0,0,0.8);
  }
  /* Overlays */
  .overlay {
    position: absolute; inset: 0;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    text-align: center; color: #fff;
    background: radial-gradient(ellipse at center, rgba(30,0,0,0.75), rgba(0,0,0,0.95));
    z-index: 20;
  }
  .overlay.hidden { display: none; }
  .title {
    font-size: 64px; font-weight: bold;
    letter-spacing: 6px;
    color: #ff3b3b;
    text-shadow: 0 0 20px #ff0000, 3px 3px 0 #7a0000;
    margin-bottom: 6px;
    font-family: Impact, 'Courier New', monospace;
  }
  .subtitle {
    font-size: 20px; letter-spacing: 8px;
    color: #ffcc44; text-shadow: 0 0 10px #aa7700;
    margin-bottom: 40px;
  }
  .hint {
    font-size: 16px; color: #cccccc; line-height: 1.9;
    background: rgba(0,0,0,0.5); padding: 20px 30px;
    border: 1px solid #552222; border-radius: 4px;
    max-width: 520px;
  }
  .hint b { color: #ffcc44; }
  .btn {
    margin-top: 34px;
    font-family: 'Courier New', monospace;
    font-size: 22px; font-weight: bold;
    letter-spacing: 2px;
    color: #100; background: #ffcc44;
    border: none; padding: 14px 42px;
    cursor: pointer; border-radius: 3px;
    box-shadow: 0 5px 0 #996b11;
    transition: transform .05s;
  }
  .btn:hover { background: #ffd968; }
  .btn:active { transform: translateY(4px); box-shadow: 0 1px 0 #996b11; }
  .result { font-size: 56px; font-weight: bold; letter-spacing: 4px; margin-bottom: 10px; }
  .win { color: #4dff7a; text-shadow: 0 0 24px #00ff55; }
  .lose { color: #ff4d4d; text-shadow: 0 0 24px #ff0000; }
  .stat { font-size: 18px; color: #ddd; margin: 4px; }
  #hintKey {
    position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%);
    color: #666; font-size: 12px; z-index: 5; letter-spacing: 1px;
  }
  .flash {
    position: absolute; inset: 0; z-index: 15; pointer-events: none;
    background: #f00; opacity: 0;
  }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="game"></canvas>
  <div id="damageFlash" class="flash"></div>

  <div id="menu" class="overlay">
    <div class="title">HAZARD</div>
    <div class="subtitle">SECTOR&nbsp;7</div>
    <div class="hint">
      <b>WASD</b> move &nbsp;·&nbsp; <b>Mouse</b> look &nbsp;·&nbsp; <b>Left Click</b> fire<br>
      <b>Space</b> jump &nbsp;·&nbsp; <b>1/2/3</b> switch weapon &nbsp;·&nbsp; <b>R</b> reload<br><br>
      Clear <b>SECTOR 7</b> of hostiles. Eliminate every monster and reach the <b>EXIT</b> gate.<br>
      Grab <span style="color:#4dff7a">+ health</span> and <span style="color:#ffcc44">✦ ammo</span> packs along the way.
    </div>
    <button class="btn" id="startBtn">ENTER SECTOR 7</button>
  </div>

  <div id="result" class="overlay hidden">
    <div class="result" id="resultText"></div>
    <div class="stat" id="resultStat"></div>
    <button class="btn" id="againBtn">PLAY AGAIN</button>
  </div>
</div>

<script>
"use strict";
(function () {
  // ============================================================
  //  HAZARD: SECTOR 7 — a raycasting retro FPS
  // ============================================================

  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');

  // Logical resolution (rendered then scaled up for pixel look)
  const W = 640, H = 400;
  canvas.width = W; canvas.height = H;

  function fitToScreen() {
    const s = Math.min(window.innerWidth / W, window.innerHeight / H);
    canvas.style.width = (W * s) + 'px';
    canvas.style.height = (H * s) + 'px';
  }
  window.addEventListener('resize', fitToScreen);
  fitToScreen();

  // ---------- Audio (WebAudio, procedural) ----------
  let AC = null;
  function initAudio() {
    if (!AC) {
      AC = new (window.AudioContext || window.webkitAudioContext)();
    }
    if (AC.state === 'suspended') AC.resume();
  }
  function tone(freq, dur, type = 'square', vol = 0.15, slideTo = null) {
    if (!AC) return;
    const t = AC.currentTime;
    const o = AC.createOscillator();
    const g = AC.createGain();
    o.type = type; o.frequency.setValueAtTime(freq, t);
    if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(1, slideTo), t + dur);
    g.gain.setValueAtTime(vol, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    o.connect(g); g.connect(AC.destination);
    o.start(t); o.stop(t + dur + 0.02);
  }
  function noise(dur, vol = 0.3) {
    if (!AC) return;
    const t = AC.currentTime;
    const buf = AC.createBuffer(1, AC.sampleRate * dur, AC.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
    const src = AC.createBufferSource(); src.buffer = buf;
    const g = AC.createGain(); g.gain.value = vol;
    const filt = AC.createBiquadFilter(); filt.type = 'lowpass'; filt.frequency.value = 1200;
    src.connect(filt); filt.connect(g); g.connect(AC.destination);
    src.start(t);
  }
  const SFX = {
    pistol() { tone(680, 0.09, 'square', 0.16, 180); noise(0.06, 0.12); },
    smg()    { tone(520, 0.05, 'square', 0.11, 140); noise(0.04, 0.09); },
    shotgun(){ tone(220, 0.18, 'sawtooth', 0.22, 60); noise(0.14, 0.28); },
    hurt()   { tone(160, 0.18, 'sawtooth', 0.22, 60); },
    kill()   { tone(300, 0.12, 'square', 0.18, 90); noise(0.1, 0.14); },
    pickup() { tone(880, 0.08, 'sine', 0.16, 1320); tone(1320, 0.1, 'sine', 0.14, 1760); },
    empty()  { tone(120, 0.06, 'square', 0.1, 80); },
    reload() { tone(300, 0.08, 'triangle', 0.12, 440); tone(500, 0.08, 'triangle', 0.12, 700); },
    gunswap(){ tone(420, 0.07, 'triangle', 0.12, 600); },
    win()    { [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,0.25,'square',0.18),i*120)); },
    lose()   { [400,320,250,180].forEach((f,i)=>setTimeout(()=>tone(f,0.3,'sawtooth',0.18),i*160)); },
    enemyhurt(){ tone(400, 0.08, 'square', 0.12, 200); },
    door()   { tone(140, 0.3, 'sawtooth', 0.15, 440); },
  };

  // ---------- Map ----------
  // 1..4 wall types (color variants), 0 floor/air. 9 = exit gate (walkable, triggers win)
  const MAP = [
    "1111111111111111111111111",
    "1000000001000000000000001",
    "1022222001003333000044441",
    "1020002001003003000040041",
    "1020222200003000002222001",
    "1020200000003004002002001",
    "1000200001000004002002001",
    "1110200001001114000000001",
    "1000000001001000000444401",
    "1033333000001000000400001",
    "1030003001111000000400001",
    "1030003001000000000400901",
    "1000003001000111110004001",
    "1044443000001000010004001",
    "1000000001111000010004001",
    "1111111111111111111111111",
  ];
  const MAP_H = MAP.length, MAP_W = MAP[0].length;
  function cell(x, y) {
    if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return 1;
    return parseInt(MAP[y | 0][x | 0], 10);
  }
  function isWall(x, y) { return cell(x, y) > 0 && cell(x, y) !== 9; }
  // locate the exit gate (cell value 9) and spawn the player at a floor tile
  function locateExit() {
    for (let y = 0; y < MAP_H; y++) {
      for (let x = 0; x < MAP_W; x++) {
        if (cell(x, y) === 9) { exitPos = { x: x + 0.5, y: y + 0.5 }; return; }
      }
    }
    exitPos = { x: 0, y: 0 };
  }

  // ---------- Entities ----------
  let enemies = [], pickups = [], particles = [];
  let exitPos = { x: 0, y: 0 };

  // Spawn points for enemies (grid coords)
  const ENEMY_SPAWNS = [
    { x: 3.5, y: 4.5 }, { x: 10.5, y: 3.5 }, { x: 6.5, y: 8.5 },
    { x: 21.5, y: 4.5 }, { x: 14.5, y: 6.5 }, { x: 4.5, y: 11.5 },
    { x: 20.5, y: 11.5 }, { x: 9.5, y: 13.5 }, { x: 15.5, y: 13.5 },
    { x: 22.5, y: 13.5 }, { x: 7.5, y: 6.5 }, { x: 18.5, y: 8.5 },
  ];

  const PICKUP_SPAWNS = [
    { x: 2.5, y: 2.5, type: 'health' }, { x: 12.5, y: 5.5, type: 'ammo' },
    { x: 5.5, y: 12.5, type: 'health' }, { x: 19.5, y: 3.5, type: 'ammo' },
    { x: 24.5, y: 9.5, type: 'health' }, { x: 10.5, y: 10.5, type: 'ammo' },
    { x: 3.5, y: 8.5, type: 'ammo' }, { x: 22.5, y: 6.5, type: 'health' },
  ];

  // ---------- Weapons ----------
  const WEAPONS = [
    { name: 'PISTOL',  mag: Infinity,  maxMag: Infinity, reserve: Infinity, dmg: 1, rate: 260, spread: 0.0, auto: false, sound: 'pistol', pellets: 1, kick: 0.03 },
    { name: 'SMG',     mag: 40,        maxMag: 40,       reserve: 200,      dmg: 0.8, rate: 85,  spread: 0.09, auto: true, sound: 'smg', pellets: 1, kick: 0.02 },
    { name: 'SHOTGUN', mag: 8,         maxMag: 8,        reserve: 40,       dmg: 0.7, rate: 620, spread: 0.22, auto: false, sound: 'shotgun', pellets: 8, kick: 0.08 },
  ];

  // ---------- Player state ----------
  let player = null;
  function freshPlayer() {
    return {
      x: 2.5, y: 2.5, dir: 0.6,
      health: 100, maxHealth: 100,
      dead: false,
      weapons: [
        { ...WEAPONS[0], mag: WEAPONS[0].mag, reserve: WEAPONS[0].reserve },
        { ...WEAPONS[1], mag: WEAPONS[1].mag, reserve: WEAPONS[1].reserve },
        { ...WEAPONS[2], mag: WEAPONS[2].mag, reserve: WEAPONS[2].reserve },
      ],
      cur: 0,
      jumpT: 0, jumpVel: 0, jumpHeight: 0,
      hurtT: 0, muzzleT: 0, fireT: 0, bobT: 0, bobY: 0,
      reloading: 0, hitFlash: 0,
    };
  }

  // ---------- Game state ----------
  let state = 'menu'; // menu, playing, dead, win
  let kills = 0, totalEnemies = ENEMY_SPAWNS.length;
  let lastTime = 0, dt = 0;
  let msgText = '', msgTimer = 0, msgColor = '#fff';
  const keys = {};
  let mouseLocked = false;

  function resetGame() {
    locateExit();
    player = freshPlayer();
    // ensure spawn is a walkable tile
    while (isWall(player.x, player.y)) { player.x += 0.5; if (player.x > MAP_W - 1) { player.x = 0.5; player.y += 0.5; } }
    enemies = ENEMY_SPAWNS.map((s, i) => makeEnemy(s.x, s.y, i));
    pickups = PICKUP_SPAWNS.map(p => ({ x: p.x, y: p.y, type: p.type, t: 0, bob: Math.random() * 6 }));
    particles = [];
    kills = 0;
    state = 'playing';
    player.msg = '';
    msgText = ''; msgTimer = 0;
    document.getElementById('menu').classList.add('hidden');
    document.getElementById('result').classList.add('hidden');
    if (canvas.requestPointerLock) canvas.requestPointerLock();
    requestAnimationFrame(loop);
  }

  // ---------- Enemies ----------
  function makeEnemy(x, y, id) {
    return {
      x, y, id, alive: true,
      health: 30, maxHealth: 30,
      speed: 1.6 + Math.random() * 0.6,
      fireT: 300 + Math.random() * 800,
      hurtT: 0, deathT: 0,
      state: 'idle',      // idle, chase, attack, hurt
      lastSeen: 0,
      painFlash: 0,
      anim: 0,
    };
  }

  function enemySpeed(e) {
    return e.speed * (e.health < e.maxHealth * 0.4 ? 1.25 : 1.0);
  }

  // ---------- Line of sight ----------
  function hasLOS(x0, y0, x1, y1) {
    const dx = x1 - x0, dy = y1 - y0;
    const dist = Math.hypot(dx, dy);
    const steps = Math.ceil(dist * 8);
    for (let i = 1; i < steps; i++) {
      const t = i / steps;
      if (isWall(x0 + dx * t, y0 + dy * t)) return false;
    }
    return true;
  }

  // ---------- Input ----------
  window.addEventListener('keydown', e => {
    keys[e.code] = true;
    if (state === 'playing' && !player.dead) {
      if (e.code === 'Digit1') switchWeapon(0);
      if (e.code === 'Digit2') switchWeapon(1);
      if (e.code === 'Digit3') switchWeapon(2);
      if (e.code === 'KeyR') reload();
      if (e.code === 'Space') { e.preventDefault(); jump(); }
    }
  });
  window.addEventListener('keyup', e => { keys[e.code] = false; });

  canvas.addEventListener('click', () => {
    if (state === 'playing' && !mouseLocked) {
      if (canvas.requestPointerLock) canvas.requestPointerLock();
    }
  });
  document.addEventListener('pointerlockchange', () => {
    mouseLocked = (document.pointerLockElement === canvas);
  });
  document.addEventListener('mousemove', e => {
    if (mouseLocked && state === 'playing' && !player.dead) {
      player.dir += e.movementX * 0.0022;
    }
  });
  document.addEventListener('mousedown', e => {
    if (e.button === 0) { keys['fire'] = true; tryFire(); }
  });
  document.addEventListener('mouseup', e => {
    if (e.button === 0) keys['fire'] = false;
  });

  function switchWeapon(idx) {
    if (idx === player.cur || state !== 'playing') return;
    player.cur = idx; player.reloading = 0;
    SFX.gunswap();
  }

  function reload() {
    const w = player.weapons[player.cur];
    if (w.mag === w.maxMag || w.reserve === 0 || w.reserve === Infinity || player.reloading) return;
    player.reloading = 900; // ms
    SFX.reload();
  }

  function jump() {
    if (player.jumpHeight <= 0.01) {
      player.jumpVel = 1.9;
    }
  }

  // ---------- Firing ----------
  let lastFireTime = 0;
  function tryFire() {
    if (state !== 'playing' || player.dead) return;
    const w = player.weapons[player.cur];
    if (player.reloading > 0) return;
    const now = performance.now();
    if (now - lastFireTime < w.rate) return;

    if (w.mag === 0) {
      SFX.empty();
      if (w.reserve > 0 && w.maxMag !== Infinity) { player.reloading = 900; SFX.reload(); }
      return;
    }
    lastFireTime = now;
    player.fireT = w.rate;
    player.muzzleT = 60;
    if (w.mag !== Infinity) w.mag--;

    SFX[w.sound]();
    applyRecoil(w.kick);

    // Hitscan: multiple pellets for shotgun
    for (let p = 0; p < w.pellets; p++) {
      const spread = (Math.random() - 0.5) * 2 * w.spread;
      hitscan(player.x, player.y, player.dir + spread, w.dmg);
    }
  }

  function applyRecoil(kick) {
    player.dir += kick * (Math.random() - 0.3);
  }

  function hitscan(ox, oy, angle, dmg) {
    const dx = Math.cos(angle), dy = Math.sin(angle);
    // Find nearest enemy along ray (clamped by wall distance)
    let bestDist = Infinity, best = null;
    for (const e of enemies) {
      if (!e.alive) continue;
      // project enemy center onto ray
      const ex = e.x - ox, ey = e.y - oy;
      const t = ex * dx + ey * dy;
      if (t <= 0) continue;
      const cx = ox + dx * t, cy = oy + dy * t;
      const distToRay = Math.hypot(cx - e.x, cy - e.y);
      if (distToRay < 0.35 && t < bestDist) {
        if (hasLOS(ox, oy, e.x, e.y)) { bestDist = t; best = e; }
      }
    }
    // muzzle flash particle toward nearest wall too, but we mainly hit enemy
    if (best) {
      damageEnemy(best, dmg);
    }
  }

  function damageEnemy(e, dmg) {
    e.health -= dmg;
    e.painFlash = 120;
    e.state = 'chase';
    e.lastSeen = performance.now();
    SFX.enemyhurt();
    if (e.health <= 0) {
      e.alive = false; e.state = 'dead'; e.deathT = performance.now();
      kills++;
      SFX.kill();
      spawnBlood(e.x, e.y, 14);
    }
  }

  function spawnBlood(x, y, n) {
    for (let i = 0; i < n; i++) {
      particles.push({
        x, y, vx: (Math.random() - 0.5) * 2, vy: (Math.random() - 0.5) * 2,
        life: 400 + Math.random() * 300, max: 700, color: '#b00', size: 2 + Math.random() * 2,
      });
    }
  }

  // ---------- Player damage / death ----------
  function damagePlayer(dmg) {
    if (player.dead) return;
    player.health -= dmg;
    player.hitFlash = 120;
    player.hurtT = 150;
    SFX.hurt();
    flashDamage();
    if (player.health <= 0) {
      player.health = 0;
      die();
    }
  }

  function die() {
    player.dead = true;
    state = 'dead';
    if (document.pointerLockElement) document.exitPointerLock();
    SFX.lose();
    setTimeout(showResult, 1400);
  }

  function win() {
    if (state !== 'playing') return;
    state = 'win';
    if (document.pointerLockElement) document.exitPointerLock();
    SFX.win();
    setTimeout(showResult, 1200);
  }

  function showResult() {
    const rt = document.getElementById('resultText');
    const rs = document.getElementById('resultStat');
    const won = state === 'win';
    rt.textContent = won ? 'MISSION ACCOMPLISHED' : 'YOU DIED';
    rt.className = 'result ' + (won ? 'win' : 'lose');
    rs.textContent = won
      ? `Hostiles eliminated: ${kills}/${totalEnemies} · Health remaining: ${Math.ceil(player.health)}`
      : `Hostiles eliminated: ${kills}/${totalEnemies}`;
    document.getElementById('result').classList.remove('hidden');
  }

  function respawn() {
    resetGame();
  }

  function flashDamage() {
    const f = document.getElementById('damageFlash');
    f.style.transition = 'none';
    f.style.opacity = '0.55';
    requestAnimationFrame(() => {
      f.style.transition = 'opacity .35s';
      f.style.opacity = '0';
    });
  }

  // ---------- Update ----------
  function update(dtMs) {
    const dtSec = dtMs / 1000;

    // timers
    if (player.muzzleT > 0) player.muzzleT -= dtMs;
    if (player.fireT > 0) player.fireT -= dtMs;
    if (player.hurtT > 0) player.hurtT -= dtMs;
    if (player.hitFlash > 0) player.hitFlash -= dtMs;
    if (msgTimer > 0) { msgTimer -= dtMs; if (msgTimer <= 0) msgText = ''; }

    // auto-fire for automatic weapons
    if (keys['fire'] && player.weapons[player.cur].auto) tryFire();

    // reload handling
    if (player.reloading > 0) {
      player.reloading -= dtMs;
      if (player.reloading <= 0) finishReload();
    }

    // jump physics
    if (player.jumpT > 0 || player.jumpHeight > 0.01) {
      player.jumpHeight += player.jumpVel * dtSec;
      player.jumpVel -= 6.5 * dtSec;
      if (player.jumpHeight <= 0) { player.jumpHeight = 0; player.jumpVel = 0; player.jumpT = 0; }
      else player.jumpT = 1;
    }

    // Movement
    const moveSpeed = 3.2 * dtSec;
    const cos = Math.cos(player.dir), sin = Math.sin(player.dir);
    let mx = 0, my = 0;
    if (keys['KeyW'] || keys['ArrowUp']) { mx += cos; my += sin; }
    if (keys['KeyS'] || keys['ArrowDown']) { mx -= cos; my -= sin; }
    if (keys['KeyA']) { mx += sin; my -= cos; }
    if (keys['KeyD']) { mx -= sin; my += cos; }
    if (keys['ArrowLeft']) player.dir -= 2.4 * dtSec;
    if (keys['ArrowRight']) player.dir += 2.4 * dtSec;

    const len = Math.hypot(mx, my);
    if (len > 0) {
      mx = mx / len * moveSpeed; my = my / len * moveSpeed;
      player.bobT += dtSec * 10;
    }
    // collide (axis separated)
    const rad = 0.28;
    if (!isWall(player.x + mx + Math.sign(mx) * rad, player.y)) player.x += mx;
    if (!isWall(player.x, player.y + my + Math.sign(my) * rad)) player.y += my;

    // step-based bob
    player.bobY = Math.sin(player.bobT) * 0.03 * (len > 0 ? 1 : 0);

    // pickups
    for (const pk of pickups) {
      pk.bob += dtSec * 4;
      if (Math.hypot(player.x - pk.x, player.y - pk.y) < 0.4) {
        if (pk.type === 'health') {
          if (player.health >= player.maxHealth) continue;
          player.health = Math.min(player.maxHealth, player.health + 35);
          SFX.pickup();
          showMsg('+35 HEALTH', '#4dff7a');
          pk.active = false;
        } else {
          for (const w of player.weapons) {
            if (w.reserve !== Infinity) w.reserve = Math.min(w.maxMag * 4, w.reserve + w.maxMag);
          }
          player.weapons[0].mag = player.weapons[0].maxMag; // pistol always full
          SFX.pickup();
          showMsg('AMMO SUPPLIED', '#ffcc44');
          pk.active = false;
        }
      }
    }
    pickups = pickups.filter(p => p.active !== false);

    // enemies AI
    const now = performance.now();
    for (const e of enemies) {
      if (e.alive) {
        e.painFlash = Math.max(0, e.painFlash - dtMs);
        e.anim += dtSec * 6;
        const dx = player.x - e.x, dy = player.y - e.y;
        const dist = Math.hypot(dx, dy);
        const see = hasLOS(e.x, e.y, player.x, player.y) && dist < 12;
        if (see) { e.lastSeen = now; e.state = 'chase'; }
        else if (now - e.lastSeen > 2500) e.state = 'idle';

        if (e.state === 'chase') {
          const ang = Math.atan2(dy, dx);
          const sp = enemySpeed(e) * dtSec;
          const nx = e.x + Math.cos(ang) * sp, ny = e.y + Math.sin(ang) * sp;
          if (!isWall(nx, e.y)) e.x = nx;
          if (!isWall(e.x, ny)) e.y = ny;
          // attack
          e.fireT -= dtMs;
          if (see && e.fireT <= 0 && dist < 10) {
            e.fireT = 900 + Math.random() * 600;
            // projectile hits player unless dodging
            damagePlayer(6 + Math.random() * 6);
            // visible tracer handled in render
            e.tracer = { t: 80, ax: player.x, ay: player.y };
          }
        } else if (e.state === 'idle') {
          // idle wander
          e.wander = (e.wander || 0) - dtMs;
          if (e.wander <= 0) { e.wander = 1500 + Math.random() * 2000; e.wanderAng = Math.random() * 6.28; }
          const nx = e.x + Math.cos(e.wanderAng) * enemySpeed(e) * 0.4 * dtSec;
          const ny = e.y + Math.sin(e.wanderAng) * enemySpeed(e) * 0.4 * dtSec;
          if (!isWall(nx, e.y)) e.x = nx; if (!isWall(e.x, ny)) e.y = ny;
        }
      } else {
        // death animation timer cleanup
      }
      if (e.tracer) { e.tracer.t -= dtMs; if (e.tracer.t <= 0) delete e.tracer; }
    }

    // particles
    for (const pt of particles) {
      pt.life -= dtMs;
      pt.x += pt.vx * dtSec; pt.y += pt.vy * dtSec;
      pt.vy += 5 * dtSec;
    }
    particles = particles.filter(p => p.life > 0);

    // win check: all dead AND reached exit
    const remaining = enemies.filter(e => e.alive).length;
    if (remaining === 0) {
      if (Math.hypot(player.x - exitPos.x, player.y - exitPos.y) < 0.6) {
        win();
      } else if (msgTimer <= 0) {
        showMsg('ALL HOSTILES DOWN — REACH THE EXIT!', '#ffcc44', 4000);
      }
    } else if (msgTimer <= 0 && kills > 0) {
      // nothing
    }
  }

  function finishReload() {
    const w = player.weapons[player.cur];
    if (w.mag === w.maxMag || w.reserve === 0 || w.reserve === Infinity) return;
    const need = w.maxMag - w.mag;
    const take = Math.min(need, w.reserve);
    w.mag += take; w.reserve -= take;
  }

  function showMsg(t, color, dur = 2500) { msgText = t; msgColor = color || '#fff'; msgTimer = dur; }

  // ---------- Rendering ----------
  const zBuffer = new Float32Array(W);

  function render() {
    // ceiling + floor gradient
    const cg = ctx.createLinearGradient(0, 0, 0, H / 2);
    cg.addColorStop(0, '#2a2a33'); cg.addColorStop(1, '#12121a');
    ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H / 2);
    const fg = ctx.createLinearGradient(0, H / 2, 0, H);
    fg.addColorStop(0, '#3a2f24'); fg.addColorStop(1, '#1a1410');
    ctx.fillStyle = fg; ctx.fillRect(0, H / 2, W, H / 2);

    const p = player;
    const fov = Math.PI / 3;
    const jump = p.jumpHeight;

    // Walls
    for (let x = 0; x < W; x++) {
      const camX = 2 * x / W - 1;
      const rayX = p.dir - Math.tan(camX) * fov; // careful, see below
      // cast
    }
    // Proper raycast:
    const dirX = Math.cos(p.dir), dirY = Math.sin(p.dir);
    const planeLen = Math.tan(fov) * 0.9;
    const planeX = -dirY * planeLen, planeY = dirX * planeLen; // camera plane perpendicular

    for (let x = 0; x < W; x++) {
      const camX = 2 * x / W - 1;
      const rayDirX = dirX + planeX * camX;
      const rayDirY = dirY + planeY * camX;

      let mapX = p.x | 0, mapY = p.y | 0;
      const deltaX = Math.abs(1 / rayDirX), deltaY = Math.abs(1 / rayDirY);
      let stepX, stepY, sideX, sideY;
      if (rayDirX < 0) { stepX = -1; sideX = (p.x - mapX) * deltaX; }
      else { stepX = 1; sideX = (mapX + 1 - p.x) * deltaX; }
      if (rayDirY < 0) { stepY = -1; sideY = (p.y - mapY) * deltaY; }
      else { stepY = 1; sideY = (mapY + 1 - p.y) * deltaY; }

      let hit = 0, side = 0, dist = 0, cellVal = 0, wallX = 0;
      let guard = 0;
      while (hit === 0 && guard++ < 64) {
        if (sideX < sideY) { sideX += deltaX; mapX += stepX; side = 0; }
        else { sideY += deltaY; mapY += stepY; side = 1; }
        cellVal = cell(mapX, mapY);
        if (cellVal > 0) hit = 1;
      }
      if (side === 0) dist = (mapX - p.x + (1 - stepX) / 2) / rayDirX;
      else dist = (mapY - p.y + (1 - stepY) / 2) / rayDirY;
      zBuffer[x] = dist;

      if (cellVal === 9) continue; // exit gate handled as floor tile (walkable)

      const lineH = Math.min(H * 4, (H / dist));
      const y0 = (H - lineH) / 2 + jump * H / dist;
      const y1 = (H + lineH) / 2 + jump * H / dist;

      // wall color by type + shading
      const shade = side === 1 ? 0.68 : 1.0;
      const fog = Math.max(0.25, 1 - dist / 14);
      const col = wallColor(cellVal, shade * fog);
      ctx.fillStyle = col;
      ctx.fillRect(x, y0, 1, lineH);
    }

    // Draw exit gate glow if visible
    drawExitGate(dirX, dirY, planeX, planeY, fov, jump);

    // Sprites (enemies + pickups), sorted far→near
    const sprites = [];
    for (const e of enemies) sprites.push({ x: e.x, y: e.y, ref: e, kind: 'enemy' });
    for (const pk of pickups) sprites.push({ x: pk.x, y: pk.y, ref: pk, kind: 'pickup' });
    sprites.forEach(s => s.dist = (s.x - p.x) ** 2 + (s.y - p.y) ** 2);
    sprites.sort((a, b) => b.dist - a.dist);

    for (const s of sprites) {
      drawSprite(s, p, dirX, dirY, planeX, planeY, fov, zBuffer, jump);
    }

    // Particles (3D-ish)
    for (const pt of particles) {
      drawParticle(pt, p, fov, planeX, planeY, dirX, dirY);
    }

    drawHUD();
    drawMinimap();
    drawWeapon();
    drawCrosshair();
  }

  function wallColor(v, shade) {
    const base = {
      1: [120, 60, 55],   // brick red
      2: [110, 110, 125],  // steel grey
      3: [90, 95, 120],    // blue steel
      4: [120, 95, 60],    // bronze/brown
    }[v] || [120, 60, 55];
    const r = Math.round(base[0] * shade);
    const g = Math.round(base[1] * shade);
    const b = Math.round(base[2] * shade);
    return `rgb(${r},${g},${b})`;
  }

  function drawExitGate(dirX, dirY, planeX, planeY, fov, jump) {
    // exit at grid cell 9 (value 9). Find it
    if (exitPos.x === 0 && exitPos.y === 0) return;
    const dx = exitPos.x - player.x, dy = exitPos.y - player.y;
    // relative to dir
    const invDet = 1.0; // plane and dir orthonormal-ish; use dot
    const planeDot = dirX * planeX + dirY * planeY; // ~0
    const tX = dx, tY = dy;
    const fwd = dx * dirX + dy * dirY;
    const right = dx * planeX + dy * planeY;
    if (fwd <= 0.1) return;
    const screenX = 0.5 + (right / fwd) / (2 * Math.tan(fov));
    const scale = H / fwd;
    if (screenX < -0.5 || screenX > 1.5) return;
    const y0 = (H - scale) / 2 + jump * H / fwd;
    // glow
    ctx.save();
    const grd = ctx.createRadialGradient(screenX * W, y0 + scale / 2, 4, screenX * W, y0 + scale / 2, scale / 1.5);
    grd.addColorStop(0, 'rgba(80,255,120,0.9)');
    grd.addColorStop(1, 'rgba(20,120,50,0)');
    ctx.fillStyle = grd;
    ctx.fillRect(screenX * W - scale, y0 - scale / 3, scale * 2, scale * 1.6);
    ctx.fillStyle = '#2f2';
    ctx.fillRect(screenX * W - scale / 3, y0 + scale / 4, scale / 1.5, scale / 2);
    ctx.fillStyle = '#0a0';
    ctx.font = 'bold ' + Math.floor(scale / 5) + 'px Courier New';
    ctx.textAlign = 'center';
    ctx.fillText('EXIT', screenX * W, y0 + scale / 4 - 4);
    ctx.restore();
  }

  // ---- Sprite drawing with simple hand-drawn sprites ----
  function drawSprite(s, p, dirX, dirY, planeX, planeY, fov, zBuffer, jump) {
    const relX = s.x - p.x, relY = s.y - p.y;
    const invDet = 1.0 / (planeX * dirY - dirX * planeY);
    const transformX = invDet * (dirY * relX - dirX * relY);
    const transformY = invDet * (-planeY * relX + planeX * relY);
    if (transformY <= 0.1) return; // behind camera

    const screenX = (W / 2) * (1 + transformX / transformY);
    const scale = H / transformY;
    const pitch = jump * H / transformY; // jump raises sprites

    if (s.kind === 'enemy') drawEnemySprite(s.ref, screenX, scale, transformY, zBuffer, pitch);
    else drawPickupSprite(s.ref, screenX, scale, transformY, zBuffer, pitch);
  }

  function drawEnemySprite(e, screenX, scale, depth, zBuffer, pitch) {
    const w = scale * 0.7;
    const h = scale;
    const x = screenX - w / 2;
    const y = (H - h) / 2 + pitch;
    const frac = scale / W; // per-column sample spacing

    // clip by zBuffer
    const leftCol = Math.max(0, Math.floor(x));
    const rightCol = Math.min(W - 1, Math.ceil(x + w));
    let anyVisible = false;
    for (let c = leftCol; c <= rightCol; c++) {
      if (depth < zBuffer[c]) { anyVisible = true; break; }
    }
    if (!anyVisible) return;

    ctx.save();
    ctx.beginPath();
    ctx.rect(Math.max(0, x), 0, Math.min(w, W - Math.max(0, x)), H);
    ctx.clip();

    // death pose: lie down
    const dying = !e.alive;
    const flash = e.painFlash > 0;
    const walk = Math.sin(e.anim) * w * 0.06;

    // body color
    const skin = flash ? '#fff' : '#9c4a3a';
    const body = flash ? '#fff' : '#5a2d2d';
    const dark = flash ? '#ddd' : '#3a1a1a';

    // draw columns for a pseudo-3D flat look
    if (dying) {
      // fallen
      ctx.fillStyle = dark;
      ctx.fillRect(x, y + h * 0.62, w, h * 0.16);
      ctx.fillStyle = skin;
      ctx.beginPath(); ctx.arc(x + w * 0.25, y + h * 0.62, w * 0.14, 0, 6.28); ctx.fill();
    } else {
      // legs
      ctx.fillStyle = dark;
      ctx.fillRect(x + w * 0.22, y + h * 0.62, w * 0.2, h * 0.22 + walk);
      ctx.fillRect(x + w * 0.58, y + h * 0.62, w * 0.2, h * 0.22 - walk);
      // torso
      ctx.fillStyle = body;
      ctx.fillRect(x + w * 0.18, y + h * 0.28, w * 0.64, h * 0.38);
      // arms
      ctx.fillStyle = skin;
      ctx.fillRect(x + w * 0.06, y + h * 0.30, w * 0.14, h * 0.30 + walk * 0.5);
      ctx.fillRect(x + w * 0.80, y + h * 0.30, w * 0.14, h * 0.30 - walk * 0.5);
      // head
      ctx.fillStyle = skin;
      ctx.beginPath();
      ctx.arc(x + w * 0.5, y + h * 0.20, w * 0.20, 0, 6.28);
      ctx.fill();
      // eyes (glowing)
      ctx.fillStyle = flash ? '#ff0' : '#ff3020';
      ctx.beginPath();
      ctx.arc(x + w * 0.42, y + h * 0.19, w * 0.045, 0, 6.28);
      ctx.arc(x + w * 0.58, y + h * 0.19, w * 0.045, 0, 6.28);
      ctx.fill();
      // mouth / teeth
      ctx.fillStyle = dark;
      ctx.fillRect(x + w * 0.44, y + h * 0.26, w * 0.12, h * 0.03);
      // health bar above if hurt
      if (e.health < e.maxHealth) {
        ctx.fillStyle = '#000';
        ctx.fillRect(x + w * 0.2, y + h * 0.06, w * 0.6, h * 0.02);
        ctx.fillStyle = '#f33';
        ctx.fillRect(x + w * 0.2, y + h * 0.06, w * 0.6 * (e.health / e.maxHealth), h * 0.02);
      }
    }
    ctx.restore();
  }

  function drawPickupSprite(pk, screenX, scale, depth, zBuffer, pitch) {
    const s = scale * 0.35;
    const x = screenX, y = H / 2 + pitch + scale * 0.18 + Math.sin(pk.bob) * s * 0.4;
    // check column center
    const cx = Math.round(screenX);
    if (cx < 0 || cx >= W || depth >= zBuffer[cx]) return;

    ctx.save();
    if (pk.type === 'health') {
      // green medkit
      ctx.fillStyle = '#0a0';
      ctx.strokeStyle = '#050';
      ctx.lineWidth = Math.max(1, s * 0.06);
      ctx.fillRect(x - s * 0.6, y - s * 0.5, s * 1.2, s * 1.0);
      ctx.strokeRect(x - s * 0.6, y - s * 0.5, s * 1.2, s * 1.0);
      ctx.fillStyle = '#fff';
      ctx.fillRect(x - s * 0.08, y - s * 0.3, s * 0.16, s * 0.6);
      ctx.fillRect(x - s * 0.3, y - s * 0.08, s * 0.6, s * 0.16);
    } else {
      // ammo box (yellow)
      ctx.fillStyle = '#da0';
      ctx.strokeStyle = '#860';
      ctx.lineWidth = Math.max(1, s * 0.06);
      ctx.fillRect(x - s * 0.6, y - s * 0.4, s * 1.2, s * 0.85);
      ctx.strokeRect(x - s * 0.6, y - s * 0.4, s * 1.2, s * 0.85);
      ctx.fillStyle = '#640';
      ctx.fillRect(x - s * 0.5, y - s * 0.3, s * 1.0, s * 0.12);
      ctx.fillStyle = '#fff';
      ctx.font = `bold ${Math.floor(s * 0.7)}px Courier New`;
      ctx.textAlign = 'center';
      ctx.fillText('✦', x, y + s * 0.28);
    }
    ctx.restore();
  }

  function drawParticle(pt, p, fov, planeX, planeY, dirX, dirY) {
    const relX = pt.x - p.x, relY = pt.y - p.y;
    const invDet = 1.0 / (planeX * dirY - dirX * planeY);
    const tx = invDet * (dirY * relX - dirX * relY);
    const ty = invDet * (-planeY * relX + planeX * relY);
    if (ty <= 0.05) return;
    const sx = (W / 2) * (1 + tx / ty);
    const sz = Math.max(1, (H / ty) * 0.02);
    const sy = H / 2 - (ty === 0 ? 0 : 0) + H / ty * 0.0; // particles at chest height
    const alpha = Math.max(0, pt.life / pt.max);
    ctx.fillStyle = `rgba(180,20,20,${alpha})`;
    ctx.fillRect(sx - sz / 2, sy - sz / 2, sz, sz);
  }

  // ---------- HUD ----------
  function drawHUD() {
    // HUD background band
    ctx.fillStyle = 'rgba(20,10,10,0.82)';
    ctx.fillRect(0, H - 46, W, 46);
    ctx.strokeStyle = '#6b2b2b';
    ctx.lineWidth = 2;
    ctx.strokeRect(1, H - 46, W - 2, 44);

    // Health (left)
    ctx.textAlign = 'left';
    ctx.font = 'bold 13px Courier New';
    ctx.fillStyle = '#888';
    ctx.fillText('HEALTH', 18, H - 30);
    ctx.font = 'bold 30px Courier New';
    ctx.fillStyle = player.health > 30 ? '#4dff7a' : '#ff4d4d';
    ctx.fillText(Math.ceil(player.health) + '%', 18, H - 5);
    // health bar
    ctx.fillStyle = '#300';
    ctx.fillRect(96, H - 34, 130, 12);
    const hp = Math.max(0, player.health / player.maxHealth);
    ctx.fillStyle = hp > 0.5 ? '#3f6' : hp > 0.25 ? '#fb0' : '#f33';
    ctx.fillRect(96, H - 34, 130 * hp, 12);

    // Ammo (right)
    const w = player.weapons[player.cur];
    ctx.textAlign = 'right';
    ctx.font = 'bold 13px Courier New';
    ctx.fillStyle = '#888';
    ctx.fillText(w.name, W - 18, H - 30);
    ctx.font = 'bold 30px Courier New';
    ctx.fillStyle = (w.mag === 0) ? '#ff4d4d' : '#ffcc44';
    const ammoStr = w.mag === Infinity ? '∞' : (w.mag + ' / ' + w.reserve);
    ctx.fillText(ammoStr, W - 18, H - 5);
    if (player.reloading > 0) {
      ctx.fillStyle = '#fff';
      ctx.font = 'bold 16px Courier New';
      ctx.textAlign = 'center';
      ctx.fillText('RELOADING...', W / 2, H - 20);
    }

    // Kill counter (center-top)
    ctx.textAlign = 'center';
    ctx.font = 'bold 16px Courier New';
    ctx.fillStyle = '#ddd';
    ctx.fillText('KILLS ' + kills + ' / ' + totalEnemies, W / 2, 26);
    const remaining = enemies.filter(e => e.alive).length;
    ctx.font = '12px Courier New';
    ctx.fillStyle = remaining === 0 ? '#4dff7a' : '#ffcc44';
    ctx.fillText(remaining === 0 ? 'EXIT UNLOCKED' : remaining + ' REMAINING', W / 2, 42);

    // current message
    if (msgTimer > 0) {
      ctx.globalAlpha = Math.min(1, msgTimer / 400);
      ctx.font = 'bold 20px Courier New';
      ctx.fillStyle = msgColor;
      ctx.textAlign = 'center';
      ctx.fillText(msgText, W / 2, H / 2 - 60);
      ctx.globalAlpha = 1;
    }

    // weapon indicators (bottom center)
    for (let i = 0; i < 3; i++) {
      const wx = W / 2 - 60 + i * 40;
      ctx.fillStyle = i === player.cur ? '#ffcc44' : '#555';
      ctx.font = 'bold 11px Courier New';
      ctx.textAlign = 'center';
      ctx.fillText((i + 1) + '', wx, H - 12);
    }

    // hit flash
    if (player.hitFlash > 0) {
      ctx.fillStyle = `rgba(255,0,0,${(player.hitFlash / 120) * 0.35})`;
      ctx.fillRect(0, 0, W, H);
    }
  }

  function drawMinimap() {
    const size = 120, cellS = size / MAP_W;
    const mx = W - size - 10, my = 10;
    ctx.globalAlpha = 0.75;
    ctx.fillStyle = '#000';
    ctx.fillRect(mx, my, size, size * (MAP_H / MAP_W));
    for (let y = 0; y < MAP_H; y++) {
      for (let x = 0; x < MAP_W; x++) {
        const c = cell(x, y);
        if (c === 9) ctx.fillStyle = '#2f2';
        else if (c > 0) ctx.fillStyle = wallColor(c, 0.9);
        else ctx.fillStyle = '#1a1a1a';
        ctx.fillRect(mx + x * cellS, my + y * cellS, cellS + 0.5, cellS + 0.5);
      }
    }
    // enemies
    for (const e of enemies) {
      ctx.fillStyle = e.alive ? '#f44' : '#822';
      ctx.fillRect(mx + e.x * cellS - 1, my + e.y * cellS - 1, 2.5, 2.5);
    }
    // pickups
    for (const pk of pickups) {
      ctx.fillStyle = pk.type === 'health' ? '#4dff7a' : '#ffcc44';
      ctx.fillRect(mx + pk.x * cellS - 1, my + pk.y * cellS - 1, 2.5, 2.5);
    }
    // player
    ctx.fillStyle = '#fff';
    ctx.fillRect(mx + player.x * cellS - 1.5, my + player.y * cellS - 1.5, 3, 3);
    ctx.strokeStyle = '#fff';
    ctx.beginPath();
    ctx.moveTo(mx + player.x * cellS, my + player.y * cellS);
    ctx.lineTo(mx + player.x * cellS + Math.cos(player.dir) * 6, my + player.y * cellS + Math.sin(player.dir) * 6);
    ctx.stroke();
    ctx.globalAlpha = 1;
  }

  // ---------- Weapon view (first person) ----------
  function drawWeapon() {
    const w = player.weapons[player.cur];
    const bobX = Math.sin(player.bobT) * 8 * (player.bobY > 0 ? 1 : 0);
    const bobY = Math.abs(Math.cos(player.bobT)) * 6 * (player.bobY > 0 ? 1 : 0);
    const kick = player.fireT > 0 ? (player.fireT / 60) * 26 : 0;
    const reloading = player.reloading > 0;
    const relRot = reloading ? Math.sin((900 - player.reloading) / 900 * Math.PI) * 0.5 : 0;

    const cx = W / 2 + bobX;
    const baseY = H + 20; // offscreen bottom
    const topY = baseY - kick + bobY;

    ctx.save();
    ctx.translate(cx, topY);
    ctx.rotate(relRot);

    if (w.name === 'SHOTGUN') drawShotgun();
    else if (w.name === 'SMG') drawSMG();
    else drawPistol();

    // muzzle flash
    if (player.muzzleT > 0) {
      const a = player.muzzleT / 60;
      ctx.save();
      const fx = w.name === 'SHOTGUN' ? 0 : (w.name === 'SMG' ? -4 : 6);
      ctx.translate(fx, -150);
      ctx.fillStyle = `rgba(255,220,120,${a})`;
      ctx.beginPath();
      for (let i = 0; i < 10; i++) {
        const ang = (i / 10) * 6.28;
        const r = 30 + Math.random() * 26;
        ctx.lineTo(Math.cos(ang) * r, Math.sin(ang) * r);
      }
      ctx.closePath(); ctx.fill();
      ctx.fillStyle = `rgba(255,255,220,${a})`;
      ctx.beginPath(); ctx.arc(0, 0, 14, 0, 6.28); ctx.fill();
      ctx.restore();
    }
    ctx.restore();
  }

  function drawPistol() {
    // barrel
    ctx.fillStyle = '#2b2b2f';
    ctx.fillRect(-8, -150, 16, 90);
    ctx.fillStyle = '#1a1a1d';
    ctx.fillRect(-6, -150, 4, 90);
    // slide
    ctx.fillStyle = '#3a3a40';
    ctx.fillRect(-14, -66, 28, 30);
    // grip
    ctx.fillStyle = '#4a3a2a';
    ctx.beginPath();
    ctx.moveTo(-14, -36); ctx.lineTo(14, -36);
    ctx.lineTo(18, 40); ctx.lineTo(-18, 40); ctx.closePath();
    ctx.fill();
    // hand
    ctx.fillStyle = '#c68b5a';
    ctx.fillRect(-22, -30, 44, 72);
    ctx.fillStyle = '#a8704a';
    ctx.fillRect(-22, 30, 44, 14);
  }

  function drawSMG() {
    // barrel
    ctx.fillStyle = '#242428';
    ctx.fillRect(-7, -150, 14, 80);
    // suppressor-ish
    ctx.fillStyle = '#333';
    ctx.fillRect(-10, -150, 20, 24);
    // body
    ctx.fillStyle = '#38383d';
    ctx.fillRect(-20, -72, 40, 40);
    // magazine
    ctx.fillStyle = '#222';
    ctx.fillRect(-10, -34, 14, 40);
    // grip
    ctx.fillStyle = '#4a3a2a';
    ctx.beginPath();
    ctx.moveTo(-14, -34); ctx.lineTo(14, -34);
    ctx.lineTo(16, 44); ctx.lineTo(-16, 44); ctx.closePath();
    ctx.fill();
    // hand
    ctx.fillStyle = '#c68b5a';
    ctx.fillRect(-24, -30, 48, 74);
    ctx.fillStyle = '#a8704a';
    ctx.fillRect(-24, 30, 48, 14);
  }

  function drawShotgun() {
    // double barrel
    ctx.fillStyle = '#2a2a2e';
    ctx.fillRect(-18, -155, 12, 95);
    ctx.fillRect(6, -155, 12, 95);
    ctx.fillStyle = '#19191c';
    ctx.fillRect(-17, -155, 3, 95);
    ctx.fillRect(7, -155, 3, 95);
    // pump / body
    ctx.fillStyle = '#4a3a2a';
    ctx.fillRect(-22, -64, 44, 34);
    ctx.fillStyle = '#333';
    ctx.fillRect(-16, -32, 32, 12);
    // grip
    ctx.fillStyle = '#5a4530';
    ctx.beginPath();
    ctx.moveTo(-16, -32); ctx.lineTo(16, -32);
    ctx.lineTo(20, 46); ctx.lineTo(-20, 46); ctx.closePath();
    ctx.fill();
    // hand
    ctx.fillStyle = '#c68b5a';
    ctx.fillRect(-26, -28, 52, 74);
    ctx.fillStyle = '#a8704a';
    ctx.fillRect(-26, 30, 52, 14);
  }

  function drawCrosshair() {
    if (state !== 'playing' || player.dead) return;
    const cx = W / 2, cy = H / 2;
    ctx.strokeStyle = 'rgba(255,255,255,0.8)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(cx - 10, cy); ctx.lineTo(cx - 3, cy);
    ctx.moveTo(cx + 3, cy); ctx.lineTo(cx + 10, cy);
    ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy - 3);
    ctx.moveTo(cx, cy + 3); ctx.lineTo(cx, cy + 10);
    ctx.stroke();
    ctx.fillStyle = 'rgba(255,80,80,0.9)';
    ctx.beginPath(); ctx.arc(cx, cy, 1.6, 0, 6.28); ctx.fill();
  }

  // ---------- Main loop ----------
  function loop(t) {
    if (state !== 'playing' && state !== 'dead') return;
    dt = t - lastTime; lastTime = t;
    if (dt > 50) dt = 50;
    if (state === 'playing') update(dt);
    render();
    if (state === 'playing' || state === 'dead') requestAnimationFrame(loop);
  }

  // ---------- Boot ----------
  document.getElementById('startBtn').addEventListener('click', () => {
    initAudio();
    resetGame();
  });
  document.getElementById('againBtn').addEventListener('click', () => {
    initAudio();
    resetGame();
  });

  // draw a static preview behind the menu
  function menuPreview() {
    if (state !== 'menu') return;
    player = freshPlayer();
    render();
    requestAnimationFrame(menuPreview);
  }
  menuPreview();

})();
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"2cdf9827-912a-4518-b7c7-767b1466aae4","tokensIn":2110921,"tokensOut":37964,"tokensTotal":2148885,"cost":0,"turns":47,"toolCalls":46,"failedToolCalls":2,"timestamp":"2026-08-23T19:05:01.047Z"} -->