JS-Doom: Retro Raycaster

Experience a classic pseudo-3D first-person shooter directly in your vault. Inspired by the legendary 1993 classic, this self-contained raycaster features procedural textures, audio synthesis, animated sprites, interactive doors, pathfinding enemies, and mobile-friendly touch controls.

<div class="doom-container">
  <!-- Title Screen / Menu Overlay -->
  <div id="menu" class="overlay">
    <div class="menu-content">
      <h1 class="doom-title">JS-DOOM</h1>
      <p class="doom-subtitle">KNEE-DEEP IN THE CODE</p>
      <button id="start-btn" class="doom-btn">START GAME</button>
      <div class="instructions">
        <p><strong>Keyboard:</strong> WASD / Arrows to move/turn, Space to Shoot, E to Open Doors, Q or 1/2 to Swap Weapons</p>
        <p><strong>Touch:</strong> Use virtual controls below</p>
      </div>
    </div>
  </div>

  <!-- Game Over / Victory Overlay -->
  <div id="gameover" class="overlay hidden">
    <div class="menu-content">
      <h1 id="gameover-title" class="doom-title text-red">YOU DIED</h1>
      <p id="gameover-subtitle">Press to restart</p>
      <button id="restart-btn" class="doom-btn">RETRY</button>
    </div>
  </div>

  <!-- Game Screen (Widescreen 16:9) -->
  <div class="game-viewport">
    <canvas id="gameCanvas" width="640" height="360"></canvas>
    
    <!-- Crosshair -->
    <div class="crosshair"></div>
    
    <!-- Screen Flash -->
    <div id="flash" class="flash-overlay"></div>
  </div>

  <!-- Status Bar (Retro HUD) -->
  <div class="hud">
    <div class="hud-stat">
      <span class="hud-label">AMMO</span>
      <span id="hud-ammo" class="hud-val">50</span>
    </div>
    <div class="hud-stat">
      <span class="hud-label">HEALTH</span>
      <span id="hud-health" class="hud-val">100%</span>
    </div>
    <div class="hud-face-container">
      <canvas id="hudFace" width="40" height="40"></canvas>
    </div>
    <div class="hud-stat">
      <span class="hud-label">ARMOR</span>
      <span id="hud-armor" class="hud-val">50%</span>
    </div>
    <div class="hud-stat">
      <span class="hud-label">SECTOR</span>
      <span id="hud-sector" class="hud-val">1/2</span>
    </div>
    <div class="hud-stat">
      <span class="hud-label">WEAPON</span>
      <span id="hud-weapon" class="hud-val">PISTOL</span>
    </div>
  </div>

  <!-- Touch Controls -->
  <div class="touch-controls">
    <div class="dpad">
      <button id="btn-up" class="ctrl-btn">▲</button>
      <div class="dpad-row">
        <button id="btn-left" class="ctrl-btn">◀</button>
        <button id="btn-right" class="ctrl-btn">▶</button>
      </div>
      <button id="btn-down" class="ctrl-btn">▼</button>
    </div>
    <div class="actions">
      <button id="btn-use" class="ctrl-btn action-btn font-sm">USE (E)</button>
      <button id="btn-fire" class="ctrl-btn action-btn fire-btn">FIRE</button>
      <button id="btn-weapon" class="ctrl-btn action-btn font-sm">SWAP</button>
    </div>
  </div>
</div>

