Skip to content
mager-bench1.3

GPT-6 Astra × doom

9.6

DDA raycaster + textures + door + minimap + Z-buffer — the signature challenge

correctness 9.7quality 9.6documentation 9.4230606ms
$ cat doom.prompt — what the model was asked
Implement a first-person 3D raycasting engine in a single self-contained HTML file with no external libraries, no external images, and no CDN scripts. This is the hardest challenge in the benchmark. Partial credit is given per requirement met.

## Rendering
- DDA (Digital Differential Analysis) raycasting — not a simplified ray-box approximation
- Fish-eye correction applied to all wall distances
- **Procedurally generated wall textures** using canvas math only (no image files, no data URIs): at least 3 distinct texture patterns (e.g. checkerboard, brick, stripe) assigned to different wall types in the map
- Perspective-correct texture mapping onto wall columns
- Distance-based shading: walls darken smoothly as they recede (multiply shade by 1/distance, clamped)
- Ceiling rendered as a flat dark color; floor as a slightly lighter flat color
- Target: 60fps at 640×480 internal resolution scaled to fill the browser window

## Map
- Hard-coded map of at least 16×16 cells encoded as a 2D array
- Non-trivial layout: at least 3 distinct rooms connected by corridors, one dead end, one secret area
- At least 3 wall types (mapped to the 3 texture patterns)
- One door cell (wall type 4) that opens when the player is within 1.5 cells and presses E; opened doors become passable and render as open archways
- One exit cell — reaching it displays a 'LEVEL COMPLETE — [MM:SS]' overlay
- Player spawn position defined in the map; facing toward the first corridor

## Player
- WASD movement with smooth speed (moveSpeed: 3 cells/sec, rotSpeed: 2 rad/sec)
- Mouse-look for horizontal rotation using the Pointer Lock API (click canvas to lock)
- Collision detection: AABB against all solid walls (player radius: 0.2 cells)
- Field of view: 66 degrees

## Z-buffer
- Maintain a per-column Z-buffer (array of wall distances) for correct depth ordering
- Use it to clip any future sprite/overlay rendering to avoid drawing behind walls

## HUD
- Minimap in top-left corner: draw map cells as colored squares, player as a dot with a direction arrow; scale: 6px per cell
- FPS counter (rolling average over last 30 frames) in top-right corner
- 'Press E to open door' hint rendered in the center-bottom when a door is within 1.5 cells

## Code quality
- Single HTML file, all JS inline in <script> tags, all CSS inline in <style>
- requestAnimationFrame game loop with delta-time movement
- Organized into clearly named functions: initMap(), castRay(), drawWallColumn(), drawHUD(), drawMinimap(), gameLoop(), handleInput()
- No god-object — game state in a plain JS object, renderer functions take state as argument

Scoring: the judge scores each major section (rendering, map, player, HUD, code) independently. A beautiful but mathematically wrong perspective loses on correctness. A correct raycaster with unreadable code loses on quality. Both must be right to score above 8.

run 1 of 1

9.6
correctness 9.7quality 9.6documentation 9.4230606ms

# judge: An excellent, feature-complete implementation with correct DDA geometry, perpendicular-distance correction, textured and shaded walls, robust movement and doors, arch-aware sprite occlusion, and strong organization; only minor edge-case rendering and restart-timing polish keep it from a perfect score.

