Wolfenstein raycaster · qwen3.8-2.4t-a95b
Build a first-person raycasting maze shooter on a canvas in the Wolfenstein 3D style: WASD movement and arrow or mouse turning through a textured grid maze with enemies you can shoot. Procedural textures only, no external assets.
transcript · 12 messages
Build a first-person raycasting maze shooter on a canvas in the Wolfenstein 3D style: WASD movement and arrow or mouse turning through a textured grid maze with enemies you can shoot. Procedural textures only, no external assets.
thinking
Let's make a raycasting maze shooter. Single index.html, procedural textures, enemies, shooting, WASD + mouse/arrow keys, minimap, HUD. I'll write something solid and compact but complete.
Key elements:
- Procedurally generated maze grid (recursive backtracker), then randomly punch holes to open it up.
- Textures: procedurally generated on offscreen canvas (brick, stone, maybe door too... keep it simple: 2 wall textures).
- Raycaster: DDA algorithm, draw walls per screen column using texture slices.
- Enemies: sprite rendering (procedurally drawn demon-like blobs), billboarded, sorted by distance, z-buffer. Simple AI: chase player when visible, shoot/damage on contact.
- Shooting: hitscan with muzzle flash, gun sprite (procedural canvas).
- HUD: health, ammo, score, minimap, crosshair, damage flash.
- Mouse look via pointer lock, arrow keys for turning, WASD for movement, space/click to shoot.
- Win condition: kill all enemies or find the exit? Let's do "kill all enemies to win", plus respawn/restart.
Let's write the code carefully.
Textures: 64x64 offscreen canvas: brick (red brick pattern), stone (gray blocks), maybe wood for door. Enemy sprites: drawn on canvas — draw a demon with simple shapes at each animation frame? Keep it simple: 1 sprite + maybe a hit flash. Also draw corpses.
Gun: bottom center, drawn as shapes on canvas, with recoil animation and muzzle flash.
Map: 24x24 maze via recursive backtracker on odd cells, then randomly punch about 15% of the walls. Wall type 1 or 2 based on position.
Enemy AI: states idle/chase/attack/pain/dead. Move toward player with simple collision detection, attack when close or when has line of sight and cooldown, deal damage. Enemy fire could be hitscan with a chance. Player health 100.
Shooting: hitscan ray, check enemies within an angular threshold and nearest by distance, also wall occlusion check.
Floor/ceiling: solid gradient fill (fast). Could do floor casting but gradient is fine.
Sound: optional WebAudio beeps — procedural, no assets. Add simple oscillator sounds for shoot/hit. Small.
Let's write it. Keep code organized.
Rendering: canvas 960x540 or responsive. Column resolution: render each pixel column via ImageData? Simpler: drawImage per column from texture canvas — 1px wide slices. That works fine.
Texture slice drawing: ctx.drawImage(tex, texX, 0, 1, 64, x, drawStart, 1, lineHeight). Darken based on side/fog: after drawing, fillRect a black rect with alpha over the column for shading. Good.
Sprite rendering: draw scaled enemy canvas with drawImage, clipped by zbuffer column by column? Standard approach: draw per column with zbuffer check... drawImage per column for sprites also works but is expensive. Simpler: check zbuffer at sprite center column, skip fully hidden ones; or draw per column. Let's do a drawImage slice per column of the sprite, same as walls — with a modest number of enemies, it's fine.
Enemy sprite canvas: 64x64, draw demon: body, eyes, horns. Variants: normal, pain (red tint), attack frame, corpse.
Pointer lock: click canvas to lock; mousemove for rotation. Click also shoots. Space also shoots.
Collision: circle vs grid, sliding along walls.
Minimap top-left, walls + player + visible enemies.
HUD: health bar, ammo count, kill count, message overlay (title screen, death, win).
Let's write the file. Probably about 600 lines. Here we go.
Details:
Map generation:
```js
const MW=23, MH=23;
function genMap(){
const g = Array.from({length:MH},()=>Array(MW).fill(1));
// carve
const stack=[[1,1]]; g[1][1]=0;
const dirs=[[2,0],[-2,0],[0,2],[0,-2]];
while(stack.length){
const [cx,cy]=stack[stack.length-1];
const opts=dirs.filter(([dx,dy])=>{const nx=cx+dx,ny=cy+dy;return nx>0&&nx<MW-1&&ny>0&&ny<MH-1&&g[ny][nx]===1;});
if(!opts.length){stack.pop();continue;}
const [dx,dy]=opts[Math.random()*opts.length|0];
g[cy+dy/2][cx+dx/2]=0; g[cy+dy][cx+dx]=0; stack.push([cx+dx,cy+dy]);
}
// punch extra openings
for(let y=1;y<MH-1;y++)for(let x=1;x<MW-1;x++)
if(g[y][x]===1 && Math.random()<0.12 && ((g[y-1][x]===0&&g[y+1][x]===0)||(g[y][x-1]===0&&g[y][x+1]===0))) g[y][x]=0;
// wall types: outer walls type 2, inner vary
...
}
```
Assign wall texture: pick type per cell: g[y][x] = 1 or 2 (random but deterministic-ish: based on (x*7+y*13)%3). Borders stay 1.
Player start at (1.5,1.5). Enemies placed on random empty cells far from start.
Textures:
- brickTex: rows of bricks, staggered, mortar lines, noise.
- stoneTex: big blocks.
- doorTex maybe skip.
Enemy AI tick:
- Distance to player; LOS check via ray march through grid.
- If LOS and dist<8: chase state; move toward player at velocity, avoid walls with collision.
- If dist<5 and LOS and cooldown ready: attack (hitscan with random damage), flash.
- Pain on hit: brief stagger.
- Death: switch to corpse sprite, add to floor corpses (render as sprite with lowered vMove? Just draw the corpse sprite small/low).
Player shooting: ammo, fire rate limit, spread. Hitscan: find enemy whose screen center is close to crosshair: compute angle diff, dist; require |angleDiff| < atan(0.4/dist) approximately, and no wall closer. Damage 34-50.
Audio: WebAudio simple functions: shoot (noise burst + square wave), hit, damage, enemy growl.
Gun render: draw at bottom center of canvas with recoil offset; muzzle flash triangle when firing.
OK let's write it all.
Player damage flash: red overlay alpha decay.
Enemy attack: projectile-like? Hitscan is simpler: if LOS and dist<6 with 0.6 chance: 5-15 damage. Add flash on enemy sprite.
Win: all enemies dead → "YOU CLEARED THE MAZE" overlay, R to restart. Death similar.
Movement: W/S forward/back, A/D strafe, left/right arrows turn, Q/E turn too? Mouse turn. Collision radius 0.25.
Sprite rendering with vertical offset for corpses: draw corpse with vOffset that shifts down. Use transform: proj screenY center = H/2 (+ pitch none). For corpse, draw at floor level: sprite bottom should be at wall bottom. Standard: spriteScreenY centered; add vOffset = spriteHeight factor. For simplicity, enemy sprite is full tile; corpse drawn with half height anchored at bottom. Compute drawStartY for full sprite; for corpse, scale height to 40% and align bottom to normal sprite's bottom.
Let me compute sprite drawing:
```
const relX = ex-px, relY=ey-py;
const invDet = 1/(planeX*dirY - dirX*planeY);
const transX = invDet*(dirY*relX - dirX*relY);
const transY = invDet*(-planeY*relX + planeX*relY); // depth
const screenX = (W/2)*(1+transX/transY);
const size = Math.abs(H/transY) * scale;
```
For full-height sprite with scale ~0.9. Center at H/2 + size*(0.5-?)... walls span from H/2 - lineHeight/2 to +... for a sprite with scale 1 standing on floor: bottom = H/2 + (H/(2*transY)). So drawStartY = H/2 + H/(2*transY) - size, with size = scale*H/transY. Good, anchored at floor.
Per-column zbuffer check: for stripe in [drawStartX, drawEndX], if transY < zbuf[stripe] then draw the column.
Muzzle light: when firing, brighten walls briefly? Skip.
Ammo: start 30, pickups? Add ammo packs as sprites on map (+health packs too). Nice touch: procedurally drawn pickups, collected on proximity. Let's do it: clip of ammo (box) and medkit (cross). Small effort: draw small sprite canvas, render like enemy with scale 0.4.
OK, code:
Let's just write it all in one go, careful with correctness.
Key constants: FOV plane 0.66.
zbuffer array sized W (render width). Use fixed internal resolution, e.g. canvas width 960, height 540, CSS scale.
Draw order: floor/ceiling gradient, walls, sprites sorted by descending distance, gun, HUD, minimap, overlays.
Line-of-sight function:
```js
function hasLOS(x0,y0,x1,y1){
const dx=x1-x0, dy=y1-y0, dist=Math.hypot(dx,dy);
const steps=Math.ceil(dist*4);
for(let i=1;i<steps;i++){
const t=i/steps, x=x0+dx*t, y=y0+dy*t;
if(grid[y|0][x|0]>0) return false;
}
return true;
}
```
Player shooting hit test:
```js
function fireShot(){
...
let best=null;
for(const e of enemies){
if(e.dead) continue;
const dx=e.x-px, dy=e.y-py, d=Math.hypot(dx,dy);
let ang=Math.atan2(dy,dx)-pAngle; normalize to [-PI,PI];
const halfWidth = Math.atan2(0.35, d);
if(Math.abs(ang)<halfWidth){
if(!best || d<best.d){ if(hasLOS(px,py,e.x,e.y)) best={e,d}; }
}
}
...
}
```
Enemy movement: move toward player, plus a bit of separation between enemies. Collision: try x, then y (like player).
Enemy count ~8.
Gun canvas: draw pistol shape 128x128: gray slide, grip, etc. Muzzle flash drawn separately.
Sounds via AudioContext lazily created on first input.
```js
function sfx(type){...oscillators...}
```
Now let's write the final file. Also handle R to restart, Enter/click to start from title.
Let's draft the full HTML.
Enemy sprite drawing function:
```js
function makeEnemySprite(variant){ // 0 normal,1 pain,2 attack,3 corpse
const c=document.createElement('canvas'); c.width=64;c.height=64;
const g=c.getContext('2d');
if(variant===3){ // corpse: low blood pool and body
g.fillStyle='#5a0d0d'; g.beginPath(); g.ellipse(32,56,22,6,0,0,7); g.fill();
g.fillStyle='#7a2'; ... hmm corpse color
}
}
```
Demon design: dark red/brown body: torso ellipse, head circle with yellow eyes, horns (triangles), arms, legs. Let's draw:
- Legs: 2 rects at bottom
- Body: ellipse center (32,40) rx 12 ry 14
- Head: circle (32,20) r 9
- Horns: triangles
- Eyes: glowing yellow (attack variant orange with mouth open)
- Arms: lines/rects to sides
Pain variant: draw same but overlay red with globalCompositeOperation... simpler: draw same then fillRect with translucent white/red on top with 'source-atop'. Let's do that: draw same then g.globalCompositeOperation='source-atop'; fill rgba(255,80,80,0.5).
Attack variant: eyes bigger / mouth open, tint slightly orange.
OK. Pickup sprites: 32x32. Medkit: white box with red cross. Ammo: olive box with bullets.
Minimap: 4px per cell scale.
Write it all. Watch the length — no problem.
Movement speed 3.2 units/sec, turn speed 2.6 rad/sec. dt clamped.
Firing: hold fire? Semi-auto with cooldown 0.28s, allow hold-fire on mouse. Space too.
Enemy damage to player: red flash and screen shake slight.
Let's write.
Enemy pain duration 0.25s, enemy attack cooldown 1.2s, enemy attack needs dist<7 and LOS, chance 0.7, damage 4+rand*8. Enemy speed 1.3. Enemy HP 60, player damage per shot 25-40.
Enemy growl occasionally when chasing — skip or simple.
Now the code. Write carefully.
One more: texture wall shading: after drawImage of slice, ctx.fillStyle=`rgba(0,0,0,${shade})`, fillRect(x, drawStart, 1, h). Shade based on side (0.25 added) and distance fog: min(0.85, dist*0.06).
Ceiling: dark gray gradient; floor: brown gradient.
Head bob: small bob offset on movement affecting horizon — optional; add slight bob to gun and horizon. Keep horizon fixed but gun bobs.
Restart: regen map, enemies, player, pickups.
Title screen: "MAZE OF THE DAMNED — Click to play. WASD move, Mouse/Arrows turn, Click/Space shoot." Overlay drawn on canvas.
Pointer lock: on canvas click during play, requestPointerLock. If lock fails (sandboxed iframe might block), arrows still work. Also allow turn while mousedown drag without lock? Add fallback: if no pointer lock, use mouse movement with buttons pressed. Good.
Let's finalize numbers and write.
drawSprites: combine enemies (with state) and pickups (with taken flag) into a list with dist, sort desc.
Corpse: keep in enemies list, dead flag, sprite index 3.
Enemy state: 'idle' until LOS within 8 or hurt; then 'chase'; when attacking show attack frame for 0.2s.
Now let me write the file.
Rough structure:
```html
<!DOCTYPE html><html><head><meta charset="utf-8"><title>...</title>
<style>...</style></head>
<body><div id="wrap"><canvas id="game" width="960" height="540"></canvas></div>
<script> ... </script></body></html>
```
Writing JS:
- Canvas setup
- RNG simple Math.random is fine
- makeTexture functions
- genLevel(): grid, pickups, enemies
- Input handling
- update(dt)
- render()
- loop
OK let me write the complete code in the write call. Be careful about grid access: grid[y][x], guard boundaries.
Collision move:
```js
function moveEntity(e,dx,dy,r){
if(!collides(e.x+dx,e.y,r)) e.x+=dx;
if(!collides(e.x,e.y+dy,r)) e.y+=dy;
}
function collides(x,y,r){
for(let gy=(y-r)|0; gy<=(y+r)|0; gy++)
for(let gx=(x-r)|0; gx<=(x+r)|0; gx++){
if(gy<0||gx<0||gy>=MH||gx>=MW) return true;
if(grid[gy][gx]>0) return true; // circle vs cell approx ok
}
return false;
}
```
Hmm (y-r)|0 floors negative wrongly? y-r positive inside map anyway. OK.
DDA raycast (Lodev style):
```js
function castRay(px,py,rdx,rdy){
let mapX=px|0, mapY=py|0;
const ddx=Math.abs(1/rdx), ddy=Math.abs(1/rdy);
let stepX,stepY,sdx,sdy;
... standard
let side=0, tile=1;
for(let i=0;i<64;i++){
if(sdx<sdy){sdx+=ddx;mapX+=stepX;side=0;}else{sdy+=ddy;mapY+=stepY;side=1;}
if(mapY<0||mapX<0||mapY>=MH||mapX>=MW){tile=1;break;}
tile=grid[mapY][mapX];
if(tile>0) break;
}
const dist = side===0 ? sdx-ddx : sdy-ddy;
let wallX = side===0 ? py+dist*rdy : px+dist*rdx;
wallX-=Math.floor(wallX);
return {dist,side,tile,wallX};
}
```
Per column x: cameraX=2*x/W-1; rdx=dirX+planeX*cameraX; etc.
Texture selection: tex = tile===1? brickTex : stoneTex. texX = wallX*64|0, flipped by side/step sign — skip flipping.
Wall draw: lineH = H/dist (maybe correct fisheye: since we use camera plane, no cos correction needed, perpendicular distance already computed — right, dist is perpendicular). drawStart = (H-lineH)/2.
OK, writing now. Gun drawn via drawImage of gunCanvas.
Audio:
```js
let AC=null;
function ac(){ if(!AC) AC=new (window.AudioContext||window.webkitAudioContext)(); return AC; }
function sound(shoot|hit|hurt|pickup|kill)
```
Simple synth:
- Shoot: noise buffer 0.15s + osc square down-sweep.
- Enemy hit: short square 200Hz.
- Player hurt: saw 110Hz 0.3s.
- Pickup: sine up-beep.
- Kill: low noise.
Implement noise with buffer.
Time to write. Also add slight screen shake on hurt: translate canvas randomly within a few px when shakeT>0.
HUD: bottom bar with HEALTH, AMMO, KILLS. Crosshair in center.
Write the file.
{"command":"ls /tmp/ws"}(no output)
thinking
(Thinking) Empty directory. I'll write index.html.
/tmp/ws/index.html (24852 bytes)Successfully wrote 24852 bytes to /tmp/ws/index.html
thinking
Let me verify with a simple sanity check — ideally a headless syntax check. Can I parse it with node? Extract the script and run node --check on it.
Let me sanity-check the JavaScript for syntax errors:
{"command":"cd /tmp/ws && python3 -c \"\nimport re\nhtml = open('index.html').read()\njs = re.search(r'<script>(.*?)</script>', html, re.S)./bin/bash: line 1: python3: command not found Command exited with code 127
thinking
python3 isn't found. Either use node directly, or use sed to extract it.
{"command":"cd /tmp/ws && node -e \"\nconst fs=require('fs');\nconst html=fs.readFileSync('index.html','utf8');\nconst js=html.match(/<scripJS SYNTAX OK -rw-r--r--. 1 node node 24872 Aug 13 01:38 index.html
thinking
Should I also try a smoke test for the runtime headless? No, DOM isn't available in node. I can stub minimal DOM/canvas and run update/render for a few frames to catch runtime errors. It's worth doing a simple smoke test with a stub canvas that has a no-op 2d context.
Syntax is clean. Let me run a headless smoke test with a stubbed canvas to catch runtime errors in the game loop:
{"command":"cd /tmp/ws && node -e \"\nconst fs=require('fs');\nconst js=fs.readFileSync('/tmp/check.js','utf8');\n// minimal DOM/canvas stub--- SMOKE TEST OK: 300 frames rendered without error
thinking
The first eval silently failed because the handler wasn't captured (head -20 showed nothing — probably nothing was actually output). On the second pass, ran 300 frames including start, movement, rotation, and firing — no errors. Good. Done. Let me wrap up.
The game runs 300 simulated frames (start → movement → turning → firing) with zero errors. **`/tmp/ws/index.html`** is complete and fully self-contained — no external assets, CDNs, or network requests. ## CORRIDORS OF THE DAMNED — feature rundown **Rendering (Wolfenstein-style DDA raycaster)** - Per-column DDA raycasting against a 25×25 grid, with distance fog, side shading, and a z-buffer - Two **procedural wall textures** generated on offscreen canvases: red brick (staggered courses, mortar, noise) and mossy stone blocks (cracks, highlights) - Gradient sky/floor, damage flash, screen shake **Maze** - Recursive-backtracker maze generation, then extra walls knocked out to create loops/rooms - Fresh maze every run (seeded RNG), wall texture variants per cell **Enemies** - 8 horned demons with hand-drawn procedural sprites: normal / attack / pain / corpse variants - AI states: idle → alert (with growl) → chase with line-of-sight checks, wall sliding, pack separation, ranged attacks and melee on contact - Billboarded sprite rendering, depth-sorted, z-buffer clipped, anchored to the floor line **Combat & gear** - Hitscan pistol (procedural gun sprite with bob, recoil, radial muzzle flash) - Ammo crates and medkits as floor pickups, synthesized WebAudio SFX (gunshot, hits, hurt, growls, pickups — no audio files) **Controls & UI** - **WASD** move, **←/→** or **mouse** (pointer lock, with drag-turn fallback) to aim, **click/Space** shoot, **R** restart - HUD: health bar, ammo, kill counter, crosshair, pickup messages, live minimap with player heading and enemy pips - Title, death, and victory screens
TAKE IT TO CODE
A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.
RUN
EVALUATION
Raycasting maze shooter renders with textured walls, minimap, gun; mouse/click/drag/wheel work but WASD/arrows/enter showed no visible change.
Vision grade · anthropic/claude-sonnet-5
MEASUREMENTS
Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.