GPT-6 Astra × doom
9.6DDA 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.6correctness 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>
```