$ cat codex-cli/gpt-6-astra.doom.r1.response
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vault — DDA Raycaster</title>
<style>
  * { box-sizing: border-box; }
  html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #10151d; }
  body { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
  canvas {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: contain;
    image-rendering: pixelated;
    cursor: crosshair;
  }
  #complete {
    position: fixed;
    inset: 0;
    display: none;
    place-content: center;
    text-align: center;
    color: #e2fff1;
    background: #071510c9;
    backdrop-filter: blur(5px);
  }
  #complete.show { display: grid; }
  h1 { font-size: clamp(20px, 4vw, 44px); letter-spacing: .08em; }
  p { color: #b4c9c0; }
  button {
    justify-self: center;
    padding: 12px 24px;
    border: 1px solid #73d7ab;
    background: #163a2a;
    color: #e2fff1;
    font: inherit;
    cursor: pointer;
  }
</style>
</head>
<body>
<canvas id="view" width="640" height="480" aria-label="First-person maze. WASD moves, mouse or arrow keys turn, E opens doors."></canvas>
<div id="complete" role="status">
  <h1 id="completeTitle"></h1>
  <p>The vault has released you.</p>
  <button id="restart">Explore again</button>
</div>
<script>
"use strict";

const WIDTH = 640;
const HEIGHT = 480;
const TEXTURE_SIZE = 64;
const FOV = 66 * Math.PI / 180;
const PLANE_LENGTH = Math.tan(FOV / 2);
const PROJECTION = WIDTH / (2 * PLANE_LENGTH);
const MOVE_SPEED = 3;
const ROT_SPEED = 2;
const PLAYER_RADIUS = 0.2;

const canvas = document.getElementById("view");
const ctx = canvas.getContext("2d", { alpha: false });
ctx.imageSmoothingEnabled = false;

function initMap() {
  // 0: floor, 1–3: walls, 4: door, 5: spawn, 6: exit.
  // Four rooms, connecting corridors, a dead-end branch at (7,8),
  // and a secret chamber behind the disguised door at (4,6).
  const map = [
    [1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2],
    [1,0,0,0,0,0,1,1,1,2,0,0,0,0,0,0,2,2],
    [1,0,0,0,0,0,1,1,1,2,0,0,0,0,0,0,2,2],
    [1,0,0,5,0,0,0,4,0,0,0,0,0,0,0,0,2,2],
    [1,0,0,0,0,0,1,1,1,2,0,0,0,0,0,0,2,2],
    [1,0,0,0,0,0,1,1,1,2,0,0,0,0,0,0,2,2],
    [1,1,1,1,4,1,1,1,1,2,2,2,0,2,2,2,2,2],
    [3,0,0,0,0,3,3,3,3,3,3,2,0,2,2,2,2,2],
    [3,0,0,0,0,3,3,0,0,0,0,0,0,2,2,2,2,2],
    [3,3,3,3,3,3,3,3,3,3,3,2,0,2,2,2,2,2],
    [3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,2,2],
    [3,3,0,0,0,0,0,3,3,3,0,0,0,0,0,0,2,2],
    [3,3,0,0,0,0,0,3,3,3,0,0,0,0,0,0,2,2],
    [3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2],
    [3,3,0,6,0,0,0,3,3,3,0,0,0,0,0,0,2,2],
    [3,3,0,0,0,0,0,3,3,3,0,0,0,0,0,0,2,2],
    [3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2],
    [3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2]
  ];

  let spawn, exit;
  const doors = new Map();
  for (let y = 0; y < map.length; y++) {
    for (let x = 0; x < map[y].length; x++) {
      if (map[y][x] === 5) {
        spawn = { x: x + 0.5, y: y + 0.5, angle: 0 };
        map[y][x] = 0;
      }
      if (map[y][x] === 6) exit = { x: x + 0.5, y: y + 0.5 };
      if (map[y][x] === 4) {
        doors.set(`${x},${y}`, {
          x, y, open: false, secret: x === 4 && y === 6
        });
      }
    }
  }
  return { map, spawn, exit, doors };
}

function createState() {
  const level = initMap();
  return {
    map: level.map,
    doors: level.doors,
    exit: level.exit,
    player: { ...level.spawn },
    keys: new Set(),
    interact: false,
    mouseDelta: 0,
    nearbyDoor: null,
    zBuffer: new Float32Array(WIDTH),
    archOccluders: Array.from({ length: WIDTH }, () => []),
    frameTimes: [],
    fps: 0,
    startedAt: null,
    elapsed: 0,
    complete: false,
    secretFound: false
  };
}

function makeTexture(type) {
  const texture = document.createElement("canvas");
  texture.width = texture.height = TEXTURE_SIZE;
  const context = texture.getContext("2d");
  const pixels = context.createImageData(TEXTURE_SIZE, TEXTURE_SIZE);

  for (let y = 0; y < TEXTURE_SIZE; y++) {
    for (let x = 0; x < TEXTURE_SIZE; x++) {
      let color;
      const grain = ((x * 13 + y * 29 + (x * y) % 17) % 13) - 6;
      if (type === 1) {
        const row = Math.floor(y / 16);
        const mortar = y % 16 < 2 || (x + (row % 2) * 16) % 32 < 2;
        color = mortar ? [52, 45, 43] : [155, 81, 61];
      } else if (type === 2) {
        const alternate = (Math.floor(x / 16) + Math.floor(y / 16)) % 2;
        color = alternate ? [68, 111, 131] : [125, 165, 170];
        if (x % 16 === 0 || y % 16 === 0) color = [41, 65, 75];
      } else if (type === 3) {
        color = x % 16 < 5 ? [119, 99, 146] : [59, 56, 87];
        if (y % 32 < 2) color = [42, 40, 61];
      } else {
        const edge = x < 5 || x > 58 || y < 5 || y > 58;
        const rivet = (x === 8 || x === 55) && (y % 16 < 3);
        color = edge ? [143, 115, 60] : [66, 83, 78];
        if (x % 16 < 2 && !edge) color = [35, 45, 43];
        if (rivet) color = [218, 183, 106];
      }
      const i = (y * TEXTURE_SIZE + x) * 4;
      pixels.data[i] = Math.max(0, color[0] + grain);
      pixels.data[i + 1] = Math.max(0, color[1] + grain);
      pixels.data[i + 2] = Math.max(0, color[2] + grain);
      pixels.data[i + 3] = 255;
    }
  }
  context.putImageData(pixels, 0, 0);
  return texture;
}

function makeExitSprite() {
  const sprite = document.createElement("canvas");
  sprite.width = sprite.height = 64;
  const c = sprite.getContext("2d");
  const glow = c.createRadialGradient(32, 32, 2, 32, 32, 30);
  glow.addColorStop(0, "#baffdaee");
  glow.addColorStop(0.35, "#51efa477");
  glow.addColorStop(1, "#51efa400");
  c.fillStyle = glow;
  c.fillRect(0, 0, 64, 64);
  c.fillStyle = "#b9ffda";
  c.beginPath();
  c.moveTo(32, 13);
  c.lineTo(44, 32);
  c.lineTo(32, 51);
  c.lineTo(20, 32);
  c.closePath();
  c.fill();
  c.fillStyle = "#279c70";
  c.fillRect(29, 24, 6, 16);
  return sprite;
}

const textures = [null, ...[1, 2, 3, 4].map(makeTexture)];
const exitSprite = makeExitSprite();
let state = createState();

function isSolid(s, x, y) {
  const tile = s.map[y]?.[x];
  if (tile === undefined) return true;
  if (tile === 4) return !s.doors.get(`${x},${y}`).open;
  return tile >= 1 && tile <= 3;
}

function canOccupy(s, x, y) {
  // AABB overlaps every grid cell covered by the player's radius.
  const minX = Math.floor(x - PLAYER_RADIUS);
  const maxX = Math.floor(x + PLAYER_RADIUS);
  const minY = Math.floor(y - PLAYER_RADIUS);
  const maxY = Math.floor(y + PLAYER_RADIUS);
  for (let cy = minY; cy <= maxY; cy++) {
    for (let cx = minX; cx <= maxX; cx++) {
      if (isSolid(s, cx, cy)) return false;
    }
  }
  return true;
}

function findNearbyDoor(s) {
  let nearest = null;
  let best = 1.5;
  for (const door of s.doors.values()) {
    const distance = Math.hypot(
      s.player.x - door.x - 0.5,
      s.player.y - door.y - 0.5
    );
    if (!door.open && distance <= best) {
      best = distance;
      nearest = door;
    }
  }
  return nearest;
}

function handleInput(s, dt, now) {
  if (s.complete) return;
  const keys = s.keys;
  const forward = Number(keys.has("KeyW")) - Number(keys.has("KeyS"));
  const strafe = Number(keys.has("KeyD")) - Number(keys.has("KeyA"));
  const rotation = Number(keys.has("ArrowRight")) - Number(keys.has("ArrowLeft"));

  if (s.startedAt === null &&
      (forward || strafe || rotation || s.mouseDelta || s.interact)) {
    s.startedAt = now;
  }
  s.player.angle += rotation * ROT_SPEED * dt + s.mouseDelta * 0.0024;
  s.player.angle = (s.player.angle % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2);
  s.mouseDelta = 0;

  const length = Math.hypot(forward, strafe) || 1;
  const cos = Math.cos(s.player.angle);
  const sin = Math.sin(s.player.angle);
  const dx = (cos * forward - sin * strafe) / length * MOVE_SPEED * dt;
  const dy = (sin * forward + cos * strafe) / length * MOVE_SPEED * dt;

  // Small steps prevent tunneling; separate axes allow wall sliding.
  const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / 0.05));
  for (let i = 0; i < steps; i++) {
    if (canOccupy(s, s.player.x + dx / steps, s.player.y)) s.player.x += dx / steps;
    if (canOccupy(s, s.player.x, s.player.y + dy / steps)) s.player.y += dy / steps;
  }

  s.nearbyDoor = findNearbyDoor(s);
  if (s.interact && s.nearbyDoor) {
    s.nearbyDoor.open = true;
    if (s.nearbyDoor.secret) s.secretFound = true;
    s.nearbyDoor = findNearbyDoor(s);
  }
  s.interact = false;
  s.elapsed = s.startedAt === null ? 0 : (now - s.startedAt) / 1000;

  if (s.map[Math.floor(s.player.y)][Math.floor(s.player.x)] === 6) {
    s.complete = true;
    s.keys.clear();
    document.getElementById("completeTitle").textContent =
      `LEVEL COMPLETE — ${formatTime(s.elapsed)}`;
    document.getElementById("complete").classList.add("show");
    if (document.pointerLockElement === canvas) document.exitPointerLock();
  }
}

