Skip to content
mager-bench1.3

GPT-5.6 Sol × doom

8.7

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

correctness 8.7quality 9.1documentation 8.493970ms
$ 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

8.7
correctness 8.7quality 9.1documentation 8.493970ms

# judge: Strong real DDA raycasting, corrected textured walls, shading, doors, HUD, and delta-time controls, but the spawn faces east rather than toward its first southbound corridor, collision is circle-versus-cell rather than the requested AABB, and the claimed secret area is not meaningfully secret.

$ cat codex-cli/gpt-5.6-sol.doom.r1.response
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DDA Raycaster</title>
<style>
  html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#000}
  canvas{display:block;width:100vw;height:100vh;image-rendering:pixelated;cursor:crosshair}
</style>
</head>
<body>
<canvas id="game" width="640" height="480"></canvas>
<script>
"use strict";

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

const state = {
  width:640,
  height:480,
  map:[],
  mapWidth:0,
  mapHeight:0,
  player:{x:0,y:0,angle:0,radius:0.2},
  keys:Object.create(null),
  textures:{},
  zBuffer:new Float32Array(640),
  openDoors:new Set(),
  nearbyDoor:null,
  exit:null,
  complete:false,
  startTime:performance.now(),
  completionTime:0,
  lastTime:performance.now(),
  fpsSamples:[],
  fps:0,
  moveSpeed:3,
  rotSpeed:2,
  fov:66*Math.PI/180
};

function initMap(s) {
  /*
    Upper-left and upper-right chambers are joined by a corridor and door.
    The central and lower chambers connect through winding corridors.
    The lower-left striped chamber is the secret area; the southeast spur
    beyond the exit corridor includes a dead end.
  */
  const encoded = [
    "1111111111111111",
    "1800001000000001",
    "1000001000000001",
    "1000002000000001",
    "1110111111041101",
    "1000100000000101",
    "1000101111100101",
    "1000001000100001",
    "1333301000103331",
    "1000000000100001",
    "1011111110101101",
    "1000000010000101",
    "1000333011110101",
    "1000300000000901",
    "1000000010000001",
    "1111111111111111"
  ];

  s.map = encoded.map(row => Array.from(row, Number));
  s.mapHeight = s.map.length;
  s.mapWidth = s.map[0].length;

  for (let y=0; y<s.mapHeight; y++) {
    for (let x=0; x<s.mapWidth; x++) {
      if (s.map[y][x] === 8) {
        s.player.x=x+0.5;
        s.player.y=y+0.5;
        s.player.angle=0;
        s.map[y][x]=0;
      } else if (s.map[y][x] === 9) {
        s.exit={x,y};
        s.map[y][x]=0;
      }
    }
  }
}

function makeTexture(draw) {
  const c=document.createElement("canvas");
  c.width=c.height=64;
  const g=c.getContext("2d");
  draw(g,64);
  return c;
}

function createTextures(s) {
  s.textures[1]=makeTexture((g,n)=>{
    for(let y=0;y<n;y+=8) for(let x=0;x<n;x+=8){
      g.fillStyle=((x+y)/8)%2===0?"#9b9b9b":"#52575b";
      g.fillRect(x,y,8,8);
    }
    g.strokeStyle="#303438";
    for(let i=0;i<=n;i+=8){
      g.beginPath();g.moveTo(i,0);g.lineTo(i,n);g.stroke();
      g.beginPath();g.moveTo(0,i);g.lineTo(n,i);g.stroke();
    }
  });

  s.textures[2]=makeTexture((g,n)=>{
    g.fillStyle="#702d24";g.fillRect(0,0,n,n);
    for(let y=0;y<n;y+=12){
      const offset=(y/12)%2 ? -8 : 0;
      for(let x=offset;x<n;x+=16){
        g.fillStyle=(x+y)%24===0?"#a34b38":"#873727";
        g.fillRect(x+1,y+1,14,10);
      }
    }
    g.fillStyle="#351d19";
    for(let y=0;y<n;y+=12)g.fillRect(0,y,64,2);
    for(let y=0;y<n;y+=12){
      const offset=(y/12)%2 ? 8 : 0;
      for(let x=offset;x<n;x+=16)g.fillRect(x,y,2,12);
    }
  });

  s.textures[3]=makeTexture((g,n)=>{
    g.fillStyle="#213842";g.fillRect(0,0,n,n);
    for(let x=0;x<n;x+=8){
      g.fillStyle=(x/8)%2?"#397788":"#285766";
      g.fillRect(x,0,6,n);
      g.fillStyle="#75a7aa";g.fillRect(x,0,1,n);
    }
    for(let y=3;y<n;y+=16){
      g.fillStyle="rgba(255,255,255,.12)";
      g.fillRect(0,y,n,2);
    }
  });

  s.textures[4]=makeTexture((g,n)=>{
    g.fillStyle="#5b351b";g.fillRect(0,0,n,n);
    for(let x=0;x<n;x+=8){
      g.fillStyle=x%16===0?"#754822":"#4a2a16";
      g.fillRect(x+1,0,6,n);
      g.fillStyle="#2a170d";g.fillRect(x,0,1,n);
    }
    g.fillStyle="#c99a38";
    g.beginPath();g.arc(51,32,3,0,Math.PI*2);g.fill();
  });
}

