Skip to content

Doom (sort of)

Doom, or near enough: a first-person raycaster that runs inside a single html code block in this document. No network, no WebAssembly, no assets. Every texture, sprite, sound, and pixel is generated in JavaScript when the preview starts.

Click the frame to play. Nine hostiles, one shotgun, one locked door, one exit switch.

Keyboard: W A S D move, arrow keys or mouse turn, Shift run, Space or click to fire, E opens doors and hits the exit switch, 1 2 switch weapons, R restarts.

Touch: on a phone or tablet a control pad appears under the view: left stick moves, right stick turns, and FIRE, USE and WEAPON are buttons. Both schemes are live at once, and a drag that starts on the view still scrolls the page.

<div id="dz">
  <canvas id="dz-view" width="320" height="200"></canvas>
  <div id="dz-pad" hidden>
    <div class="dz-row">
      <button type="button" class="dz-pill" id="dz-use">USE</button>
      <button type="button" class="dz-pill" id="dz-wpn">WEAPON</button>
      <button type="button" class="dz-pill" id="dz-rst">RESTART</button>
    </div>
    <div class="dz-row dz-main">
      <div class="dz-stick" id="dz-move" aria-hidden="true">
        <span class="dz-knob"></span><span class="dz-cap">MOVE</span>
      </div>
      <button type="button" class="dz-fire" id="dz-fire">FIRE</button>
      <div class="dz-stick" id="dz-turn" aria-hidden="true">
        <span class="dz-knob"></span><span class="dz-cap">TURN</span>
      </div>
    </div>
  </div>
  <p id="dz-keys">
    <b>W A S D</b> move &middot; <b>&larr; &rarr;</b> or mouse turn &middot;
    <b>SPACE</b> / click fire &middot; <b>E</b> open &amp; use &middot;
    <b>1 2</b> weapon &middot; <b>R</b> restart
  </p>
  <p id="dz-touch" hidden>Left stick moves, right stick turns. Tap the view to start.</p>
  <p id="dz-diag" title="temporary diagnostic readout"></p>
</div>