function castRay(s, camera, cameraX) {
  const rayX = camera.dirX + camera.planeX * cameraX;
  const rayY = camera.dirY + camera.planeY * cameraX;
  let cellX = Math.floor(s.player.x);
  let cellY = Math.floor(s.player.y);
  const deltaX = rayX === 0 ? Infinity : Math.abs(1 / rayX);
  const deltaY = rayY === 0 ? Infinity : Math.abs(1 / rayY);
  const stepX = rayX < 0 ? -1 : 1;
  const stepY = rayY < 0 ? -1 : 1;
  let sideX = rayX < 0
    ? (s.player.x - cellX) * deltaX
    : (cellX + 1 - s.player.x) * deltaX;
  let sideY = rayY < 0
    ? (s.player.y - cellY) * deltaY
    : (cellY + 1 - s.player.y) * deltaY;

  const arches = [];
  const rayLength = Math.hypot(rayX, rayY);
  const cosCorrection = (rayX * camera.dirX + rayY * camera.dirY) / rayLength;

  for (;;) {
    let side, parameter;
    // DDA: advance to the next exact grid boundary.
    if (sideX < sideY) {
      parameter = sideX;
      sideX += deltaX;
      cellX += stepX;
      side = 0;
    } else {
      parameter = sideY;
      sideY += deltaY;
      cellY += stepY;
      side = 1;
    }

    const tile = s.map[cellY]?.[cellX];
    if (tile === 0 || tile === 6) continue;

    // Euclidean ray length times cos(ray angle - view angle).
    // With camera-plane rays this equals the DDA parameter.
    const distance = Math.max(0.0001, parameter * rayLength * cosCorrection);
    const coordinate = side === 0
      ? s.player.y + parameter * rayY
      : s.player.x + parameter * rayX;
    let u = coordinate - Math.floor(coordinate);
    if ((side === 0 && rayX > 0) || (side === 1 && rayY < 0)) u = 1 - u;

    const door = tile === 4 ? s.doors.get(`${cellX},${cellY}`) : null;
    const hit = {
      distance, u, side,
      texture: door?.secret ? 1 : (tile || 1),
      lower: 0,
      upper: 1
    };
    if (door?.open) {
      // An open arch has a curved lintel, with empty space underneath.
      hit.lower = 0.72 + 0.16 * Math.sqrt(Math.max(0, 1 - (u * 2 - 1) ** 2));
      arches.push(hit);
      continue; // Continue DDA through the opening to the wall beyond it.
    }
    return { wall: hit, arches };
  }
}