<style>
  .doom-container {
    --doom-red: #ff0000;
    --doom-gold: #ffcc00;
    --doom-gray: #333333;
    --doom-hud-bg: #1c1c1c;
    
    font-family: 'Courier New', Courier, monospace;
    background: #000;
    color: #fff;
    user-select: none;
    -webkit-user-select: none;
    position: relative;
    width: 100%;
    max-width: 1000px;
    margin: 0 auto;
    border: 4px solid var(--doom-gray);
    box-shadow: 0 10px 30px rgba(0,0,0,0.8);
    overflow: hidden;
  }

  .overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0,0,0,0.85);
    z-index: 10;
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    transition: opacity 0.3s ease;
  }
  .overlay.hidden {
    display: none;
    opacity: 0;
    pointer-events: none;
  }

  .menu-content {
    padding: 20px;
  }

  .doom-title {
    font-size: 3rem;
    font-weight: 900;
    color: var(--doom-red);
    text-shadow: 3px 3px 0px var(--doom-gold);
    margin: 0 0 5px 0;
    letter-spacing: 4px;
    font-family: Arial, sans-serif;
  }
  
  .text-red {
    color: var(--doom-red) !important;
    text-shadow: 2px 2px 0px #000 !important;
  }

  .doom-subtitle {
    font-size: 1rem;
    color: var(--doom-gold);
    margin: 0 0 30px 0;
    letter-spacing: 2px;
  }

  .doom-btn {
    background: #5a0000;
    border: 3px solid var(--doom-red);
    color: #fff;
    font-family: inherit;
    font-size: 1.2rem;
    font-weight: bold;
    padding: 10px 25px;
    cursor: pointer;
    box-shadow: 0 4px 0px #300000;
    transition: all 0.1s ease;
  }
  .doom-btn:active {
    transform: translateY(4px);
    box-shadow: none;
  }

  .instructions {
    margin-top: 30px;
    font-size: 0.8rem;
    color: #888;
    line-height: 1.4;
    max-width: 500px;
  }

  .game-viewport {
    position: relative;
    width: 100%;
    padding-bottom: 56.25%; /* 16:9 widescreen aspect ratio */
    background: #000;
  }

  #gameCanvas {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    image-rendering: pixelated;
  }

  .crosshair {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 6px;
    height: 6px;
    transform: translate(-50%, -50%);
    border: 1px solid rgba(255, 0, 0, 0.4);
    border-radius: 50%;
    pointer-events: none;
  }

  .flash-overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(255, 0, 0, 0);
    pointer-events: none;
    z-index: 5;
    transition: background-color 0.05s ease-out;
  }

  /* Retro HUD styles */
  .hud {
    display: grid;
    grid-template-columns: 1fr 1fr 60px 1fr 1fr 1.2fr;
    background: var(--doom-hud-bg);
    border-top: 4px solid var(--doom-gray);
    padding: 5px 10px;
    align-items: center;
    box-sizing: border-box;
  }

  .hud-stat {
    display: flex;
    flex-direction: column;
    align-items: center;
    border-right: 2px solid #2d2d2d;
  }
  .hud-stat:last-child {
    border-right: none;
  }

  .hud-label {
    font-size: 0.6rem;
    color: #666;
    font-weight: bold;
    margin-bottom: 2px;
  }

  .hud-val {
    font-size: 1.1rem;
    font-weight: 900;
    color: var(--doom-red);
    text-shadow: 1px 1px 0px #000;
  }

  .hud-face-container {
    display: flex;
    justify-content: center;
    align-items: center;
    border-right: 2px solid #2d2d2d;
    height: 100%;
  }

  #hudFace {
    image-rendering: pixelated;
    background: #000;
    border: 1px solid #444;
  }

  /* Touch controls */
  .touch-controls {
    display: flex;
    justify-content: space-between;
    padding: 10px;
    background: #111;
    border-top: 2px solid var(--doom-gray);
  }

  .dpad {
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 120px;
  }
  .dpad-row {
    display: flex;
    justify-content: space-between;
    width: 100%;
  }

  .ctrl-btn {
    background: #222;
    border: 2px solid #444;
    color: #fff;
    width: 38px;
    height: 38px;
    border-radius: 4px;
    font-size: 1.1rem;
    font-weight: bold;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    touch-action: manipulation;
  }
  .ctrl-btn:active {
    background: var(--doom-red);
    border-color: var(--doom-gold);
  }

  .actions {
    display: flex;
    gap: 8px;
    align-items: center;
  }

  .action-btn {
    width: 55px;
    height: 55px;
    border-radius: 50%;
  }
  .font-sm {
    font-size: 0.7rem !important;
  }

  .fire-btn {
    width: 65px;
    height: 65px;
    background: #800;
    border-color: var(--doom-red);
    font-size: 0.9rem;
  }

  @media(min-width: 480px) {
    .hud-val {
      font-size: 1.4rem;
    }
  }
</style>