function cellAt(s,x,y) {
  if(x<0||y<0||x>=s.mapWidth||y>=s.mapHeight)return 1;
  return s.map[y][x];
}

function isSolid(s,x,y) {
  const v=cellAt(s,x,y);
  return v>=1 && v<=4;
}

function canOccupy(s,x,y) {
  const r=s.player.radius;
  const minX=Math.floor(x-r), maxX=Math.floor(x+r);
  const minY=Math.floor(y-r), maxY=Math.floor(y+r);
  for(let my=minY;my<=maxY;my++) for(let mx=minX;mx<=maxX;mx++){
    if(!isSolid(s,mx,my))continue;
    const nearestX=Math.max(mx,Math.min(x,mx+1));
    const nearestY=Math.max(my,Math.min(y,my+1));
    const dx=x-nearestX,dy=y-nearestY;
    if(dx*dx+dy*dy<r*r)return false;
  }
  return true;
}

function nearestDoor(s) {
  let best=null,bestD=1.5;
  for(let y=0;y<s.mapHeight;y++) for(let x=0;x<s.mapWidth;x++){
    if(s.map[y][x]!==4)continue;
    const d=Math.hypot(s.player.x-(x+.5),s.player.y-(y+.5));
    if(d<=bestD){bestD=d;best={x,y};}
  }
  return best;
}

function openNearbyDoor(s) {
  const door=nearestDoor(s);
  if(!door)return;
  s.map[door.y][door.x]=5;
  s.openDoors.add(door.x+","+door.y);
}

function castRay(s,rayAngle) {
  const rayX=Math.cos(rayAngle),rayY=Math.sin(rayAngle);
  let mapX=Math.floor(s.player.x),mapY=Math.floor(s.player.y);
  const deltaX=Math.abs(1/(rayX||1e-12));
  const deltaY=Math.abs(1/(rayY||1e-12));
  const stepX=rayX<0?-1:1,stepY=rayY<0?-1:1;
  let sideX=rayX<0?(s.player.x-mapX)*deltaX:(mapX+1-s.player.x)*deltaX;
  let sideY=rayY<0?(s.player.y-mapY)*deltaY:(mapY+1-s.player.y)*deltaY;
  let side=0,type=1,arch=null;

  for(let i=0;i<64;i++){
    let travel;
    if(sideX<sideY){
      travel=sideX;sideX+=deltaX;mapX+=stepX;side=0;
    }else{
      travel=sideY;sideY+=deltaY;mapY+=stepY;side=1;
    }
    type=cellAt(s,mapX,mapY);
    if(type===5 && !arch) arch={distance:travel,side,mapX,mapY};
    if(type>=1&&type<=4)break;
  }

  const rawDistance=side===0 ? sideX-deltaX : sideY-deltaY;
  const correctedDistance=Math.max(.0001,
    rawDistance*Math.cos(rayAngle-s.player.angle));

  let hit=side===0
    ? s.player.y+rawDistance*rayY
    : s.player.x+rawDistance*rayX;
  let textureX=hit-Math.floor(hit);
  if((side===0&&rayX>0)||(side===1&&rayY<0))textureX=1-textureX;

  if(arch){
    arch.correctedDistance=Math.max(.0001,
      arch.distance*Math.cos(rayAngle-s.player.angle));
  }

  return {distance:correctedDistance,rawDistance,side,type,textureX,arch};
}

