Wolfenstein raycaster · qwen3.8-27b
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 · 7 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
Build a single-file raycast maze shooter. Procedural textures on an offscreen canvas. WASD movement, mouse look + arrow keys, shooting, enemies (sprites) with health, HUD. Write the file.
Let's write a solid implementation:
- A maze generated via recursive backtracking on a grid, walls encoded as 1/2 (2 kinds of wall types for texture variety).
- 64x64 procedural textures: brick, stone, mossy, doors, etc.
- Raycasting DDA, zbuffer.
- Enemies: sprites at positions, billboard rendering with distance sorting, zbuffer occlusion. Simple AI: if visible, chase player (move toward player, avoid walls), shoot the player with damage.
- Shooting: hitscan, hits the nearest enemy within a small angle and unoccluded.
- HUD: health, ammo, score, crosshair, muzzle flash, damage flash.
- Minimap is optional.
- Textures generated with noise-based patterns.
Write carefully.
Key code:
Textures: create a 64x64 canvas, draw with per-pixel noise.
Maze: grid W=21, H=21 (odd). Use cells. Carve passages. Leave some walls to open it up. Map cells: 1 = brick wall, 2 = stone wall, 0 = floor. Place enemies in empty cells away from the player. Add a few items? Keep it simple: enemies + score, win condition when all enemies are defeated.
Player movement: collision detection with a radius.
Mouse turning: pointer lock on canvas click; also turn with arrow keys.
Enemy rendering: draw the sprite (procedural demon) scaled by distance, clip per-column with zbuffer. Use an offscreen enemy sprite canvas.
Enemy shooting: if there is line of sight and within range, fire with cooldown; flash effect; damage the player with a red flash.
Sound: use WebAudio beeps? No external assets — WebAudio is fine (generated). Add simple gunshots/hit sounds via oscillators. Keep it simple and safe (initialize on first click).
Let's write the code.
Enemy sprite: 64x64, draw a demon: red body, horns, eyes. Do it per-pixel with shapes.
Also a floor/ceiling gradient — simple dark colors with a vertical gradient per scanline? Simple: fill the ceiling with a dark gradient, floor also with a gradient. Distance fog for shading.
Enemy AI: simple — move toward player each frame with wall collision detection; stop at distance < 1.5; attack with a 1-second cooldown; player takes damage 8-15. Enemies have 3 hit points or health 30, 10 damage per shot.
Bullets: hitscan; check enemies within 8 degrees of the aim direction, pick the closest, and verify line of sight to it isn't blocked by a wall.
Win: when enemies.length === 0, show a victory overlay. Restart on R. Death: overlay, restart on R.
Minimap in the top-right.
Now write the file.
Sprite rendering details: transform sprite position into camera space:
dx = sx - px, dy = sy - py
inv = 1/(planeX*dirY - dirX*planeY)
tx = inv*(dirY*dx - dirX*dy)
ty = inv*(-planeY*dx + planeX*dy) (depth)
screenX = (w/2)*(1 + tx/ty)
size = h / ty
Draw with per-column clipping: loop columns, if ty < zbuffer[col] then draw a 1px slice.
Use drawImage with source x slice for 1px columns — fine for performance at ~900 columns and enemies up to ~20.
Fog: multiply a black rectangle's alpha based on distance? Simpler: after drawing wall columns, per-column shading via a precomputed shading rectangle? A common trick: per-column fillRect with rgba(0,0,0,alpha) for fog. That works.
Enemy shading: draw the sprite, then overlay per-column black with alpha based on depth? Do it once per enemy with globalAlpha.
Enemy hit flash: temporarily draw the sprite white (a pre-generated white version). Pre-render a white version.
Player damage: red vignette flash.
Muzzle flash: draw a gun at the bottom center of the screen (a procedural gun), recoil + flash when firing.
Let's write it.
Grid: N=21. Maze: standard cell (i,j) with i,j from 0..10; carve between adjacent. Wall type randomly 1 or 2. Then punch holes at some wall positions (random inner walls with open cells on both sides) to create loops. Add some open rooms? Keep it to just the loops.
Map array map[y][x].
Collision: try to move x, then y separately, check map at the cell with a radius 0.25 margin.
Enemy movement: same per-axis; plus if blocked on both axes, jitter/random turn. Keep it simple.
Line of sight: DDA raycast between two points, check for a wall before reaching the target distance.
Weapon: 8 rounds of ammo, empty the gun and R? Let's make it infinite ammo with a reload: 8 rounds, R to reload (1 second). Or pick up... Simpler: 8-round magazine, R reloads 1 second. That's fine.
Score: +100 per kill.
HUD font: monospace.
Write the code:
const canvas 960x540, scaled to fit the window with CSS.
Main loop via requestAnimationFrame, dt clamped.
Textures: makeTex(type) function returns a canvas.
- Brick: rows of bricks with mortar, noise.
- Stone: large blocks.
- Mossy brick: brick + green noise patch.
- Metal/tech: dark with rivets, for doors? Just use it as wall type 3 for decoration.
Wall type assignment: mostly brick (1), stone (2), randomly; a few mossy (3).
Floor/ceiling: flat color with gradient — ceiling is a dark grey gradient, floor is a dark brown gradient. Could add a checker pattern to the floor with a per-scanline pattern but skip; gradient + fog is enough.
Actually, per-scanline floor casting is better but adds code. The Wolfenstein style uses flat floor. A gradient with a subtle stripe pattern? Let's do per-row gradient fill and subtle horizontal stripes for the floor.
Write the code.
function makeNoise helper: per-pixel random.
Brick texture 64x64:
Mortar color #3a3a3a, brick base #7a3b2e with per-brick variation and noise. Brick h=8, w=16, offset every other row.
Stone: 32x32 blocks with base #6a6f75, bevel highlights.
Mossy: brick + random green pixels in bottom patches.
Enemy sprite 64x96? Use 64x64. Draw:
- Background transparent.
- Body: red #a33, shape via pixel math: define an ellipse-ish silhouette. Simpler: use path drawing on the sprite canvas: torso is a rounded rectangle, head is a circle, horns are triangles, eyes are yellow circles, mouth. With a bit of noise overlay. That's enough.
White version: draw the sprite, then 'source-in' white.
Gun: draw per frame at bottom center: dark grey barrel with a gradient, muzzle flash is a star shape with random rotation.
Mouse: canvas.requestPointerLock on click; on mousemove use movementX * sens.
Keys: KeyW etc., plus arrow keys for turning (left/right) and forward (up/down) — spec says WASD movement + arrow keys or mouse turn. So arrows = turn, WASD = movement. Up/down arrows... allow only left/right turn (or up/down too as forward). Left/right = turn; up/down also move forward/back for convenience. Hmm, might be confusing. Follow the spec: arrows for turn; up/down also for movement (harmless).
Enemy attack: when LOS && dist<10, 1.2s cooldown, deal 10 damage, small flash.
Also enemy hit reaction: brief white flash, knockback? Just the flash.
Sound via WebAudio: shoot (noise burst + low osc), hit (short square wave), enemy hit (lower tone), damage (sawtooth), reload click. Init audio context on first user gesture.
Victory/death overlay div, or draw on canvas. Draw on canvas.
Restart: R key resets the game state.
Minimap: 20px per cell? Map 21x21 → at 4px scale = 84px. Draw walls, player, enemies (only nearby ones? all of them).
OK, let's write. Watch the size: 960x540.
Code sketch:
```js
const W=960,H=540;
const canvas, ctx.
let map, MW=21, MH=21;
const FOV plane = 0.66.
player {x,y,ang,hp:100,ammo:8,relT:0}
enemies: [{x,y,hp:30,flash:0,atkT:0,dead:false,anim:t}]
function genMaze(){ ... }
function genTextures(){ ... }
function genEnemySprite(){...}
reset() { map=genMaze(); place player at cell(1,1)*2+1? Actually cell (0,0) center = (1,1). Place enemies in random open cells with dist > 4. }
update(dt): handle input, movement, enemy AI, reload, shooting handled in event.
castRay: DDA per column step=1 (960 rays). Can be done in a 2px step → 480 rays, draw with 2px wide for perf. Use step 1; 960 DDA iterations is fine in JS.
render:
- Ceiling/floor gradients
- Walls with fog
- Sorted sprites
- Gun, HUD, minimap, crosshair, flash
```
zbuffer is a Float32Array(W).
Enemy shooting: draw a small flash at the enemy's position? Skip; the player takes damage + red flash + sound.
Muzzle: shoot() sets flashT, ammo--, recoil.
Shooting logic:
```
for each enemy alive: vector to enemy, angle diff to player.ang; if |diff| < 0.05 + asin(0.4/dist)? Use angle threshold ~ 0.09; also require dist < wallHitDist via castRay toward enemy (DDA). Pick the smallest dist. Apply 12 damage.
```
castRayTo(x,y): DDA from player to point; return the distance to a wall (or max).
Enemy collision radius 0.3.
Now write the full file. Also handle pointer lock errors gracefully.
HUD: "HP 100 | AMMO 8/8 | KILLS 3/10". Crosshair is +. Damage flash alpha decays.
Death: overlay "YOU DIED — press R". Victory: "MAZE CLEARED — press R".
Maze gen function:
```
function genMaze(){
const g = MW*MH... use cells cx=10, cy=10.
map = array of zeros size MH x MW (odd 21 → 10 cells + walls).
init all 1.
carve from cell (0,0): stack-based DFS; mark cell (2*i+1,2*j+1) as 0; wall between (2*i+1+di, 2*j+1+dj).
then random loops: for each internal wall position where both sides are open, 15% chance to remove.
assign texture types: for wall cells, type = pick(1,1,1,2,2,3).
}
```
MW=MH=21.
Enemy count: 12.
Placement: random open cell, distance from player cell > 5, not too close to other enemies.
Movement: speed 3.2 units/sec, turn speed 2.5 rad/s (arrow keys), mouse sens 0.0025.
Collision check function:
```
function canMove(x,y,r){ for corners... check the cell }
```
Check the 4 points x±r, y±r (or 8 points). The cell at each point must be 0.
Enemy update:
```
e.anim += dt;
dist to player; los = hasLOS(e,player);
if los && dist < 12: move toward player if dist>1.2; else attack: e.atkT-=dt; if <=0 { damage player; e.atkT=1.2; sound }
else: wander: change direction every few seconds, move slowly.
```
Keep wandering simple: if no LOS, drift with a slight random turn; stop on wall collision.
hasLOS(a,b): DDA raycast, step along, check map cell; sample at t intervals 0.05.
Damage to player: dmgFlash=1; hp-=rand(6,12). If hp<=0 → dead.
OK, write the file. Also add a title screen? Add a small "click to play" overlay explaining controls; pointer lock on click. Start overlay drawn on canvas: "RAYCAST DUNGEON — click to start".
Audio:
```
let AC; function beep(type...)
shot: noise buffer 0.12s + osc 90Hz drop
```
Implement a small helper: noiseBuf cached.
Alright, let's write.
Floor gradient: create a linear gradient each frame (cheap enough) or pre-create. Pre-create offscreen? The gradient object is reusable; create once.
Ceiling gradient: top #1a1a22 → middle #0a0a10. Floor: middle #201812 → bottom #3a2c1e? Wolfenstein-ish. Add a subtle scanline pattern? Skip.
Per-column wall shading: fillRect x, drawStart, 1, h with rgba(0,0,0,fog) after drawImage.
Side shading: if side==1, multiply darkening 0.7 — apply an additional black alpha 0.25.
OK, code:
Texture selection: textures array indexed by type 1..3.
Wall texture x: wallX = fractional part; texX = (wallX*64)|0; if side==0 && rayDirX>0 → texX = 63-texX, etc. (mirror correction).
Let's finalize and write.
Enemy sprite draw details (64x96? use 64x64, but the sprite is tall: use 48x64):
Canvas 48x64:
- Legs: 2 dark rectangles
- Torso: rounded trapezoid #8f2d20
- Head: circle radius 10 at (24,16) #a83a28
- Horns: triangles on top
- Eyes: yellow 2 circles, glow
- Mouth: dark with white teeth
- Arms on both sides
Add pixel noise: overlay random dark spots with globalAlpha 0.1.
White version: another canvas, draw the sprite, composite 'source-in' fillStyle white.
Sprite scale: screenH = (H / ty) * scale, scale ~0.9; vOffset to align feet: draw at (H/2 - screenH*0.95?) Let's do: sprite is centered, size = H/ty * 0.85; top = H/2 - size/2 + (H/ty)*0.08 (shift slightly down). Simpler: top = H/2 - size/2 + size*0.12.
Per-column draw with zbuffer clip:
```
const sw = size; const sx = screenX - sw/2;
for x in [max(0,sx)..min(W,sx+sw)]: if(ty<zbuf[x]){ u=(x-sx)/sw*srcW; ctx.drawImage(spr, u,0, srcW/sw?, 0 ... )}
```
Use slice width 1: srcX = (x - sx)/sw * srcW, drawImage(spr, srcX, 0, 1, srcH, x, top, 1, size). This is 960 * enemy count drawImage calls; with 12 enemies probably OK. To reduce cost, draw in 2px slices: srcW slice 2. Use slice 2.
Good.
Muzzle flash overlay: when flashT>0, draw a yellow radial gradient circle at the top of the gun.
Gun drawing: at bottom center: barrel rectangle with gradient, side grips. Recoil: offset y by recoil*20.
Let's write.
Crosshair: two lines.
Minimap: top-right, cell 5px → 105px.
Alright, final code. Estimated ~450 lines.
One more detail: enemy attack telegraph — draw an orange flash on the sprite when atkT is near 0 and ready to fire. Simple: when firing, set e.flash=0.15 (white) or an orange version. Just do white flash + sound.
Reload: R key, if ammo<8 and not already reloading: relT=1.0; on complete ammo=8.
Shoot key: mousedown while locked, or space? Add space also to shoot. Fire rate limit 0.25s.
Now write the file.
{"command":"ls /tmp/ws"}(no output)
thinking
It's an empty directory. I'll write index.html.
Empty directory — I'll create the full game in a single self-contained `index.html`.
/tmp/ws/index.html (26810 bytes)Successfully wrote 26810 bytes to /tmp/ws/index.html
thinking
Let me verify with a simple sanity check — run it with node? A headless browser might not be available. At the very least, let me extract the script and run a syntax check with node.
Let me sanity-check the JavaScript syntax and simulate the core game logic (maze gen, raycast, collision) headlessly:
{"command":"cd /tmp/ws && node -e \"\nconst fs = require('fs');\nconst html = fs.readFileSync('index.html','utf8');\nconst m = html.match(/<syntax OK
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 renders with textured walls, minimap, HUD; movement/shooting respond, but view stuck in a corner with no visible enemies.
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.