function drawWallColumn(s, x, hit, isArch = false) {
  const projectedHeight = PROJECTION / hit.distance;
  const fullTop = HEIGHT / 2 - projectedHeight / 2;
  const top = Math.max(0, Math.ceil(fullTop + (1 - hit.upper) * projectedHeight));
  const bottom = Math.min(HEIGHT, Math.ceil(fullTop + (1 - hit.lower) * projectedHeight));
  if (bottom <= top) return;

  const textureX = Math.min(TEXTURE_SIZE - 1, Math.floor(hit.u * TEXTURE_SIZE));
  // Source coordinates derive from the original, unclipped projection.
  // This prevents stretched textures when a wall extends off screen.
  const sourceY = (top - fullTop) / projectedHeight * TEXTURE_SIZE;
  const sourceHeight = (bottom - top) / projectedHeight * TEXTURE_SIZE;
  ctx.drawImage(
    textures[hit.texture],
    textureX, sourceY, 1, sourceHeight,
    x, top, 1, bottom - top
  );

  const distanceShade = Math.min(1, Math.max(0.16, 2.8 / hit.distance));
  const shade = distanceShade * (hit.side === 1 ? 0.78 : 1);
  ctx.fillStyle = `rgba(0,0,0,${1 - shade})`;
  ctx.fillRect(x, top, 1, bottom - top);

  if (isArch) {
    s.archOccluders[x].push({ top, bottom, distance: hit.distance });
  }
}

