← DOOM results

DOOM

Grok 4.5 · legacy

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 49,070 bytes · SHA-256 32978183176f
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CRIMSON SECTOR</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body {
    width: 100%; height: 100%;
    background: #0a0505;
    overflow: hidden;
    font-family: 'Press Start 2P', monospace;
    color: #ffccaa;
    user-select: none;
  }
  #wrap {
    position: relative;
    width: 100%; height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: radial-gradient(ellipse at center, #1a0808 0%, #050202 70%);
  }
  canvas#game {
    image-rendering: pixelated;
    image-rendering: crisp-edges;
    background: #000;
    box-shadow: 0 0 40px rgba(180, 20, 20, 0.35), 0 0 0 4px #3a1010, 0 0 0 6px #180808;
    cursor: none;
    max-width: 100vw;
    max-height: 100vh;
  }
  #overlay {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background:
      linear-gradient(180deg, rgba(40,0,0,0.92), rgba(10,0,0,0.96)),
      repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.15) 2px, rgba(0,0,0,0.15) 4px);
    z-index: 10;
    text-align: center;
    padding: 24px;
  }
  #overlay.hidden { display: none; }
  #overlay h1 {
    font-size: clamp(22px, 4vw, 42px);
    color: #ff3030;
    text-shadow: 0 0 20px #ff0000, 4px 4px 0 #400;
    margin-bottom: 18px;
    letter-spacing: 2px;
  }
  #overlay h2 {
    font-size: clamp(12px, 2vw, 18px);
    color: #ffaa66;
    margin-bottom: 28px;
    line-height: 1.8;
  }
  #overlay p {
    font-size: 10px;
    line-height: 1.9;
    color: #cca088;
    max-width: 520px;
    margin-bottom: 10px;
  }
  #overlay .btn {
    margin-top: 28px;
    padding: 14px 28px;
    background: #400808;
    border: 3px solid #ff4040;
    color: #ffccaa;
    font-family: inherit;
    font-size: 12px;
    cursor: pointer;
    box-shadow: 0 0 20px rgba(255,40,40,0.4);
    transition: transform 0.1s, background 0.15s;
  }
  #overlay .btn:hover { background: #601010; transform: scale(1.04); }
  #overlay .btn:active { transform: scale(0.98); }
  #overlay .keys {
    margin-top: 22px;
    font-size: 9px;
    color: #886655;
    line-height: 1.8;
  }
  #hud {
    position: absolute;
    left: 50%;
    bottom: 2.5%;
    transform: translateX(-50%);
    width: min(920px, 94vw);
    pointer-events: none;
    z-index: 5;
    display: none;
  }
  #hud.show { display: block; }
  .hud-bar {
    display: flex;
    gap: 10px;
    align-items: stretch;
    background: linear-gradient(180deg, rgba(30,8,8,0.85), rgba(10,2,2,0.92));
    border: 2px solid #5a1818;
    padding: 8px 12px;
    box-shadow: 0 0 16px rgba(0,0,0,0.6);
  }
  .stat {
    flex: 1;
    min-width: 0;
  }
  .stat label {
    display: block;
    font-size: 8px;
    color: #886655;
    margin-bottom: 6px;
    letter-spacing: 1px;
  }
  .stat .val {
    font-size: 14px;
    color: #ffd0a0;
  }
  .stat.hp .val { color: #ff5555; }
  .stat.ammo .val { color: #ffdd55; }
  .stat.weapon .val { color: #88ccff; font-size: 11px; line-height: 1.3; }
  .stat.kills .val { color: #a0ff70; }
  .meter {
    height: 10px;
    background: #1a0808;
    border: 1px solid #4a2020;
    margin-top: 6px;
    overflow: hidden;
  }
  .meter > i {
    display: block;
    height: 100%;
    width: 100%;
    background: linear-gradient(90deg, #800, #f33, #faa);
    transform-origin: left center;
    transition: transform 0.12s linear;
  }
  #msg {
    position: absolute;
    top: 12%;
    left: 50%;
    transform: translateX(-50%);
    font-size: 12px;
    color: #fff;
    text-shadow: 0 0 8px #000, 2px 2px 0 #400;
    opacity: 0;
    transition: opacity 0.2s;
    z-index: 6;
    pointer-events: none;
    text-align: center;
    max-width: 80vw;
  }
  #msg.show { opacity: 1; }
  #cross {
    position: absolute;
    left: 50%; top: 50%;
    width: 18px; height: 18px;
    transform: translate(-50%, -50%);
    pointer-events: none;
    z-index: 4;
    display: none;
  }
  #cross.show { display: block; }
  #cross::before, #cross::after {
    content: '';
    position: absolute;
    background: rgba(255, 220, 180, 0.85);
    box-shadow: 0 0 4px #000;
  }
  #cross::before { left: 50%; top: 0; width: 2px; height: 100%; transform: translateX(-50%); }
  #cross::after { top: 50%; left: 0; height: 2px; width: 100%; transform: translateY(-50%); }
  #minimap {
    position: absolute;
    top: 12px; right: 12px;
    border: 2px solid #5a1818;
    background: rgba(10,2,2,0.75);
    image-rendering: pixelated;
    z-index: 5;
    display: none;
    box-shadow: 0 0 12px rgba(0,0,0,0.5);
  }
  #minimap.show { display: block; }
  #dmg {
    position: absolute;
    inset: 0;
    background: radial-gradient(circle at center, transparent 30%, rgba(180,0,0,0.55) 100%);
    opacity: 0;
    pointer-events: none;
    z-index: 3;
    transition: opacity 0.15s;
  }
  #ombo {
    position: absolute;
    top: 18%;
    right: 4%;
    font-size: 11px;
    color: #ffaa00;
    text-shadow: 0 0 10px #f80;
    opacity: 0;
    z-index: 6;
    transition: opacity 0.2s;
    pointer-events: none;
  }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="game" width="640" height="400"></canvas>
  <canvas id="minimap" width="128" height="128"></canvas>
  <div id="cross"></div>
  <div id="dmg"></div>
  <div id="msg"></div>
  <div id="ombo"></div>
  <div id="hud">
    <div class="hud-bar">
      <div class="stat hp">
        <label>VITALS</label>
        <div class="val" id="hpText">100</div>
        <div class="meter"><i id="hpBar"></i></div>
      </div>
      <div class="stat weapon">
        <label>ARMAMENT</label>
        <div class="val" id="wepText">PEAK-9</div>
      </div>
      <div class="stat ammo">
        <label>AMMO</label>
        <div class="val" id="ammoText">∞</div>
      </div>
      <div class="stat kills">
        <label>CLEARED</label>
        <div class="val" id="killText">0 / ?</div>
      </div>
    </div>
  </div>
  <div id="overlay">
    <h1>CRIMSON SECTOR</h1>
    <h2>Protocol Breach // Deep Facility 07</h2>
    <p>
      A containment failure poured hostiles through the lower halls.
      Your orders: exterminate the swarm and reach the amber extraction gate.
    </p>
    <p>
      Three weapons. Limited munitions. One way out.
    </p>
    <button class="btn" id="startBtn">ENGAGE (click + lock mouse)</button>
    <div class="keys">
      WASD move · Mouse look · LMB / SPACE fire · 1 2 3 weapons<br>
      R reloads current · SHIFT sprint · P pause
    </div>
  </div>