<script>
  // Simple retro synthesizer for sound effects (Web Audio API)
  class SoundSynth {
    constructor() {
      this.ctx = null;
    }
    init() {
      if (!this.ctx) {
        this.ctx = new (window.AudioContext || window.webkitAudioContext)();
      }
    }
    playShoot() {
      this.init();
      if (!this.ctx) return;
      const osc = this.ctx.createOscillator();
      const gain = this.ctx.createGain();
      osc.connect(gain);
      gain.connect(this.ctx.destination);
      
      osc.type = 'sawtooth';
      osc.frequency.setValueAtTime(300, this.ctx.currentTime);
      osc.frequency.exponentialRampToValueAtTime(40, this.ctx.currentTime + 0.15);
      
      gain.gain.setValueAtTime(0.3, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.15);
      
      osc.start();
      osc.stop(this.ctx.currentTime + 0.15);
    }
    playExplosion() {
      this.init();
      if (!this.ctx) return;
      const bufferSize = this.ctx.sampleRate * 0.25;
      const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
      const data = buffer.getChannelData(0);
      for (let i = 0; i < bufferSize; i++) {
        data[i] = Math.random() * 2 - 1;
      }
      const noise = this.ctx.createBufferSource();
      noise.buffer = buffer;
      
      const filter = this.ctx.createBiquadFilter();
      filter.type = 'lowpass';
      filter.frequency.setValueAtTime(400, this.ctx.currentTime);
      filter.frequency.exponentialRampToValueAtTime(10, this.ctx.currentTime + 0.25);
      
      const gain = this.ctx.createGain();
      gain.gain.setValueAtTime(0.4, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.25);
      
      noise.connect(filter);
      filter.connect(gain);
      gain.connect(this.ctx.destination);
      noise.start();
    }
    playPain() {
      this.init();
      if (!this.ctx) return;
      const osc = this.ctx.createOscillator();
      const gain = this.ctx.createGain();
      osc.connect(gain);
      gain.connect(this.ctx.destination);
      osc.type = 'sawtooth';
      osc.frequency.setValueAtTime(120, this.ctx.currentTime);
      osc.frequency.linearRampToValueAtTime(60, this.ctx.currentTime + 0.2);
      gain.gain.setValueAtTime(0.3, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.2);
      osc.start();
      osc.stop(this.ctx.currentTime + 0.2);
    }
    playItem() {
      this.init();
      if (!this.ctx) return;
      const osc = this.ctx.createOscillator();
      const gain = this.ctx.createGain();
      osc.connect(gain);
      gain.connect(this.ctx.destination);
      osc.type = 'sine';
      osc.frequency.setValueAtTime(440, this.ctx.currentTime);
      osc.frequency.setValueAtTime(880, this.ctx.currentTime + 0.08);
      gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.2);
      osc.start();
      osc.stop(this.ctx.currentTime + 0.2);
    }
    playDoor() {
      this.init();
      if (!this.ctx) return;
      const osc = this.ctx.createOscillator();
      const gain = this.ctx.createGain();
      osc.connect(gain);
      gain.connect(this.ctx.destination);
      osc.type = 'triangle';
      osc.frequency.setValueAtTime(80, this.ctx.currentTime);
      osc.frequency.linearRampToValueAtTime(100, this.ctx.currentTime + 0.5);
      gain.gain.setValueAtTime(0.2, this.ctx.currentTime);
      gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.5);
      osc.start();
      osc.stop(this.ctx.currentTime + 0.5);
    }
  }

  const sfx = new SoundSynth();

  // Multi-Sector / Level definitions
  const sectorsData = {
    1: {
      map: [
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
        [1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1],
        [1,0,1,1,0,0,3,0,0,2,2,0,2,2,0,1],
        [1,0,1,0,0,0,1,0,0,2,0,0,0,2,0,1],
        [1,0,1,0,1,1,1,0,0,2,0,0,0,2,0,1],
        [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
        [1,0,0,0,0,0,0,0,0,2,2,3,2,2,0,1],
        [1,0,1,1,1,1,0,0,0,0,0,0,0,0,0,1],
        [1,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1],
        [1,0,1,0,0,1,0,0,1,1,1,1,1,1,0,1],
        [1,0,0,0,0,0,0,0,1,0,0,0,0,1,0,1],
        [1,0,2,2,0,2,0,0,1,0,4,0,0,1,0,1],
        [1,0,2,0,0,2,0,0,1,0,0,0,0,3,0,1],
        [1,0,2,0,0,2,0,0,1,1,1,1,1,1,0,1],
        [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
      ],
      sprites: [
        { x: 5.5, y: 5.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
        { x: 3.5, y: 3.5, texture: 'imp', health: 40, dead: false, type: 'enemy', speed: 0.015 },
        { x: 12.5, y: 3.5, texture: 'imp', health: 40, dead: false, type: 'enemy', speed: 0.015 },
        { x: 12.5, y: 11.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
        { x: 10.5, y: 12.5, texture: 'imp', health: 40, dead: false, type: 'enemy', speed: 0.015 },
        { x: 8.5, y: 8.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
        { x: 4.5, y: 12.5, texture: 'medkit', type: 'item' },
        { x: 5.5, y: 12.5, texture: 'ammo', type: 'item' },
        { x: 14.5, y: 1.5, texture: 'medkit', type: 'item' },
        { x: 14.5, y: 2.5, texture: 'ammo', type: 'item' }
      ],
      startX: 1.5,
      startY: 1.5
    },
    2: {
      map: [
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
        [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
        [1,0,2,0,3,0,4,0,3,0,2,0,3,0,2,1],
        [1,0,2,0,1,0,0,0,1,0,2,0,1,0,0,1],
        [1,0,0,0,1,1,1,0,1,1,1,0,1,1,0,1],
        [1,1,3,1,1,0,0,0,0,0,1,1,1,0,0,1],
        [1,0,0,0,1,0,2,2,2,0,1,0,0,0,0,1],
        [1,0,4,0,3,0,2,0,2,0,3,0,4,0,0,1],
        [1,0,0,0,1,0,2,2,2,0,1,0,0,0,0,1],
        [1,1,3,1,1,0,0,0,0,0,1,1,3,1,1,1],
        [1,0,0,0,1,1,1,0,1,1,1,0,0,0,0,1],
        [1,0,2,0,1,0,0,0,1,0,2,0,1,0,2,1],
        [1,0,2,0,3,0,4,0,3,0,2,0,3,0,2,1],
        [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
        [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
      ],
      sprites: [
        { x: 2.5, y: 2.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
        { x: 13.5, y: 2.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
        { x: 7.5, y: 7.5, texture: 'imp', health: 50, dead: false, type: 'enemy', speed: 0.025 },
        { x: 7.5, y: 6.5, texture: 'imp', health: 50, dead: false, type: 'enemy', speed: 0.025 },
        { x: 7.5, y: 8.5, texture: 'imp', health: 50, dead: false, type: 'enemy', speed: 0.025 },
        { x: 2.5, y: 13.5, texture: 'cacodemon', health: 35, dead: false, type: 'enemy', speed: 0.02 },
        { x: 13.5, y: 13.5, texture: 'cacodemon', health: 35, dead: false, type: 'enemy', speed: 0.025 },
        { x: 5.5, y: 1.5, texture: 'medkit', type: 'item' },
        { x: 9.5, y: 1.5, texture: 'ammo', type: 'item' },
        { x: 5.5, y: 14.5, texture: 'medkit', type: 'item' },
        { x: 9.5, y: 14.5, texture: 'ammo', type: 'item' }
      ],
      startX: 7.5,
      startY: 14.5
    }
  };

  // Canvas configuration
  const canvas = document.getElementById('gameCanvas');
  const ctx = canvas.getContext('2d');
  const viewWidth = canvas.width;
  const viewHeight = canvas.height;

  // Player State
  let currentSector = 1;
  let worldMap = JSON.parse(JSON.stringify(sectorsData[1].map));
  let posX = sectorsData[1].startX;
  let posY = sectorsData[1].startY;
  let dirX = 1.0, dirY = 0.0; // Direction vector
  let planeX = 0.0, planeY = 0.66; // Camera plane
  let health = 100;
  let armor = 50;
  let ammo = 50;
  let currentWeapon = 'PISTOL';
  let isGameOver = false;
  let gameStarted = false;
  let weaponAnimFrame = 0;
  let isShooting = false;
  let moveSpeed = 0.07;
  let rotSpeed = 0.04;

  // Standardized lowercase controls map to prevent case mismatch bugs
  const keys = { 
    w: false, s: false, a: false, d: false, 
    arrowup: false, arrowdown: false, arrowleft: false, arrowright: false, 
    e: false 
  };

  // Multiple active door management (independent doors)
  let openDoors = [];

  // Sprites (Enemies & Collectibles) loaded per sector
  let sprites = [];

  function loadSector(sectorNum) {
    currentSector = sectorNum;
    const sector = sectorsData[sectorNum];
    worldMap = JSON.parse(JSON.stringify(sector.map));
    sprites = JSON.parse(JSON.stringify(sector.sprites));
    posX = sector.startX;
    posY = sector.startY;
    dirX = 1.0;
    dirY = 0.0;
    planeX = 0.0;
    planeY = 0.66;
    openDoors = [];
    document.getElementById('hud-sector').textContent = `${currentSector}/2`;
    updateHUD();
  }

  // Procedural Textures Cache
  const texSize = 64;
  const textures = {};

  function generateTextures() {
    // Wall 1: Gray Tech wall
    const c1 = document.createElement('canvas'); c1.width = texSize; c1.height = texSize;
    const ctx1 = c1.getContext('2d');
    ctx1.fillStyle = '#444'; ctx1.fillRect(0, 0, texSize, texSize);
    ctx1.strokeStyle = '#222'; ctx1.lineWidth = 2;
    ctx1.strokeRect(0, 0, texSize, texSize);
    ctx1.strokeRect(8, 8, texSize - 16, texSize - 16);
    ctx1.fillStyle = '#0f0'; ctx1.fillRect(12, 12, 6, 6); // green light
    ctx1.fillStyle = '#f00'; ctx1.fillRect(texSize - 18, 12, 6, 6); // red light
    textures[1] = c1;

    // Wall 2: Red Brick Wall
    const c2 = document.createElement('canvas'); c2.width = texSize; c2.height = texSize;
    const ctx2 = c2.getContext('2d');
    ctx2.fillStyle = '#7a2214'; ctx2.fillRect(0, 0, texSize, texSize);
    ctx2.strokeStyle = '#3a110a'; ctx2.lineWidth = 1.5;
    for (let y = 0; y < texSize; y += 16) {
      ctx2.beginPath(); ctx2.moveTo(0, y); ctx2.lineTo(texSize, y); ctx2.stroke();
      const offset = (y / 16) % 2 === 0 ? 0 : 16;
      for (let x = offset; x < texSize; x += 32) {
        ctx2.beginPath(); ctx2.moveTo(x, y); ctx2.lineTo(x, y + 16); ctx2.stroke();
      }
    }
    textures[2] = c2;

    // Door (Blue Door)
    const c3 = document.createElement('canvas'); c3.width = texSize; c3.height = texSize;
    const ctx3 = c3.getContext('2d');
    ctx3.fillStyle = '#223366'; ctx3.fillRect(0, 0, texSize, texSize);
    ctx3.fillStyle = '#ffcc00'; ctx3.fillRect(20, 10, 24, 44);
    ctx3.fillStyle = '#111'; ctx3.fillRect(24, 14, 16, 36);
    textures[3] = c3;

    // Pillar (Metal cylinder / pillar block)
    const c4 = document.createElement('canvas'); c4.width = texSize; c4.height = texSize;
    const ctx4 = c4.getContext('2d');
    ctx4.fillStyle = '#1c1c1c'; ctx4.fillRect(0, 0, texSize, texSize);
    ctx4.fillStyle = '#888'; ctx4.fillRect(16, 0, 32, texSize);
    ctx4.strokeStyle = '#444'; strokeRect = (16, 0, 32, texSize);
    ctx4.strokeRect(16, 0, 32, texSize);
    textures[4] = c4;
  }

  // Draw procedural Cacodemon sprite
  function drawCacodemon(ctx, size) {
    ctx.fillStyle = '#cc1111'; // red ball body
    ctx.beginPath(); ctx.arc(size/2, size/2, size*0.4, 0, Math.PI*2); ctx.fill();
    // Horns
    ctx.fillStyle = '#990000';
    ctx.beginPath(); ctx.moveTo(size*0.2, size*0.2); ctx.lineTo(size*0.1, size*0.05); ctx.lineTo(size*0.35, size*0.2); ctx.fill();
    ctx.beginPath(); ctx.moveTo(size*0.8, size*0.2); ctx.lineTo(size*0.9, size*0.05); ctx.lineTo(size*0.65, size*0.2); ctx.fill();
    // Eye
    ctx.fillStyle = '#00ffff';
    ctx.beginPath(); ctx.arc(size/2, size*0.4, size*0.08, 0, Math.PI*2); ctx.fill();
    // Mouth
    ctx.fillStyle = '#111';
    ctx.fillRect(size*0.3, size*0.6, size*0.4, size*0.15);
    ctx.fillStyle = '#fff'; // teeth
    ctx.fillRect(size*0.35, size*0.6, size*0.05, size*0.05);
    ctx.fillRect(size*0.6, size*0.6, size*0.05, size*0.05);
  }

  // Draw procedural Imp sprite
  function drawImp(ctx, size) {
    ctx.fillStyle = '#5c4033'; // brown body
    ctx.beginPath(); ctx.ellipse(size/2, size/2, size*0.25, size*0.4, 0, 0, Math.PI*2); ctx.fill();
    // Head
    ctx.beginPath(); ctx.arc(size/2, size*0.25, size*0.18, 0, Math.PI*2); ctx.fill();
    // Red glowing eyes
    ctx.fillStyle = '#ff0000';
    ctx.fillRect(size*0.42, size*0.22, size*0.04, size*0.04);
    ctx.fillRect(size*0.54, size*0.22, size*0.04, size*0.04);
    // Spikes/Horns
    ctx.fillStyle = '#9c7a5c';
    ctx.fillRect(size*0.48, size*0.05, size*0.04, size*0.08);
  }

  // Helper to pre-render sprite assets
  const spriteCanvases = {};
  function generateSpriteCanvases() {
    const size = 64;
    ['cacodemon', 'imp', 'medkit', 'ammo', 'dead_imp', 'dead_cacodemon'].forEach(name => {
      const c = document.createElement('canvas'); c.width = size; c.height = size;
      const sCtx = c.getContext('2d');
      if (name === 'cacodemon') drawCacodemon(sCtx, size);
      else if (name === 'imp') drawImp(sCtx, size);
      else if (name === 'dead_cacodemon') {
        sCtx.fillStyle = '#aa0000'; sCtx.beginPath(); sCtx.ellipse(size/2, size*0.7, size*0.4, size*0.15, 0, 0, Math.PI*2); sCtx.fill();
        sCtx.fillStyle = '#660000'; sCtx.fillRect(size*0.4, size*0.6, size*0.2, size*0.1);
      }
      else if (name === 'dead_imp') {
        sCtx.fillStyle = '#5c2211'; sCtx.beginPath(); sCtx.ellipse(size/2, size*0.7, size*0.35, size*0.12, 0, 0, Math.PI*2); sCtx.fill();
      }
      else if (name === 'medkit') {
        sCtx.fillStyle = '#fff'; sCtx.fillRect(size*0.25, size*0.4, size*0.5, size*0.4);
        sCtx.fillStyle = '#ff0000';
        sCtx.fillRect(size*0.45, size*0.45, size*0.1, size*0.3);
        sCtx.fillRect(size*0.35, size*0.55, size*0.3, size*0.1);
      }
      else if (name === 'ammo') {
        sCtx.fillStyle = '#ccaa00'; sCtx.fillRect(size*0.3, size*0.45, size*0.4, size*0.3);
        sCtx.fillStyle = '#555'; sCtx.fillRect(size*0.35, size*0.45, size*0.1, size*0.3);
        sCtx.fillRect(size*0.55, size*0.45, size*0.1, size*0.3);
      }
      spriteCanvases[name] = c;
    });
  }

  // Draw HUD face
  const hudFaceCanvas = document.getElementById('hudFace');
  const hfCtx = hudFaceCanvas.getContext('2d');
  function updateHudFace() {
    const w = hudFaceCanvas.width;
    const h = hudFaceCanvas.height;
    hfCtx.fillStyle = '#dca380'; hfCtx.fillRect(0,0,w,h); // skin tone
    
    // Hair
    hfCtx.fillStyle = '#442200'; hfCtx.fillRect(0, 0, w, 10);
    
    // Eyes looking left/right depending on game frame
    hfCtx.fillStyle = '#fff';
    hfCtx.fillRect(8, 14, 6, 4);
    hfCtx.fillRect(26, 14, 6, 4);
    
    const lookOffset = Math.sin(Date.now() / 1500) > 0 ? 1 : -1;
    hfCtx.fillStyle = '#0000ff'; // pupils
    hfCtx.fillRect(9 + lookOffset, 15, 3, 3);
    hfCtx.fillRect(27 + lookOffset, 15, 3, 3);

    // Mouth / Nose
    hfCtx.fillStyle = '#a05030';
    hfCtx.fillRect(18, 20, 4, 6); // nose
    
    // Bloody overlay if health is low
    if (health < 40) {
      hfCtx.fillStyle = 'rgba(200, 0, 0, 0.5)';
      hfCtx.fillRect(0, 0, w, h);
    }
  }

  // Keyboard Event Handlers
  window.addEventListener('keydown', e => {
    const k = e.key.toLowerCase();
    if (k === ' ') {
      fireWeapon();
      if (gameStarted) e.preventDefault();
    }
    if (k === 'q' || k === '1' || k === '2') {
      swapWeapon();
      if (gameStarted) e.preventDefault();
    }
    if (k in keys) {
      keys[k] = true;
      if (gameStarted) e.preventDefault();
    }
  });

  window.addEventListener('keyup', e => {
    const k = e.key.toLowerCase();
    if (k in keys) {
      keys[k] = false;
      if (gameStarted) e.preventDefault();
    }
  });

  // Touch Controller Listeners
  const hookTouch = (id, actionDown, actionUp) => {
    const el = document.getElementById(id);
    if (!el) return;
    el.addEventListener('mousedown', e => { e.preventDefault(); actionDown(); });
    el.addEventListener('mouseup', e => { e.preventDefault(); actionUp(); });
    el.addEventListener('touchstart', e => { e.preventDefault(); actionDown(); }, { passive: false });
    el.addEventListener('touchend', e => { e.preventDefault(); actionUp(); }, { passive: false });
  };

  hookTouch('btn-up', () => { keys.w = true; }, () => { keys.w = false; });
  hookTouch('btn-down', () => { keys.s = true; }, () => { keys.s = false; });
  hookTouch('btn-left', () => { keys.arrowleft = true; }, () => { keys.arrowleft = false; });
  hookTouch('btn-right', () => { keys.arrowright = true; }, () => { keys.arrowright = false; });
  
  document.getElementById('btn-use').onclick = () => { triggerInteraction(); };
  document.getElementById('btn-fire').onclick = () => { fireWeapon(); };
  document.getElementById('btn-weapon').onclick = () => { swapWeapon(); };

  // Setup start overlay click
  document.getElementById('start-btn').onclick = startGame;
  document.getElementById('restart-btn').onclick = retryGame;

  function startGame() {
    sfx.init();
    document.getElementById('menu').classList.add('hidden');
    loadSector(1);
    gameStarted = true;
    gameLoop();
  }

  function retryGame() {
    health = 100;
    armor = 50;
    ammo = 50;
    isGameOver = false;
    loadSector(1);
    document.getElementById('gameover').classList.add('hidden');
  }

  function swapWeapon() {
    currentWeapon = currentWeapon === 'PISTOL' ? 'SHOTGUN' : 'PISTOL';
    document.getElementById('hud-weapon').textContent = currentWeapon;
    sfx.playItem();
  }

  function triggerFlash() {
    const f = document.getElementById('flash');
    f.style.backgroundColor = 'rgba(255, 0, 0, 0.4)';
    setTimeout(() => {
      f.style.backgroundColor = 'rgba(255, 0, 0, 0)';
    }, 80);
  }

  function fireWeapon() {
    if (!gameStarted || isGameOver || isShooting) return;
    if (ammo <= 0) {
      sfx.playPain(); // Out of ammo click/grunt
      return;
    }
    isShooting = true;
    weaponAnimFrame = 1;
    sfx.playShoot();

    // Damage checking on active enemies near central view axis
    let hitSomething = false;
    sprites.forEach(s => {
      if (s.type === 'enemy' && !s.dead) {
        // Calculate vector from player to sprite
        const sX = s.x - posX;
        const sY = s.y - posY;
        // Check alignment with direction vector
        const angle = Math.atan2(sY, sX);
        const playerAngle = Math.atan2(dirY, dirX);
        let diff = angle - playerAngle;
        while (diff < -Math.PI) diff += Math.PI * 2;
        while (diff > Math.PI) diff -= Math.PI * 2;
        
        const dist = Math.sqrt(sX*sX + sY*sY);
        if (Math.abs(diff) < 0.25 && dist < 12) {
          const dmg = currentWeapon === 'SHOTGUN' ? 25 : 12;
          s.health -= dmg;
          sfx.playExplosion();
          hitSomething = true;
          if (s.health <= 0) {
            s.dead = true;
            s.texture = s.texture === 'cacodemon' ? 'dead_cacodemon' : 'dead_imp';
          }
        }
      }
    });

    ammo -= currentWeapon === 'SHOTGUN' ? 2 : 1;
    if (ammo < 0) ammo = 0;
    updateHUD();
  }

  function triggerInteraction() {
    // Check cell directly ahead of player
    const checkX = Math.floor(posX + dirX * 1.5);
    const checkY = Math.floor(posY + dirY * 1.5);
    if (worldMap[checkY] && worldMap[checkY][checkX] === 3) {
      // Trigger independent door open
      if (!openDoors.some(d => d.x === checkX && d.y === checkY)) {
        worldMap[checkY][checkX] = 0; // set passable
        openDoors.push({ x: checkX, y: checkY, timer: 180 });
        sfx.playDoor();
      }
    }
  }

  // Update HUD
  function updateHUD() {
    document.getElementById('hud-health').textContent = `${health}%`;
    document.getElementById('hud-armor').textContent = `${armor}%`;
    document.getElementById('hud-ammo').textContent = ammo;
  }

  // Basic Enemy AI & Item pickups
  function updateGameObjects() {
    if (keys.e) {
      triggerInteraction();
      keys.e = false;
    }

    // Process open doors independently
    for (let i = openDoors.length - 1; i >= 0; i--) {
      const door = openDoors[i];
      door.timer--;
      if (door.timer <= 0) {
        // Close door if player is not standing in it
        if (Math.floor(posX) !== door.x || Math.floor(posY) !== door.y) {
          worldMap[door.y][door.x] = 3;
          sfx.playDoor();
          openDoors.splice(i, 1);
        } else {
          door.timer = 60; // wait 1 second and check again
        }
      }
    }

    sprites.forEach(s => {
      // Pickups
      if (s.type === 'item') {
        const dist = Math.sqrt((s.x - posX)**2 + (s.y - posY)**2);
        if (dist < 0.5) {
          if (s.texture === 'medkit' && health < 100) {
            health = Math.min(100, health + 25);
            sfx.playItem();
            sprites = sprites.filter(item => item !== s);
            updateHUD();
          } else if (s.texture === 'ammo' && ammo < 100) {
            ammo = Math.min(100, ammo + 30);
            sfx.playItem();
            sprites = sprites.filter(item => item !== s);
            updateHUD();
          }
        }
      }

      // Enemy AI
      if (s.type === 'enemy' && !s.dead) {
        const dist = Math.sqrt((s.x - posX)**2 + (s.y - posY)**2);
        if (dist < 10 && dist > 1.2) {
          // Move towards player
          const dx = (posX - s.x) / dist;
          const dy = (posY - s.y) / dist;
          
          const newX = s.x + dx * s.speed;
          const newY = s.y + dy * s.speed;
          
          if (worldMap[Math.floor(s.y)][Math.floor(newX)] === 0) s.x = newX;
          if (worldMap[Math.floor(newY)][Math.floor(s.x)] === 0) s.y = newY;
        } else if (dist <= 1.2) {
          // Attack player
          if (Math.random() < 0.05) {
            triggerFlash();
            sfx.playPain();
            if (armor > 0) {
              armor = Math.max(0, armor - 5);
              health = Math.max(0, health - 5);
            } else {
              health = Math.max(0, health - 10);
            }
            updateHUD();
            if (health <= 0) {
              isGameOver = true;
              document.getElementById('gameover-title').textContent = "YOU DIED";
              document.getElementById('gameover').classList.remove('hidden');
            }
          }
        }
      }
    });

    // Check Sector Victory & Progression
    const aliveEnemies = sprites.filter(s => s.type === 'enemy' && !s.dead).length;
    if (aliveEnemies === 0 && !isGameOver) {
      if (currentSector === 1) {
        loadSector(2);
        sfx.playItem();
      } else {
        isGameOver = true;
        document.getElementById('gameover-title').textContent = "VICTORY!";
        document.getElementById('gameover-subtitle').textContent = "All Sectors cleared!";
        document.getElementById('gameover').classList.remove('hidden');
      }
    }
  }

  // Main Raycast Render Frame
  function renderFrame() {
    // 1. Clear background (Ceiling & Floor)
    ctx.fillStyle = '#1e1e1e'; // Ceiling
    ctx.fillRect(0, 0, viewWidth, viewHeight/2);
    ctx.fillStyle = '#2d251e'; // Floor
    ctx.fillRect(0, viewHeight/2, viewWidth, viewHeight/2);

    // Z-buffer for sprite rendering
    const zBuffer = [];

    // 2. Wall Casting
    for (let x = 0; x < viewWidth; x++) {
      const cameraX = 2 * x / viewWidth - 1;
      const rayDirX = dirX + planeX * cameraX;
      const rayDirY = dirY + planeY * cameraX;

      let mapX = Math.floor(posX);
      let mapY = Math.floor(posY);

      let sideDistX, sideDistY;

      const deltaDistX = (rayDirX === 0) ? 1e30 : Math.abs(1 / rayDirX);
      const deltaDistY = (rayDirY === 0) ? 1e30 : Math.abs(1 / rayDirY);
      let perpWallDist;

      let stepX, stepY;
      let hit = 0;
      let side;

      if (rayDirX < 0) {
        stepX = -1;
        sideDistX = (posX - mapX) * deltaDistX;
      } else {
        stepX = 1;
        sideDistX = (mapX + 1.0 - posX) * deltaDistX;
      }
      if (rayDirY < 0) {
        stepY = -1;
        sideDistY = (posY - mapY) * deltaDistY;
      } else {
        stepY = 1;
        sideDistY = (mapY + 1.0 - posY) * deltaDistY;
      }

      // DDA
      while (hit === 0) {
        if (sideDistX < sideDistY) {
          sideDistX += deltaDistX;
          mapX += stepX;
          side = 0;
        } else {
          sideDistY += deltaDistY;
          mapY += stepY;
          side = 1;
        }
        if (worldMap[mapY] && worldMap[mapY][mapX] > 0) hit = 1;
      }

      if (side === 0) perpWallDist = (sideDistX - deltaDistX);
      else perpWallDist = (sideDistY - deltaDistY);

      zBuffer[x] = perpWallDist;

      const lineHeight = Math.floor(viewHeight / perpWallDist);
      let drawStart = -lineHeight / 2 + viewHeight / 2;
      if (drawStart < 0) drawStart = 0;
      let drawEnd = lineHeight / 2 + viewHeight / 2;
      if (drawEnd >= viewHeight) drawEnd = viewHeight - 1;

      // Draw Wall Column Texture
      const texNum = worldMap[mapY] ? worldMap[mapY][mapX] : 1;
      const tex = textures[texNum] || textures[1];
      
      let wallX;
      if (side === 0) wallX = posY + perpWallDist * rayDirY;
      else wallX = posX + perpWallDist * rayDirX;
      wallX -= Math.floor(wallX);

      let texX = Math.floor(wallX * texSize);
      if (side === 0 && rayDirX > 0) texX = texSize - texX - 1;
      if (side === 1 && rayDirY < 0) texX = texSize - texX - 1;

      ctx.drawImage(tex, texX, 0, 1, texSize, x, drawStart, 1, drawEnd - drawStart);

      // Simple shade for depth
      if (side === 1) {
        ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
        ctx.fillRect(x, drawStart, 1, drawEnd - drawStart);
      }
    }

    // 3. Sprite Casting (Enemies & Items)
    const spriteOrder = sprites.map((s, i) => ({ index: i, dist: ((posX - s.x)**2 + (posY - s.y)**2) }));
    spriteOrder.sort((a,b) => b.dist - a.dist);

    spriteOrder.forEach(item => {
      const s = sprites[item.index];
      const spriteX = s.x - posX;
      const spriteY = s.y - posY;

      const invDet = 1.0 / (planeX * dirY - dirX * planeY);
      const transformX = invDet * (dirY * spriteX - dirX * spriteY);
      const transformY = invDet * (-planeY * spriteX + planeX * spriteY); // depth in front screen

      if (transformY > 0) {
        const spriteScreenX = Math.floor((viewWidth / 2) * (1 + transformX / transformY));
        const spriteHeight = Math.abs(Math.floor(viewHeight / transformY));
        let drawStartY = -spriteHeight / 2 + viewHeight / 2;
        if (drawStartY < 0) drawStartY = 0;
        let drawEndY = spriteHeight / 2 + viewHeight / 2;
        if (drawEndY >= viewHeight) drawEndY = viewHeight - 1;

        const spriteWidth = Math.abs(Math.floor(viewHeight / transformY));
        let drawStartX = -spriteWidth / 2 + spriteScreenX;
        if (drawStartX < 0) drawStartX = 0;
        let drawEndX = spriteWidth / 2 + spriteScreenX;
        if (drawEndX >= viewWidth) drawEndX = viewWidth - 1;

        const sTex = spriteCanvases[s.texture];
        if (sTex) {
          for (let stripe = drawStartX; stripe < drawEndX; stripe++) {
            const texX = Math.floor(256 * (stripe - (-spriteWidth / 2 + spriteScreenX)) * texSize / spriteWidth) / 256;
            if (transformY < zBuffer[stripe]) {
              ctx.drawImage(sTex, texX, 0, 1, texSize, stripe, drawStartY, 1, drawEndY - drawStartY);
            }
          }
        }
      }
    });

    // 4. Render Weapon HUD Overlay
    const weaponYOffset = isShooting ? Math.sin(weaponAnimFrame * 0.5) * 15 : 0;
    const weaponBob = Math.sin(Date.now() / 150) * 4 * (keys.w || keys.s ? 1 : 0);
    
    ctx.save();
    ctx.translate(viewWidth / 2 + weaponBob, viewHeight - 20 + weaponYOffset);
    
    ctx.fillStyle = '#444'; // Gun barrel shape
    if (currentWeapon === 'PISTOL') {
      ctx.fillRect(-10, -50, 20, 50);
      ctx.fillStyle = '#222';
      ctx.fillRect(-6, -45, 12, 12);
      if (isShooting && weaponAnimFrame < 5) {
        // Flash muzzle
        ctx.fillStyle = '#ffcc00';
        ctx.beginPath(); ctx.arc(0, -60, 12, 0, Math.PI * 2); ctx.fill();
        ctx.fillStyle = '#ffffff';
        ctx.beginPath(); ctx.arc(0, -60, 6, 0, Math.PI * 2); ctx.fill();
      }
    } else {
      // Shotgun
      ctx.fillRect(-16, -60, 14, 60);
      ctx.fillRect(2, -60, 14, 60);
      if (isShooting && weaponAnimFrame < 5) {
        ctx.fillStyle = '#ff3300';
        ctx.beginPath(); ctx.arc(-8, -75, 16, 0, Math.PI * 2); ctx.fill();
        ctx.beginPath(); ctx.arc(8, -75, 16, 0, Math.PI * 2); ctx.fill();
        ctx.fillStyle = '#ffcc00';
        ctx.beginPath(); ctx.arc(-8, -75, 8, 0, Math.PI * 2); ctx.fill();
        ctx.beginPath(); ctx.arc(8, -75, 8, 0, Math.PI * 2); ctx.fill();
      }
    }
    ctx.restore();

    if (isShooting) {
      weaponAnimFrame++;
      if (weaponAnimFrame > 8) {
        isShooting = false;
        weaponAnimFrame = 0;
      }
    }

    // Update Face HUD
    updateHudFace();
  }

  // Game input update & loop
  function updatePlayerInput() {
    if (isGameOver) return;

    let moveX = 0;
    let moveY = 0;

    if (keys.w || keys.arrowup) {
      moveX += dirX * moveSpeed;
      moveY += dirY * moveSpeed;
    }
    if (keys.s || keys.arrowdown) {
      moveX -= dirX * moveSpeed;
      moveY -= dirY * moveSpeed;
    }

    // Smooth Sliding Collision Detection with wall padding
    const buffer = 0.2;
    if (moveX !== 0) {
      const signX = moveX > 0 ? buffer : -buffer;
      const checkX = posX + moveX + signX;
      if (worldMap[Math.floor(posY)] && worldMap[Math.floor(posY)][Math.floor(checkX)] === 0) {
        posX += moveX;
      }
    }
    if (moveY !== 0) {
      const signY = moveY > 0 ? buffer : -buffer;
      const checkY = posY + moveY + signY;
      if (worldMap[Math.floor(checkY)] && worldMap[Math.floor(checkY)][Math.floor(posX)] === 0) {
        posY += moveY;
      }
    }

    // Rotation
    if (keys.a || keys.arrowleft) {
      const oldDirX = dirX;
      dirX = dirX * Math.cos(-rotSpeed) - dirY * Math.sin(-rotSpeed);
      dirY = oldDirX * Math.sin(-rotSpeed) + dirY * Math.cos(-rotSpeed);
      const oldPlaneX = planeX;
      planeX = planeX * Math.cos(-rotSpeed) - planeY * Math.sin(-rotSpeed);
      planeY = oldPlaneX * Math.sin(-rotSpeed) + planeY * Math.cos(-rotSpeed);
    }
    if (keys.d || keys.arrowright) {
      const oldDirX = dirX;
      dirX = dirX * Math.cos(rotSpeed) - dirY * Math.sin(rotSpeed);
      dirY = oldDirX * Math.sin(rotSpeed) + dirY * Math.cos(rotSpeed);
      const oldPlaneX = planeX;
      planeX = planeX * Math.cos(rotSpeed) - planeY * Math.sin(rotSpeed);
      planeY = oldPlaneX * Math.sin(rotSpeed) + planeY * Math.cos(rotSpeed);
    }
  }

  function gameLoop() {
    if (!gameStarted) return;
    updatePlayerInput();
    updateGameObjects();
    renderFrame();
    requestAnimationFrame(gameLoop);
  }

  // Pre-generate assets
  generateTextures();
  generateSpriteCanvases();
</script>

Game Features & Controls