function drawSpriteColumn(s, x, depth, top, bottom, sourceX) {
  if (depth >= s.zBuffer[x]) return;
  let visible = [[Math.max(0, top), Math.min(HEIGHT, bottom)]];
  // Arches only occlude their lintel pixels, not the opening underneath.
  for (const occluder of s.archOccluders[x]) {
    if (depth <= occluder.distance) continue;
    const next = [];
    for (const [a, b] of visible) {
      if (occluder.bottom <= a || occluder.top >= b) {
        next.push([a, b]);
      } else {
        if (a < occluder.top) next.push([a, occluder.top]);
        if (b > occluder.bottom) next.push([occluder.bottom, b]);
      }
    }
    visible = next;
  }
  for (const [a, b] of visible) {
    if (b <= a) continue;
    ctx.drawImage(
      exitSprite,
      sourceX, (a - top) / (bottom - top) * 64,
      1, (b - a) / (bottom - top) * 64,
      x, a, 1, b - a
    );
  }
}

function drawExitBeacon(s, camera) {
  const dx = s.exit.x - s.player.x;
  const dy = s.exit.y - s.player.y;
  const depth = dx * camera.dirX + dy * camera.dirY;
  if (depth <= 0.08) return;
  const lateral = -dx * camera.dirY + dy * camera.dirX;
  const centerX = WIDTH / 2 + lateral * PROJECTION / depth;
  const size = 0.55 * PROJECTION / depth;
  const left = centerX - size / 2;
  const top = HEIGHT / 2 - size / 2;
  const start = Math.max(0, Math.ceil(left));
  const end = Math.min(WIDTH, Math.ceil(left + size));
  for (let x = start; x < end; x++) {
    const sourceX = Math.max(0, Math.min(63, Math.floor((x - left) / size * 64)));
    drawSpriteColumn(s, x, depth, top, top + size, sourceX);
  }
}

function renderScene(s) {
  ctx.fillStyle = "#141923";
  ctx.fillRect(0, 0, WIDTH, HEIGHT / 2);
  ctx.fillStyle = "#292b32";
  ctx.fillRect(0, HEIGHT / 2, WIDTH, HEIGHT / 2);

  const dirX = Math.cos(s.player.angle);
  const dirY = Math.sin(s.player.angle);
  const camera = {
    dirX, dirY,
    planeX: -dirY * PLANE_LENGTH,
    planeY: dirX * PLANE_LENGTH
  };

  for (let x = 0; x < WIDTH; x++) {
    s.archOccluders[x].length = 0;
    const result = castRay(s, camera, 2 * (x + 0.5) / WIDTH - 1);
    s.zBuffer[x] = result.wall.distance;
    drawWallColumn(s, x, result.wall);
    for (let i = result.arches.length - 1; i >= 0; i--) {
      drawWallColumn(s, x, result.arches[i], true);
    }
  }
  drawExitBeacon(s, camera);
}