<style>
  #dz {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    padding: 8px 0 4px;
    font-family: "Courier New", ui-monospace, monospace;
  }
  #dz-view {
    width: 100%;
    max-width: 640px;
    aspect-ratio: 320 / 200;
    display: block;
    background: #000;
    border: 2px solid #2a2a2a;
    border-radius: 3px;
    box-shadow: 0 0 0 1px #000, 0 6px 22px rgba(0, 0, 0, 0.55);
    /* The backing store is an integer multiple of 320x200 and never smaller
       than this element, so the browser only ever scales DOWN. Smooth is the
       correct filter for that; `pixelated` would alias on the downscale. */
    image-rendering: auto;
    cursor: crosshair;
    outline: none;
    /* pan-y, not none: a reader must still be able to scroll the page with a
       drag that starts on the view. All game gestures live on the pad below. */
    touch-action: pan-y;
  }
  #dz-view:focus-visible { border-color: #b33; }
  #dz-view.dz-direct { image-rendering: pixelated; }
  #dz-keys {
    margin: 0;
    max-width: 640px;
    text-align: center;
    font-size: 12px;
    line-height: 1.7;
    color: var(--color-muted-foreground, #8b8b8b);
  }
  #dz-keys b { color: var(--color-foreground, #d8d8d8); font-weight: 700; }
  #dz-diag {
    margin: 0;
    max-width: 640px;
    text-align: center;
    font: 400 10px/1.6 "Courier New", ui-monospace, monospace;
    color: var(--color-muted-foreground, #7d7d7d);
    opacity: 0.75;
    word-break: break-word;
  }
  #dz-touch {
    margin: 0;
    text-align: center;
    font-size: 12px;
    color: var(--color-muted-foreground, #8b8b8b);
  }
  #dz-pad {
    width: 100%;
    max-width: 640px;
    display: flex;
    flex-direction: column;
    gap: 10px;
    /* the pad owns every gesture that lands on it */
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
    -webkit-tap-highlight-color: transparent;
  }
  #dz-pad[hidden] { display: none; }
  .dz-row { display: flex; align-items: center; justify-content: center; gap: 10px; }
  .dz-main { justify-content: space-between; gap: 8px; }
  .dz-pill {
    flex: 1 1 0;
    min-height: 44px;
    padding: 0 8px;
    border: 1px solid #4a4a4a;
    border-radius: 8px;
    background: #232323;
    color: #d8d0bc;
    font: 700 12px/1 "Courier New", ui-monospace, monospace;
    letter-spacing: 0.06em;
    cursor: pointer;
  }
  .dz-stick {
    position: relative;
    width: 112px;
    height: 112px;
    flex: 0 0 auto;
    border: 2px solid #4a4a4a;
    border-radius: 50%;
    background: radial-gradient(circle at 50% 45%, #2e2e2e 0%, #1c1c1c 70%);
  }
  .dz-knob {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 46px;
    height: 46px;
    margin: -23px 0 0 -23px;
    border-radius: 50%;
    background: #6a6a6a;
    border: 2px solid #8a8a8a;
    will-change: transform;
  }
  .dz-cap {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 6px;
    text-align: center;
    font: 700 9px/1 "Courier New", ui-monospace, monospace;
    letter-spacing: 0.1em;
    color: #7d7d7d;
  }
  .dz-fire {
    width: 92px;
    height: 92px;
    flex: 0 0 auto;
    border-radius: 50%;
    border: 2px solid #8e2a20;
    background: radial-gradient(circle at 50% 40%, #b4342a 0%, #6e1c15 75%);
    color: #ffe8d8;
    font: 700 15px/1 "Courier New", ui-monospace, monospace;
    letter-spacing: 0.08em;
    cursor: pointer;
  }
  .dz-active { filter: brightness(1.45); }
  @media (max-width: 400px) {
    .dz-stick { width: 96px; height: 96px; }
    .dz-fire { width: 78px; height: 78px; font-size: 13px; }
  }
</style>

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

  // ---------------------------------------------------------------- constants
  var W = 320, H = 200, VH = 168, HUD_Y = 168;   // 320x200 with a 32px status bar
  var MAPW = 24, MAPH = 24;
  var TEX = 64;                                   // texture edge, power of two
  var MOVE = 3.1, RUN = 4.9, TURN = 2.5;          // units/sec, radians/sec

  var BUILD = 'v8';

  // The game draws into an OFF-SCREEN 320x200 surface, which is then blitted to
  // the on-screen canvas at a whole-number scale.
  //
  // Why not draw straight into the visible canvas: a 320-wide canvas stretched
  // by CSS to an arbitrary width is resampled by a FRACTIONAL factor, and with
  // `image-rendering: pixelated` that means some source pixels get 2 device
  // pixels and their neighbours get 3. Any sub-pixel change in where the
  // element lands — a scrollbar, a fractional display scale, a compositor
  // rounding differently between frames — reshuffles which pixels are doubled.
  // Static content then crawls. It shows up on hard edges against flat ground
  // (the weapon, banner text) and hides inside noisy wall textures, and it is
  // invisible to any pixel readback, because the CONTENT never changed.
  //
  // So the backing store is always an exact integer multiple of 320x200, at
  // least as large as the element's device-pixel size, and the browser only
  // ever scales it DOWN — a smooth, temporally stable operation.
  var view = document.getElementById('dz-view');
  var dctx = view.getContext('2d', { alpha: false });
  var canvas = document.createElement('canvas');
  canvas.width = W;
  canvas.height = H;
  var ctx = canvas.getContext('2d', { alpha: false });
  ctx.imageSmoothingEnabled = false;
  // The raw framebuffer lands on its OWN surface, which receives nothing but
  // putImageData; the scene surface receives nothing but drawImage/fillText.
  // Mixing the two on one canvas makes a browser flip that canvas between
  // accelerated and software backing, and the draw calls that follow the
  // putImageData are the ones that come out unreliable — which is exactly the
  // weapon, the HUD and the end-screen banner.
  var world = document.createElement('canvas');
  world.width = W;
  world.height = VH;
  var wctx = world.getContext('2d', { alpha: false, willReadFrequently: true });
  var frame = wctx.createImageData(W, VH);
  var buf = new Uint32Array(frame.data.buffer);
  var zbuf = new Float32Array(W);

  var scale = 1, dprSeen = 1;
  // Tapping the readout switches presentation strategy. If one mode shows the
  // weapon and the other does not, that isolates the scaled backing store as
  // the cause without another round of guessing.
  var directMode = false;
  function sizeDisplay() {
    // clientWidth, not getBoundingClientRect: the element has a border, and
    // what must be covered is the CONTENT box the bitmap is painted into.
    var host = view.parentNode;
    var cssW = view.clientWidth || (host ? host.clientWidth : 0) || 320;
    cssW = Math.max(160, Math.min(640, cssW));
    dprSeen = window.devicePixelRatio || 1;
    // ceil, never floor: the backing store must cover the element so the
    // browser only downscales.
    var n = directMode ? 1 : clamp(Math.ceil((cssW * dprSeen) / W), 1, 6);
    if (n !== scale || view.width !== W * n) {
      scale = n;
      view.width = W * n;
      view.height = H * n;
    }
    dctx.imageSmoothingEnabled = false;   // our own blit stays nearest-neighbour
  }
  function present() {
    dctx.drawImage(canvas, 0, 0, view.width, view.height);
  }

  function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
  function rnd(a, b) { return a + Math.random() * (b - a); }
  function irnd(a, b) { return Math.floor(rnd(a, b + 1)); }

  // ------------------------------------------------------------------- level
  var MAP_ROWS = [
    '########################',
    '##%%%%%%%%%%%%%###====##',
    '#%.............%%=....=#',
    '#%..............D.....=#',
    '#%.............%%=....=#',
    '#%.............%#=....=#',
    '#%.............%##====##',
    '#%.............%########',
    '#..............%########',
    '#..#%%%%%%%#..##########',
    '#..#########..##########',
    '#..#########..##########',
    '#..#########..##########',
    '#..#########..##########',
    '#..######........#=====#',
    '#..######........=.....=',
    '#.......#........=.....=',
    '#.......#..#..#..=.....=',
    '#.......#........=.....X',
    '#................D.....X',
    '#................=.....=',
    '#.......#..#..#..=.....=',
    '#.......#........=.....=',
    '##################=====#'
  ];
  // cell codes: 0 floor, 1 brick, 2 tech, 3 stone, 4 exit switch, 5 door
  var CODE = { '#': 1, '=': 2, '%': 3, 'X': 4, 'D': 5, '.': 0 };
  var grid = new Uint8Array(MAPW * MAPH);
  var doorOpen = new Float32Array(MAPW * MAPH);   // 0 shut .. 1 gone
  function cell(x, y) {
    if (x < 0 || y < 0 || x >= MAPW || y >= MAPH) return 1;
    return grid[y * MAPW + x];
  }
  function solid(x, y) { return cell(x, y) !== 0; }

  // ---------------------------------------------------------- texture baking
  // Everything is drawn once with the 2D API, then read back as raw pixels so
  // the renderer only ever touches a Uint32Array.
  function bakeCanvas(w, h, draw, readback) {
    var c = document.createElement('canvas');
    c.width = w; c.height = h;
    var g = c.getContext('2d', readback ? { willReadFrequently: true } : undefined);
    g.imageSmoothingEnabled = false;
    draw(g, w, h);
    return c;
  }
  function bake(w, h, draw) {
    var c = bakeCanvas(w, h, draw, true);
    var d = c.getContext('2d').getImageData(0, 0, w, h);
    return { w: w, h: h, px: new Uint32Array(d.data.buffer) };
  }
  function speckle(g, w, h, n, alpha) {
    for (var i = 0; i < n; i++) {
      var v = Math.random() < 0.5 ? 0 : 255;
      g.fillStyle = 'rgba(' + v + ',' + v + ',' + v + ',' + alpha + ')';
      g.fillRect(Math.random() * w | 0, Math.random() * h | 0, 1, 1);
    }
  }

  var texBrick = bake(TEX, TEX, function (g) {
    g.fillStyle = '#3b2a1c'; g.fillRect(0, 0, TEX, TEX);
    for (var row = 0; row < 8; row++) {
      for (var col = -1; col < 5; col++) {
        var x = col * 16 + (row % 2 ? 8 : 0), y = row * 8;
        var t = irnd(-14, 14);
        g.fillStyle = 'rgb(' + (124 + t) + ',' + (78 + t) + ',' + (46 + t) + ')';
        g.fillRect(x + 1, y + 1, 14, 6);
        g.fillStyle = 'rgba(255,220,180,0.10)';
        g.fillRect(x + 1, y + 1, 14, 1);
        g.fillStyle = 'rgba(0,0,0,0.28)';
        g.fillRect(x + 1, y + 6, 14, 1);
      }
    }
    speckle(g, TEX, TEX, 700, 0.07);
  });

  var texTech = bake(TEX, TEX, function (g) {
    g.fillStyle = '#4b5057'; g.fillRect(0, 0, TEX, TEX);
    g.fillStyle = '#585e66'; g.fillRect(4, 4, 56, 56);
    g.fillStyle = 'rgba(0,0,0,0.35)';
    for (var x = 0; x < TEX; x += 16) g.fillRect(x, 0, 2, TEX);
    g.fillStyle = 'rgba(255,255,255,0.07)';
    for (var y = 0; y < TEX; y += 16) g.fillRect(0, y, TEX, 1);
    g.fillStyle = '#2c3036'; g.fillRect(8, 12, 20, 26);
    g.fillStyle = '#1d5f8a';
    for (var i = 0; i < 6; i++) g.fillRect(11 + (i % 3) * 6, 16 + ((i / 3) | 0) * 10, 4, 4);
    g.fillStyle = '#c4d64a'; g.fillRect(44, 16, 10, 3);
    g.fillStyle = '#8a3a3a'; g.fillRect(44, 24, 10, 3);
    g.fillStyle = 'rgba(0,0,0,0.5)'; g.fillRect(0, 60, TEX, 4);
    speckle(g, TEX, TEX, 500, 0.06);
  });

  var texStone = bake(TEX, TEX, function (g) {
    g.fillStyle = '#3d4438'; g.fillRect(0, 0, TEX, TEX);
    for (var i = 0; i < 26; i++) {
      var x = Math.random() * TEX | 0, y = Math.random() * TEX | 0;
      var t = irnd(-10, 12);
      g.fillStyle = 'rgb(' + (74 + t) + ',' + (82 + t) + ',' + (66 + t) + ')';
      g.fillRect(x, y, irnd(8, 22), irnd(6, 14));
    }
    g.strokeStyle = 'rgba(120,20,18,0.55)';
    g.lineWidth = 2;
    for (var v = 0; v < 3; v++) {
      g.beginPath();
      var px = Math.random() * TEX, py = 0;
      g.moveTo(px, py);
      while (py < TEX) { px += rnd(-9, 9); py += 8; g.lineTo(px, py); }
      g.stroke();
    }
    g.fillStyle = 'rgba(0,0,0,0.35)';
    g.fillRect(0, 0, TEX, 2); g.fillRect(0, 62, TEX, 2);
    speckle(g, TEX, TEX, 900, 0.09);
  });

  var texExit = bake(TEX, TEX, function (g) {
    g.fillStyle = '#4b5057'; g.fillRect(0, 0, TEX, TEX);
    g.fillStyle = 'rgba(0,0,0,0.4)'; g.fillRect(0, 0, TEX, 6); g.fillRect(0, 58, TEX, 6);
    g.fillStyle = '#2a2d33'; g.fillRect(14, 12, 36, 40);
    g.fillStyle = '#8b1f1f'; g.fillRect(18, 16, 28, 22);
    g.fillStyle = '#e04a2a'; g.fillRect(20, 18, 24, 8);
    g.fillStyle = '#f2c14a'; g.fillRect(15, 39, 34, 13);
    g.fillStyle = '#111'; g.font = 'bold 12px monospace'; g.textAlign = 'center';
    g.fillText('EXIT', 32, 49);
    speckle(g, TEX, TEX, 400, 0.06);
  });

  var texDoor = bake(TEX, TEX, function (g) {
    g.fillStyle = '#6a6f77'; g.fillRect(0, 0, TEX, TEX);
    g.fillStyle = '#7d838c'; g.fillRect(3, 0, 58, TEX);
    g.fillStyle = 'rgba(0,0,0,0.4)';
    for (var x = 8; x < 58; x += 10) g.fillRect(x, 4, 3, 56);
    g.fillStyle = '#c8b23a'; g.fillRect(0, 26, TEX, 12);
    g.fillStyle = '#2a2a2a';
    for (var s = -12; s < TEX; s += 12) { g.beginPath(); g.moveTo(s, 38); g.lineTo(s + 8, 26); g.lineTo(s + 14, 26); g.lineTo(s + 6, 38); g.closePath(); g.fill(); }
    g.fillStyle = 'rgba(255,255,255,0.12)'; g.fillRect(3, 0, 2, TEX);
    g.fillStyle = 'rgba(0,0,0,0.4)'; g.fillRect(59, 0, 2, TEX);
    speckle(g, TEX, TEX, 300, 0.05);
  });

  var texFloor = bake(TEX, TEX, function (g) {
    g.fillStyle = '#31302c'; g.fillRect(0, 0, TEX, TEX);
    for (var y = 0; y < 4; y++) for (var x = 0; x < 4; x++) {
      var t = irnd(-9, 9);
      g.fillStyle = 'rgb(' + (68 + t) + ',' + (64 + t) + ',' + (56 + t) + ')';
      g.fillRect(x * 16 + 1, y * 16 + 1, 14, 14);
      g.fillStyle = 'rgba(0,0,0,0.22)';
      g.fillRect(x * 16 + 1, y * 16 + 13, 14, 2);
    }
    speckle(g, TEX, TEX, 1100, 0.08);
  });

  var texCeil = bake(TEX, TEX, function (g) {
    g.fillStyle = '#24262a'; g.fillRect(0, 0, TEX, TEX);
    g.fillStyle = '#2e3136';
    for (var y = 0; y < TEX; y += 32) for (var x = 0; x < TEX; x += 32) g.fillRect(x + 2, y + 2, 28, 28);
    g.fillStyle = 'rgba(0,0,0,0.4)';
    for (var i = 0; i < TEX; i += 32) { g.fillRect(0, i, TEX, 2); g.fillRect(i, 0, 2, TEX); }
    speckle(g, TEX, TEX, 600, 0.05);
  });

  var WALLTEX = [null, texBrick, texTech, texStone, texExit, texDoor];

  // ------------------------------------------------------------ sprite baking
  // One humanoid routine drives both enemy types; pose numbers pick the frame.
  function humanoid(g, c, pose) {
    var lean = pose === 'walk1' ? -1 : pose === 'walk2' ? 1 : 0;
    g.save();
    g.translate(24, 0);
    // legs
    g.fillStyle = c.dark;
    g.fillRect(-9 + lean * 3, 34, 7, 14);
    g.fillRect(3 - lean * 3, 34, 7, 14);
    g.fillStyle = '#1d1a17';
    g.fillRect(-10 + lean * 3, 45, 9, 3);
    g.fillRect(2 - lean * 3, 45, 9, 3);
    // torso
    g.fillStyle = c.body;
    g.beginPath(); g.moveTo(-11, 14); g.lineTo(11, 14); g.lineTo(9, 36); g.lineTo(-9, 36); g.closePath(); g.fill();
    g.fillStyle = c.dark;
    g.fillRect(-11, 26, 22, 3);
    // arms
    g.fillStyle = c.body;
    if (pose === 'fire') {
      g.fillRect(-16, 16, 6, 14);
      g.fillRect(10, 14, 6, 8);
      g.save(); g.translate(13, 16); g.rotate(-0.5); g.fillRect(-3, -12, 6, 14); g.restore();
    } else {
      g.fillRect(-16, 16 + lean, 6, 16);
      g.fillRect(10, 16 - lean, 6, 16);
    }
    // head
    g.fillStyle = c.head;
    g.beginPath(); g.ellipse(0, 8, 9, 9, 0, 0, 6.3); g.fill();
    if (c.horns) {
      g.fillStyle = c.dark;
      g.beginPath(); g.moveTo(-9, 2); g.lineTo(-14, -8); g.lineTo(-5, -1); g.closePath(); g.fill();
      g.beginPath(); g.moveTo(9, 2); g.lineTo(14, -8); g.lineTo(5, -1); g.closePath(); g.fill();
    } else {
      g.fillStyle = c.dark;
      g.beginPath(); g.ellipse(0, 2, 9, 5, 0, 0, 6.3); g.fill();
    }
    g.fillStyle = c.eye;
    g.fillRect(-6, 6, 4, 3);
    g.fillRect(2, 6, 4, 3);
    g.fillStyle = '#000';
    g.fillRect(-3, 12, 6, 2);
    // weapon / claws
    if (c.gun) {
      g.fillStyle = '#26282c';
      if (pose === 'fire') { g.fillRect(8, 4, 20, 4); g.fillRect(6, 2, 6, 8); }
      else { g.fillRect(-20, 22 + lean, 18, 4); }
    }
    if (pose === 'fire' && c.glow) {
      g.fillStyle = c.glow;
      g.beginPath(); g.ellipse(20, 8, 7, 7, 0, 0, 6.3); g.fill();
      g.fillStyle = '#ffe9a8';
      g.beginPath(); g.ellipse(20, 8, 3, 3, 0, 0, 6.3); g.fill();
    }
    if (pose === 'pain') {
      g.fillStyle = 'rgba(150,10,10,0.45)';
      g.fillRect(-16, 0, 32, 48);
    }
    g.restore();
  }
  function corpse(g, c, stage) {
    g.save();
    g.translate(24, 0);
    if (stage === 0) {
      g.fillStyle = c.body;
      g.beginPath(); g.moveTo(-14, 28); g.lineTo(14, 24); g.lineTo(12, 46); g.lineTo(-12, 46); g.closePath(); g.fill();
      g.fillStyle = c.head;
      g.beginPath(); g.ellipse(-10, 26, 8, 7, 0, 0, 6.3); g.fill();
      g.fillStyle = 'rgba(120,12,12,0.75)';
      g.fillRect(-16, 40, 32, 6);
    } else {
      g.fillStyle = c.dark;
      g.beginPath(); g.ellipse(0, 44, 17, 5, 0, 0, 6.3); g.fill();
      g.fillStyle = '#6b1414';
      g.beginPath(); g.ellipse(0, 46, 21, 4, 0, 0, 6.3); g.fill();
      g.fillStyle = c.head;
      g.beginPath(); g.ellipse(-8, 42, 6, 4, 0, 0, 6.3); g.fill();
    }
    g.restore();
  }
  function enemyFrames(c) {
    var poses = ['walk1', 'walk2', 'fire', 'pain'];
    var out = {};
    for (var i = 0; i < poses.length; i++) {
      (function (p) { out[p] = bake(48, 48, function (g) { humanoid(g, c, p); }); })(poses[i]);
    }
    out.die0 = bake(48, 48, function (g) { corpse(g, c, 0); });
    out.die1 = bake(48, 48, function (g) { corpse(g, c, 1); });
    return out;
  }

  var ZOMBIE = { body: '#6f6a3a', dark: '#4a462a', head: '#c39b78', eye: '#ffe9b0', gun: true, glow: '#ffd257' };
  var IMP = { body: '#8a5a30', dark: '#5a3618', head: '#a36c39', eye: '#ffcf3a', horns: true, glow: '#ff7a1e' };
  var artZombie = enemyFrames(ZOMBIE);
  var artImp = enemyFrames(IMP);

  var artFireball = bake(24, 24, function (g) {
    var grad = g.createRadialGradient(12, 12, 1, 12, 12, 11);
    grad.addColorStop(0, '#fff3c4');
    grad.addColorStop(0.4, '#ff9a2e');
    grad.addColorStop(1, 'rgba(180,40,0,0)');
    g.fillStyle = grad;
    g.beginPath(); g.ellipse(12, 12, 11, 11, 0, 0, 6.3); g.fill();
  });

  function pickupArt(draw) { return bake(32, 32, draw); }
  var artMedkit = pickupArt(function (g) {
    g.fillStyle = '#d9d4c8'; g.fillRect(6, 12, 20, 15);
    g.fillStyle = '#8e8a80'; g.fillRect(6, 12, 20, 2);
    g.fillStyle = '#b4241f'; g.fillRect(14, 15, 4, 10); g.fillRect(11, 18, 10, 4);
  });
  var artClip = pickupArt(function (g) {
    g.fillStyle = '#6d6f74'; g.fillRect(10, 16, 12, 11);
    g.fillStyle = '#c8a23a'; g.fillRect(10, 13, 12, 4);
    g.fillStyle = '#3c3d40'; g.fillRect(13, 18, 2, 7); g.fillRect(17, 18, 2, 7);
  });
  var artShells = pickupArt(function (g) {
    g.fillStyle = '#8e2a20'; g.fillRect(7, 15, 18, 12);
    g.fillStyle = '#c2b04a';
    for (var i = 0; i < 4; i++) g.fillRect(9 + i * 4, 12, 3, 5);
    g.fillStyle = 'rgba(0,0,0,0.3)'; g.fillRect(7, 24, 18, 3);
  });
  var artArmor = pickupArt(function (g) {
    g.fillStyle = '#1f7a35';
    g.beginPath(); g.moveTo(16, 8); g.lineTo(26, 13); g.lineTo(23, 27); g.lineTo(9, 27); g.lineTo(6, 13); g.closePath(); g.fill();
    g.fillStyle = '#3fc45e';
    g.beginPath(); g.moveTo(16, 11); g.lineTo(23, 15); g.lineTo(21, 24); g.lineTo(11, 24); g.lineTo(9, 15); g.closePath(); g.fill();
  });
  var artShotgunPick = pickupArt(function (g) {
    g.fillStyle = '#2f3237'; g.fillRect(4, 16, 24, 4);
    g.fillStyle = '#6b4526'; g.fillRect(4, 15, 9, 7);
    g.fillStyle = '#4c5057'; g.fillRect(13, 20, 11, 3);
  });

  // Weapons are drawn straight to the view with drawImage, so they are baked
  // at their FINAL on-screen size: scaling at draw time resamples a pixel-art
  // source every frame, and at fractional offsets that shimmers.
  var GUN_SCALE = 1.35;
  function bakeWeapon(w, h, draw) {
    var c = document.createElement('canvas');
    c.width = Math.round(w * GUN_SCALE);
    c.height = Math.round(h * GUN_SCALE);
    var g = c.getContext('2d');
    g.scale(GUN_SCALE, GUN_SCALE);
    draw(g, w, h);
    return c;
  }
  function glove(g, x, y, w, h, flip) {
    g.fillStyle = '#5f4b2c'; g.fillRect(x, y, w, h);
    g.fillStyle = '#7a6238'; g.fillRect(x + (flip ? 0 : w - 5), y, 5, h);
    g.fillStyle = '#3f321e';
    for (var i = 1; i < 4; i++) g.fillRect(x + 2, y + i * (h / 4) | 0, w - 4, 2);
    g.fillStyle = '#8d7748'; g.fillRect(x, y, w, 2);
  }
  var gunPistol = bakeWeapon(120, 104, function (g) {
    g.fillStyle = '#14161a'; g.fillRect(53, 0, 12, 9);          // muzzle
    g.fillStyle = '#3c4048'; g.fillRect(48, 6, 20, 42);         // slide
    g.fillStyle = '#50555e'; g.fillRect(48, 6, 5, 42);
    g.fillStyle = '#22252a'; g.fillRect(48, 20, 20, 3);
    g.fillStyle = '#2a2d33'; g.fillRect(44, 44, 30, 13);        // frame
    g.strokeStyle = '#2a2d33'; g.lineWidth = 4;
    g.beginPath(); g.arc(59, 62, 8, 0, Math.PI); g.stroke();    // trigger guard
    g.fillStyle = '#33373d'; g.fillRect(47, 55, 22, 24);        // grip
    glove(g, 40, 60, 20, 40, true);
    glove(g, 62, 66, 22, 38, false);
    g.fillStyle = '#3f321e'; g.fillRect(58, 66, 6, 8);
  });
  var gunShotgun = bakeWeapon(150, 110, function (g) {
    g.fillStyle = '#15171b'; g.fillRect(44, 0, 16, 8);          // muzzle
    g.fillStyle = '#383c43'; g.fillRect(42, 4, 20, 56);         // barrel
    g.fillStyle = '#4b5058'; g.fillRect(42, 4, 5, 56);
    g.fillStyle = '#2b2e33'; g.fillRect(62, 22, 12, 40);        // magazine tube
    g.fillStyle = '#6b4526'; g.fillRect(38, 42, 32, 16);        // pump
    g.fillStyle = '#8a5c33'; g.fillRect(38, 42, 32, 3);
    g.fillStyle = '#2f3238'; g.fillRect(50, 56, 34, 18);        // receiver
    g.fillStyle = '#5a3a1f'; g.fillRect(66, 70, 30, 40);        // stock
    glove(g, 30, 46, 22, 40, true);
    glove(g, 76, 62, 24, 46, false);
  });
  var flashArt = bakeWeapon(96, 96, function (g) {
    var grad = g.createRadialGradient(48, 48, 2, 48, 48, 46);
    grad.addColorStop(0, '#fffbe6');
    grad.addColorStop(0.35, '#ffcf4a');
    grad.addColorStop(1, 'rgba(255,120,0,0)');
    g.fillStyle = grad;
    g.beginPath(); g.ellipse(48, 48, 46, 46, 0, 0, 6.3); g.fill();
  });

  // ------------------------------------------------------------------- audio
  var actx = null;
  function audio() {
    if (!actx) {
      var AC = window.AudioContext || window.webkitAudioContext;
      if (AC) actx = new AC();
    }
    if (actx && actx.state === 'suspended') actx.resume();
    return actx;
  }
  var noiseBuf = null;
  function noise(a) {
    if (!noiseBuf) {
      noiseBuf = a.createBuffer(1, a.sampleRate, a.sampleRate);
      var d = noiseBuf.getChannelData(0);
      for (var i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
    }
    return noiseBuf;
  }
  function burst(vol, dur, freq, q) {
    var a = audio(); if (!a) return;
    var src = a.createBufferSource(); src.buffer = noise(a);
    var flt = a.createBiquadFilter(); flt.type = 'lowpass';
    flt.frequency.setValueAtTime(freq, a.currentTime);
    flt.frequency.exponentialRampToValueAtTime(Math.max(60, freq * 0.15), a.currentTime + dur);
    flt.Q.value = q || 1;
    var g = a.createGain();
    g.gain.setValueAtTime(vol, a.currentTime);
    g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
    src.connect(flt); flt.connect(g); g.connect(a.destination);
    src.start(); src.stop(a.currentTime + dur);
  }
  function tone(type, f0, f1, dur, vol) {
    var a = audio(); if (!a) return;
    var o = a.createOscillator(); o.type = type;
    o.frequency.setValueAtTime(f0, a.currentTime);
    o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), a.currentTime + dur);
    var g = a.createGain();
    g.gain.setValueAtTime(vol, a.currentTime);
    g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
    o.connect(g); g.connect(a.destination);
    o.start(); o.stop(a.currentTime + dur);
  }
  var SFX = {
    pistol: function () { burst(0.28, 0.16, 2600, 3); tone('square', 220, 60, 0.09, 0.09); },
    shotgun: function () { burst(0.42, 0.36, 1500, 2); tone('sawtooth', 150, 40, 0.22, 0.14); },
    hit: function () { burst(0.16, 0.09, 3800, 1); },
    sight: function () { tone('sawtooth', 300, 120, 0.34, 0.13); tone('square', 150, 70, 0.3, 0.07); },
    pain: function () { tone('square', 420, 130, 0.2, 0.17); burst(0.14, 0.14, 900, 1); },
    death: function () { tone('sawtooth', 260, 45, 0.62, 0.19); burst(0.22, 0.5, 700, 1); },
    fire: function () { tone('sawtooth', 700, 220, 0.3, 0.1); },
    boom: function () { burst(0.32, 0.34, 1100, 1); tone('sine', 180, 40, 0.3, 0.14); },
    pickup: function () { tone('square', 700, 1350, 0.11, 0.13); },
    door: function () { tone('sawtooth', 90, 190, 0.75, 0.1); burst(0.1, 0.7, 400, 1); },
    ouch: function () { tone('square', 300, 90, 0.25, 0.2); },
    exit: function () { tone('square', 400, 900, 0.5, 0.16); tone('square', 600, 1400, 0.55, 0.1); },
    dry: function () { burst(0.12, 0.05, 5200, 1); }
  };

  // ------------------------------------------------------------------- state
  var WEAPONS = [
    { name: 'PISTOL', ammo: 'bullets', cost: 1, cool: 0.42, pellets: 1, dmg: [9, 16], spread: 0.012, art: gunPistol, sfx: SFX.pistol, kick: 5 },
    { name: 'SHOTGUN', ammo: 'shells', cost: 1, cool: 0.88, pellets: 7, dmg: [5, 11], spread: 0.09, art: gunShotgun, sfx: SFX.shotgun, kick: 12 }
  ];
  var P, ents, msg, msgT, over, elapsed, kills, totalKills, secretFound, tick;

  function spawn(type, x, y) {
    var e = { type: type, x: x, y: y, dead: false, remove: false, anim: 0, state: 'idle', t: 0 };
    if (type === 'zombie') { e.hp = 22; e.speed = 1.4; e.art = artZombie; e.range = 10; e.rate = 2.3; e.r = 0.42; }
    if (type === 'imp') { e.hp = 34; e.speed = 1.7; e.art = artImp; e.range = 11; e.rate = 2.7; e.r = 0.45; }
    if (type === 'fireball') { e.art = artFireball; e.r = 0.2; e.speed = 4.4; }
    ents.push(e);
    return e;
  }
  function pickup(kind, x, y) {
    var art = { medkit: artMedkit, clip: artClip, shells: artShells, armor: artArmor, shotgun: artShotgunPick }[kind];
    ents.push({ type: 'pickup', kind: kind, x: x, y: y, art: art, r: 0.4, dead: false, remove: false });
  }

  function reset() {
    for (var y = 0; y < MAPH; y++) {
      for (var x = 0; x < MAPW; x++) grid[y * MAPW + x] = CODE[MAP_ROWS[y].charAt(x)] || 0;
    }
    doorOpen.fill(0);
    opening.length = 0;
    P = {
      x: 3.5, y: 19.5, dirX: 1, dirY: 0, planeX: 0, planeY: 0.66,
      health: 100, armor: 0, bullets: 50, shells: 0, weapon: 0, hasShotgun: false,
      cool: 0, flash: 0, hurt: 0, bob: 0, bobAmp: 0, fireAnim: 0, dead: false
    };
    ents = [];
    spawn('imp', 11.5, 15.5); spawn('imp', 15.5, 21.5); spawn('zombie', 15.5, 16.5);
    spawn('zombie', 4.5, 4.5); spawn('zombie', 9.5, 3.5); spawn('zombie', 6.5, 7.5);
    spawn('imp', 12.5, 5.5); spawn('imp', 20.5, 17.5); spawn('imp', 21.5, 21.5);
    pickup('shotgun', 12.5, 20.5);
    pickup('shells', 13.5, 17.5); pickup('shells', 20.5, 20.5);
    pickup('clip', 5.5, 17.5); pickup('clip', 3.5, 3.5);
    pickup('medkit', 2.5, 21.5); pickup('medkit', 13.5, 2.5); pickup('medkit', 19.5, 16.5);
    pickup('armor', 19.5, 3.5);
    totalKills = 0;
    for (var i = 0; i < ents.length; i++) if (ents[i].type === 'zombie' || ents[i].type === 'imp') totalKills++;
    kills = 0; elapsed = 0; over = null; secretFound = false; tick = 0;
    say('KNEE-DEEP IN THE MARKDOWN');
  }
  function say(text) { msg = text; msgT = 3.2; }

  // ------------------------------------------------------------------ input
  var keys = Object.create(null);
  var running = false, started = false, fireLatch = false;
  // Analog touch state, summed with the keyboard so both work at once.
  var touch = { moveX: 0, moveY: 0, turn: 0, fire: false };
  var isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
  function keyName(e) {
    var k = e.key;
    if (k === ' ') return 'space';
    if (k.length === 1) return k.toLowerCase();
    return k.toLowerCase();
  }
  window.addEventListener('keydown', function (e) {
    if (!started) return;
    var k = keyName(e);
    keys[k] = true;
    if (k === 'space') fireLatch = true;   // a tap shorter than one frame still fires
    if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'space', 'tab'].indexOf(k) >= 0) e.preventDefault();
    if (k === 'r') reset();
    if (k === '1') P.weapon = 0;
    if (k === '2' && P.hasShotgun) P.weapon = 1;
    if (k === 'e') use();
  });
  window.addEventListener('keyup', function (e) { keys[keyName(e)] = false; });
  window.addEventListener('blur', function () { keys = Object.create(null); running = false; });
  view.addEventListener('mousedown', function (e) {
    e.preventDefault();
    if (!started || !running) { begin(); return; }
    keys.space = true;
    fireLatch = true;
  });
  window.addEventListener('mouseup', function () { keys.space = false; });
  view.addEventListener('mousemove', function (e) {
    // Touch generates compatibility mouse events with jumpy movementX; the
    // turn stick owns aiming there.
    if (!running || P.dead || isTouch) return;
    var dx = e.movementX || 0;
    if (dx) turn(clamp(dx, -60, 60) * 0.0032);
  });
  view.addEventListener('contextmenu', function (e) { e.preventDefault(); });
  view.tabIndex = 0;
  view.addEventListener('focus', function () { if (started) running = true; });
  // Pausing on canvas blur would stop the game the moment a thumb lands on the
  // pad below it, so only leaving the page pauses.
  document.addEventListener('visibilitychange', function () {
    if (document.hidden) running = false;
  });

  // ------------------------------------------------------------ touch pad
  var pad = document.getElementById('dz-pad');
  function makeStick(el, onMove, axisX) {
    var knob = el.querySelector('.dz-knob');
    var active = null, cx = 0, cy = 0, radius = 1;
    function apply(e) {
      var dx = e.clientX - cx, dy = e.clientY - cy;
      var dist = Math.hypot(dx, dy);
      if (dist > radius) { dx = dx / dist * radius; dy = dy / dist * radius; }
      if (axisX) dy = 0;
      knob.style.transform = 'translate(' + dx.toFixed(1) + 'px,' + dy.toFixed(1) + 'px)';
      onMove(dx / radius, dy / radius);
    }
    el.addEventListener('pointerdown', function (e) {
      if (active !== null) return;
      e.preventDefault();
      if (!started) begin();
      active = e.pointerId;
      // Capture keeps events coming when a thumb slides off the pad, but it
      // throws if the pointer is already gone. Losing it is a downgrade, not
      // a failure — never let it abort the rest of this handler.
      try { el.setPointerCapture(active); } catch (err) { /* no capture */ }
      var r = el.getBoundingClientRect();
      cx = r.left + r.width / 2;
      cy = r.top + r.height / 2;
      radius = r.width / 2 - 12;
      apply(e);
    });
    el.addEventListener('pointermove', function (e) {
      if (e.pointerId !== active) return;
      e.preventDefault();
      apply(e);
    });
    function release(e) {
      if (e.pointerId !== active) return;
      try { el.releasePointerCapture(active); } catch (err) { /* already gone */ }
      active = null;
      knob.style.transform = 'translate(0px,0px)';
      onMove(0, 0);
    }
    el.addEventListener('pointerup', release);
    el.addEventListener('pointercancel', release);
  }
  function makeButton(el, down, up) {
    el.addEventListener('pointerdown', function (e) {
      e.preventDefault();
      if (!started) begin();
      el.classList.add('dz-active');
      down();
    });
    function off() { el.classList.remove('dz-active'); if (up) up(); }
    el.addEventListener('pointerup', off);
    el.addEventListener('pointercancel', off);
    el.addEventListener('pointerleave', off);
  }
  // Controls are bound unconditionally and revealed by input mode, which is
  // WATCHED rather than sampled once: `(pointer: coarse)` can resolve after
  // this script runs, and a tablet gains a mouse or loses its keyboard mid
  // session. Reading it a single time at boot leaves the pad hidden forever
  // on any device that answers late.
  {
    makeStick(document.getElementById('dz-move'), function (x, y) {
      touch.moveX = x;
      touch.moveY = -y;               // screen y grows downward
    }, false);
    makeStick(document.getElementById('dz-turn'), function (x) {
      touch.turn = x;
    }, true);
    makeButton(document.getElementById('dz-fire'), function () {
      touch.fire = true;
      fireLatch = true;
    }, function () { touch.fire = false; });
    makeButton(document.getElementById('dz-use'), function () { use(); });
    makeButton(document.getElementById('dz-wpn'), function () {
      P.weapon = P.weapon === 0 && P.hasShotgun ? 1 : 0;
    });
    makeButton(document.getElementById('dz-rst'), function () { reset(); });
  }
  var coarseMq = window.matchMedia ? window.matchMedia('(pointer: coarse)') : null;
  var touchLatched = false;
  // No single signal is dependable. `(pointer: coarse)` can answer late or
  // wrongly; `maxTouchPoints` is non-zero on touchscreen laptops that are
  // really mouse-driven. So: coarse primary, "has touch points AND no fine
  // pointer anywhere" as the fallback, and an actual touch always wins.
  function detectTouch() {
    if (touchLatched) return true;
    if (coarseMq && coarseMq.matches) return true;
    var anyFine = window.matchMedia ? window.matchMedia('(any-pointer: fine)').matches : true;
    return (navigator.maxTouchPoints || 0) > 0 && !anyFine;
  }
  window.addEventListener('pointerdown', function (e) {
    if (e.pointerType === 'touch' && !touchLatched) {
      touchLatched = true;
      applyInputMode();
  // Pointer capabilities are not always settled when an inline script runs, and
  // no change event fires if the value was simply not ready to be read yet.
  // Two cheap re-reads cover that without polling forever.
  setTimeout(applyInputMode, 250);
  setTimeout(applyInputMode, 1200);
  window.addEventListener('load', applyInputMode);
    }
  }, true);
  function applyInputMode() {
    isTouch = detectTouch();
    pad.hidden = !isTouch;
    document.getElementById('dz-keys').hidden = isTouch;
    document.getElementById('dz-touch').hidden = !isTouch;
    if (!isTouch) { touch.moveX = 0; touch.moveY = 0; touch.turn = 0; touch.fire = false; }
  }
  if (coarseMq) {
    if (coarseMq.addEventListener) coarseMq.addEventListener('change', applyInputMode);
    else if (coarseMq.addListener) coarseMq.addListener(applyInputMode);
  }
  applyInputMode();

  function begin() {
    started = true;
    running = true;
    view.focus();
    audio();
  }

  function turn(a) {
    var cs = Math.cos(a), sn = Math.sin(a);
    var dx = P.dirX;
    P.dirX = dx * cs - P.dirY * sn;
    P.dirY = dx * sn + P.dirY * cs;
    var pxx = P.planeX;
    P.planeX = pxx * cs - P.planeY * sn;
    P.planeY = pxx * sn + P.planeY * cs;
  }

  // -------------------------------------------------------------- simulation
  function tryMove(nx, ny) {
    var r = 0.24;
    if (!solid((nx + (nx > P.x ? r : -r)) | 0, P.y | 0)) P.x = nx;
    if (!solid(P.x | 0, (ny + (ny > P.y ? r : -r)) | 0)) P.y = ny;
  }
  function los(ax, ay, bx, by) {
    var dx = bx - ax, dy = by - ay;
    var d = Math.hypot(dx, dy);
    var steps = Math.ceil(d * 8);
    for (var i = 1; i < steps; i++) {
      var t = i / steps;
      if (solid((ax + dx * t) | 0, (ay + dy * t) | 0)) return false;
    }
    return true;
  }
  function use() {
    if (P.dead) return;
    for (var i = 1; i <= 3; i++) {
      var tx = (P.x + P.dirX * i * 0.55) | 0, ty = (P.y + P.dirY * i * 0.55) | 0;
      var c = cell(tx, ty);
      if (c === 5) { openDoor(tx, ty); return; }
      if (c === 4) { finish(true); return; }
      if (c !== 0) return;
    }
  }
  var opening = [];
  function openDoor(x, y) {
    var idx = y * MAPW + x;
    for (var i = 0; i < opening.length; i++) if (opening[i] === idx) return;
    opening.push(idx);
    SFX.door();
    say('THE DOOR GRINDS OPEN');
  }
  function hurtPlayer(amount) {
    if (P.dead) return;
    var soak = Math.min(P.armor, Math.floor(amount / 3));
    P.armor -= soak;
    P.health -= (amount - soak);
    P.hurt = 0.55;
    SFX.ouch();
    if (P.health <= 0) {
      P.health = 0;
      P.dead = true;
      SFX.death();
      finish(false);
    }
  }
  function finish(won) {
    if (over) return;
    over = { won: won, t: 0 };
    // The end screen is a held image, so nothing may still be animating under
    // it: a fading message keeps repainting text, and a decaying pain kick
    // keeps shifting the world by whole pixels behind the translucent banner.
    msgT = 0;
    P.hurt = 0;
    if (won) SFX.exit();
  }

  // hitscan along the view ray; returns the nearest enemy struck
  function traceShot(angleOffset, reach) {
    var cs = Math.cos(angleOffset), sn = Math.sin(angleOffset);
    var rx = P.dirX * cs - P.dirY * sn, ry = P.dirX * sn + P.dirY * cs;
    var best = null, bestT = reach;
    for (var i = 0; i < ents.length; i++) {
      var e = ents[i];
      if (e.dead || e.remove || (e.type !== 'zombie' && e.type !== 'imp')) continue;
      var ox = e.x - P.x, oy = e.y - P.y;
      var t = ox * rx + oy * ry;
      if (t <= 0 || t >= bestT) continue;
      var perp = Math.abs(ox * ry - oy * rx);
      if (perp > e.r) continue;
      if (!los(P.x, P.y, e.x, e.y)) continue;
      best = e; bestT = t;
    }
    // a wall in between eats the shot
    var step = 0.06;
    for (var d = step; d < bestT; d += step) {
      if (solid((P.x + rx * d) | 0, (P.y + ry * d) | 0)) return null;
    }
    return best;
  }
  function shoot() {
    var w = WEAPONS[P.weapon];
    if (P.cool > 0 || P.dead) return;
    if (P[w.ammo] < w.cost) { SFX.dry(); say('OUT OF AMMO — PRESS 1'); P.cool = 0.3; return; }
    P[w.ammo] -= w.cost;
    P.cool = w.cool;
    P.flash = 0.09;
    P.fireAnim = 0.16;
    w.sfx();
    for (var i = 0; i < w.pellets; i++) {
      var hit = traceShot(rnd(-w.spread, w.spread), 22);
      if (hit) damageEnemy(hit, irnd(w.dmg[0], w.dmg[1]));
    }
  }
  function damageEnemy(e, dmg) {
    e.hp -= dmg;
    SFX.hit();
    if (e.hp <= 0) {
      e.dead = true; e.state = 'dying'; e.t = 0;
      kills++;
      SFX.death();
      if (kills === totalKills) say('ALL HOSTILES DOWN — FIND THE EXIT');
    } else {
      if (e.state === 'idle') { e.state = 'chase'; SFX.sight(); }
      if (Math.random() < 0.55) { e.pain = 0.22; }
    }
  }

  function stepEnemy(e, dt) {
    if (e.dead) { e.t += dt; return; }
    if (e.pain > 0) { e.pain -= dt; return; }
    var dx = P.x - e.x, dy = P.y - e.y;
    var dist = Math.hypot(dx, dy);
    var sees = dist < e.range && los(e.x, e.y, P.x, P.y);
    if (e.state === 'idle') {
      if (sees) { e.state = 'chase'; e.t = rnd(0.8, 1.7); SFX.sight(); }
      return;
    }
    e.t -= dt;
    e.anim += dt * 3.4;
    if (sees && e.t <= 0 && !P.dead) {
      e.fireAnim = 0.3;
      e.t = e.rate * rnd(0.75, 1.35);
      if (e.type === 'zombie') {
        if (Math.random() < 0.55) hurtPlayer(irnd(3, 9));
        SFX.pistol();
      } else {
        var f = spawn('fireball', e.x + dx / dist * 0.5, e.y + dy / dist * 0.5);
        f.vx = dx / dist * f.speed; f.vy = dy / dist * f.speed;
        SFX.fire();
      }
    }
    if (e.fireAnim > 0) { e.fireAnim -= dt; return; }
    if (dist > 1.15) {
      var sp = e.speed * dt * (sees ? 1 : 0.6);
      var nx = e.x + dx / dist * sp, ny = e.y + dy / dist * sp;
      if (!solid(nx | 0, e.y | 0)) e.x = nx;
      if (!solid(e.x | 0, ny | 0)) e.y = ny;
    }
  }
  function stepFireball(e, dt) {
    var nx = e.x + e.vx * dt, ny = e.y + e.vy * dt;
    if (solid(nx | 0, ny | 0)) { e.remove = true; SFX.boom(); return; }
    e.x = nx; e.y = ny;
    if (Math.hypot(P.x - e.x, P.y - e.y) < 0.45) {
      e.remove = true;
      SFX.boom();
      hurtPlayer(irnd(7, 15));
    }
  }
  function stepPickup(e) {
    if (Math.hypot(P.x - e.x, P.y - e.y) > 0.55) return;
    var took = true;
    if (e.kind === 'medkit') {
      if (P.health >= 100) took = false; else { P.health = Math.min(100, P.health + 25); say('MEDIKIT +25 HEALTH'); }
    } else if (e.kind === 'clip') { P.bullets += 20; say('CLIP +20 BULLETS'); }
    else if (e.kind === 'shells') { P.shells += 8; say('SHELLS +8'); }
    else if (e.kind === 'armor') { P.armor = 100; secretFound = true; say('SECRET! COMBAT ARMOR +100'); }
    else if (e.kind === 'shotgun') { P.hasShotgun = true; P.shells += 8; P.weapon = 1; say('YOU GOT THE SHOTGUN — PRESS 2'); }
    if (took) { e.remove = true; SFX.pickup(); }
  }

  function update(dt) {
    tick += dt;
    if (!over && !P.dead) elapsed += dt;
    if (msgT > 0) msgT -= dt;
    P.cool = Math.max(0, P.cool - dt);
    P.flash = Math.max(0, P.flash - dt);
    P.hurt = Math.max(0, P.hurt - dt);
    P.fireAnim = Math.max(0, P.fireAnim - dt);
    if (over) { over.t += dt; return; }

    if (!P.dead) {
      var fast = keys.shift ? RUN : MOVE;
      var fwd = clamp((keys.w || keys.arrowup ? 1 : 0) - (keys.s || keys.arrowdown ? 1 : 0) + touch.moveY, -1, 1);
      var strafe = clamp((keys.d ? 1 : 0) - (keys.a ? 1 : 0) + touch.moveX, -1, 1);
      var rot = clamp((keys.arrowright ? 1 : 0) - (keys.arrowleft ? 1 : 0) + touch.turn * 1.15, -1.15, 1.15);
      if (rot) turn(rot * TURN * dt);
      var moved = 0;
      if (fwd || strafe) {
        var mx = (P.dirX * fwd - P.dirY * strafe) * fast * dt;
        var my = (P.dirY * fwd + P.dirX * strafe) * fast * dt;
        var x0 = P.x, y0 = P.y;
        tryMove(P.x + mx, P.y + my);
        moved = Math.hypot(P.x - x0, P.y - y0);
      }
      // The bob phase follows DISTANCE TRAVELLED, not input. Holding a key
      // against a wall therefore bobs nothing, and no stuck input can leave
      // the weapon swinging in place.
      if (moved > 1e-4) {
        P.bob += moved * 2.5;
        P.bobAmp = Math.min(1, P.bobAmp + dt * 5);
      } else {
        // Standing still must be PERFECTLY still, so the amplitude SNAPS to
        // zero instead of easing out. An eased return looks nicer but spends
        // ~100ms at a small amplitude, and small is exactly the dangerous
        // regime: the view pitch scales the bob by 1.6 and the weapon by 7,
        // so there is a band where the rounded pitch is still 0 while the
        // rounded weapon offset is already +/-1 — the weapon jitters ALONE
        // against a frozen world. No in-between amplitude, no such band.
        // While moving there is no risk, because the world is moving anyway.
        P.bobAmp = 0;
        P.bob = 0;
      }
      if (keys.space || touch.fire || fireLatch) { fireLatch = false; shoot(); }
    }

    for (var i = 0; i < opening.length; i++) {
      var idx = opening[i];
      doorOpen[idx] = Math.min(1, doorOpen[idx] + dt * 1.15);
      if (doorOpen[idx] >= 1) { grid[idx] = 0; opening.splice(i, 1); i--; }
    }

    for (var j = 0; j < ents.length; j++) {
      var e = ents[j];
      if (e.remove) continue;
      if (e.type === 'fireball') stepFireball(e, dt);
      else if (e.type === 'pickup') stepPickup(e);
      else stepEnemy(e, dt);
    }
    for (var k = ents.length - 1; k >= 0; k--) if (ents[k].remove) ents.splice(k, 1);
  }

  // ------------------------------------------------------------------ render
  function shade(c, f) {
    var r = (c & 255) * f >> 8;
    var g = (c >> 8 & 255) * f >> 8;
    var b = (c >> 16 & 255) * f >> 8;
    return 0xff000000 | (b << 16) | (g << 8) | r;
  }
  function lightAt(dist, side) {
    var f = 300 / (1 + dist * dist * 0.055);
    if (side) f *= 0.72;
    return clamp(f, 26, 256) | 0;
  }

  function renderWorld(pitch) {
    var horizon = (VH >> 1) + pitch;

    // floor + ceiling, one texture-mapped span per row
    var rdx0 = P.dirX - P.planeX, rdy0 = P.dirY - P.planeY;
    var rdx1 = P.dirX + P.planeX, rdy1 = P.dirY + P.planeY;
    for (var y = 0; y < VH; y++) {
      var p = y - horizon;
      var isFloor = p > 0;
      if (p === 0) p = 1;
      var rowDist = (0.5 * VH) / Math.abs(p);
      var stepX = rowDist * (rdx1 - rdx0) / W;
      var stepY = rowDist * (rdy1 - rdy0) / W;
      var fx = P.x + rowDist * rdx0;
      var fy = P.y + rowDist * rdy0;
      var lit = lightAt(rowDist * 0.85, false);
      var tex = isFloor ? texFloor : texCeil;
      var base = y * W;
      for (var x = 0; x < W; x++) {
        var tx = ((fx * TEX) | 0) & (TEX - 1);
        var ty = ((fy * TEX) | 0) & (TEX - 1);
        buf[base + x] = shade(tex.px[(ty << 6) + tx], lit);
        fx += stepX; fy += stepY;
      }
    }

    // walls
    for (var col = 0; col < W; col++) {
      var camX = 2 * col / W - 1;
      var rayX = P.dirX + P.planeX * camX;
      var rayY = P.dirY + P.planeY * camX;
      var mapX = P.x | 0, mapY = P.y | 0;
      var ddx = rayX === 0 ? 1e30 : Math.abs(1 / rayX);
      var ddy = rayY === 0 ? 1e30 : Math.abs(1 / rayY);
      var stepMX, stepMY, sideX, sideY;
      if (rayX < 0) { stepMX = -1; sideX = (P.x - mapX) * ddx; } else { stepMX = 1; sideX = (mapX + 1 - P.x) * ddx; }
      if (rayY < 0) { stepMY = -1; sideY = (P.y - mapY) * ddy; } else { stepMY = 1; sideY = (mapY + 1 - P.y) * ddy; }
      var side = 0, hit = 0, guard = 0;
      while (guard++ < 128) {
        if (sideX < sideY) { sideX += ddx; mapX += stepMX; side = 0; }
        else { sideY += ddy; mapY += stepMY; side = 1; }
        hit = cell(mapX, mapY);
        if (hit) break;
      }
      var dist = side === 0 ? (sideX - ddx) : (sideY - ddy);
      if (dist < 0.02) dist = 0.02;
      zbuf[col] = dist;

      var wallX = side === 0 ? P.y + dist * rayY : P.x + dist * rayX;
      wallX -= Math.floor(wallX);
      var texX = (wallX * TEX) | 0;
      // Flip the faces whose wall coordinate runs opposite to screen order, so
      // a texture reads the same way it was authored on every face. Getting
      // this backwards mirrors every wall in the level, which is invisible on
      // noise-like textures and glaring on the one that contains lettering.
      if ((side === 0 && rayX < 0) || (side === 1 && rayY > 0)) texX = TEX - texX - 1;

      var lineH = (VH / dist) | 0;
      var start = ((VH - lineH) >> 1) + pitch;
      var y0 = start < 0 ? 0 : start;
      var y1 = start + lineH; if (y1 > VH) y1 = VH;
      var tex2 = WALLTEX[hit] || texBrick;
      var stepT = TEX / lineH;
      var slide = hit === 5 ? doorOpen[mapY * MAPW + mapX] * TEX * 1.6 : 0;
      var texPos = (y0 - start) * stepT + slide;
      var lit2 = lightAt(dist, side === 1);
      if (hit === 4) lit2 = clamp(lit2 + 60, 0, 256) | 0;   // the exit reads as lit
      for (var yy = y0; yy < y1; yy++) {
        var ty2 = (texPos | 0) & (TEX - 1);
        texPos += stepT;
        buf[yy * W + col] = shade(tex2.px[(ty2 << 6) + texX], lit2);
      }
    }

    // sprites, far to near
    var order = [];
    for (var i = 0; i < ents.length; i++) {
      var e = ents[i];
      if (e.remove) continue;
      e.d2 = (P.x - e.x) * (P.x - e.x) + (P.y - e.y) * (P.y - e.y);
      order.push(e);
    }
    order.sort(function (a, b) { return b.d2 - a.d2; });
    var invDet = 1 / (P.planeX * P.dirY - P.dirX * P.planeY);
    for (var s = 0; s < order.length; s++) {
      var sp = order[s];
      var art = frameFor(sp);
      if (!art) continue;
      var relX = sp.x - P.x, relY = sp.y - P.y;
      var tX = invDet * (P.dirY * relX - P.dirX * relY);
      var tY = invDet * (-P.planeY * relX + P.planeX * relY);
      if (tY < 0.15) continue;
      var screenX = ((W / 2) * (1 + tX / tY)) | 0;
      var worldH = sp.type === 'pickup' ? 0.42 : sp.type === 'fireball' ? 0.34 : 0.95;
      var floorY = (VH >> 1) + pitch + ((VH / tY) / 2 | 0);
      var spH = ((VH / tY) * worldH) | 0;
      var spW = (spH * (art.w / art.h)) | 0;
      if (sp.type === 'fireball' || sp.type === 'pickup') floorY -= (VH / tY) * (sp.type === 'fireball' ? 0.34 : 0.03) | 0;
      var top = floorY - spH;
      var x0 = screenX - (spW >> 1);
      var lit3 = lightAt(Math.sqrt(sp.d2), false);
      if (sp.type === 'fireball') lit3 = 256;
      for (var sx = 0; sx < spW; sx++) {
        var cx = x0 + sx;
        if (cx < 0 || cx >= W) continue;
        if (tY >= zbuf[cx]) continue;
        var texCol = ((sx * art.w / spW) | 0);
        for (var sy = 0; sy < spH; sy++) {
          var cy = top + sy;
          if (cy < 0 || cy >= VH) continue;
          var texRow = ((sy * art.h / spH) | 0);
          var c = art.px[texRow * art.w + texCol];
          if ((c & 0xff000000) >>> 24 < 128) continue;
          buf[cy * W + cx] = shade(c, lit3);
        }
      }
    }
    wctx.putImageData(frame, 0, 0);
    ctx.drawImage(world, 0, 0);
  }

  function frameFor(e) {
    if (e.type === 'pickup' || e.type === 'fireball') return e.art;
    if (e.dead) return e.t < 0.35 ? e.art.die0 : e.art.die1;
    if (e.pain > 0) return e.art.pain;
    if (e.fireAnim > 0) return e.art.fire;
    if (e.state === 'idle') return e.art.walk1;
    return (e.anim | 0) % 2 ? e.art.walk2 : e.art.walk1;
  }

  // ------------------------------------------------------------- HUD + chrome
  function bigNum(text, x, y, color) {
    ctx.font = 'bold 21px "Courier New", monospace';
    ctx.textAlign = 'right';
    ctx.fillStyle = '#1a0000';
    ctx.fillText(text, x + 1, y + 1);
    ctx.fillStyle = color;
    ctx.fillText(text, x, y);
  }
  function label(text, x, y) {
    ctx.font = 'bold 7px "Courier New", monospace';
    ctx.textAlign = 'center';
    ctx.fillStyle = '#7a7060';
    ctx.fillText(text, x, y);
  }
  function drawFace(cx, cy) {
    var h = P.health;
    var hurtNow = P.hurt > 0.25;
    ctx.save();
    ctx.translate(cx, cy);
    ctx.fillStyle = '#151515'; ctx.fillRect(-14, -14, 28, 28);
    if (P.dead) {
      ctx.fillStyle = '#8e2a20'; ctx.fillRect(-12, -12, 24, 24);
      ctx.fillStyle = '#d8c8a8';
      ctx.beginPath(); ctx.ellipse(0, 0, 9, 10, 0, 0, 6.3); ctx.fill();
      ctx.fillStyle = '#111'; ctx.fillRect(-6, -4, 4, 5); ctx.fillRect(2, -4, 4, 5);
      ctx.fillRect(-4, 5, 8, 3);
      ctx.restore();
      return;
    }
    ctx.fillStyle = '#c99a6e';
    ctx.beginPath(); ctx.ellipse(0, 1, 10, 11, 0, 0, 6.3); ctx.fill();
    ctx.fillStyle = '#5a3a20';
    ctx.beginPath(); ctx.ellipse(0, -7, 10, 6, 0, 0, 6.3); ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.fillRect(-7, -3, 5, 4); ctx.fillRect(2, -3, 5, 4);
    ctx.fillStyle = '#111';
    var look = ((tick * 1.4) % 3 | 0) - 1;
    ctx.fillRect(-6 + look, -2, 2, 3); ctx.fillRect(3 + look, -2, 2, 3);
    ctx.fillStyle = '#6b3f22';
    ctx.fillRect(-8, -5, 6, 2); ctx.fillRect(2, -5, 6, 2);
    ctx.fillStyle = '#6b2222';
    if (hurtNow) ctx.fillRect(-5, 5, 10, 5);
    else if (h > 70) ctx.fillRect(-4, 6, 8, 2);
    else if (h > 35) { ctx.fillRect(-4, 6, 8, 3); ctx.fillRect(-5, 7, 10, 1); }
    else ctx.fillRect(-5, 5, 10, 4);
    if (h < 70) { ctx.fillStyle = 'rgba(150,20,20,0.75)'; ctx.fillRect(-9, -9, 5, 3); }
    if (h < 40) { ctx.fillStyle = 'rgba(150,20,20,0.8)'; ctx.fillRect(3, 2, 6, 8); ctx.fillRect(-9, 0, 4, 6); }
    ctx.restore();
  }
  function drawHud() {
    var g = ctx.createLinearGradient(0, HUD_Y, 0, H);
    g.addColorStop(0, '#4a4136');
    g.addColorStop(1, '#2a251e');
    ctx.fillStyle = g;
    ctx.fillRect(0, HUD_Y, W, H - HUD_Y);
    ctx.fillStyle = '#7d7259'; ctx.fillRect(0, HUD_Y, W, 1);
    ctx.fillStyle = 'rgba(0,0,0,0.35)';
    ctx.fillRect(58, HUD_Y + 3, 1, 26); ctx.fillRect(126, HUD_Y + 3, 1, 26);
    ctx.fillRect(186, HUD_Y + 3, 1, 26); ctx.fillRect(246, HUD_Y + 3, 1, 26);

    var w = WEAPONS[P.weapon];
    bigNum(String(P[w.ammo]), 50, HUD_Y + 22, '#d2201c');
    label('AMMO', 29, HUD_Y + 29);

    bigNum(String(P.health) + '%', 118, HUD_Y + 22, P.health <= 30 ? '#ff4a3a' : '#d2201c');
    label('HEALTH', 92, HUD_Y + 29);

    drawFace(156, HUD_Y + 16);

    bigNum(String(P.armor) + '%', 238, HUD_Y + 22, '#2fae4a');
    label('ARMOR', 212, HUD_Y + 29);

    ctx.textAlign = 'left';
    ctx.font = 'bold 8px "Courier New", monospace';
    ctx.fillStyle = P.weapon === 0 ? '#e0c060' : '#6a6152';
    ctx.fillText('1 PISTOL', 252, HUD_Y + 10);
    ctx.fillStyle = !P.hasShotgun ? '#4a453c' : P.weapon === 1 ? '#e0c060' : '#6a6152';
    ctx.fillText('2 SHOTGUN', 252, HUD_Y + 19);
    ctx.fillStyle = '#9a8f78';
    ctx.fillText('KILLS ' + kills + '/' + totalKills, 252, HUD_Y + 28);
  }
  function drawWeapon() {
    var w = WEAPONS[P.weapon];
    var art = w.art;
    // Every offset is rounded and the art is already at final size, so the
    // sprite lands on whole pixels and is never resampled.
    var bobX = Math.round(Math.cos(P.bob) * 7 * P.bobAmp);
    var bobY = Math.round(Math.abs(Math.sin(P.bob)) * 6 * P.bobAmp);
    var kick = P.fireAnim > 0 ? Math.round((P.fireAnim / 0.16) * w.kick) : 0;
    var x = Math.round((W - art.width) / 2) + bobX;
    var y = VH - art.height + 26 + bobY + kick + (P.dead ? 60 : 0);
    if (P.flash > 0) {
      ctx.globalAlpha = clamp(P.flash / 0.09, 0, 1);
      ctx.drawImage(flashArt, Math.round(x + (art.width - flashArt.width) / 2), y - 46);
      ctx.globalAlpha = 1;
    }
    ctx.drawImage(art, x, y);
  }
  function drawOverlays() {
    if (P.hurt > 0) {
      ctx.fillStyle = 'rgba(170,20,10,' + (0.42 * (P.hurt / 0.55)).toFixed(3) + ')';
      ctx.fillRect(0, 0, W, VH);
    }
    if (P.flash > 0) {
      ctx.fillStyle = 'rgba(255,220,140,' + (0.13 * (P.flash / 0.09)).toFixed(3) + ')';
      ctx.fillRect(0, 0, W, VH);
    }
    // crosshair
    ctx.fillStyle = 'rgba(230,230,230,0.55)';
    ctx.fillRect(W / 2 - 4, VH / 2 - 1, 3, 1);
    ctx.fillRect(W / 2 + 2, VH / 2 - 1, 3, 1);
    ctx.fillRect(W / 2 - 1, VH / 2 - 5, 1, 3);
    ctx.fillRect(W / 2 - 1, VH / 2 + 2, 1, 3);

    if (msgT > 0 && msg) {
      ctx.font = 'bold 9px "Courier New", monospace';
      ctx.textAlign = 'left';
      ctx.globalAlpha = clamp(msgT, 0, 1);
      ctx.fillStyle = '#0d0d0d'; ctx.fillText(msg, 7, 15);
      ctx.fillStyle = '#e8dfa8'; ctx.fillText(msg, 6, 14);
      ctx.globalAlpha = 1;
    }
  }
  function banner(lines, tint) {
    ctx.fillStyle = tint;
    ctx.fillRect(0, 0, W, VH);
    ctx.textAlign = 'center';
    for (var i = 0; i < lines.length; i++) {
      var l = lines[i];
      ctx.font = l.big ? 'bold 20px "Courier New", monospace' : 'bold 10px "Courier New", monospace';
      ctx.fillStyle = '#120a06';
      ctx.fillText(l.t, W / 2 + 1, l.y + 1);
      ctx.fillStyle = l.c;
      ctx.fillText(l.t, W / 2, l.y);
    }
  }
  function drawEndScreen() {
    var mins = Math.floor(elapsed / 60), secs = Math.floor(elapsed % 60);
    var time = mins + ':' + (secs < 10 ? '0' : '') + secs;
    if (over.won) {
      banner([
        { t: 'LEVEL COMPLETE', y: 48, c: '#e8c24a', big: true },
        { t: 'KILLS   ' + Math.round(kills / Math.max(1, totalKills) * 100) + '%   (' + kills + '/' + totalKills + ')', y: 82, c: '#d8d0bc' },
        { t: 'SECRET  ' + (secretFound ? '100%' : '0%'), y: 98, c: '#d8d0bc' },
        { t: 'TIME    ' + time, y: 114, c: '#d8d0bc' },
        { t: 'PRESS R TO PLAY AGAIN', y: 140, c: '#8fd07a' }
      ], 'rgba(20,12,6,0.88)');
    } else {
      banner([
        { t: 'YOU DIED', y: 60, c: '#c8281c', big: true },
        { t: 'KILLS ' + kills + '/' + totalKills + '   TIME ' + time, y: 92, c: '#d8b0a0' },
        { t: 'PRESS R TO TRY AGAIN', y: 126, c: '#e8dfa8' }
      ], 'rgba(70,6,4,0.72)');
    }
  }
  function drawStartScreen() {
    ctx.fillStyle = '#0a0a0c';
    ctx.fillRect(0, 0, W, H);
    ctx.textAlign = 'center';
    ctx.font = 'bold 30px "Courier New", monospace';
    ctx.fillStyle = '#5c0f0a';
    ctx.fillText('D O O M', W / 2 + 2, 66);
    ctx.fillStyle = '#c8281c';
    ctx.fillText('D O O M', W / 2, 64);
    ctx.font = 'bold 9px "Courier New", monospace';
    ctx.fillStyle = '#9a8f78';
    ctx.fillText('E1M1  KNEE-DEEP IN THE MARKDOWN', W / 2, 86);
    ctx.fillStyle = '#e8dfa8';
    ctx.fillText(
      isTouch ? (started ? 'TAP TO RESUME' : 'TAP TO PLAY') : started ? 'CLICK TO RESUME' : 'CLICK TO PLAY',
      W / 2,
      122,
    );
    ctx.fillStyle = '#6f6858';
    ctx.font = 'bold 8px "Courier New", monospace';
    if (isTouch) {
      ctx.fillText('LEFT STICK MOVES   RIGHT STICK TURNS', W / 2, 146);
      ctx.fillText('FIRE, USE AND WEAPON ARE BUTTONS BELOW', W / 2, 158);
    } else {
      ctx.fillText('WASD MOVE   ARROWS/MOUSE TURN   SPACE FIRE', W / 2, 146);
      ctx.fillText('E OPENS DOORS AND THE EXIT   R RESTARTS', W / 2, 158);
    }
    ctx.fillStyle = '#3a3630';
    ctx.fillText('a raycaster running inside a Strata html block', W / 2, 186);
  }

  function render() {
    if (!running) {
      drawStartScreen();
    } else {
      var pitch = Math.round(Math.sin(P.bob) * 1.6 * P.bobAmp) + (P.dead ? 34 : 0) + (P.hurt > 0 ? Math.round(P.hurt * 5) : 0);
      renderWorld(clamp(pitch, -40, 60));
      drawWeapon();
      drawOverlays();
      if (over) drawEndScreen();
      drawHud();
    }
    // ALWAYS present, including the title and pause screens. Everything above
    // draws to the off-screen surface, so an early return here leaves the
    // visible canvas holding whatever it last had — and resizing the backing
    // store blanks it — which is a black block until something else repaints.
    present();
  }

  // -------------------------------------------------------------------- loop
  var last = 0;
  function loop(now) {
    var dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016;
    last = now;
    if (running) update(dt);
    render();
    requestAnimationFrame(loop);
  }

  sizeDisplay();
  if (window.ResizeObserver && view.parentNode) {
    new ResizeObserver(function () { sizeDisplay(); render(); }).observe(view.parentNode);
  }
  window.addEventListener('resize', function () { sizeDisplay(); render(); });
  // devicePixelRatio changes when the window moves between displays or the
  // user zooms; the media query re-arms each time because the threshold moves.
  (function watchDpr() {
    if (!window.matchMedia) return;
    var mq = window.matchMedia('(resolution: ' + (window.devicePixelRatio || 1) + 'dppx)');
    var onChange = function () { sizeDisplay(); render(); watchDpr(); };
    if (mq.addEventListener) mq.addEventListener('change', onChange, { once: true });
    else if (mq.addListener) mq.addListener(onChange);
  })();

  // ------------------------------------------------- temporary diagnostics
  // Reports what THIS machine is doing, because the flicker has not reproduced
  // on any browser, scale, or engine available for testing. Remove once the
  // cause is confirmed.
  var diagEl = document.getElementById('dz-diag');
  // Counts weapon-coloured pixels: dark neutral greys, which the gun is made of
  // and the floor/ceiling around it is not.
  function gunPixels(context, x, y, w, h) {
    try {
      var d = context.getImageData(x, y, w, h).data;
      var n = 0;
      for (var i = 0; i < d.length; i += 4) {
        var r = d[i], g = d[i + 1], b = d[i + 2];
        if (Math.abs(r - g) < 26 && Math.abs(g - b) < 26 && r > 30 && r < 110) n++;
      }
      return n;
    } catch (err) {
      return -1;
    }
  }
  // Pixel counts require a readback, and a readback on a timer makes the
  // browser warn about getImageData — which the sandbox relays as an error
  // under the preview. So sampling happens only on tap.
  var lastSample = null;
  function sampleNow(label) {
    lastSample = {
      mode: label,
      scene: gunPixels(ctx, 100, 60, 130, 100),
      shown: gunPixels(
        dctx,
        Math.round(view.width * 0.31),
        Math.round(view.height * 0.3),
        Math.round(view.width * 0.38),
        Math.round(view.height * 0.5),
      ),
    };
  }
  function updateDiag() {
    // clientWidth, matching sizeDisplay: the border box is 4px wider and would
    // report a phantom fractional scale.
    var cw = view.clientWidth, ch = view.clientHeight;
    var devW = Math.round(cw * dprSeen);
    var exact = devW === view.width ? 'exact 1:1' : 'scale x' + (view.width / Math.max(1, devW)).toFixed(3);
    diagEl.textContent =
      BUILD + ' [' + (directMode ? 'DIRECT 1:1' : 'scaled') + ' - tap this line to switch + sample]' +
      ' | dpr ' + dprSeen + ' | css ' + cw + 'x' + ch +
      ' | device ' + devW + ' | backing ' + view.width + 'x' + view.height + ' (x' + scale + ', ' + exact + ')' +
      ' | bob ' + P.bob.toFixed(3) + ' amp ' + P.bobAmp.toFixed(3) +
      ' | pos ' + P.x.toFixed(2) + ',' + P.y.toFixed(2) +
      (lastSample
        ? ' | GUN in ' + lastSample.mode + ': drawn ' + lastSample.scene + ', shown ' + lastSample.shown
        : ' | gun: tap to sample');
  }
  diagEl.style.cursor = 'pointer';
  diagEl.addEventListener('click', function () {
    sampleNow(directMode ? 'direct' : 'scaled');   // measure the mode being left
    directMode = !directMode;
    view.classList.toggle('dz-direct', directMode);
    sizeDisplay();
    render();
    updateDiag();
  });
  setInterval(updateDiag, 400);

  reset();
  sizeDisplay();
  render();
  requestAnimationFrame(loop);
})();
</script>a