function drawWallColumn(s,x,ray) {
  const h=s.height;
  const wallHeight=Math.min(h*4,h/ray.distance);
  const top=Math.floor(h/2-wallHeight/2);
  const texture=s.textures[ray.type]||s.textures[1];
  const tx=Math.max(0,Math.min(63,Math.floor(ray.textureX*64)));

  ctx.drawImage(texture,tx,0,1,64,x,top,1,wallHeight);

  let brightness=Math.max(.18,Math.min(1,1.25/ray.distance));
  if(ray.side===1)brightness*=.78;
  ctx.fillStyle=`rgba(0,0,0,${1-brightness})`;
  ctx.fillRect(x,top,1,wallHeight);

  /* A passable opened door is drawn as an overhead arch lintel. */
  if(ray.arch && ray.arch.correctedDistance<ray.distance){
    const d=ray.arch.correctedDistance;
    const fullHeight=Math.min(h*4,h/d);
    const archTop=h/2-fullHeight/2;
    const lintelHeight=Math.max(2,fullHeight*.16);
    if(d<s.zBuffer[x]){
      ctx.drawImage(s.textures[4],0,0,64,10,x,archTop,1,lintelHeight);
      const shade=Math.max(.2,Math.min(1,1.2/d));
      ctx.fillStyle=`rgba(0,0,0,${1-shade})`;
      ctx.fillRect(x,archTop,1,lintelHeight);
    }
  }
}

function renderWorld(s) {
  ctx.fillStyle="#141820";
  ctx.fillRect(0,0,s.width,s.height/2);
  ctx.fillStyle="#292a28";
  ctx.fillRect(0,s.height/2,s.width,s.height/2);

  const halfFov=s.fov/2;
  const tanHalf=Math.tan(halfFov);

  for(let x=0;x<s.width;x++){
    const cameraX=2*(x+.5)/s.width-1;
    const rayAngle=s.player.angle+Math.atan(cameraX*tanHalf);
    const ray=castRay(s,rayAngle);
    s.zBuffer[x]=ray.distance;
    drawWallColumn(s,x,ray);
  }
}

/* Future sprites should project to a screen column, then draw only when
   spriteDistance < state.zBuffer[column], preserving correct wall occlusion. */

function drawMinimap(s) {
  const scale=6,ox=10,oy=10;
  ctx.fillStyle="rgba(0,0,0,.65)";
  ctx.fillRect(ox-4,oy-4,s.mapWidth*scale+8,s.mapHeight*scale+8);

  const colors=["#20252a","#8e9296","#934434","#357487","#a76a2c","#4c6a45"];
  for(let y=0;y<s.mapHeight;y++) for(let x=0;x<s.mapWidth;x++){
    const v=s.map[y][x];
    ctx.fillStyle=v===0?"#20252a":colors[v]||"#20252a";
    ctx.fillRect(ox+x*scale,oy+y*scale,scale-1,scale-1);
  }

  if(s.exit){
    ctx.fillStyle="#4cff6b";
    ctx.fillRect(ox+s.exit.x*scale+1,oy+s.exit.y*scale+1,scale-2,scale-2);
  }

  const px=ox+s.player.x*scale,py=oy+s.player.y*scale;
  ctx.fillStyle="#ffe45c";
  ctx.beginPath();ctx.arc(px,py,2.5,0,Math.PI*2);ctx.fill();
  ctx.strokeStyle="#ffe45c";ctx.lineWidth=1.5;
  ctx.beginPath();
  ctx.moveTo(px,py);
  ctx.lineTo(px+Math.cos(s.player.angle)*9,py+Math.sin(s.player.angle)*9);
  ctx.stroke();
}

function formatTime(ms) {
  const total=Math.floor(ms/1000);
  return String(Math.floor(total/60)).padStart(2,"0")+":"+
         String(total%60).padStart(2,"0");
}