function drawMinimap(s) {
  const scale = 6, originX = 14, originY = 14;
  ctx.fillStyle = "#080d16dc";
  ctx.fillRect(originX - 5, originY - 5, 118, 118);
  const colors = ["#1c2931", "#955943", "#527f93", "#76628a", "#e0b66b", "", "#6fffb4"];
  for (let y = 0; y < s.map.length; y++) {
    for (let x = 0; x < s.map[y].length; x++) {
      const tile = s.map[y][x];
      const door = tile === 4 ? s.doors.get(`${x},${y}`) : null;
      ctx.fillStyle = door?.open ? "#3c6c59"
        : door?.secret && !s.secretFound ? colors[1] : colors[tile];
      ctx.fillRect(originX + x * scale, originY + y * scale, scale, scale);
    }
  }
  const px = originX + s.player.x * scale;
  const py = originY + s.player.y * scale;
  ctx.save();
  ctx.translate(px, py);
  ctx.rotate(s.player.angle);
  ctx.strokeStyle = "#fff4d6";
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(10, 0);
  ctx.lineTo(6, -3);
  ctx.moveTo(10, 0);
  ctx.lineTo(6, 3);
  ctx.stroke();
  ctx.fillStyle = "#fff4d6";
  ctx.beginPath();
  ctx.arc(0, 0, 2.2, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

function formatTime(seconds) {
  const minutes = Math.floor(seconds / 60).toString().padStart(2, "0");
  const remainder = Math.floor(seconds % 60).toString().padStart(2, "0");
  return `${minutes}:${remainder}`;
}

function drawHUD(s) {
  drawMinimap(s);
  ctx.font = "12px monospace";
  ctx.textAlign = "right";
  ctx.fillStyle = "#0a111bd9";
  ctx.fillRect(WIDTH - 117, 9, 108, 44);
  ctx.fillStyle = "#d7e7e7";
  ctx.fillText(`${Math.round(s.fps)} FPS`, WIDTH - 18, 27);
  ctx.fillStyle = "#90b0ba";
  ctx.fillText(formatTime(s.elapsed), WIDTH - 18, 44);

  ctx.textAlign = "center";
  ctx.fillStyle = "#e4e9db99";
  ctx.fillRect(WIDTH / 2 - 3, HEIGHT / 2, 7, 1);
  ctx.fillRect(WIDTH / 2, HEIGHT / 2 - 3, 1, 7);

  if (s.nearbyDoor && !s.complete) {
    ctx.fillStyle = "#080d16dc";
    ctx.fillRect(211, HEIGHT - 76, 218, 30);
    ctx.fillStyle = "#ffe1a6";
    ctx.font = "14px monospace";
    ctx.fillText("Press E to open door", WIDTH / 2, HEIGHT - 56);
  }

  ctx.fillStyle = "#080d16cc";
  ctx.fillRect(0, HEIGHT - 30, WIDTH, 30);
  ctx.font = "11px monospace";
  ctx.fillStyle = "#b7c8d0";
  const locked = document.pointerLockElement === canvas;
  ctx.fillText(
    locked
      ? "WASD move  ·  Mouse / ← → turn  ·  E open  ·  Find the green exit"
      : "Click to capture mouse  ·  WASD move  ·  ← → turn  ·  E open",
    WIDTH / 2, HEIGHT - 11
  );
  if (s.secretFound) {
    ctx.textAlign = "left";
    ctx.fillStyle = "#d7bbff";
    ctx.fillText("SECRET REVEALED", 14, 139);
  }
}

const controlKeys = new Set([
  "KeyW", "KeyA", "KeyS", "KeyD", "KeyE", "ArrowLeft", "ArrowRight"
]);

window.addEventListener("keydown", event => {
  if (!controlKeys.has(event.code)) return;
  event.preventDefault();
  state.keys.add(event.code);
  if (event.code === "KeyE" && !event.repeat) state.interact = true;
});
window.addEventListener("keyup", event => state.keys.delete(event.code));
window.addEventListener("blur", () => {
  state.keys.clear();
  state.mouseDelta = 0;
  state.interact = false;
});
document.addEventListener("pointerlockchange", () => {
  state.keys.clear();
  state.mouseDelta = 0;
});
document.addEventListener("mousemove", event => {
  if (document.pointerLockElement === canvas && !state.complete) {
    state.mouseDelta += event.movementX;
  }
});
canvas.addEventListener("click", () => {
  if (state.complete) return;
  if (state.startedAt === null) state.startedAt = performance.now();
  try {
    const request = canvas.requestPointerLock();
    if (request?.catch) request.catch(() => {});
  } catch (_) {
    // Arrow-key rotation remains available if pointer lock is unavailable.
  }
});
document.getElementById("restart").addEventListener("click", () => {
  state = createState();
  document.getElementById("complete").classList.remove("show");
});

let previousTime = null;
function gameLoop(now) {
  const frameSeconds = previousTime === null ? 1 / 60 : (now - previousTime) / 1000;
  previousTime = now;
  if (frameSeconds > 0) {
    state.frameTimes.push(frameSeconds);
    if (state.frameTimes.length > 30) state.frameTimes.shift();
    state.fps = state.frameTimes.length /
      state.frameTimes.reduce((total, duration) => total + duration, 0);
  }
  // Clamp simulation after tab switches; completion time uses the real clock.
  handleInput(state, Math.min(frameSeconds, 0.05), now);
  renderScene(state);
  drawHUD(state);
  requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
```