What is actually running

The block is a small software renderer. Nothing is drawn with WebGL or the 2D API in the hot path. Each frame writes 320x168 pixels into a Uint32Array and hands it over with one putImageData.

  • Walls come from a DDA grid raycast, one column per screen pixel, textured by the exact fractional hit position and darkened by distance. Two of the four faces need their horizontal texture coordinate flipped so a texture reads as authored from every side — get it backwards and the whole level is mirrored, which noise-like stone hides completely and a painted EXIT sign does not. Wall depth is kept in a z-buffer so sprites clip correctly against corners.

  • Floor and ceiling are cast per row: the horizontal distance for a scanline is a constant, so each row is a straight linear walk through the texture.

  • Art is procedural. Bricks, tech panels, blood-veined stone, the door, the imps, the zombies, the pickups, and both weapons are drawn once at startup with ordinary canvas calls, then read back as raw pixel arrays. That is why a 43 KB block needs no image files.

  • Sound is synthesised. Gunshots are filtered noise bursts, imp fireballs are pitch-swept oscillators, all built on the fly with WebAudio.

  • Enemies wake on line of sight, chase, and attack on a cooldown: zombies fire hitscans, imps throw fireballs you can sidestep. Damage lands on armor first, then health.

  • The framebuffer gets its own surface. putImageData writes to a canvas that receives nothing else; the weapon, HUD and banners are drawn on a second surface that never sees putImageData. Mixing the two on one canvas makes browsers flip it between accelerated and software backing, and the draw calls after the putImageData are the ones that come out unreliable.

  • The bitmap is presented at a whole-number scale. The game draws into an off-screen 320x200 surface, and the visible canvas is always an exact integer multiple of that, sized to cover its own device pixels. A canvas stretched by a fractional factor with image-rendering: pixelated gives some source pixels two device pixels and their neighbours three, and any sub-pixel shift reshuffles which is which — static content then crawls on hard edges while noisy textures hide it.

  • The weapon bob follows distance travelled, not input. Walking into a wall bobs nothing, and the bob amplitude is either full or exactly zero, never in between. The view pitch scales the bob by 1.6 and the weapon by 7, so a small residual amplitude would round to a still world and a twitching gun — the one artifact this block cannot afford.

  • Touch input is analog. The sticks report a vector, not a direction, so a small lean walks slowly and a full push runs. Stick values are summed with the keyboard rather than replacing it, which is why a laptop with a touchscreen can use either.

Why it is a raycaster and not the real thing

Strata runs html blocks in a locked-down iframe: default-src 'none', connect-src 'none', no allow-same-origin, and no wasm-unsafe-eval. A genuine port needs to fetch a multi-megabyte WAD and compile a WebAssembly module — the sandbox denies both, by design, because this code is assumed hostile. So the level, the sprites, and the engine had to fit inside the one thing the sandbox does allow: inline script drawing to a canvas.

Everything above is the sandbox working as intended. The block cannot reach the network, cannot see the document around it, cannot touch your session, and cannot navigate anywhere. It can only paint inside its own rectangle.