← DOOM results

DOOM

Claude haiku 5.5 · full

Initial view of DOOM

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · aed2baef24
Create a retro FPS game inspired by (but legally distinct from) classics like DOOM and Wolfenstein, focus on fun and engaging gameplay. Include 3 weapons, ammo and health pickups, player death/respawn and a win condition
Inspect original source 41,058 bytes · SHA-256 d2076317b176
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>NEON DEPTHS - Sector 9 Retro FPS</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#050405;overflow:hidden;font-family:"Courier New",Courier,monospace;user-select:none}
#app{position:fixed;inset:0;display:flex;align-items:center;justify-content:center}
#view{position:relative;width:min(100vw,160vh);height:min(100vh,62.5vw);overflow:hidden;background:#000;color:#ffd27a;font-size:calc(min(100vw,160vh)/64);cursor:crosshair}
#scr{position:absolute;inset:0;width:100%;height:100%;image-rendering:pixelated}
#dmg{position:absolute;inset:0;pointer-events:none;opacity:0;background:radial-gradient(ellipse at center,rgba(255,0,0,0) 35%,rgba(255,20,0,.85) 100%)}
#pickflash{position:absolute;inset:0;pointer-events:none;opacity:0;background:rgba(90,255,170,.18)}
#cross{position:absolute;left:50%;top:50%;width:.9em;height:.9em;transform:translate(-50%,-50%);pointer-events:none}
#cross::before,#cross::after{content:'';position:absolute;background:#fff;box-shadow:0 0 0 1px #000;transition:background .08s}
#cross::before{left:50%;top:0;width:.12em;height:100%;transform:translateX(-50%)}
#cross::after{top:50%;left:0;height:.12em;width:100%;transform:translateY(-50%)}
#cross.hit::before,#cross.hit::after{background:#ff4040}
#topL{position:absolute;right:.8em;top:.8em;text-align:right;line-height:1.5;text-shadow:0 0 .2em #000,0 .1em 0 #000;font-size:.95em}
#topL b{color:#fff}
#msgs{position:absolute;left:.8em;top:.8em;display:flex;flex-direction:column;gap:.25em;pointer-events:none;max-width:60%}
#msgs div{background:rgba(0,0,0,.55);padding:.2em .5em;border-left:.25em solid #ffb347;font-size:.95em;animation:fadeout 3.2s forwards}
@keyframes fadeout{0%,75%{opacity:1}100%{opacity:0}}
#hud{position:absolute;left:0;right:0;bottom:0;height:4.4em;display:flex;align-items:stretch;background:linear-gradient(#2c1b10,#0d0705);border-top:.2em solid #8a5a24;box-shadow:0 -.3em 1em rgba(0,0,0,.6)}
.blk{padding:.4em .7em;display:flex;flex-direction:column;justify-content:center;border-right:.15em solid #4a2e14}
.lbl{font-size:.7em;letter-spacing:.12em;color:#c9a06a}
.num{font-size:1.9em;font-weight:bold;color:#fff;line-height:1.1;text-shadow:0 0 .3em #ff7a20}
.bar{width:9em;height:.45em;background:#1a0e08;border:1px solid #6b4520;margin-top:.2em}
#hpbar{height:100%;width:100%;background:linear-gradient(#ff5a4a,#b8201a);transition:width .15s}
#wblk{flex:1;align-items:flex-start}
#wname{font-size:1.1em;color:#ffe08a;letter-spacing:.1em}
#wammo{font-size:1.7em;font-weight:bold;color:#fff}
#slots{display:flex;gap:.35em;align-items:center;padding:0 .8em;border-right:.15em solid #4a2e14}
.slot{width:9.5em;white-space:nowrap;padding:.3em .4em;border:1px solid #6b4520;background:rgba(0,0,0,.35);font-size:.75em;line-height:1.35;color:#b99866}
.slot.on{border-color:#ffc04a;background:rgba(255,160,40,.18);color:#fff;box-shadow:0 0 .4em rgba(255,160,40,.5)}
#exitState{display:flex;align-items:center;padding:0 .8em;font-size:.8em;color:#ff8a7a;letter-spacing:.06em;max-width:16em;line-height:1.3}
#exitState.open{color:#7dff9a}
.ov{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:rgba(0,0,0,.72);text-align:center;padding:1em;line-height:1.5}
.ov.hidden{display:none}
.ov h1{font-size:2.6em;margin:0;color:#ffcf5a;text-shadow:0 0 .3em #ff4a10,.12em .12em 0 #6a1a00;letter-spacing:.08em}
.ov h2{font-size:2.2em;margin:0;color:#ff5a4a;text-shadow:0 0 .3em #000}
.ov h2.win{color:#7dff9a;text-shadow:0 0 .3em #0a4}
.ov p{margin:.3em 0;font-size:.95em;color:#e8d4b0;max-width:36em}
.ov .sub{color:#9a7a50;font-size:.85em}
.btn{margin-top:1em;padding:.6em 1.4em;font-family:inherit;font-size:1em;font-weight:bold;letter-spacing:.1em;background:#b5561c;color:#fff;border:.15em solid #ffb347;cursor:pointer;box-shadow:0 .25em 0 #5a2a0a}
.btn:hover{background:#d0681f}
.btn:active{transform:translateY(.15em);box-shadow:none}
kbd{background:#2a1a10;border:1px solid #8a5a24;padding:0 .3em;color:#ffd27a}
</style>
</head>
<body>
<div id="app">
  <div id="view">
    <canvas id="scr"></canvas>
    <div id="dmg"></div>
    <div id="pickflash"></div>
    <div id="cross"></div>
    <div id="topL">
      <div>TIME <b id="time">0:00</b></div>
      <div>KILLS <b id="kills">0</b>/<span id="total">0</span></div>
      <div>DEATHS <b id="deaths">0</b></div>
    </div>
    <div id="msgs"></div>
    <div id="hud">
      <div class="blk" style="min-width:11em">
        <div class="lbl">HEALTH</div>
        <div class="num" id="hp">100</div>
        <div class="bar"><div id="hpbar"></div></div>
      </div>
      <div class="blk" id="wblk">
        <div class="lbl">WEAPON</div>
        <div id="wname">SIDEARM</div>
        <div id="wammo">0</div>
      </div>
      <div id="slots"></div>
      <div id="exitState">EXIT SEALED</div>
    </div>

    <div class="ov" id="ov-title">
      <h1>NEON DEPTHS</h1>
      <p class="sub">SECTOR 9 // HOLLOW FORGE // HOSTILE CONTACT</p>
      <p>Clear every hostile in the complex, then reach the <b style="color:#7dff9a">green exit gate</b> to win the sector.</p>
      <p>Collect <b>health</b>, <b>bullets</b>, <b>shells</b> and <b>cells</b> to keep fighting. You will respawn if you fall, but your progress stays.</p>
      <p class="sub">MOVE <kbd>W A S D</kbd>/<kbd>ARROWS</kbd> &nbsp; LOOK <kbd>MOUSE</kbd> or <kbd>Q E</kbd> &nbsp; FIRE <kbd>CLICK</kbd>/<kbd>SPACE</kbd><br>WEAPONS <kbd>1 2 3</kbd> or <kbd>WHEEL</kbd> &nbsp; ESC releases mouse</p>
      <button class="btn" id="startBtn">DEPLOY</button>
    </div>
    <div class="ov hidden" id="ov-dead">
      <h2>YOU DIED</h2>
      <p>Reinforcements inbound in <b id="respawnT">3</b>...</p>
      <p class="sub">Your arsenal is preserved. The hostiles are not.</p>
    </div>
    <div class="ov hidden" id="ov-win">
      <h2 class="win">SECTOR CLEARED</h2>
      <p id="winStats"></p>
      <p class="sub">Press ENTER or click to play again.</p>
      <button class="btn" id="againBtn">PLAY AGAIN</button>
    </div>
  </div>
</div>

<script>
(() => {
'use strict';

// ---------------- CONSTANTS ----------------
const W = 320, H = 200, TS = 64;
const FOV = 0.66;
const PLAYER_R = 0.22;
const MAX_HP = 100;
const AMMO_MAX = { bullets: 200, shells: 48, cells: 150 };
const AMMO_PICK = { bullets: 20, shells: 8, cells: 15 };
const HEALTH_PICK = 25;
const RESPAWN_TIME = 3;

const WEAPONS = [
  { name: 'SIDEARM',     ammo: 'bullets', cool: 0.32, pellets: 1, spread: 0.015, dmg: 18, auto: false, snd: 'pistol',  range: 30 },
  { name: 'SCATTER GUN', ammo: 'shells',  cool: 0.9,  pellets: 8, spread: 0.13,  dmg: 8,  auto: false, snd: 'shotgun', range: 14 },
  { name: 'ARC RIFLE',   ammo: 'cells',   cool: 0.13, pellets: 1, spread: 0.035, dmg: 12, auto: true,  snd: 'rifle',   range: 30 }
];

const ENEMY_DEF = {
  grunt: { hp: 30, speed: 1.5, dmg: 7,  cool: 1.2, acc: 0.6, rng: 9,   melee: false, hit: 0.4,  hh: 1.0, color: '#d8643a', corpse: '#7a2a1a' },
  brute: { hp: 90, speed: 1.0, dmg: 14, cool: 1.1, acc: 0.8, rng: 1.9, melee: true,  hit: 0.55, hh: 1.1, color: '#7a52b0', corpse: '#3b1a4a' }
};

const PICK_INFO = {
  health:  { label: 'HEALTH' },
  bullets: { label: 'BULLETS' },
  shells:  { label: 'SHELLS' },
  cells:   { label: 'CELLS' }
};

// ---------------- MAP ----------------
// digits = walls (1 brick, 2 metal, 3 stone, 4 tech)
// P player start, E grunt, B brute, X exit gate
// h health, b bullets, s shells, c cells
const RAW = [
  "111111111111111111111111",
  "1P.....1.......1......X1",
  "1......2...E...1...E...1",
  "1..h...1.......1.......1",
  "1......1...b...22222...1",
  "111.1111.......1.......1",
  "1.....1........1...s...1",
  "1.E...1...4444.1...B...1",
  "1.....1...4..4.1.......1",
  "1.....2...4..4.........1",
  "1.....1...4444.........1",
  "1.....1................1",
  "1111.11111.11111.11.1111",
  "1.....1.....1...1......1",
  "1..c..1.E...1...1..E...1",
  "1.....2.....1.........h1",
  "1.....1.....1...1......1",
  "1.....1..b..1...1.s....1",
  "1..E..1.....11.11......1",
  "1.....1................1",
  "1.....1.....E.....B....1",
  "1.....2....c.......b...1",
  "1..h..1.................1",
  "111111111111111111111111"
];
const MW = Math.max(...RAW.map(r => r.length));
const MH = RAW.length;
const grid = new Uint8Array(MW * MH);
let SPAWN = { x: 1.5, y: 1.5 };
let EXIT = { x: -1, y: -1 };
const ENEMY_SPAWNS = [];
const PICK_SPAWNS = [];
for (let y = 0; y < MH; y++) {
  for (let x = 0; x < MW; x++) {
    const ch = RAW[y][x] || '1';
    const i = y * MW + x;
    if (ch >= '1' && ch <= '4') { grid[i] = +ch; continue; }
    if (ch === 'P') SPAWN = { x: x + 0.5, y: y + 0.5 };
    else if (ch === 'X') EXIT = { x, y };
    else if (ch === 'E') ENEMY_SPAWNS.push({ type: 'grunt', x: x + 0.5, y: y + 0.5 });
    else if (ch === 'B') ENEMY_SPAWNS.push({ type: 'brute', x: x + 0.5, y: y + 0.5 });
    else if (ch === 'h') PICK_SPAWNS.push({ kind: 'health', x: x + 0.5, y: y + 0.5 });
    else if (ch === 'b') PICK_SPAWNS.push({ kind: 'bullets', x: x + 0.5, y: y + 0.5 });
    else if (ch === 's') PICK_SPAWNS.push({ kind: 'shells', x: x + 0.5, y: y + 0.5 });
    else if (ch === 'c') PICK_SPAWNS.push({ kind: 'cells', x: x + 0.5, y: y + 0.5 });
  }
}

function cellAt(x, y) {
  if (x < 0 || y < 0 || x >= MW || y >= MH) return 1;
  return grid[y * MW + x];
}
function solid(x, y) { return cellAt(Math.floor(x), Math.floor(y)) !== 0; }
function blocked(x, y, r) {
  return solid(x - r, y - r) || solid(x + r, y - r) || solid(x - r, y + r) || solid(x + r, y + r);
}
function tryMove(o, mx, my, r) {
  if (!blocked(o.x + mx, o.y, r)) o.x += mx;
  if (!blocked(o.x, o.y + my, r)) o.y += my;
}
function los(x0, y0, x1, y1) {
  const dx = x1 - x0, dy = y1 - y0;
  const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / 0.25));
  for (let i = 1; i < steps; i++) {
    const t = i / steps;
    if (solid(x0 + dx * t, y0 + dy * t)) return false;
  }
  return true;
}

// DDA ray cast; returns perpendicular distance, side, wall id
function dda(px, py, rx, ry) {
  if (Math.abs(rx) < 1e-6) rx = 1e-6;
  if (Math.abs(ry) < 1e-6) ry = 1e-6;
  let mx = Math.floor(px), my = Math.floor(py);
  const ddx = Math.abs(1 / rx), ddy = Math.abs(1 / ry);
  let sx, sy, sdx, sdy;
  if (rx < 0) { sx = -1; sdx = (px - mx) * ddx; } else { sx = 1; sdx = (mx + 1 - px) * ddx; }
  if (ry < 0) { sy = -1; sdy = (py - my) * ddy; } else { sy = 1; sdy = (my + 1 - py) * ddy; }
  let side = 0, w = 0;
  for (let i = 0; i < 300; i++) {
    if (sdx < sdy) { sdx += ddx; mx += sx; side = 0; }
    else { sdy += ddy; my += sy; side = 1; }
    w = cellAt(mx, my);
    if (w) break;
  }
  const dist = side === 0 ? sdx - ddx : sdy - ddy;
  return { dist, side, w };
}

// ---------------- PROCEDURAL TEXTURES ----------------
function pack(r, g, b) { return ((255 << 24) | ((b & 255) << 16) | ((g & 255) << 8) | (r & 255)) >>> 0; }
function nz(x, y, s) {
  let h = Math.imul(x + s * 131, 374761393) + Math.imul(y + s * 17, 668265263);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
}
function genTex(fn) {
  const a = new Uint32Array(TS * TS);
  for (let y = 0; y < TS; y++) for (let x = 0; x < TS; x++) {
    const c = fn(x, y);
    a[y * TS + x] = pack(c[0], c[1], c[2]);
  }
  return a;
}
const TEX_BRICK = genTex((x, y) => {
  const row = y >> 4, off = (row & 1) ? 8 : 0;
  const bx = (x + off) & 31, by = y & 15;
  if (bx < 2 || by < 2) return [70, 64, 58];
  const v = 0.72 + 0.38 * nz(((x + off) >> 5) + row * 7, row, 1) + 0.08 * nz(x, y, 2);
  return [142 * v, 58 * v, 44 * v];
});
const TEX_METAL = genTex((x, y) => {
  const n = nz(x, y, 5) * 14 - 7;
  let r = 112, g = 124, b = 138;
  if (x < 2 || y < 2 || x > 61 || y > 61) { r = 58; g = 64; b = 74; }
  if (x % 32 === 0 || y % 32 === 0) { r -= 25; g -= 25; b -= 25; }
  for (const [cx, cy] of [[8, 8], [56, 8], [8, 56], [56, 56]]) {
    const d = (x - cx) * (x - cx) + (y - cy) * (y - cy);
    if (d < 9) { r = 200; g = 210; b = 220; }
    else if (d < 12) { r = 60; g = 66; b = 76; }
  }
  return [r + n, g + n, b + n];
});
const TEX_STONE = genTex((x, y) => {
  const n = nz(x, y, 9) * 14 - 7;
  let r = 78, g = 92, b = 116;
  if ((x & 31) < 2 || (y & 31) < 2) { r = 38; g = 44; b = 58; }
  return [r + n, g + n, b + n];
});
const TEX_TECH = genTex((x, y) => {
  const n = nz(x, y, 11) * 10 - 5;
  let r = 36, g = 56, b = 50;
  if ((x & 15) === 0) { r += 18; g += 22; b += 20; }
  if (y >= 28 && y <= 31) { const k = 0.8 + 0.2 * nz(x, 0, 4); r = 40 * k; g = 230 * k; b = 190 * k; }
  return [r + n, g + n, b + n];
});
const TEX_FLOOR = genTex((x, y) => {
  const n = nz(x, y, 21) * 16 - 8;
  const c = ((x >> 4) + (y >> 4)) & 1 ? 1.08 : 0.9;
  let r = 58 * c, g = 52 * c, b = 46 * c;
  if ((x & 15) < 1 || (y & 15) < 1) { r *= 0.6; g *= 0.6; b *= 0.6; }
  return [r + n, g + n, b + n];
});
const TEX_CEIL = genTex((x, y) => {
  const n = nz(x, y, 31) * 10 - 5;
  let r = 38, g = 36, b = 48;
  if ((x & 31) < 2 || (y & 31) < 2) { r = 66; g = 62; b = 80; }
  if ((x & 31) > 12 && (x & 31) < 20 && (y & 31) > 12 && (y & 31) < 20) { r += 14; g += 14; b += 20; }
  return [r + n, g + n, b + n];
});
const TEX_EXIT_LOCKED = genTex((x, y) => (((x + y) >> 3) & 1) ? [120, 26, 18] : [54, 12, 10]);
const TEX_EXIT_OPEN = genTex((x, y) => (((x + y) >> 3) & 1) ? [70, 255, 140] : [18, 130, 70]);
const WALL_TEX = [TEX_BRICK, TEX_METAL, TEX_STONE, TEX_TECH];

// ---------------- SPRITES ----------------
function mk(w, h, fn) {
  const c = document.createElement('canvas');
  c.width = w; c.height = h;
  fn(c.getContext('2d'), w, h);
  return c;
}
function pinkTint(g, w, h) {
  g.globalCompositeOperation = 'source-atop';
  g.fillStyle = 'rgba(255,255,255,0.7)';
  g.fillRect(0, 0, w, h);
  g.globalCompositeOperation = 'source-over';
}
function gruntSprite(pose) {
  return mk(48, 64, (g, w, h) => {
    const body = '#d8643a', dark = '#7a2a1a', horn = '#efe2b0';
    g.fillStyle = '#3b1f1a'; g.fillRect(14, 46, 8, 16); g.fillRect(26, 46, 8, 16);
    g.fillStyle = dark; g.fillRect(12, 60, 10, 4); g.fillRect(26, 60, 10, 4);
    g.fillStyle = body;
    if (pose === 'attack') {
      g.fillRect(4, 26, 7, 18);
      g.fillRect(33, 22, 13, 6);
      g.fillStyle = '#2a2a30'; g.fillRect(39, 19, 9, 6);
    } else {
      g.fillRect(4, 26, 7, 18); g.fillRect(37, 26, 7, 18);
    }
    g.fillStyle = body; g.fillRect(12, 24, 24, 24);
    g.fillStyle = dark; g.fillRect(12, 40, 24, 4);
    g.fillStyle = '#f0c040'; g.fillRect(22, 40, 4, 4);
    g.fillStyle = body; g.beginPath(); g.arc(24, 16, 11, 0, Math.PI * 2); g.fill();
    g.fillStyle = horn;
    g.beginPath(); g.moveTo(15, 9); g.lineTo(10, 0); g.lineTo(18, 6); g.fill();
    g.beginPath(); g.moveTo(33, 9); g.lineTo(38, 0); g.lineTo(30, 6); g.fill();
    g.fillStyle = '#ffe14a'; g.fillRect(17, 12, 5, 4); g.fillRect(26, 12, 5, 4);
    g.fillStyle = '#200'; g.fillRect(20, 13, 2, 3); g.fillRect(28, 13, 2, 3);
    g.fillStyle = '#2a0a05'; g.fillRect(18, 21, 12, 3);
    g.fillStyle = horn; g.fillRect(19, 21, 2, 2); g.fillRect(25, 21, 2, 2); g.fillRect(28, 21, 2, 2);
    if (pose === 'attack') {
      g.fillStyle = '#ffd23a'; g.beginPath(); g.arc(50, 22, 6, 0, Math.PI * 2); g.fill();
      g.fillStyle = '#fff'; g.beginPath(); g.arc(50, 22, 3, 0, Math.PI * 2); g.fill();
    }
    if (pose === 'pain') pinkTint(g, w, h);
  });
}
function bruteSprite(pose) {
  return mk(48, 64, (g, w, h) => {
    const body = '#5c3a8a', head = '#7a52b0', dark = '#2a1840';
    g.fillStyle = dark; g.fillRect(9, 46, 11, 18); g.fillRect(28, 46, 11, 18);
    g.fillStyle = body; g.fillRect(6, 20, 36, 28);
    g.fillStyle = '#8e6ac4'; g.fillRect(2, 18, 12, 8); g.fillRect(34, 18, 12, 8);
    g.fillStyle = '#4a2c70';
    if (pose === 'attack') { g.fillRect(0, 8, 9, 14); g.fillRect(39, 8, 9, 14); }
    else { g.fillRect(0, 26, 8, 22); g.fillRect(40, 26, 8, 22); }
    g.fillStyle = head; g.fillRect(12, 4, 24, 18);
    g.fillStyle = '#3a2358'; g.fillRect(12, 4, 24, 3);
    g.fillStyle = '#ff3050'; g.fillRect(19, 10, 10, 5);
    g.fillStyle = '#ffd0d8'; g.fillRect(22, 11, 4, 3);
    g.fillStyle = '#e8e0c0';
    for (let i = 0; i < 4; i++) g.fillRect(14 + i * 6, 18, 3, 4);
    g.fillStyle = dark; g.fillRect(6, 40, 36, 3);
    if (pose === 'attack') {
      g.strokeStyle = '#ff9040'; g.lineWidth = 2;
      g.beginPath(); g.arc(24, 30, 28, 0, Math.PI * 2); g.stroke();
    }
    if (pose === 'pain') pinkTint(g, w, h);
  });
}
function corpseSprite(type) {
  const D = ENEMY_DEF[type];
  return mk(64, 32, g => {
    g.fillStyle = D.corpse;
    g.beginPath(); g.ellipse(32, 26, 30, 6, 0, 0, Math.PI * 2); g.fill();
    g.fillStyle = D.color; g.fillRect(12, 18, 30, 10);
    g.beginPath(); g.arc(47, 22, 8, 0, Math.PI * 2); g.fill();
    g.fillStyle = D.corpse; g.fillRect(6, 22, 10, 4); g.fillRect(40, 28, 12, 2);
  });
}
function pickSprite(kind) {
  return mk(32, 32, g => {
    g.fillStyle = 'rgba(0,0,0,.5)'; g.fillRect(2, 2, 28, 28);
    if (kind === 'health') {
      g.fillStyle = '#1a1a1a'; g.fillRect(2, 4, 28, 24);
      g.fillStyle = '#f2f2f2'; g.fillRect(4, 6, 24, 20);
      g.fillStyle = '#e0242b'; g.fillRect(13, 9, 6, 14); g.fillRect(9, 13, 14, 6);
    } else if (kind === 'bullets') {
      g.fillStyle = '#2e4a2a'; g.fillRect(4, 18, 24, 10);
      g.fillStyle = '#e8b33a';
      for (let i = 0; i < 4; i++) { g.fillRect(7 + i * 5, 6, 3, 14); }
      g.fillStyle = '#fff3b0';
      for (let i = 0; i < 4; i++) { g.fillRect(7 + i * 5, 6, 3, 2); }
    } else if (kind === 'shells') {
      for (let i = 0; i < 3; i++) {
        g.fillStyle = '#c42a2a'; g.fillRect(5 + i * 9, 6, 6, 20);
        g.fillStyle = '#f3d14a'; g.fillRect(5 + i * 9, 6, 6, 4);
      }
    } else if (kind === 'cells') {
      g.fillStyle = '#16303a'; g.fillRect(4, 8, 22, 18);
      g.fillStyle = '#2ab8d0'; g.fillRect(7, 11, 16, 12);
      g.fillStyle = '#4ff0ff'; g.fillRect(26, 13, 4, 8);
      g.fillStyle = '#d8ffff'; g.fillRect(8, 12, 6, 2);
    }
  });
}
const ENEMY_SPR = {
  grunt: { idle: gruntSprite('idle'), attack: gruntSprite('attack'), pain: gruntSprite('pain') },
  brute: { idle: bruteSprite('idle'), attack: bruteSprite('attack'), pain: bruteSprite('pain') }
};
const CORPSE_SPR = { grunt: corpseSprite('grunt'), brute: corpseSprite('brute') };
const PICK_SPR = { health: pickSprite('health'), bullets: pickSprite('bullets'), shells: pickSprite('shells'), cells: pickSprite('cells') };

// Viewmodel canvas
const VM = document.createElement('canvas');
VM.width = 160; VM.height = 120;
const vg = VM.getContext('2d');
function drawViewmodel() {
  vg.clearRect(0, 0, 160, 120);
  const skin = '#c4875c';
  if (P.wi === 0) {
    vg.fillStyle = '#5a3520'; vg.fillRect(72, 70, 16, 34);
    vg.fillStyle = '#50555f'; vg.fillRect(60, 56, 40, 16);
    vg.fillStyle = '#8a909c'; vg.fillRect(60, 56, 40, 2);
    vg.fillStyle = '#111'; vg.fillRect(96, 52, 4, 5);
    vg.fillStyle = '#222'; vg.fillRect(84, 72, 8, 4);
    vg.fillStyle = skin; vg.fillRect(66, 84, 26, 36);
    vg.fillStyle = '#9a6a44'; vg.fillRect(66, 90, 26, 2);
  } else if (P.wi === 1) {
    vg.fillStyle = '#7a4826'; vg.fillRect(110, 70, 42, 34);
    vg.fillStyle = '#5a3418'; vg.fillRect(110, 70, 42, 3);
    vg.fillStyle = '#3f444d'; vg.fillRect(14, 54, 130, 8);
    vg.fillStyle = '#30343c'; vg.fillRect(14, 62, 130, 8);
    vg.fillStyle = '#6b3f22'; vg.fillRect(62, 70, 36, 12);
    vg.fillStyle = '#8a5a34'; vg.fillRect(62, 70, 36, 2);
    vg.fillStyle = skin; vg.fillRect(58, 82, 26, 36);
  } else {
    vg.fillStyle = '#2a2a30'; vg.fillRect(76, 84, 14, 30);
    vg.fillStyle = '#3b5a64'; vg.fillRect(34, 62, 92, 26);
    vg.fillStyle = '#2a3f47'; vg.fillRect(50, 56, 60, 6);
    vg.fillStyle = '#22343a'; vg.fillRect(120, 64, 22, 12);
    vg.fillStyle = '#1a2a30'; vg.fillRect(58, 70, 24, 14);
    vg.fillStyle = '#4ff0ff'; vg.fillRect(62, 73, 18, 2); vg.fillRect(62, 79, 18, 2);
    vg.fillStyle = '#2a8a9a'; vg.fillRect(126, 66, 14, 8);
    vg.fillStyle = skin; vg.fillRect(84, 80, 24, 38);
  }
  if (P.flash > 0) {
    const fx = P.wi === 0 ? 100 : P.wi === 1 ? 146 : 140;
    const fy = P.wi === 0 ? 54 : P.wi === 1 ? 60 : 68;
    const r = P.wi === 1 ? 14 : 9;
    vg.fillStyle = P.wi === 2 ? 'rgba(120,255,255,.85)' : 'rgba(255,230,120,.95)';
    vg.beginPath(); vg.arc(fx, fy, r, 0, Math.PI * 2); vg.fill();
    vg.fillStyle = '#fff';
    vg.beginPath(); vg.arc(fx, fy, r * 0.45, 0, Math.PI * 2); vg.fill();
  }
}

// ---------------- AUDIO ----------------
let AC = null;
function ac() {
  if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { AC = null; } }
  if (AC && AC.state === 'suspended') AC.resume();
  return AC;
}
let NOISE = null;
function tone(f, d, type, v, slide) {
  const c = ac(); if (!c) return;
  const o = c.createOscillator(), g = c.createGain();
  o.type = type;
  o.frequency.setValueAtTime(f, c.currentTime);
  if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, f * slide), c.currentTime + d);
  g.gain.setValueAtTime(v, c.currentTime);
  g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + d);
  o.connect(g); g.connect(c.destination);
  o.start(); o.stop(c.currentTime + d + 0.02);
}
function noise(d, v, lp) {
  const c = ac(); if (!c) return;
  if (!NOISE) {
    NOISE = c.createBuffer(1, c.sampleRate * 0.6, c.sampleRate);
    const data = NOISE.getChannelData(0);
    for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  }
  const s = c.createBufferSource(); s.buffer = NOISE;
  const g = c.createGain(), f = c.createBiquadFilter();
  f.type = 'lowpass'; f.frequency.value = lp || 1800;
  g.gain.setValueAtTime(v, c.currentTime);
  g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + d);
  s.connect(f); f.connect(g); g.connect(c.destination);
  s.start(); s.stop(c.currentTime + d);
}
const SFX = {
  pistol:  () => tone(900, 0.12, 'square', 0.05, 0.4),
  shotgun: () => { noise(0.3, 0.3, 1500); tone(180, 0.2, 'sawtooth', 0.06, 0.5); },
  rifle:   () => tone(1400, 0.08, 'sawtooth', 0.03, 0.5),
  enemy:   () => tone(300, 0.15, 'square', 0.03, 0.6),
  hurt:    () => tone(140, 0.25, 'sawtooth', 0.09, 0.5),
  pickup:  () => { tone(660, 0.1, 'sine', 0.1); setTimeout(() => tone(990, 0.14, 'sine', 0.1), 80); },
  hit:     () => tone(520, 0.05, 'square', 0.03),
  kill:    () => tone(120, 0.25, 'square', 0.06, 0.4),
  death:   () => tone(200, 0.7, 'sawtooth', 0.09, 0.15),
  win:     () => [523, 659, 784, 1046].forEach((f, i) => setTimeout(() => tone(f, 0.25, 'square', 0.06), i * 140)),
  empty:   () => tone(200, 0.05, 'square', 0.03)
};

// ---------------- GAME STATE ----------------
const canvas = document.getElementById('scr');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const frame = ctx.createImageData(W, H);
const buf32 = new Uint32Array(frame.data.buffer);
const zbuf = new Float32Array(W);

let P = null;
let enemies = [], pickups = [];
let state = 'title';   // title | play | dead | won
let deathT = 0;
let playTime = 0;
let kills = 0, deaths = 0, shots = 0, hitShots = 0;
let animT = 0;
let fireHeld = false, firePressed = false;
const keys = {};
const field = new Int16Array(MW * MH);
let fieldKey = '';

function resetWorld() {
  P = {
    x: SPAWN.x, y: SPAWN.y, a: 0,
    hp: MAX_HP,
    ammo: { bullets: 50, shells: 8, cells: 20 },
    wi: 0, cool: 0, recoil: 0, flash: 0, bob: 0, moving: false,
    dmgFlash: 0, hitMark: 0, pickFlash: 0
  };
  enemies = ENEMY_SPAWNS.map(s => ({
    type: s.type, x: s.x, y: s.y, hp: ENEMY_DEF[s.type].hp, state: 'idle',
    cool: Math.random(), pain: 0, atk: 0
  }));
  pickups = PICK_SPAWNS.map(s => ({ kind: s.kind, x: s.x, y: s.y, taken: false }));
  kills = 0; deaths = 0; shots = 0; hitShots = 0; playTime = 0;
  fieldKey = '';
  buildSlots();
}

function aliveCount() { let n = 0; for (const e of enemies) if (e.state !== 'dead') n++; return n; }

function msg(text, color) {
  const box = document.getElementById('msgs');
  const d = document.createElement('div');
  d.textContent = text;
  if (color) d.style.borderLeftColor = color;
  box.appendChild(d);
  while (box.children.length > 5) box.removeChild(box.firstChild);
  setTimeout(() => d.remove(), 3200);
}

function showOv(id, show) { document.getElementById(id).classList.toggle('hidden', !show); }

function startGame() {
  ac();
  resetWorld();
  state = 'play';
  showOv('ov-title', false); showOv('ov-dead', false); showOv('ov-win', false);
  msg('SECTOR BREACHED - clear all hostiles, then reach the green exit', '#7dff9a');
  requestLock();
}

function requestLock() {
  const v = document.getElementById('view');
  if (v.requestPointerLock && document.pointerLockElement !== canvas) {
    try { canvas.requestPointerLock(); } catch (e) {}
  }
}

// ---------------- AI / PATHING ----------------
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
function updateField() {
  const pcx = Math.floor(P.x), pcy = Math.floor(P.y);
  const k = pcx + ',' + pcy;
  if (k === fieldKey) return;
  fieldKey = k;
  field.fill(-1);
  const q = [pcy * MW + pcx];
  field[q[0]] = 0;
  for (let h = 0; h < q.length; h++) {
    const i = q[h], cx = i % MW, cy = (i / MW) | 0, d = field[i];
    for (const [ox, oy] of DIRS) {
      const nx = cx + ox, ny = cy + oy;
      if (nx < 0 || ny < 0 || nx >= MW || ny >= MH) continue;
      const j = ny * MW + nx;
      if (grid[j] === 0 && field[j] < 0) { field[j] = d + 1; q.push(j); }
    }
  }
}
function flowTarget(e) {
  const cx = Math.floor(e.x), cy = Math.floor(e.y);
  const i = cy * MW + cx;
  let bestVal = field[i];
  if (bestVal < 0) return null;
  let bx = cx, by = cy;
  for (const [ox, oy] of DIRS) {
    const nx = cx + ox, ny = cy + oy;
    if (nx < 0 || ny < 0 || nx >= MW || ny >= MH) continue;
    const j = ny * MW + nx;
    if (grid[j] === 0 && field[j] >= 0 && field[j] < bestVal) { bestVal = field[j]; bx = nx; by = ny; }
  }
  return { x: bx + 0.5, y: by + 0.5 };
}
function wakeNear(x, y, r) {
  for (const e of enemies) {
    if (e.state === 'idle' && Math.hypot(e.x - x, e.y - y) < r) e.state = 'chase';
  }
}
function damagePlayer(n) {
  if (state !== 'play') return;
  P.hp -= n;
  P.dmgFlash = 0.7;
  SFX.hurt();
  if (P.hp <= 0) { P.hp = 0; die(); }
}
function updEnemy(e, dt) {
  if (e.state === 'dead') return;
  const D = ENEMY_DEF[e.type];
  e.cool -= dt;
  if (e.pain > 0) e.pain -= dt;
  if (e.atk > 0) e.atk -= dt;
  const dx = P.x - e.x, dy = P.y - e.y, d = Math.hypot(dx, dy);
  const sees = d < 16 && los(e.x, e.y, P.x, P.y);
  if (e.state === 'idle') {
    if ((sees && d < 12) || d < 2.2) e.state = 'chase';
    else return;
  }
  if (e.pain > 0) return;

  let tx = null, ty = null;
  if (sees) { tx = P.x; ty = P.y; }
  else { const t = flowTarget(e); if (t) { tx = t.x; ty = t.y; } }
  const stopDist = D.melee ? 1.1 : D.rng * 0.6;
  if (tx !== null && !(sees && d < stopDist)) {
    const ux = tx - e.x, uy = ty - e.y, L = Math.hypot(ux, uy) || 1;
    tryMove(e, ux / L * D.speed * dt, uy / L * D.speed * dt, 0.25);
  }
  if (sees && d < D.rng && e.cool <= 0) {
    e.cool = D.cool * (0.8 + Math.random() * 0.4);
    e.atk = 0.3;
    SFX.enemy();
    if (D.melee) {
      if (Math.random() < 0.8) damagePlayer(D.dmg);
    } else {
      const p = D.acc * (1 - d / (D.rng * 1.8));
      if (Math.random() < p) damagePlayer(D.dmg);
    }
  }
}

// ---------------- WEAPONS ----------------
function angDiff(a, b) {
  let d = a - b;
  while (d > Math.PI) d -= Math.PI * 2;
  while (d < -Math.PI) d += Math.PI * 2;
  return d;
}
function hurtEnemy(e, dmg) {
  if (e.state === 'dead') return;
  e.hp -= dmg;
  e.pain = 0.12;
  e.state = 'chase';
  if (e.hp <= 0) {
    e.state = 'dead';
    e.hp = 0;
    kills++;
    SFX.kill();
    msg(ENEMY_DEF[e.type].name || (e.type === 'brute' ? 'Brute' : 'Grunt') + ' down', '#ffb347');
    if (aliveCount() === 0) msg('ALL HOSTILES CLEARED - EXIT UNSEALED', '#7dff9a');
  } else {
    SFX.hit();
  }
}
function fire() {
  const w = WEAPONS[P.wi];
  if (P.ammo[w.ammo] <= 0) {
    SFX.empty();
    P.cool = 0.3;
    msg('OUT OF ' + w.ammo.toUpperCase(), '#ff6a4a');
    return;
  }
  P.ammo[w.ammo]--;
  P.cool = w.cool;
  P.recoil = 1;
  P.flash = 0.06;
  shots++;
  SFX[w.snd]();
  wakeNear(P.x, P.y, 9);
  let anyHit = false;
  for (let i = 0; i < w.pellets; i++) {
    const a = P.a + (Math.random() * 2 - 1) * w.spread;
    const wall = dda(P.x, P.y, Math.cos(a), Math.sin(a)).dist;
    let best = null, bd = Math.min(wall, w.range);
    for (const e of enemies) {
      if (e.state === 'dead') continue;
      const ex = e.x - P.x, ey = e.y - P.y;
      const d = Math.hypot(ex, ey);
      if (d >= bd) continue;
      const diff = angDiff(Math.atan2(ey, ex), a);
      const half = Math.asin(Math.min(1, ENEMY_DEF[e.type].hit / d));
      if (Math.abs(diff) <= half) { best = e; bd = d; }
    }
    if (best) { hurtEnemy(best, w.dmg); anyHit = true; }
  }
  if (anyHit) { hitShots++; P.hitMark = 0.2; }
}
function nextWeapon(dir) {
  P.wi = (P.wi + dir + WEAPONS.length) % WEAPONS.length;
  P.recoil = 0;
  msg('WEAPON: ' + WEAPONS[P.wi].name, '#9ad8ff');
}

// ---------------- PLAYER LIFECYCLE ----------------
function die() {
  state = 'dead';
  deathT = RESPAWN_TIME;
  deaths++;
  fireHeld = false; firePressed = false;
  SFX.death();
  msg('YOU DIED', '#ff4a4a');
  showOv('ov-dead', true);
}
function respawn() {
  P.x = SPAWN.x; P.y = SPAWN.y; P.a = 0;
  P.hp = MAX_HP;
  P.ammo.bullets = Math.max(P.ammo.bullets, 30);
  P.ammo.shells = Math.max(P.ammo.shells, 8);
  P.ammo.cells = Math.max(P.ammo.cells, 12);
  P.cool = 0.5; P.recoil = 0; P.dmgFlash = 0;
  state = 'play';
  fieldKey = '';
  showOv('ov-dead', false);
  msg('RESPAWNED - sector hostiles still active', '#7dff9a');
}
function win() {
  state = 'won';
  SFX.win();
  const acc = shots ? Math.round(hitShots / shots * 100) : 0;
  document.getElementById('winStats').textContent =
    `TIME ${fmt(playTime)}  |  KILLS ${kills}/${enemies.length}  |  DEATHS ${deaths}  |  ACCURACY ${acc}%`;
  showOv('ov-win', true);
}
function fmt(t) {
  const s = Math.floor(t);
  return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}

// ---------------- UPDATE ----------------
function update(dt) {
  animT += dt;
  P.dmgFlash = Math.max(0, P.dmgFlash - dt * 2.2);
  P.pickFlash = Math.max(0, P.pickFlash - dt * 3);
  P.hitMark = Math.max(0, P.hitMark - dt);
  P.flash = Math.max(0, P.flash - dt);
  P.recoil = Math.max(0, P.recoil - dt * 7);
  P.cool -= dt;

  if (state === 'dead') {
    deathT -= dt;
    document.getElementById('respawnT').textContent = Math.max(1, Math.ceil(deathT));
    if (deathT <= 0) respawn();
    return;
  }
  if (state !== 'play') return;

  playTime += dt;
  if (keys.ArrowLeft || keys.KeyQ) P.a -= 2.4 * dt;
  if (keys.ArrowRight || keys.KeyE) P.a += 2.4 * dt;
  const f = (keys.KeyW || keys.ArrowUp ? 1 : 0) - (keys.KeyS || keys.ArrowDown ? 1 : 0);
  const s = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
  P.moving = !!(f || s);
  if (P.moving) {
    const ca = Math.cos(P.a), sa = Math.sin(P.a);
    const mx = ca * f - sa * s, my = sa * f + ca * s;
    const L = Math.hypot(mx, my) || 1;
    const sp = 3.2 * dt;
    tryMove(P, mx / L * sp, my / L * sp, PLAYER_R);
    P.bob += dt * 9;
  }

  const w = WEAPONS[P.wi];
  if (P.cool <= 0 && ((fireHeld && w.auto) || firePressed)) {
    fire();
    firePressed = false;
  }
  if (P.cool > 0 || P.ammo[w.ammo] > 0) firePressed = firePressed && P.cool > 0 ? firePressed : false;

  updateField();
  for (const e of enemies) updEnemy(e, dt);

  for (const p of pickups) {
    if (p.taken) continue;
    if (Math.hypot(p.x - P.x, p.y - P.y) < 0.55) {
      if (p.kind === 'health') {
        if (P.hp >= MAX_HP) continue;
        P.hp = Math.min(MAX_HP, P.hp + HEALTH_PICK);
        msg('+' + HEALTH_PICK + ' HEALTH', '#ff6a6a');
      } else {
        if (P.ammo[p.kind] >= AMMO_MAX[p.kind]) continue;
        const amt = AMMO_PICK[p.kind];
        P.ammo[p.kind] = Math.min(AMMO_MAX[p.kind], P.ammo[p.kind] + amt);
        msg('+' + amt + ' ' + PICK_INFO[p.kind].label, '#ffd24a');
      }
      p.taken = true;
      P.pickFlash = 1;
      SFX.pickup();
    }
  }

  if (aliveCount() === 0 && Math.floor(P.x) === EXIT.x && Math.floor(P.y) === EXIT.y) win();
}

// ---------------- RENDER ----------------
function sh(c, f) {
  return (255 << 24) |
    (((((c >>> 16) & 255) * f) & 255) << 16) |
    (((((c >>> 8) & 255) * f) & 255) << 8) |
    (((c & 255) * f) & 255);
}

function renderWorld() {
  const px = P.x, py = P.y;
  const dx = Math.cos(P.a), dy = Math.sin(P.a);
  const plx = -dy * FOV, ply = dx * FOV;
  const exitOpen = aliveCount() === 0;
  const exitTex = exitOpen ? TEX_EXIT_OPEN : TEX_EXIT_LOCKED;
  const pulse = exitOpen ? 0.75 + 0.25 * Math.sin(animT * 5) : 0.6;

  // Floor & ceiling
  const rdx0 = dx - plx, rdy0 = dy - ply, rdx1 = dx + plx, rdy1 = dy + ply;
  const hh = H >> 1;
  for (let y = hh + 1; y < H; y++) {
    const p = y - hh;
    const rowDist = (0.5 * H) / p;
    const stepX = rowDist * (rdx1 - rdx0) / W;
    const stepY = rowDist * (rdy1 - rdy0) / W;
    let fx = px + rowDist * rdx0, fy = py + rowDist * rdy0;
    const fog = Math.max(0.1, 1 - rowDist / 13);
    const ceilRow = H - 1 - y;
    const rowF = y * W, rowC = ceilRow * W;
    for (let x = 0; x < W; x++) {
      const cx = Math.floor(fx), cy = Math.floor(fy);
      const tx = ((fx - cx) * TS) | 0, ty = ((fy - cy) * TS) | 0;
      const ti = ty * TS + tx;
      let fc;
      if (cx === EXIT.x && cy === EXIT.y) fc = sh(exitTex[ti], fog * pulse);
      else fc = sh(TEX_FLOOR[ti], fog);
      buf32[rowF + x] = fc;
      buf32[rowC + x] = sh(TEX_CEIL[ti], fog * 0.85);
      fx += stepX; fy += stepY;
    }
  }

  // Walls
  for (let x = 0; x < W; x++) {
    const cam = 2 * x / W - 1;
    const rx = dx + plx * cam, ry = dy + ply * cam;
    const hit = dda(px, py, rx, ry);
    const perp = hit.dist;
    zbuf[x] = perp;
    const lh = H / perp;
    const ds = Math.max(0, (H / 2 - lh / 2) | 0);
    const de = Math.min(H - 1, (H / 2 + lh / 2) | 0);
    let wx = hit.side === 0 ? py + perp * ry : px + perp * rx;
    wx -= Math.floor(wx);
    let tx = (wx * TS) | 0;
    if ((hit.side === 0 && rx > 0) || (hit.side === 1 && ry < 0)) tx = TS - tx - 1;
    const tex = WALL_TEX[(hit.w - 1) & 3];
    const f = (hit.side ? 0.68 : 1) * Math.max(0.12, 1 - perp / 19);
    for (let y = ds; y <= de; y++) {
      let ty = ((2 * y - H + lh) * TS / (2 * lh)) | 0;
      if (ty < 0) ty = 0; else if (ty >= TS) ty = TS - 1;
      buf32[y * W + x] = sh(tex[ty * TS + tx], f);
    }
  }
  ctx.putImageData(frame, 0, 0);
}

function drawSprite(s, dx, dy, plx, ply, invDet) {
  const sx = s.x - P.x, sy = s.y - P.y;
  const tX = invDet * (dy * sx - dx * sy);
  const tY = invDet * (-ply * sx + plx * sy);
  if (tY < 0.2) return;
  const cv = s.cv;
  const scrX = (W / 2) * (1 + tX / tY);
  const topY = H / 2 + (0.5 - (s.z0 + s.hh)) * H / tY;
  const botY = H / 2 + (0.5 - s.z0) * H / tY;
  const sH = botY - topY;
  const sW = sH * cv.width / cv.height;
  const left = scrX - sW / 2;
  const x0 = Math.max(0, Math.floor(left)), x1 = Math.min(W - 1, Math.floor(left + sW));
  for (let x = x0; x <= x1; x++) {
    if (tY >= zbuf[x]) continue;
    const tc = Math.min(cv.width - 1, Math.floor((x - left) / sW * cv.width));
    ctx.drawImage(cv, tc, 0, 1, cv.height, x, topY, 1, sH);
  }
}

function renderSprites() {
  const dx = Math.cos(P.a), dy = Math.sin(P.a);
  const plx = -dy * FOV, ply = dx * FOV;
  const invDet = 1 / (plx * dy - dx * ply);
  const list = [];
  for (const e of enemies) {
    const D = ENEMY_DEF[e.type];
    if (e.state === 'dead') {
      list.push({ x: e.x, y: e.y, cv: CORPSE_SPR[e.type], hh: 0.3, z0: 0 });
    } else {
      const pose = e.pain > 0 ? 'pain' : e.atk > 0 ? 'attack' : 'idle';
      list.push({ x: e.x, y: e.y, cv: ENEMY_SPR[e.type][pose], hh: D.hh, z0: 0 });
    }
  }
  for (const p of pickups) {
    if (p.taken) continue;
    list.push({ x: p.x, y: p.y, cv: PICK_SPR[p.kind], hh: 0.42, z0: 0.06 + 0.03 * Math.sin(animT * 3 + p.x) });
  }
  for (const s of list) s.dist2 = (s.x - P.x) ** 2 + (s.y - P.y) ** 2;
  list.sort((a, b) => b.dist2 - a.dist2);
  for (const s of list) drawSprite(s, dx, dy, plx, ply, invDet);
}

function renderViewmodel() {
  drawViewmodel();
  const bobX = P.moving ? Math.sin(P.bob) * 3 : 0;
  const bobY = P.moving ? Math.abs(Math.cos(P.bob)) * 2 : 0;
  const idle = Math.sin(animT * 2) * 1.2;
  ctx.drawImage(VM, W / 2 - 80 + bobX, H - 120 + 14 * P.recoil + bobY + idle);
}

function render() {
  renderWorld();
  renderSprites();
  renderViewmodel();
}

// ---------------- HUD ----------------
const el = id => document.getElementById(id);
const slotEls = [];
function buildSlots() {
  const box = el('slots');
  if (box.childElementCount) return;
  WEAPONS.forEach((w, i) => {
    const d = document.createElement('div');
    d.className = 'slot';
    box.appendChild(d);
    slotEls.push(d);
  });
}
function updateHUD() {
  if (!P) return;
  el('hp').textContent = Math.max(0, Math.ceil(P.hp));
  el('hpbar').style.width = Math.max(0, Math.min(100, P.hp)) + '%';
  el('hpbar').style.background = P.hp > 50 ? 'linear-gradient(#7dff9a,#2a9a4a)' : P.hp > 25 ? 'linear-gradient(#ffd24a,#b07a10)' : 'linear-gradient(#ff5a4a,#b8201a)';
  const w = WEAPONS[P.wi];
  el('wname').textContent = w.name;
  el('wammo').textContent = P.ammo[w.ammo] + '  ' + w.ammo.toUpperCase();
  WEAPONS.forEach((ww, i) => {
    if (!slotEls[i]) return;
    slotEls[i].className = 'slot' + (i === P.wi ? ' on' : '');
    slotEls[i].innerHTML = (i + 1) + ' ' + ww.name + '<br>' + P.ammo[ww.ammo] + ' ' + ww.ammo;
  });
  el('kills').textContent = kills;
  el('total').textContent = enemies.length;
  el('deaths').textContent = deaths;
  el('time').textContent = fmt(playTime);
  const alive = aliveCount();
  const ex = el('exitState');
  if (alive > 0) { ex.textContent = 'EXIT SEALED - ' + alive + ' HOSTILE' + (alive > 1 ? 'S' : '') + ' LEFT'; ex.className = ''; }
  else { ex.textContent = 'EXIT OPEN - REACH THE GREEN GATE'; ex.className = 'open'; }
  el('dmg').style.opacity = P.dmgFlash.toFixed(2);
  el('pickflash').style.opacity = (P.pickFlash * 0.8).toFixed(2);
  el('cross').className = P.hitMark > 0 ? 'hit' : '';
}

// ---------------- INPUT ----------------
window.addEventListener('keydown', e => {
  keys[e.code] = true;
  if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
  if (e.code === 'Space' && !e.repeat && state === 'play') firePressed = true;
  if (e.code === 'Space' && state !== 'play' && state !== 'dead' && !e.repeat) startGame();
  if (e.code === 'Enter' && state !== 'play' && state !== 'dead') startGame();
  if (state === 'play' && !e.repeat) {
    if (e.code === 'Digit1') P.wi = 0;
    if (e.code === 'Digit2') P.wi = 1;
    if (e.code === 'Digit3') P.wi = 2;
    if (['Digit1', 'Digit2', 'Digit3'].includes(e.code)) msg('WEAPON: ' + WEAPONS[P.wi].name, '#9ad8ff');
  }
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; fireHeld = false; });
canvas.addEventListener('mousedown', e => {
  if (e.button !== 0) return;
  if (state === 'play') {
    fireHeld = true; firePressed = true;
    if (document.pointerLockElement !== canvas) requestLock();
  }
});
window.addEventListener('mouseup', e => { if (e.button === 0) fireHeld = false; });
document.addEventListener('mousemove', e => {
  if (document.pointerLockElement === canvas && P) P.a += e.movementX * 0.0028;
});
canvas.addEventListener('wheel', e => {
  if (state === 'play') nextWeapon(e.deltaY > 0 ? 1 : -1);
  e.preventDefault();
}, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
el('startBtn').addEventListener('click', startGame);
el('againBtn').addEventListener('click', startGame);
el('startBtn').addEventListener('mousedown', e => e.stopPropagation());

// ---------------- LOOP ----------------
// Initial state for title screen: show world with player at spawn
resetWorld();
buildSlots();
let last = performance.now();
function loop(t) {
  let dt = (t - last) / 1000;
  last = t;
  if (dt > 0.05) dt = 0.05;
  if (dt < 0) dt = 0;
  update(dt);
  render();
  updateHUD();
  requestAnimationFrame(loop);
}
requestAnimationFrame(t => { last = t; loop(t); });

// Expose tiny debug hook (read-only snapshot)
window.__game = { get state() { return state; }, get P() { return P; }, enemies: () => enemies, pickups: () => pickups, EXIT, MW, MH, grid, field: () => field, updateField, win, die, respawn, startGame };
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"cd3daa3d-b782-4cfd-9fbb-8a611d3a6c30","tokensIn":142791,"tokensOut":66142,"tokensTotal":208933,"cost":0.038899510000000005,"turns":5,"toolCalls":6,"failedToolCalls":0,"timestamp":"2026-10-08T01:13:46.153Z"} -->