{"slug":"wolfenstein","number":"12","title":"Wolfenstein raycaster","era":"id Software · 1991","summary":"A DDA raycaster. WASD to walk, pointer to look.","description":"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.","apis":["Canvas 2D","Keyboard Events","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["raycast","3d"],"hasGlsl":false,"interaction":"WASD or arrows to walk. Move the pointer to look. A/D strafe.","url":"https://3d-retro.com/experiments/wolfenstein","example_url":"https://3d-retro.com/examples/wolfenstein.html","api_url":"https://3d-retro.com/api/v1/experiments/wolfenstein","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"A 1991 DDA raycaster. WASD to walk, pointer to look.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/wolfenstein\" />\n  <title>Wolfenstein raycaster · 3d retro graphics</title>\n  <style>\n    html, body {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      overflow: hidden;\n      background: #0b0c0e;\n      color: #e8e6e1;\n      position: absolute;\n      inset: 0;\n    }\n    canvas {\n      display: block;\n      position: absolute;\n      inset: 0;\n      width: 100%;\n      height: 100%;\n      touch-action: none;\n    }\n    .fallback {\n      position: absolute;\n      inset: 0;\n      display: grid;\n      place-items: center;\n      padding: 24px;\n      font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace;\n      text-align: center;\n      color: #e8e6e1;\n      background: #0b0c0e;\n    }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#c45c3e\",\n    speed: 1,\n    fov: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [196, 92, 62];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n  function mix(a, b, t) {\n    return [\n      a[0] + (b[0] - a[0]) * t,\n      a[1] + (b[1] - a[1]) * t,\n      a[2] + (b[2] - a[2]) * t\n    ];\n  }\n  function css(rgb, a) {\n    return \"rgba(\" + (rgb[0] | 0) + \",\" + (rgb[1] | 0) + \",\" + (rgb[2] | 0) + \",\" + a + \")\";\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const WMAP = [\n    \"1111111111111111\",\n    \"1..............1\",\n    \"1..22......33..1\",\n    \"1..............1\",\n    \"11....44....11.1\",\n    \"1..............1\",\n    \"1.222....333...1\",\n    \"1.2..........3.1\",\n    \"1.2..11..11..3.1\",\n    \"1..............1\",\n    \"1....1....1....1\",\n    \"1111111111111111\"\n  ];\n  const MW = WMAP[0].length;\n  const MH = WMAP.length;\n  function cell(x, y) {\n    if (x < 0 || y < 0 || x >= MW || y >= MH) return \"1\";\n    return WMAP[y | 0][x | 0];\n  }\n\n  const PAL = {\n    \"1\": [196, 92, 62],\n    \"2\": [156, 118, 62],\n    \"3\": [72, 118, 92],\n    \"4\": [92, 86, 120]\n  };\n\n  let px = 2.5, py = 9.5, ang = 0;\n  const keys = new Set();\n  let manual = false;\n  let autoTurn = 0;\n  let last = 0;\n  let raf = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  const RAD = 0.18;\n\n  function onKey(ev, down) {\n    keys[down ? \"add\" : \"delete\"](ev.code);\n    if (down && (ev.code === \"KeyW\" || ev.code === \"KeyA\" || ev.code === \"KeyS\" || ev.code === \"KeyD\" ||\n        ev.code === \"ArrowLeft\" || ev.code === \"ArrowRight\" || ev.code === \"ArrowUp\" || ev.code === \"ArrowDown\")) {\n      manual = true;\n      ev.preventDefault();\n    }\n  }\n  window.addEventListener(\"keydown\", function (e) { onKey(e, true); });\n  window.addEventListener(\"keyup\", function (e) { onKey(e, false); });\n  window.addEventListener(\"blur\", function () { keys.clear(); });\n\n  let lookOff = 0;\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    const r = canvas.getBoundingClientRect();\n    lookOff = ((ev.clientX - r.left) / r.width - 0.5) * 0.7;\n    if (ev.buttons) {\n      ang += ev.movementX * 0.005;\n      manual = true;\n    }\n  });\n  canvas.addEventListener(\"pointerleave\", function () { lookOff = 0; });\n\n  function tryMove(dx, dy) {\n    const nx = px + dx, ny = py + dy;\n    if (cell(nx + Math.sign(dx) * RAD, py) === \".\") px = nx;\n    if (cell(px, ny + Math.sign(dy) * RAD) === \".\") py = ny;\n  }\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n  }\n\n  function cast(dirX, dirY) {\n    let mapX = px | 0, mapY = py | 0;\n    const deltaX = Math.abs(1 / (dirX || 1e-8));\n    const deltaY = Math.abs(1 / (dirY || 1e-8));\n    const stepX = dirX < 0 ? -1 : 1;\n    const stepY = dirY < 0 ? -1 : 1;\n    let sideX = dirX < 0 ? (px - mapX) * deltaX : (mapX + 1 - px) * deltaX;\n    let sideY = dirY < 0 ? (py - mapY) * deltaY : (mapY + 1 - py) * deltaY;\n    let side = 0, hit = \".\";\n    for (let i = 0; i < 48; i++) {\n      if (sideX < sideY) { sideX += deltaX; mapX += stepX; side = 0; }\n      else { sideY += deltaY; mapY += stepY; side = 1; }\n      hit = cell(mapX, mapY);\n      if (hit !== \".\") break;\n    }\n    const dist = side === 0\n      ? (mapX - px + (1 - stepX) * 0.5) / dirX\n      : (mapY - py + (1 - stepY) * 0.5) / dirY;\n    const wallX = side === 0 ? py + dist * dirY : px + dist * dirX;\n    return { dist: Math.max(0.12, dist), side: side, hit: hit, wallX: wallX - Math.floor(wallX) };\n  }\n\n  function draw(w, h) {\n    const rgb = hexToRgb(PARAMS.color);\n    const ceil = mix([11, 12, 14], rgb, 0.08);\n    const floor = mix([18, 16, 14], rgb, 0.16);\n    ctx.fillStyle = css(ceil, 1);\n    ctx.fillRect(0, 0, w, h * 0.5);\n    ctx.fillStyle = css(floor, 1);\n    ctx.fillRect(0, h * 0.5, w, h * 0.5);\n\n    const view = ang + lookOff;\n    const dirX = Math.cos(view);\n    const dirY = Math.sin(view);\n    const planeX = -dirY * 0.66 * PARAMS.fov;\n    const planeY = dirX * 0.66 * PARAMS.fov;\n    const cols = Math.min(w, 320);\n    const step = w / cols;\n    for (let i = 0; i < cols; i++) {\n      const cam = (i / cols) * 2 - 1;\n      const hit = cast(dirX + planeX * cam, dirY + planeY * cam);\n      const perp = hit.dist;\n      const lineH = Math.min(h * 1.8, h / perp);\n      const y0 = (h - lineH) * 0.5;\n      const base = PAL[hit.hit] || rgb;\n      const tint = mix(base, rgb, 0.35);\n      const stripe = ((hit.wallX * 8) | 0) & 1 ? 0.78 : 1;\n      const sideK = hit.side ? 0.55 : 0.88;\n      const fog = 1 / (1 + perp * 0.28);\n      const c = [\n        tint[0] * stripe * sideK * fog,\n        tint[1] * stripe * sideK * fog,\n        tint[2] * stripe * sideK * fog\n      ];\n      ctx.fillStyle = css(c, 1);\n      ctx.fillRect((i * step) | 0, y0 | 0, Math.ceil(step), lineH | 0);\n    }\n\n    const ms = Math.max(3, (Math.min(w, h) / 90) | 0);\n    const mx = 8, my = 8;\n    for (let y = 0; y < MH; y++) {\n      for (let x = 0; x < MW; x++) {\n        const t = WMAP[y][x];\n        if (t === \".\") ctx.fillStyle = \"rgba(20,22,24,0.7)\";\n        else ctx.fillStyle = css(mix(PAL[t] || rgb, rgb, 0.25), 0.85);\n        ctx.fillRect(mx + x * ms, my + y * ms, ms - 1, ms - 1);\n      }\n    }\n    ctx.fillStyle = css(rgb, 1);\n    ctx.fillRect(mx + px * ms - 1.5, my + py * ms - 1.5, 3, 3);\n    ctx.strokeStyle = css(rgb, 0.9);\n    ctx.beginPath();\n    ctx.moveTo(mx + px * ms, my + py * ms);\n    ctx.lineTo(mx + (px + Math.cos(view) * 1.4) * ms, my + (py + Math.sin(view) * 1.4) * ms);\n    ctx.stroke();\n  }\n\n  function step(dt) {\n    const dirX = Math.cos(ang);\n    const dirY = Math.sin(ang);\n    const planeX = -dirY;\n    const planeY = dirX;\n    const sp = 2.4 * PARAMS.speed * dt;\n    const turn = 1.7 * dt;\n    let mx = 0, my = 0, yaw = 0;\n    if (keys.has(\"KeyW\") || keys.has(\"ArrowUp\")) { mx += dirX; my += dirY; }\n    if (keys.has(\"KeyS\") || keys.has(\"ArrowDown\")) { mx -= dirX; my -= dirY; }\n    if (keys.has(\"KeyD\")) { mx += planeX; my += planeY; }\n    if (keys.has(\"KeyA\")) { mx -= planeX; my -= planeY; }\n    if (keys.has(\"ArrowLeft\")) yaw -= 1;\n    if (keys.has(\"ArrowRight\")) yaw += 1;\n    if (manual) {\n      if (mx || my) {\n        const len = Math.hypot(mx, my) || 1;\n        tryMove((mx / len) * sp, (my / len) * sp);\n      }\n      ang += yaw * turn;\n    } else {\n      const hit = cell(px + dirX * 0.6, py + dirY * 0.6);\n      if (hit !== \".\") autoTurn = 1.2;\n      if (autoTurn > 0) {\n        ang += 1.8 * dt;\n        autoTurn -= dt;\n      } else {\n        tryMove(dirX * sp * 0.55, dirY * sp * 0.55);\n      }\n    }\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (!reduced) step(dt);\n    resize();\n    draw(canvas.width, canvas.height);\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  window.__controlsTest = {\n    getYaw: function () { return ang; },\n    getX: function () { return px; },\n    getY: function () { return py; },\n    setKeys: function (codes) {\n      keys.clear();\n      manual = true;\n      for (let i = 0; i < codes.length; i++) keys.add(codes[i]);\n    }\n  };\n\n  if (reduced) {\n    resize();\n    draw(canvas.width, canvas.height);\n  } else {\n    raf = requestAnimationFrame(frame);\n  }\n})();\n</script>\n</body>\n</html>\n"}