</div>

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

  // ─── Constants ───────────────────────────────────────────
  const W = 640, H = 400;
  const FOV = Math.PI / 3;
  const HALF_FOV = FOV / 2;
  const NUM_RAYS = 160;          // column batches (4px wide) for retro look
  const MAX_DEPTH = 18;
  const TILE = 1;
  const PLAYER_RADIUS = 0.18;
  const MOVE_SPEED = 2.6;
  const SPRINT_MULT = 1.55;
  const ROT_SPEED = 2.4;
  const MOUSE_SENS = 0.0022;

  // ─── Map  (0 empty, 1-4 wall types, 8 spawn, 9 exit) ─────
  // Larger facility with rooms, corners, pickups and encounters.
  const MAP_RAW = [
    '111111111111111111111111',
    '1P.....1........1.....1',
    '1......1..222...1..H..1',
    '1......1..2.2...1.....1',
    '1..A...1112.21111..A..1',
    '1............1........1',
    '11111.111....1...111111',
    '1.....1......1...1....1',
    '1.E...1..A.......1.E..1',
    '1.....1..........1....1',
    '1.11111....1111111....1',
    '1......A...1..........1',
    '1.E........1....E...H.1',
    '1......11111..........1',
    '1..H...1.....1...A....1',
    '1......1..E..1........1',
    '111.1111.....11111.1111',
    '1.........E...........1',
    '1..A.............H....1',
    '1.........333.........1',
    '1..E......3X3.....E...1',
    '1.........333.........1',
    '1.....................1',
    '11111111111111111111111',
  ];

  const MAP_H = MAP_RAW.length;
  const MAP_W = MAP_RAW[0].length;
  const map = Array.from({ length: MAP_H }, () => Array(MAP_W).fill(0));

  // ─── Canvas / DOM ────────────────────────────────────────
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const mmCanvas = document.getElementById('minimap');
  const mm = mmCanvas.getContext('2d');
  const overlay = document.getElementById('overlay');
  const hud = document.getElementById('hud');
  const cross = document.getElementById('cross');
  const dmgEl = document.getElementById('dmg');
  const msgEl = document.getElementById('msg');
  const omboEl = document.getElementById('ombo');
  const startBtn = document.getElementById('startBtn');

  // Scale canvas display
  function fitCanvas() {
    const scale = Math.min(window.innerWidth / W, window.innerHeight / H) * 0.96;
    canvas.style.width = Math.floor(W * scale) + 'px';
    canvas.style.height = Math.floor(H * scale) + 'px';
  }
  window.addEventListener('resize', fitCanvas);
  fitCanvas();

  // ─── Audio (tiny WebAudio synth) ─────────────────────────
  let AC = null;
  function ensureAudio() {
    if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)();
    if (AC.state === 'suspended') AC.resume();
  }
  function beep(freq, dur, type = 'square', vol = 0.08, slide = 0) {
    if (!AC) return;
    const t0 = AC.currentTime;
    const o = AC.createOscillator();
    const g = AC.createGain();
    o.type = type;
    o.frequency.setValueAtTime(freq, t0);
    if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t0 + dur);
    g.gain.setValueAtTime(vol, t0);
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
    o.connect(g); g.connect(AC.destination);
    o.start(t0); o.stop(t0 + dur + 0.02);
  }
  function noiseBurst(dur, vol = 0.06) {
    if (!AC) return;
    const n = Math.floor(AC.sampleRate * dur);
    const buf = AC.createBuffer(1, n, AC.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
    const s = AC.createBufferSource();
    const g = AC.createGain();
    s.buffer = buf;
    g.gain.value = vol;
    s.connect(g); g.connect(AC.destination);
    s.start();
  }
  const SFX = {
    shoot0: () => { beep(520, 0.06, 'square', 0.07, -200); },
    shoot1: () => { noiseBurst(0.1, 0.09); beep(180, 0.08, 'sawtooth', 0.05, -80); },
    shoot2: () => { beep(90, 0.18, 'sawtooth', 0.1, 40); beep(880, 0.12, 'square', 0.04); },
    hit: () => beep(140, 0.05, 'triangle', 0.06, -60),
    hurt: () => { beep(110, 0.15, 'sawtooth', 0.1, -50); noiseBurst(0.08, 0.05); },
    die: () => { beep(220, 0.4, 'sawtooth', 0.1, -180); beep(90, 0.5, 'square', 0.08, -40); },
    pickup: () => { beep(520, 0.06, 'square', 0.06); setTimeout(() => beep(780, 0.08, 'square', 0.06), 60); },
    empty: () => beep(90, 0.05, 'square', 0.04),
    switch: () => beep(300, 0.04, 'triangle', 0.05),
    win: () => { [440, 554, 659, 880].forEach((f, i) => setTimeout(() => beep(f, 0.18, 'square', 0.07), i * 120)); },
    kill: () => { beep(160, 0.08, 'square', 0.06, -100); beep(80, 0.12, 'sawtooth', 0.05); },
  };

  // ─── Weapons ─────────────────────────────────────────────
  const WEAPONS = [
    {
      id: 0, name: 'PEAK-9 PISTOL', short: 'PEAK-9',
      dmg: 18, rate: 0.22, pellets: 1, spread: 0.015, range: 14,
      ammoKey: null, // infinite
      kick: 0.04, color: '#ffd080', sfx: 'shoot0'
    },
    {
      id: 1, name: 'RIVEN SCATTERGUN', short: 'SCATTERGUN',
      dmg: 11, rate: 0.55, pellets: 6, spread: 0.14, range: 7,
      ammoKey: 'shells', ammoPerShot: 1,
      kick: 0.12, color: '#ff8866', sfx: 'shoot1'
    },
    {
      id: 2, name: 'AETHER RAIL', short: 'AETHER RAIL',
      dmg: 95, rate: 0.95, pellets: 1, spread: 0.0, range: 18,
      ammoKey: 'cells', ammoPerShot: 1,
      kick: 0.18, color: '#66ddff', sfx: 'shoot2', pierce: true
    },
  ];

  // ─── Game state ──────────────────────────────────────────
  const state = {
    running: false,
    paused: false,
    dead: false,
    won: false,
    time: 0,
    player: {
      x: 1.5, y: 1.5, a: 0,
      hp: 100, maxHp: 100,
      weapon: 0,
      ammo: { shells: 16, cells: 4 },
      bob: 0, hurtT: 0, fireCD: 0, muzzle: 0,
      pitchKick: 0,
    },
    keys: Object.create(null),
    mouseDX: 0,
    entities: [],   // enemies + pickups + exit marker
    particles: [],
    projectiles: [],
    kills: 0,
    totalEnemies: 0,
    messages: [],
    combo: 0,
    comboT: 0,
    zBuffer: new Float32Array(NUM_RAYS),
    screenFlash: 0,
  };

  // ─── Helpers ─────────────────────────────────────────────
  function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
  function dist(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return Math.hypot(dx, dy); }
  function angDiff(a, b) {
    let d = (a - b + Math.PI) % (Math.PI * 2);
    if (d < 0) d += Math.PI * 2;
    return d - Math.PI;
  }
  function showMsg(text, ms = 1600) {
    msgEl.textContent = text;
    msgEl.classList.add('show');
    clearTimeout(showMsg._t);
    showMsg._t = setTimeout(() => msgEl.classList.remove('show'), ms);
  }
  function isWall(x, y) {
    const mx = Math.floor(x), my = Math.floor(y);
    if (mx < 0 || my < 0 || mx >= MAP_W || my >= MAP_H) return 1;
    return map[my][mx] >= 1 && map[my][mx] <= 4 ? map[my][mx] : 0;
  }
  function solidAt(x, y) {
    return isWall(x, y) > 0;
  }

  // ─── Parse map ───────────────────────────────────────────
  function parseMap() {
    state.entities = [];
    state.kills = 0;
    state.totalEnemies = 0;
    let px = 1.5, py = 1.5, spawnExit = null;

    for (let y = 0; y < MAP_H; y++) {
      for (let x = 0; x < MAP_W; x++) {
        const c = MAP_RAW[y][x];
        if (c === '1' || c === '2' || c === '3' || c === '4') {
          map[y][x] = parseInt(c, 10);
        } else {
          map[y][x] = 0;
          if (c === 'P') { px = x + 0.5; py = y + 0.5; }
          else if (c === 'E') {
            state.entities.push(makeEnemy(x + 0.5, y + 0.5));
            state.totalEnemies++;
          } else if (c === 'H') {
            state.entities.push(makePickup(x + 0.5, y + 0.5, 'health'));
          } else if (c === 'A') {
            // alternate shell / cell pickups
            const type = ((x + y) % 2 === 0) ? 'shells' : 'cells';
            state.entities.push(makePickup(x + 0.5, y + 0.5, type));
          } else if (c === 'X') {
            spawnExit = { x: x + 0.5, y: y + 0.5 };
          }
        }
      }
    }
    if (spawnExit) {
      state.entities.push({
        type: 'exit', x: spawnExit.x, y: spawnExit.y,
        r: 0.45, alive: true, bob: 0
      });
    }
    const p = state.player;
    p.x = px; p.y = py; p.a = 0;
    p.hp = 100; p.weapon = 0;
    p.ammo = { shells: 16, cells: 4 };
    p.fireCD = 0; p.hurtT = 0; p.muzzle = 0;
    state.dead = false; state.won = false;
    state.particles = []; state.projectiles = [];
    state.combo = 0; state.comboT = 0;
  }

  function makeEnemy(x, y) {
    // Variants by rough region for variety
    const roll = Math.random();
    let kind = 'grunt';
    if (roll > 0.72) kind = 'brute';
    else if (roll > 0.45) kind = 'spitter';
    const defs = {
      grunt:  { hp: 40,  speed: 1.35, dmg: 8,  range: 1.2,  score: 1, color: '#c04030', size: 0.85 },
      spitter:{ hp: 30,  speed: 1.1,  dmg: 6,  range: 5.5,  score: 1, color: '#50a040', size: 0.8, ranged: true },
      brute:  { hp: 95,  speed: 0.95, dmg: 16, range: 1.4,  score: 2, color: '#8030a0', size: 1.15 },
    };
    const d = defs[kind];
    return {
      type: 'enemy', kind, x, y, r: 0.28 * d.size,
      hp: d.hp, maxHp: d.hp, speed: d.speed, dmg: d.dmg,
      range: d.range, score: d.score, color: d.color, size: d.size,
      ranged: !!d.ranged,
      alive: true, alert: false, attackCD: 0.5 + Math.random(),
      hurtT: 0, frame: Math.random() * 10, facing: 0,
    };
  }
  function makePickup(x, y, kind) {
    return {
      type: 'pickup', kind, x, y, r: 0.25,
      alive: true, bob: Math.random() * Math.PI * 2
    };
  }

  // ─── Raycast ─────────────────────────────────────────────
  // Returns wall distance, wall type, side (0=ns,1=ew), texture u
  function castRay(ox, oy, angle) {
    const sin = Math.sin(angle), cos = Math.cos(angle);
    // DDA
    let mapX = Math.floor(ox), mapY = Math.floor(oy);
    const deltaX = Math.abs(1 / (cos || 1e-12));
    const deltaY = Math.abs(1 / (sin || 1e-12));
    let stepX, stepY, sideX, sideY;

    if (cos < 0) { stepX = -1; sideX = (ox - mapX) * deltaX; }
    else { stepX = 1; sideX = (mapX + 1 - ox) * deltaX; }
    if (sin < 0) { stepY = -1; sideY = (oy - mapY) * deltaY; }
    else { stepY = 1; sideY = (mapY + 1 - oy) * deltaY; }

    let hit = 0, side = 0, depth = 0;
    while (hit === 0 && depth < MAX_DEPTH) {
      if (sideX < sideY) {
        sideX += deltaX; mapX += stepX; side = 0;
      } else {
        sideY += deltaY; mapY += stepY; side = 1;
      }
      if (mapX < 0 || mapY < 0 || mapX >= MAP_W || mapY >= MAP_H) {
        hit = 1; break;
      }
      if (map[mapY][mapX] > 0) hit = map[mapY][mapX];
      depth++;
    }
    let dist;
    if (side === 0) dist = (mapX - ox + (1 - stepX) / 2) / cos;
    else dist = (mapY - oy + (1 - stepY) / 2) / sin;
    dist = Math.abs(dist);

    // texture coordinate
    let wallX;
    if (side === 0) wallX = oy + dist * sin;
    else wallX = ox + dist * cos;
    wallX -= Math.floor(wallX);

    return { dist, hit, side, wallX, mapX, mapY };
  }

  // ─── Wall palette (retro) ────────────────────────────────
  function wallColor(type, side, shade, u, v) {
    // procedural “brick / rivet” pattern
    const bx = Math.floor(u * 8);
    const by = Math.floor(v * 12);
    const brick = ((bx + by) % 2 === 0) ? 1 : 0.82;
    const mortar = (Math.abs((u * 8) % 1 - 0.5) < 0.05 || Math.abs((v * 12) % 1 - 0.08) < 0.04) ? 0.55 : 1;
    let r, g, b;
    if (type === 1) { r = 150; g = 50; b = 45; }       // crimson brick
    else if (type === 2) { r = 70; g = 75; b = 90; }   // steel
    else if (type === 3) { r = 130; g = 90; b = 40; }  // amber (exit room)
    else { r = 60; g = 100; b = 60; }
    const s = shade * brick * mortar * (side ? 0.72 : 1);
    r = clamp((r * s) | 0, 0, 255);
    g = clamp((g * s) | 0, 0, 255);
    b = clamp((b * s) | 0, 0, 255);
    return `rgb(${r},${g},${b})`;
  }

  // ─── Rendering ───────────────────────────────────────────
  function render() {
    const p = state.player;
    const colW = W / NUM_RAYS;

    // Ceiling + floor
    const ceilGrad = ctx.createLinearGradient(0, 0, 0, H / 2);
    ceilGrad.addColorStop(0, '#1a0a0c');
    ceilGrad.addColorStop(1, '#0e0608');
    ctx.fillStyle = ceilGrad;
    ctx.fillRect(0, 0, W, H / 2);

    const floorGrad = ctx.createLinearGradient(0, H / 2, 0, H);
    floorGrad.addColorStop(0, '#1a0c0a');
    floorGrad.addColorStop(1, '#080403');
    ctx.fillStyle = floorGrad;
    ctx.fillRect(0, H / 2, W, H / 2);

    // Fake floor strips for depth cue
    ctx.globalAlpha = 0.15;
    for (let i = 0; i < 10; i++) {
      const y = H / 2 + (i * i * 2.2);
      ctx.fillStyle = i % 2 ? '#000' : '#3a1510';
      ctx.fillRect(0, y, W, 3);
    }
    ctx.globalAlpha = 1;

    // Rays / walls
    for (let i = 0; i < NUM_RAYS; i++) {
      const rayScreen = (i / NUM_RAYS) * 2 - 1; // -1..1
      // correct fisheye: angle from camera plane approximation
      const rayAngle = p.a + Math.atan(rayScreen * Math.tan(HALF_FOV));
      const hit = castRay(p.x, p.y, rayAngle);
      // fisheye correction
      const corr = hit.dist * Math.cos(rayAngle - p.a);
      state.zBuffer[i] = corr;

      const wallH = Math.min(H * 2.2, (H * 0.85) / Math.max(0.05, corr));
      const shade = clamp(1.15 - corr / MAX_DEPTH, 0.08, 1);
      // sample vertical texture with a few bands
      const top = (H - wallH) / 2;
      const bands = 16;
      const bh = wallH / bands;
      for (let b = 0; b < bands; b++) {
        const v = b / bands;
        ctx.fillStyle = wallColor(hit.hit || 1, hit.side, shade, hit.wallX, v);
        ctx.fillRect(i * colW, top + b * bh, colW + 0.5, bh + 0.5);
      }
      // slim dark outline at far edges
      if (hit.side) {
        ctx.fillStyle = `rgba(0,0,0,${0.15 * shade})`;
        ctx.fillRect(i * colW, top, 1, wallH);
      }
    }

    // Sprites (entities)
    const sprites = state.entities.filter(e => e.alive);
    // depth sort
    sprites.sort((a, b) => dist(p.x, p.y, b.x, b.y) - dist(p.x, p.y, a.x, a.y));

    for (const e of sprites) {
      drawSprite(e);
    }

    // Projectiles
    for (const pr of state.projectiles) {
      drawBillboard(pr.x, pr.y, 0.1, 0.1, pr.color || '#fff', 1);
    }

    // Particles
    for (const pt of state.particles) {
      drawBillboard(pt.x, pt.y, pt.size || 0.08, pt.size || 0.08, pt.color, pt.alpha ?? 1, pt.z || 0.3);
    }

    // Weapon viewmodel
    drawWeapon();

    // Muzzle flash overlay
    if (p.muzzle > 0) {
      ctx.fillStyle = `rgba(255,200,120,${p.muzzle * 0.35})`;
      ctx.beginPath();
      ctx.arc(W * 0.58, H * 0.72, 60 * p.muzzle, 0, Math.PI * 2);
      ctx.fill();
    }

    // Hurt / flash
    if (p.hurtT > 0) {
      dmgEl.style.opacity = String(clamp(p.hurtT * 1.5, 0, 0.75));
    } else {
      dmgEl.style.opacity = '0';
    }
    if (state.screenFlash > 0) {
      ctx.fillStyle = `rgba(255,240,180,${state.screenFlash * 0.35})`;
      ctx.fillRect(0, 0, W, H);
    }

    // Scanlines + vignette for retro CRT
    ctx.fillStyle = 'rgba(0,0,0,0.12)';
    for (let y = 0; y < H; y += 3) ctx.fillRect(0, y, W, 1);
    const vig = ctx.createRadialGradient(W/2, H/2, H*0.25, W/2, H/2, H*0.75);
    vig.addColorStop(0, 'rgba(0,0,0,0)');
    vig.addColorStop(1, 'rgba(0,0,0,0.45)');
    ctx.fillStyle = vig;
    ctx.fillRect(0, 0, W, H);

    // Minimap
    drawMinimap();
    updateHUD();
  }

  function projectSprite(ex, ey) {
    const p = state.player;
    const dx = ex - p.x, dy = ey - p.y;
    // transform into camera space
    const cos = Math.cos(p.a), sin = Math.sin(p.a);
    const rx = dy * cos - dx * sin; // camera x (right)
    const rz = dx * cos + dy * sin; // camera z (forward)
    if (rz <= 0.05) return null;
    const screenX = (W / 2) * (1 + rx / (rz * Math.tan(HALF_FOV)));
    const size = (H * 0.9) / rz;
    return { screenX, size, rz, rx };
  }

  function drawBillboard(ex, ey, wScale, hScale, color, alpha = 1, zLift = 0) {
    const pr = projectSprite(ex, ey);
    if (!pr) return;
    const sw = pr.size * wScale;
    const sh = pr.size * hScale;
    const left = pr.screenX - sw / 2;
    const top = (H - sh) / 2 - zLift * pr.size;
    // occlusion sample at center column
    const col = clamp(Math.floor((pr.screenX / W) * NUM_RAYS), 0, NUM_RAYS - 1);
    if (pr.rz > state.zBuffer[col] + 0.15) return;
    ctx.globalAlpha = alpha;
    ctx.fillStyle = color;
    ctx.fillRect(left, top, sw, sh);
    ctx.globalAlpha = 1;
  }

  function drawSprite(e) {
    const pr = projectSprite(e.x, e.y);
    if (!pr) return;

    if (e.type === 'exit') {
      const pulse = 0.7 + Math.sin(state.time * 5 + e.bob) * 0.3;
      const sw = pr.size * 0.7 * pulse;
      const sh = pr.size * 1.1;
      const left = pr.screenX - sw / 2;
      const top = (H - sh) / 2;
      // draw with column occlusion
      drawOccludedRect(left, top, sw, sh, pr.rz, (u, v) => {
        const ring = Math.hypot(u - 0.5, v - 0.5);
        if (ring < 0.22) return `rgba(255,220,100,${0.9 * pulse})`;
        if (ring < 0.35) return `rgba(255,140,40,${0.7 * pulse})`;
        if (Math.abs(u - 0.5) < 0.06 || Math.abs(v - 0.5) < 0.05) return `rgba(255,240,160,${0.8})`;
        return null;
      });
      return;
    }

    if (e.type === 'pickup') {
      const bob = Math.sin(state.time * 4 + e.bob) * 0.12;
      const sw = pr.size * 0.35;
      const sh = pr.size * 0.35;
      const left = pr.screenX - sw / 2;
      const top = (H - sh) / 2 - bob * pr.size - pr.size * 0.1;
      let c1, c2;
      if (e.kind === 'health') { c1 = '#40ff70'; c2 = '#104020'; }
      else if (e.kind === 'shells') { c1 = '#ffaa40'; c2 = '#603010'; }
      else { c1 = '#40d0ff'; c2 = '#103050'; }
      drawOccludedRect(left, top, sw, sh, pr.rz, (u, v) => {
        // cross / box icon
        if (e.kind === 'health') {
          if ((u > 0.35 && u < 0.65) || (v > 0.35 && v < 0.65)) return c1;
          return null;
        }
        const edge = u < 0.1 || u > 0.9 || v < 0.1 || v > 0.9;
        if (edge) return c2;
        if ((Math.floor(u * 4) + Math.floor(v * 4)) % 2 === 0) return c1;
        return c2;
      });
      return;
    }

    // Enemy
    const sw = pr.size * 0.55 * e.size;
    const sh = pr.size * 0.95 * e.size;
    const left = pr.screenX - sw / 2;
    const top = (H - sh) / 2 + pr.size * 0.05;
    const flash = e.hurtT > 0 ? 1 : 0;
    const limp = e.hp / e.maxHp;
    drawOccludedRect(left, top, sw, sh, pr.rz, (u, v) => {
      // simple pixel humanoid
      const walk = Math.sin(e.frame * 6) * 0.04;
      const head = v < 0.22 && u > 0.3 && u < 0.7;
      const body = v >= 0.22 && v < 0.62 && u > 0.28 && u < 0.72;
      const legL = v >= 0.62 && u > 0.28 + walk && u < 0.48 + walk;
      const legR = v >= 0.62 && u > 0.52 - walk && u < 0.72 - walk;
      const armL = v > 0.25 && v < 0.55 && u > 0.12 && u < 0.3;
      const armR = v > 0.25 && v < 0.55 && u > 0.7 && u < 0.88;
      const eye = v > 0.08 && v < 0.14 && ((u > 0.38 && u < 0.45) || (u > 0.55 && u < 0.62));
      if (eye) return flash ? '#fff' : '#ff2020';
      if (head || body || legL || legR || armL || armR) {
        if (flash) return '#ffffff';
        // shade by height & health
        const shade = 0.55 + v * 0.45;
        const r = parseInt(e.color.slice(1, 3), 16);
        const g = parseInt(e.color.slice(3, 5), 16);
        const b = parseInt(e.color.slice(5, 7), 16);
        const dim = 0.45 + limp * 0.55;
        return `rgb(${(r * shade * dim) | 0},${(g * shade * dim) | 0},${(b * shade * dim) | 0})`;
      }
      return null;
    });
    // hp pip for alert enemies
    if (e.alert && e.hp < e.maxHp) {
      const bw = sw * 0.7, bh = 4;
      const bx = pr.screenX - bw / 2, by = top - 8;
      const col = clamp(Math.floor((pr.screenX / W) * NUM_RAYS), 0, NUM_RAYS - 1);
      if (pr.rz <= state.zBuffer[col] + 0.2) {
        ctx.fillStyle = '#300';
        ctx.fillRect(bx, by, bw, bh);
        ctx.fillStyle = '#f33';
        ctx.fillRect(bx, by, bw * limp, bh);
      }
    }
  }

  function drawOccludedRect(left, top, sw, sh, depth, colorFn) {
    // scan horizontally in column resolution for occlusion & look
    const x0 = Math.floor(left);
    const x1 = Math.ceil(left + sw);
    const step = Math.max(2, Math.floor(sw / 28));
    for (let x = x0; x < x1; x += step) {
      const col = clamp(Math.floor((x / W) * NUM_RAYS), 0, NUM_RAYS - 1);
      if (depth > state.zBuffer[col] + 0.1) continue;
      const u = (x - left) / sw;
      // vertical blocky samples
      const vStep = Math.max(2, Math.floor(sh / 24));
      for (let y = Math.floor(top); y < top + sh; y += vStep) {
        const v = (y - top) / sh;
        if (v < 0 || v > 1) continue;
        const colr = colorFn(u, v);
        if (!colr) continue;
        ctx.fillStyle = colr;
        ctx.fillRect(x, y, step, vStep);
      }
    }
  }

  function drawWeapon() {
    if (state.dead || state.won) return;
    const p = state.player;
    const wep = WEAPONS[p.weapon];
    const bobX = Math.sin(p.bob) * 6;
    const bobY = Math.abs(Math.cos(p.bob)) * 5 + p.pitchKick * 40;
    const baseX = W * 0.55 + bobX;
    const baseY = H * 0.72 + bobY;

    ctx.save();
    ctx.translate(baseX, baseY);
    // weapon body per type
    if (p.weapon === 0) {
      // pistol
      ctx.fillStyle = '#2a2a30';
      ctx.fillRect(-14, -10, 28, 22);
      ctx.fillStyle = '#3a3a44';
      ctx.fillRect(-6, -28, 12, 22);
      ctx.fillStyle = '#1a1a20';
      ctx.fillRect(-4, -36, 8, 10);
      ctx.fillStyle = wep.color;
      ctx.fillRect(-3, -38, 6, 3);
    } else if (p.weapon === 1) {
      // scattergun
      ctx.fillStyle = '#3a2218';
      ctx.fillRect(-28, -14, 50, 24);
      ctx.fillStyle = '#2a1810';
      ctx.fillRect(10, -22, 34, 12);
      ctx.fillRect(10, -10, 34, 8);
      ctx.fillStyle = '#1a1008';
      ctx.fillRect(-18, 6, 16, 20);
      ctx.fillStyle = wep.color;
      ctx.fillRect(40, -20, 6, 18);
    } else {
      // rail
      ctx.fillStyle = '#1a2830';
      ctx.fillRect(-20, -12, 40, 20);
      ctx.fillStyle = '#0a1820';
      ctx.fillRect(8, -18, 50, 10);
      ctx.fillStyle = '#204050';
      ctx.fillRect(-14, -22, 18, 14);
      // glowing coil
      const g = 0.5 + Math.sin(state.time * 12) * 0.5;
      ctx.fillStyle = `rgba(80,220,255,${0.4 + g * 0.5})`;
      ctx.fillRect(12, -16, 44, 6);
      ctx.fillStyle = wep.color;
      ctx.fillRect(56, -17, 5, 8);
    }
    ctx.restore();

    // hands
    ctx.fillStyle = '#5a3030';
    ctx.fillRect(baseX - 30, baseY + 8, 18, 14);
    ctx.fillRect(baseX + 8, baseY + 10, 16, 12);
  }

  function drawMinimap() {
    const S = 128;
    const scale = S / Math.max(MAP_W, MAP_H);
    mm.clearRect(0, 0, S, S);
    mm.fillStyle = 'rgba(0,0,0,0.5)';
    mm.fillRect(0, 0, S, S);
    for (let y = 0; y < MAP_H; y++) {
      for (let x = 0; x < MAP_W; x++) {
        if (map[y][x] > 0) {
          mm.fillStyle = map[y][x] === 3 ? '#a07020' : '#602020';
          mm.fillRect(x * scale, y * scale, scale, scale);
        }
      }
    }
    for (const e of state.entities) {
      if (!e.alive) continue;
      if (e.type === 'enemy') mm.fillStyle = '#ff4040';
      else if (e.type === 'exit') mm.fillStyle = '#ffdd40';
      else mm.fillStyle = '#40ff80';
      mm.fillRect(e.x * scale - 1.5, e.y * scale - 1.5, 3, 3);
    }
    // player
    const p = state.player;
    mm.fillStyle = '#80c0ff';
    mm.beginPath();
    mm.arc(p.x * scale, p.y * scale, 2.5, 0, Math.PI * 2);
    mm.fill();
    mm.strokeStyle = '#fff';
    mm.beginPath();
    mm.moveTo(p.x * scale, p.y * scale);
    mm.lineTo(p.x * scale + Math.cos(p.a) * 6, p.y * scale + Math.sin(p.a) * 6);
    mm.stroke();
  }

  function updateHUD() {
    const p = state.player;
    const wep = WEAPONS[p.weapon];
    document.getElementById('hpText').textContent = String(Math.max(0, Math.ceil(p.hp)));
    document.getElementById('hpBar').style.transform = `scaleX(${clamp(p.hp / p.maxHp, 0, 1)})`;
    document.getElementById('wepText').textContent = wep.short + '  [' + (p.weapon + 1) + ']';
    if (wep.ammoKey) {
      document.getElementById('ammoText').textContent = String(p.ammo[wep.ammoKey] ?? 0);
    } else {
      document.getElementById('ammoText').textContent = '∞';
    }
    document.getElementById('killText').textContent = `${state.kills} / ${state.totalEnemies}`;
    if (state.combo > 1 && state.comboT > 0) {
      omboEl.textContent = `x${state.combo} STREAK`;
      omboEl.style.opacity = '1';
    } else {
      omboEl.style.opacity = '0';
    }
  }

  // ─── Shooting ────────────────────────────────────────────
  function tryFire() {
    const p = state.player;
    if (p.fireCD > 0 || state.dead || state.won) return;
    const wep = WEAPONS[p.weapon];
    if (wep.ammoKey) {
      if ((p.ammo[wep.ammoKey] || 0) < (wep.ammoPerShot || 1)) {
        SFX.empty();
        showMsg('OUT OF AMMO', 700);
        return;
      }
      p.ammo[wep.ammoKey] -= wep.ammoPerShot || 1;
    }
    p.fireCD = wep.rate;
    p.muzzle = 1;
    p.pitchKick = wep.kick;
    SFX[wep.sfx]();

    // hitscan pellets
    for (let i = 0; i < wep.pellets; i++) {
      const spread = (Math.random() - 0.5) * wep.spread * 2;
      const ang = p.a + spread;
      hitscan(ang, wep);
    }
  }

  function hitscan(angle, wep) {
    const p = state.player;
    const cos = Math.cos(angle), sin = Math.sin(angle);
    // step along ray
    let bestE = null, bestD = wep.range;
    for (const e of state.entities) {
      if (!e.alive || e.type !== 'enemy') continue;
      // ray-sphere (circle) intersect in 2d
      const dx = e.x - p.x, dy = e.y - p.y;
      const proj = dx * cos + dy * sin;
      if (proj < 0 || proj > wep.range) continue;
      const closestX = p.x + cos * proj;
      const closestY = p.y + sin * proj;
      const d = dist(closestX, closestY, e.x, e.y);
      // stretch hit radius with distance slightly for fairness
      const hitR = e.r * e.size * 1.35 + 0.05;
      if (d < hitR && proj < bestD) {
        // wall check
        const wall = castRay(p.x, p.y, angle);
        if (wall.dist < proj - 0.05) continue;
        bestE = e; bestD = proj;
        if (!wep.pierce) break;
      }
    }

    const impactDist = bestE ? bestD : Math.min(wep.range, castRay(p.x, p.y, angle).dist);
    // tracer particle
    spawnTracer(p.x, p.y, angle, impactDist, wep.color);

    if (bestE) {
      damageEnemy(bestE, wep.dmg + (Math.random() * 4 - 2), angle);
      // pierce continues lightly
      if (wep.pierce) {
        // extra weak hit on next
        let next = null, nd = wep.range;
        for (const e of state.entities) {
          if (!e.alive || e.type !== 'enemy' || e === bestE) continue;
          const dx = e.x - p.x, dy = e.y - p.y;
          const proj = dx * cos + dy * sin;
          if (proj <= bestD || proj > wep.range) continue;
          const cx = p.x + cos * proj, cy = p.y + sin * proj;
          if (dist(cx, cy, e.x, e.y) < e.r * e.size * 1.3 && proj < nd) {
            const wall = castRay(p.x, p.y, angle);
            if (wall.dist < proj - 0.05) continue;
            next = e; nd = proj;
          }
        }
        if (next) damageEnemy(next, wep.dmg * 0.6, angle);
      }
    } else {
      // wall sparks
      const ix = p.x + cos * impactDist * 0.98;
      const iy = p.y + sin * impactDist * 0.98;
      for (let k = 0; k < 4; k++) {
        state.particles.push({
          x: ix, y: iy,
          vx: (Math.random() - 0.5) * 1.5,
          vy: (Math.random() - 0.5) * 1.5,
          life: 0.2 + Math.random() * 0.2,
          size: 0.05, color: '#ffcc88', alpha: 1, z: 0.4
        });
      }
    }
  }

  function spawnTracer(x, y, ang, d, color) {
    const steps = 6;
    for (let i = 1; i <= steps; i++) {
      const t = (i / steps) * Math.min(d, 8);
      state.particles.push({
        x: x + Math.cos(ang) * t,
        y: y + Math.sin(ang) * t,
        vx: 0, vy: 0,
        life: 0.04 + i * 0.01,
        size: 0.04, color, alpha: 0.7, z: 0.45
      });
    }
  }

  function damageEnemy(e, dmg, fromAng) {
    e.hp -= dmg;
    e.hurtT = 0.12;
    e.alert = true;
    SFX.hit();
    // knockback
    e.x += Math.cos(fromAng) * 0.08;
    e.y += Math.sin(fromAng) * 0.08;
    // blood
    for (let i = 0; i < 5; i++) {
      state.particles.push({
        x: e.x, y: e.y,
        vx: Math.cos(fromAng) * (1 + Math.random()) + (Math.random() - 0.5),
        vy: Math.sin(fromAng) * (1 + Math.random()) + (Math.random() - 0.5),
        life: 0.25 + Math.random() * 0.25,
        size: 0.07, color: '#a01010', alpha: 1, z: 0.35
      });
    }
    if (e.hp <= 0) killEnemy(e);
  }

  function killEnemy(e) {
    e.alive = false;
    state.kills++;
    state.combo++;
    state.comboT = 2.5;
    SFX.kill();
    showMsg(e.kind === 'brute' ? 'BRUTE DOWN' : 'HOSTILE NEUTRALIZED', 700);
    for (let i = 0; i < 12; i++) {
      const a = Math.random() * Math.PI * 2;
      state.particles.push({
        x: e.x, y: e.y,
        vx: Math.cos(a) * (1 + Math.random() * 2),
        vy: Math.sin(a) * (1 + Math.random() * 2),
        life: 0.4 + Math.random() * 0.4,
        size: 0.08, color: e.color, alpha: 1, z: 0.3
      });
    }
    // occasional drop
    if (Math.random() < 0.35) {
      const kind = Math.random() < 0.5 ? 'shells' : (Math.random() < 0.5 ? 'cells' : 'health');
      state.entities.push(makePickup(e.x, e.y, kind));
    }
    if (state.kills >= state.totalEnemies) {
      showMsg('ALL HOSTILES CLEARED — REACH EXTRACTION', 2800);
      SFX.pickup();
    }
  }

  // ─── Player damage / death ───────────────────────────────
  function hurtPlayer(dmg) {
    const p = state.player;
    if (state.dead || state.won) return;
    p.hp -= dmg;
    p.hurtT = 0.45;
    SFX.hurt();
    if (p.hp <= 0) {
      p.hp = 0;
      state.dead = true;
      SFX.die();
      overlay.classList.remove('hidden');
      overlay.innerHTML = `
        <h1 style="color:#ff2020">FLATLINED</h1>
        <h2>Biometrics failed · Sector holds</h2>
        <p>Hostiles remaining: ${Math.max(0, state.totalEnemies - state.kills)}</p>
        <p>You cleared ${state.kills} targets this run.</p>
        <button class="btn" id="rezBtn">RESPAWN (R)</button>
        <div class="keys">Extraction gate is still live. Try another approach.</div>
      `;
      document.getElementById('rezBtn').onclick = respawn;
      document.exitPointerLock?.();
      hud.classList.remove('show');
      cross.classList.remove('show');
      mmCanvas.classList.remove('show');
    }
  }

  function respawn() {
    parseMap();
    ensureAudio();
    state.running = true;
    state.paused = false;
    overlay.classList.add('hidden');
    hud.classList.add('show');
    cross.classList.add('show');
    mmCanvas.classList.add('show');
    canvas.requestPointerLock?.();
    showMsg('RE-INSERTED INTO SECTOR', 1400);
  }

  function winGame() {
    if (state.won) return;
    state.won = true;
    SFX.win();
    document.exitPointerLock?.();
    hud.classList.remove('show');
    cross.classList.remove('show');
    mmCanvas.classList.remove('show');
    const grade = state.kills >= state.totalEnemies ? 'S-RANK CLEAR' :
                  state.kills >= state.totalEnemies * 0.6 ? 'B-RANK EXTRACT' : 'C-RANK ESCAPE';
    overlay.classList.remove('hidden');
    overlay.innerHTML = `
      <h1 style="color:#ffcc44">EXTRACTION SECURE</h1>
      <h2>${grade}</h2>
      <p>Hostiles neutralized: ${state.kills} / ${state.totalEnemies}</p>
      <p>The amber gate sealed behind you. Facility 07 is no longer your problem.</p>
      <button class="btn" id="againBtn">RUN SECTOR AGAIN</button>
      <div class="keys">Thanks for playing CRIMSON SECTOR</div>
    `;
    document.getElementById('againBtn').onclick = () => {
      parseMap();
      startGame(false);
    };
  }

  // ─── Movement / collisions ───────────────────────────────
  function tryMove(nx, ny) {
    const p = state.player;
    // axis-separated with radius
    if (!blocked(nx, p.y, PLAYER_RADIUS)) p.x = nx;
    if (!blocked(p.x, ny, PLAYER_RADIUS)) p.y = ny;
  }
  function blocked(x, y, r) {
    // 4 corners + center
    const pts = [
      [x - r, y - r], [x + r, y - r],
      [x - r, y + r], [x + r, y + r],
      [x, y]
    ];
    for (const [px, py] of pts) {
      if (solidAt(px, py)) return true;
    }
    return false;
  }

  // ─── Update ──────────────────────────────────────────────
  function update(dt) {
    if (!state.running || state.paused) return;
    state.time += dt;
    const p = state.player;

    if (state.dead || state.won) {
      // still animate particles lightly
      updateParticles(dt);
      return;
    }

    // look
    if (state.mouseDX) {
      p.a += state.mouseDX * MOUSE_SENS;
      state.mouseDX = 0;
    }
    if (state.keys['ArrowLeft']) p.a -= ROT_SPEED * dt;
    if (state.keys['ArrowRight']) p.a += ROT_SPEED * dt;

    // move
    let mx = 0, my = 0;
    if (state.keys['w'] || state.keys['ArrowUp']) { mx += Math.cos(p.a); my += Math.sin(p.a); }
    if (state.keys['s'] || state.keys['ArrowDown']) { mx -= Math.cos(p.a); my -= Math.sin(p.a); }
    if (state.keys['a']) { mx += Math.cos(p.a - Math.PI / 2); my += Math.sin(p.a - Math.PI / 2); }
    if (state.keys['d']) { mx += Math.cos(p.a + Math.PI / 2); my += Math.sin(p.a + Math.PI / 2); }
    const len = Math.hypot(mx, my);
    if (len > 0) {
      mx /= len; my /= len;
      const sprint = (state.keys['Shift'] ? SPRINT_MULT : 1);
      const sp = MOVE_SPEED * sprint;
      tryMove(p.x + mx * sp * dt, p.y + my * sp * dt);
      p.bob += dt * 10 * sprint;
    } else {
      p.bob *= 0.9;
    }

    // fire
    if (state.keys[' '] || state.keys['mouse']) tryFire();

    p.fireCD = Math.max(0, p.fireCD - dt);
    p.muzzle = Math.max(0, p.muzzle - dt * 6);
    p.pitchKick = Math.max(0, p.pitchKick - dt * 1.5);
    p.hurtT = Math.max(0, p.hurtT - dt);
    state.screenFlash = Math.max(0, state.screenFlash - dt * 3);
    state.comboT = Math.max(0, state.comboT - dt);
    if (state.comboT <= 0) state.combo = 0;

    // entities
    for (const e of state.entities) {
      if (!e.alive) continue;
      if (e.type === 'enemy') updateEnemy(e, dt);
      else if (e.type === 'pickup') updatePickup(e, dt);
      else if (e.type === 'exit') updateExit(e, dt);
    }

    updateParticles(dt);
    updateProjectiles(dt);
  }

  function updateEnemy(e, dt) {
    const p = state.player;
    e.frame += dt;
    e.hurtT = Math.max(0, e.hurtT - dt);
    e.attackCD = Math.max(0, e.attackCD - dt);

    const d = dist(e.x, e.y, p.x, p.y);
    // line of sight
    let los = false;
    if (d < 10) {
      const ang = Math.atan2(p.y - e.y, p.x - e.x);
      const wall = castRay(e.x, e.y, ang);
      los = wall.dist > d - 0.1;
      if (los && d < 8) e.alert = true;
    }
    // noise alert if player fires nearby
    if (p.muzzle > 0.8 && d < 9) e.alert = true;

    if (!e.alert) {
      // idle sway
      e.x += Math.cos(e.frame * 0.7) * 0.15 * dt;
      e.y += Math.sin(e.frame * 0.5) * 0.15 * dt;
      return;
    }

    // chase
    if (d > e.range * 0.85) {
      const ang = Math.atan2(p.y - e.y, p.x - e.x);
      const sp = e.speed * (e.hurtT > 0 ? 0.5 : 1);
      const nx = e.x + Math.cos(ang) * sp * dt;
      const ny = e.y + Math.sin(ang) * sp * dt;
      if (!solidAt(nx, e.y) && !entityBlocked(nx, e.y, e)) e.x = nx;
      if (!solidAt(e.x, ny) && !entityBlocked(e.x, ny, e)) e.y = ny;
      e.facing = ang;
    }

    // attack
    if (d < e.range && e.attackCD <= 0 && los) {
      e.attackCD = e.ranged ? 1.4 : 0.85;
      if (e.ranged) {
        // spit projectile
        const ang = Math.atan2(p.y - e.y, p.x - e.x);
        state.projectiles.push({
          x: e.x, y: e.y,
          vx: Math.cos(ang) * 5.5,
          vy: Math.sin(ang) * 5.5,
          life: 2.5, dmg: e.dmg, color: '#80ff60', r: 0.12
        });
        beep(200, 0.08, 'sawtooth', 0.05, -40);
      } else {
        hurtPlayer(e.dmg);
        // brief push
        const ang = Math.atan2(p.y - e.y, p.x - e.x);
        tryMove(p.x + Math.cos(ang) * 0.12, p.y + Math.sin(ang) * 0.12);
      }
    }
  }

  function entityBlocked(x, y, self) {
    for (const o of state.entities) {
      if (!o.alive || o === self || o.type !== 'enemy') continue;
      if (dist(x, y, o.x, o.y) < (self.r + o.r) * 0.9) return true;
    }
    return false;
  }

  function updatePickup(e, dt) {
    e.bob += dt;
    const p = state.player;
    if (dist(e.x, e.y, p.x, p.y) < 0.55) {
      e.alive = false;
      SFX.pickup();
      state.screenFlash = 0.4;
      if (e.kind === 'health') {
        p.hp = clamp(p.hp + 28, 0, p.maxHp);
        showMsg('+ HEALTH', 800);
      } else if (e.kind === 'shells') {
        p.ammo.shells += 8;
        showMsg('+ SCATTER SHELLS', 800);
      } else if (e.kind === 'cells') {
        p.ammo.cells += 3;
        showMsg('+ RAIL CELLS', 800);
      }
    }
  }

  function updateExit(e, dt) {
    e.bob += dt;
    const p = state.player;
    if (dist(e.x, e.y, p.x, p.y) < 0.55) {
      // soft gate: allow early extract but award messaging
      if (state.kills < state.totalEnemies) {
        // still allow win — tactical extraction
        winGame();
      } else {
        winGame();
      }
    }
  }

  function updateParticles(dt) {
    for (let i = state.particles.length - 1; i >= 0; i--) {
      const pt = state.particles[i];
      pt.life -= dt;
      if (pt.life <= 0) { state.particles.splice(i, 1); continue; }
      pt.x += pt.vx * dt;
      pt.y += pt.vy * dt;
      pt.vx *= 0.92; pt.vy *= 0.92;
      pt.alpha = clamp(pt.life * 3, 0, 1);
    }
  }

  function updateProjectiles(dt) {
    const p = state.player;
    for (let i = state.projectiles.length - 1; i >= 0; i--) {
      const pr = state.projectiles[i];
      pr.life -= dt;
      pr.x += pr.vx * dt;
      pr.y += pr.vy * dt;
      if (pr.life <= 0 || solidAt(pr.x, pr.y)) {
        state.projectiles.splice(i, 1);
        continue;
      }
      if (dist(pr.x, pr.y, p.x, p.y) < 0.3 + (pr.r || 0.1)) {
        hurtPlayer(pr.dmg);
        state.projectiles.splice(i, 1);
      }
    }
  }

  // ─── Input ───────────────────────────────────────────────
  window.addEventListener('keydown', (e) => {
    const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    state.keys[k] = true;
    if (k === ' ') e.preventDefault();

    if (state.dead && (k === 'r' || k === 'R')) {
      respawn();
      return;
    }
    if (!state.running) return;

    if (k === 'p' || k === 'P' || k === 'Escape') {
      if (state.won || state.dead) return;
      state.paused = !state.paused;
      if (state.paused) {
        document.exitPointerLock?.();
        showMsg('PAUSED', 99999);
      } else {
        canvas.requestPointerLock?.();
        msgEl.classList.remove('show');
      }
    }
    if (k === '1') switchWeapon(0);
    if (k === '2') switchWeapon(1);
    if (k === '3') switchWeapon(2);
    if (k === 'r' || k === 'R') {
      // "reload" flavor — regenerates a bit of feel, free for pistol
      const wep = WEAPONS[state.player.weapon];
      showMsg(wep.ammoKey ? 'CHAMBERING…' : 'SIDEARM READY', 500);
      SFX.switch();
    }
  });
  window.addEventListener('keyup', (e) => {
    const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    state.keys[k] = false;
  });
  canvas.addEventListener('mousedown', (e) => {
    if (e.button === 0) {
      state.keys['mouse'] = true;
      if (state.running && !state.paused && !state.dead) tryFire();
    }
  });
  window.addEventListener('mouseup', (e) => {
    if (e.button === 0) state.keys['mouse'] = false;
  });
  document.addEventListener('mousemove', (e) => {
    if (document.pointerLockElement === canvas) {
      state.mouseDX += e.movementX;
    }
  });

  function switchWeapon(i) {
    if (state.player.weapon === i) return;
    state.player.weapon = i;
    state.player.fireCD = 0.15;
    SFX.switch();
    showMsg(WEAPONS[i].name, 700);
  }

  // ─── Game loop ───────────────────────────────────────────
  let last = performance.now();
  function frame(now) {
    const dt = clamp((now - last) / 1000, 0, 0.05);
    last = now;
    update(dt);
    if (state.running) render();
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  function startGame(fromOverlay = true) {
    ensureAudio();
    if (fromOverlay) parseMap();
    state.running = true;
    state.paused = false;
    state.dead = false;
    state.won = false;
    overlay.classList.add('hidden');
    hud.classList.add('show');
    cross.classList.add('show');
    mmCanvas.classList.add('show');
    canvas.requestPointerLock?.();
    showMsg('PUSH TO EXTRACTION GATE', 2200);
  }

  startBtn.addEventListener('click', () => startGame(true));

  // clicking canvas after pause re-locks
  canvas.addEventListener('click', () => {
    if (state.running && !state.dead && !state.won) {
      canvas.requestPointerLock?.();
      if (state.paused) {
        state.paused = false;
        msgEl.classList.remove('show');
      }
    }
  });

  // Initial decorative render of title walls (empty static)
  parseMap();
  // don't auto-run until click
})();
</script>
</body>
</html>