Home

12. id Software · 1992 · Retro techniques

Wolfenstein raycaster

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

Wolfenstein 3D's 2.5D engine: for each column, a ray steps a grid with DDA until it hits a wall, then a vertical strip is drawn at 1/distance. No polygons. Ceiling and floor are flats. A tiny map sits in the corner. It patrols the maze until you take over.

Click the preview first so it has keyboard focus. WASD or arrows to walk, A/D strafe, arrow keys turn. Move the pointer to glance; drag to turn. Walking follows the view.

Reference

id Software, Wolfenstein 3D, 1992 (source released 1995); the per-column DDA raycast as described by Lode Vandevenne

Browser APIs

  • Canvas 2D
  • Keyboard Events
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#c45c3e",
    speed: 1,
    fov: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [196, 92, 62];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }
  function mix(a, b, t) {
    return [
      a[0] + (b[0] - a[0]) * t,
      a[1] + (b[1] - a[1]) * t,
      a[2] + (b[2] - a[2]) * t
    ];
  }
  function css(rgb, a) {
    return "rgba(" + (rgb[0] | 0) + "," + (rgb[1] | 0) + "," + (rgb[2] | 0) + "," + a + ")";
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const WMAP = [
    "1111111111111111",
    "1..............1",
    "1..22......33..1",
    "1..............1",
    "11....44....11.1",
    "1..............1",
    "1.222....333...1",
    "1.2..........3.1",
    "1.2..11..11..3.1",
    "1..............1",
    "1....1....1....1",
    "1111111111111111"
  ];
  const MW = WMAP[0].length;
  const MH = WMAP.length;
  function cell(x, y) {
    if (x < 0 || y < 0 || x >= MW || y >= MH) return "1";
    return WMAP[y | 0][x | 0];
  }

  const PAL = {
    "1": [196, 92, 62],
    "2": [156, 118, 62],
    "3": [72, 118, 92],
    "4": [92, 86, 120]
  };

  let px = 2.5, py = 9.5, ang = 0;
  const keys = new Set();
  let manual = false;
  let autoTurn = 0;
  let last = 0;
  let raf = 0;
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;
  const RAD = 0.18;

  function onKey(ev, down) {
    keys[down ? "add" : "delete"](ev.code);
    if (down && (ev.code === "KeyW" || ev.code === "KeyA" || ev.code === "KeyS" || ev.code === "KeyD" ||
        ev.code === "ArrowLeft" || ev.code === "ArrowRight" || ev.code === "ArrowUp" || ev.code === "ArrowDown")) {
      manual = true;
      ev.preventDefault();
    }
    if (reduced) { step(0.05); wake(); }
  }
  window.addEventListener("keydown", function (e) { onKey(e, true); });
  window.addEventListener("keyup", function (e) { onKey(e, false); });
  window.addEventListener("blur", function () { keys.clear(); });

  // The pointer's distance from the centre glances the view sideways; a drag
  // turns the player. Walking always follows the view (see step), so W goes
  // where the screen is looking.
  let lookOff = 0;
  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    lookOff = ((ev.clientX - r.left) / r.width - 0.5) * 0.7;
    if (ev.buttons) {
      ang += ev.movementX * 0.005;
      manual = true;
    }
    wake();
  });
  canvas.addEventListener("pointerleave", function () { lookOff = 0; wake(); });

  function tryMove(dx, dy) {
    const nx = px + dx, ny = py + dy;
    if (cell(nx + Math.sign(dx) * RAD, py) === ".") px = nx;
    if (cell(px, ny + Math.sign(dy) * RAD) === ".") py = ny;
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function cast(dirX, dirY) {
    let mapX = px | 0, mapY = py | 0;
    const deltaX = Math.abs(1 / (dirX || 1e-8));
    const deltaY = Math.abs(1 / (dirY || 1e-8));
    const stepX = dirX < 0 ? -1 : 1;
    const stepY = dirY < 0 ? -1 : 1;
    let sideX = dirX < 0 ? (px - mapX) * deltaX : (mapX + 1 - px) * deltaX;
    let sideY = dirY < 0 ? (py - mapY) * deltaY : (mapY + 1 - py) * deltaY;
    let side = 0, hit = ".";
    for (let i = 0; i < 48; i++) {
      if (sideX < sideY) { sideX += deltaX; mapX += stepX; side = 0; }
      else { sideY += deltaY; mapY += stepY; side = 1; }
      hit = cell(mapX, mapY);
      if (hit !== ".") break;
    }
    const dist = side === 0
      ? (mapX - px + (1 - stepX) * 0.5) / dirX
      : (mapY - py + (1 - stepY) * 0.5) / dirY;
    const wallX = side === 0 ? py + dist * dirY : px + dist * dirX;
    return { dist: Math.max(0.12, dist), side: side, hit: hit, wallX: wallX - Math.floor(wallX) };
  }

  function draw(w, h) {
    const rgb = hexToRgb(PARAMS.color);
    const ceil = mix([11, 12, 14], rgb, 0.08);
    const floor = mix([18, 16, 14], rgb, 0.16);
    ctx.fillStyle = css(ceil, 1);
    ctx.fillRect(0, 0, w, h * 0.5);
    ctx.fillStyle = css(floor, 1);
    ctx.fillRect(0, h * 0.5, w, h * 0.5);

    const view = ang + lookOff;
    const dirX = Math.cos(view);
    const dirY = Math.sin(view);
    const planeX = -dirY * 0.66 * PARAMS.fov;
    const planeY = dirX * 0.66 * PARAMS.fov;
    const cols = Math.min(w, 320);
    const step = w / cols;
    for (let i = 0; i < cols; i++) {
      const cam = (i / cols) * 2 - 1;
      const hit = cast(dirX + planeX * cam, dirY + planeY * cam);
      const perp = hit.dist;
      const lineH = Math.min(h * 1.8, h / perp);
      const y0 = (h - lineH) * 0.5;
      const base = PAL[hit.hit] || rgb;
      const tint = mix(base, rgb, 0.35);
      const stripe = ((hit.wallX * 8) | 0) & 1 ? 0.78 : 1;
      const sideK = hit.side ? 0.55 : 0.88;
      const fog = 1 / (1 + perp * 0.28);
      const c = [
        tint[0] * stripe * sideK * fog,
        tint[1] * stripe * sideK * fog,
        tint[2] * stripe * sideK * fog
      ];
      ctx.fillStyle = css(c, 1);
      ctx.fillRect((i * step) | 0, y0 | 0, Math.ceil(step), lineH | 0);
    }

    const ms = Math.max(3, (Math.min(w, h) / 90) | 0);
    const mx = 8, my = 8;
    for (let y = 0; y < MH; y++) {
      for (let x = 0; x < MW; x++) {
        const t = WMAP[y][x];
        if (t === ".") ctx.fillStyle = "rgba(20,22,24,0.7)";
        else ctx.fillStyle = css(mix(PAL[t] || rgb, rgb, 0.25), 0.85);
        ctx.fillRect(mx + x * ms, my + y * ms, ms - 1, ms - 1);
      }
    }
    ctx.fillStyle = css(rgb, 1);
    ctx.fillRect(mx + px * ms - 1.5, my + py * ms - 1.5, 3, 3);
    ctx.strokeStyle = css(rgb, 0.9);
    ctx.beginPath();
    ctx.moveTo(mx + px * ms, my + py * ms);
    ctx.lineTo(mx + (px + Math.cos(view) * 1.4) * ms, my + (py + Math.sin(view) * 1.4) * ms);
    ctx.stroke();
  }

  function step(dt) {
    // Same angle the renderer uses, so movement and picture agree.
    const view = ang + lookOff;
    const dirX = Math.cos(view);
    const dirY = Math.sin(view);
    const planeX = -dirY;
    const planeY = dirX;
    const sp = 2.4 * PARAMS.speed * dt;
    const turn = 1.7 * dt;
    let mx = 0, my = 0, yaw = 0;
    if (keys.has("KeyW") || keys.has("ArrowUp")) { mx += dirX; my += dirY; }
    if (keys.has("KeyS") || keys.has("ArrowDown")) { mx -= dirX; my -= dirY; }
    if (keys.has("KeyD")) { mx += planeX; my += planeY; }
    if (keys.has("KeyA")) { mx -= planeX; my -= planeY; }
    if (keys.has("ArrowLeft")) yaw -= 1;
    if (keys.has("ArrowRight")) yaw += 1;
    if (manual) {
      if (mx || my) {
        const len = Math.hypot(mx, my) || 1;
        tryMove((mx / len) * sp, (my / len) * sp);
      }
      ang += yaw * turn;
    } else {
      const hit = cell(px + dirX * 0.6, py + dirY * 0.6);
      if (hit !== ".") autoTurn = 1.2;
      if (autoTurn > 0) {
        ang += 1.8 * dt;
        autoTurn -= dt;
      } else {
        tryMove(dirX * sp * 0.55, dirY * sp * 0.55);
      }
    }
  }

  function frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (!reduced) step(dt);
    resize();
    draw(canvas.width, canvas.height);
    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  window.__controlsTest = {
    getYaw: function () { return ang; },
    getView: function () { return ang + lookOff; },
    getX: function () { return px; },
    getY: function () { return py; },
    setLook: function (off) { lookOff = off; },
    setKeys: function (codes) {
      keys.clear();
      manual = true;
      for (let i = 0; i < codes.length; i++) keys.add(codes[i]);
    },
    step: step
  };

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();