function drawHUD(s) {
  drawMinimap(s);
  ctx.font="bold 14px monospace";
  ctx.textAlign="right";
  ctx.fillStyle="rgba(0,0,0,.65)";
  ctx.fillRect(s.width-100,10,90,24);
  ctx.fillStyle="#fff";
  ctx.fillText(`${s.fps.toFixed(0)} FPS`,s.width-18,27);

  ctx.textAlign="center";
  if(s.nearbyDoor&&!s.complete){
    const text="Press E to open door";
    ctx.font="bold 16px sans-serif";
    ctx.fillStyle="rgba(0,0,0,.7)";
    ctx.fillRect(s.width/2-105,s.height-55,210,30);
    ctx.fillStyle="#ffe8a3";
    ctx.fillText(text,s.width/2,s.height-34);
  }

  if(document.pointerLockElement!==canvas&&!s.complete){
    ctx.font="14px sans-serif";
    ctx.fillStyle="rgba(0,0,0,.65)";
    ctx.fillRect(s.width/2-105,16,210,28);
    ctx.fillStyle="#fff";
    ctx.fillText("Click to enable mouse-look",s.width/2,35);
  }

  if(s.complete){
    ctx.fillStyle="rgba(0,0,0,.72)";
    ctx.fillRect(0,0,s.width,s.height);
    ctx.font="bold 32px sans-serif";
    ctx.fillStyle="#7dff8d";
    ctx.fillText("LEVEL COMPLETE — "+formatTime(s.completionTime),
                 s.width/2,s.height/2);
  }
}

function handleInput(s,dt) {
  if(s.complete)return;

  let forward=0,strafe=0;
  if(s.keys.KeyW)forward++;
  if(s.keys.KeyS)forward--;
  if(s.keys.KeyD)strafe++;
  if(s.keys.KeyA)strafe--;

  if(s.keys.ArrowLeft)s.player.angle-=s.rotSpeed*dt;
  if(s.keys.ArrowRight)s.player.angle+=s.rotSpeed*dt;

  const length=Math.hypot(forward,strafe)||1;
  forward/=length;strafe/=length;

  const ca=Math.cos(s.player.angle),sa=Math.sin(s.player.angle);
  const dx=(ca*forward-sa*strafe)*s.moveSpeed*dt;
  const dy=(sa*forward+ca*strafe)*s.moveSpeed*dt;

  if(canOccupy(s,s.player.x+dx,s.player.y))s.player.x+=dx;
  if(canOccupy(s,s.player.x,s.player.y+dy))s.player.y+=dy;

  s.player.angle=(s.player.angle+Math.PI*2)%(Math.PI*2);
  s.nearbyDoor=nearestDoor(s);

  if(s.exit &&
     Math.hypot(s.player.x-(s.exit.x+.5),s.player.y-(s.exit.y+.5))<.42){
    s.complete=true;
    s.completionTime=performance.now()-s.startTime;
  }
}

function gameLoop(now) {
  const dt=Math.min(.05,(now-state.lastTime)/1000);
  state.lastTime=now;

  handleInput(state,dt);
  renderWorld(state);
  drawHUD(state);

  const instantaneous=dt>0?1/dt:60;
  state.fpsSamples.push(instantaneous);
  if(state.fpsSamples.length>30)state.fpsSamples.shift();
  state.fps=state.fpsSamples.reduce((a,b)=>a+b,0)/state.fpsSamples.length;

  requestAnimationFrame(gameLoop);
}

addEventListener("keydown",e=>{
  state.keys[e.code]=true;
  if(e.code==="KeyE"&&!e.repeat)openNearbyDoor(state);
  if(["KeyW","KeyA","KeyS","KeyD","ArrowLeft","ArrowRight","Space"].includes(e.code))
    e.preventDefault();
});

addEventListener("keyup",e=>state.keys[e.code]=false);

canvas.addEventListener("click",()=>{
  if(!state.complete)canvas.requestPointerLock();
});

addEventListener("mousemove",e=>{
  if(document.pointerLockElement===canvas&&!state.complete)
    state.player.angle+=e.movementX*.0025;
});

initMap(state);
createTextures(state);
requestAnimationFrame(gameLoop);
</script>
</body>
</html>