oneshotlm
Compare all models on this prompt →

Wolfenstein raycaster · kimi-k2.7-code

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.

⛨ sandboxed Open in new tab ↗
transcript · 10 messages
user

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.

assistant
thinking
 Let me start by exploring the current directory and then build this first-person raycasting maze shooter.

I need to create a single index.html with:
- Canvas-based raycasting engine (Wolfenstein 3D style)
- WASD movement, arrow keys and mouse turning
- Textured grid maze (procedural textures generated in JS)
- Enemies that can be shot
- All self-contained, no external resources

Let me first check the current directory.

bash {"command":"ls -la"}
toolResult

total 4 drwxrwxrwx 2 node node 64 Jul 13 03:07 . drwxr-xr-x 1 root root 4096 Jul 13 03:07 ..

assistant
thinking
  Empty directory. I'll create a complete single-file index.html. Let me design the game:

1. **Map system**: 2D grid of walls, with empty spaces for movement
2. **Raycasting**: Cast rays for each screen column, find wall distance, render vertical strips with texture
3. **Procedural textures**: Generate wall textures, enemy textures, gun textures, floor/ceiling via canvas pixel manipulation
4. **Enemies**: Billboards/sprites that face player, animated, shootable with hit detection
5. **Input**: Keyboard and mouse
6. **HUD**: Health, ammo, score, gun sprite
7. **Sounds**: Optional but can do simple oscillator beeps

Let me write robust code.

I'll use:
- 320x200 render resolution (scaled up with CSS)
- 16x16 procedural textures
- Simple map editor array
- DDA raycasting algorithm
- Sprite rendering with z-buffer
- Enemy AI: simple state, turn to face player, chase, shoot

Let me create the file.

write index.html (22825 bytes)
toolResult

Successfully wrote 22825 bytes to index.html

assistant
thinking
Now I wrote the file. Let me verify it actually works by reading it and checking for syntax issues. Also, I want to test if there are bugs:

1. I used `map[mapY][mapX]` in `raycast()` without bounds check after loop, but the loop sets hit and mapX/mapY are within bounds. Good.

2. In `wallBetween`, I'm indexing `map[y][x]` directly but need to make sure indexes are valid. The function uses ceil/floor which is fine since coordinates are within map.

3. The sprite rendering transform formula may be wrong. Let me think: standard transform is:
```
relX = spriteX - playerX
relY = spriteY - playerY
invDet = 1 / (planeX * dirY - dirX * planeY)
transformX = invDet * (dirY * relX - dirX * relY)
transformY = invDet * (-planeY * relX + planeX * relY)
```
I did:
```
const transformX = planeX * relX + planeY * relY;
const transformY = -dirX * relX - dirY * relY;
```
This assumes a specific orientation. Actually, this works if we interpret plane as left plane and dir as forward. Let's verify:
- plane should be perpendicular to dir: `dirX*planeX + dirY*planeY = dirX*(-dirY) + dirY*dirX = 0`. Good.
- In camera space, the forward vector is (0, 1) (actually -1 due to screen conventions). We want transformY to be the dot product of (relX, relY) with the forward direction in world, which is (dirX, dirY). However because of the camera convention, screen depth uses negative. So `transformY = dirX*relX + dirY*relY` gives distance along view. But I used `-dirX*relX - dirY*relY`. This would give a negative value when sprite is in front. So `dist = -transformY` would be positive if in front. OK.

Actually, let me check: if sprite is in front, relX and relX are aligned with dir, so `dirX*relX + dirY*relY > 0`. Then `-dirX*relX - dirY*relY < 0`. So `transformY < 0` when in front. The code continues if `transformY >= -0.3` (behind or too close). Good.

But `transformX` should measure lateral offset. Let's derive properly:
We have camera basis `(planeX, planeY)` and the camera-forward is `(-dirX, -dirY)` in this convention? Wait.

In screen space we want `(x, y)` where sprite position = x * plane + y * forward, and forward is opposite of dir (because we look down negative z if z is into screen). 

Actually, standard Wolfenstein screen transform:
cameraX = 2 * x / w - 1
rayDir = dir + plane * cameraX

This places camera plane to the left/right of view. The plane vector is leftward (negative side) or rightward? In standard formulas, `plane` is perpendicular: plane = (0, 0.66) and dir = (0, -1) for looking up. That places plane to the right when looking up. Wait, if looking up, dir=(0,-1). The right side is (1,0), but plane=(0,0.66) points in the same direction as dir? No, (0,0.66) is up, same direction as the positive y axis. The cameraX negative (left screen) adds left side which would be negative y (down) not up. Hmm. There are sign conventions.

Anyway, my `transformX = planeX * relX + planeY * relY` with `plane = -dirY, dirX`. Let's test with player looking right (dir=(1,0), plane=(0,1)). A sprite directly in front (same y as player) has relY=0, so transformX=0. Good. A sprite to the left of player (positive y? Actually left when looking right is positive y in right-handed coordinate system). `planeX=0`, `planeY=1`, relY>0 gives transformX>0. But on screen, positive should move right or left? Standard `spriteScreenX = (1 + transformX/transformY) * w/2`. With positive transformX, screen x increases (right). If planeY is positive, then positive relY (above player) goes to right. If looking right, "above" is left of view? Use right-hand: dir=(1,0,0), up=(0,0,1). Then left = cross(dir, up) = (0,-1,0) which is negative y. So a sprite at positive y is to the right of view. So the sign is correct.

But wait, `plane = (-dirY, dirX)` for dir=(1,0) gives (0,1), which is positive y, which we found is right of view. Hmm, but cross(dir, up) gave (0,-1,0) = negative y. Which coordinate system are we using? In screen coordinates, y increases downward. The math may be flipped. Let's not overthink. The important thing is that sprites render. If they render at wrong positions, we can fix.

Actually, in my code: `planeX = -dirY * tan(FOV/2)` (using half-plane length). For dir=(1,0), planeX=0, planeY=tan(30)=0.577. That makes plane vector along positive y. In a left-handed system, if x is forward, y may be to the left or right? Hmm.

Let's derive with standard screen-space transform. Standard formula for camera plane:
- View direction `D = (dirX, dirY)`.
- Plane vector `P = (-dirY, dirX)` is perpendicular (90° counter-clockwise) normalized/scaled.
- The ray is `D + P * camX`.
Given a point `S`, we want to find screen x coordinate. The standard derivation: camera space coordinates (x_cam, z_cam) where x is right, z is forward (negative because camera looks into screen). The transformation from world to camera uses:
`inverse * rel` where matrix columns are plane and dir? Actually the matrix that goes from world to camera (view matrix) has rows: right, up, -forward. If `plane` is the right vector, then cameraX = rel dot plane, cameraZ = rel dot dir. Then in screen, the x coordinate is `cameraX / cameraZ` and y is similar.

If `plane` is the right vector, then for a point in front of camera: ray from camera is k*dir, so rel = k*dir. cameraZ = k*dir·dir = k. cameraX = k*dir·plane = 0. Good. So screen x = 0.

Screen x in pixels: `screenX = centerX + planeFactor / cameraZ`. So `transformX = rel·plane`, `transformY = rel·dir`. Then `screenX = centerX * (1 + transformX/transformY)`. This is the standard formula. But they usually define `transformY = -(rel·dir)` because camera looks toward negative Z, so they take `dist = -transformY`.

Let me recheck: My code:
```
const transformX = planeX * relX + planeY * relY;
const transformY = -dirX * relX - dirY * relY;
```
Wait, `rel·plane = planeX*relX + planeY*relY`. Good. But `rel·dir = dirX*relX + dirY*relY` and negative is `-dirX*relX - dirY*relY`. Good. So it matches transformX = rel·plane, transformY = -rel·dir. Then `screenX = center * (1 - transformX/transformY)` in standard. Wait my code:
```
const spriteScreenX = Math.floor((SCREEN_W / 2) * (1 - transformX / transformY));
```
But standard is `screenX = center * (1 + transformX/transformY)` with `transformX = rel·plane` and `transformY = rel·dir` (positive in front). If instead transformY = -rel·dir, then `transformX/transformY` has opposite sign, so you need `screenX = center * (1 - transformX/transformY)`. Actually both are equivalent if we define things differently. Let me re-derive.

Standard (Wolfenstein):
```
spriteX = sprite.x - player.x
spriteY = sprite.y - player.y
// transform sprite with the inverse camera matrix
invDet = 1.0 / (planeX * dirY - dirX * planeY)
transformX = invDet * (dirY * spriteX - dirX * spriteY)
transformY = invDet * (-planeY * spriteX + planeX * spriteY)
spriteScreenX = (1 + transformX / transformY) * w / 2
```

This formula has inverse matrix because the matrix (dir, plane) transforms camera space to world. So inverse of [[dirX, planeX], [dirY, planeY]] times (spriteX, spriteY) gives camera space. The inverse determinant is `dirX*planeY - dirY*planeX`.

If `plane = (-dirY, dirX)`, then invDet = 1 / (dirX*dirX + dirY*dirY) = 1.
```
transformX = dirY*spriteX - dirX*spriteY  // lateral in camera space? No, this is inverse matrix * sprite.
            = (-dirY)*spriteX + dirX*spriteY? Wait.
```
Actually:
`invDet * (dirY*spriteX - dirX*spriteY) = rel·perp(dir)` where perp(dir) = (dirY, -dirX). That is perpendicular.
`transformY = -planeY*spriteX + planeX*spriteY = -dirX*spriteX - dirY*spriteY = -rel·dir`.

So `screenX = (1 + transformX/transformY) * w/2`.

But `transformX = (dirY, -dirX)·rel`. Is that equal to `plane·rel`? `plane = (-dirY, dirX)`. `plane·rel = -dirY*relx + dirX*rely`. And `(dirY, -dirX)·rel = dirY*relx - dirX*rely`. These are negatives of each other. So standard transformX = -(plane·rel) = rel·(-plane) which is lateral offset in opposite direction.

Given this, my formula:
`transformX = planeX*relX + planeY*relY = plane·rel`
`transformY = -dirX*relX - dirY*relY = -dir·rel`
`screenX = (1 - transformX/transformY) * w/2` is equivalent to standard with transformX' = -transformX. Because `(1 + (-transformX)/transformY) = (1 - transformX/transformY)`. So my formulas are correct as long as I use the minus sign.

So the math should work. Good.

Now other potential bugs:
- Enemy sprite data array: `enemyDataCache` works.
- `wallBetween` may access out-of-bounds if x/y reach 24 due to floating point. Let me fix it with a clamp or floor in range. Actually `Math.floor(x0 + dx*t)` could be 23 max if target is 24? We pass x1 as up to 20, so within 0-23. Good.
- The pickup rendering: I used the enemy texture for ammo/health. That's OK but not great. Could improve but maybe enough for a first pass.
- `muzzleFlash` writes over `screenImg` data but not necessarily used in main loop for next frame since we redraw. OK.
- `fireGun` called on mouse down and click? It duplicates but OK for now.

Let me read the file to make sure there are no syntax errors and see if anything is missing.
read {"path":"index.html","offset":1,"limit":50}
toolResult

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Wolf3D-Style Raycasting Maze Shooter</title> <style> html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #050505; overflow: hidden; font-family: 'Courier New', monospace; color: #0f0; } #game { display: block; image-rendering: pixelated; width: 100vw; height: 100vh; cursor: none; } #overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; flex-direction: column; justify-content: space-between; text-shadow: 2px 2px 0 #000; } #hud { display: flex; justify-content: space-between; padding: 12px 18px; font-size: 24px; font-weight: bold; background: rgba(0,0,0,0.35); } #messages { text-align: center; padding: 22px; font-size: 20px; min-height: 30px; } #crosshair { [650 more lines in file. Use offset=51 to continue.]

assistant
thinking
Let me read the rest of the file, particularly the JS, to ensure no obvious issues.
read {"path":"index.html","offset":51,"limit":300}
toolResult

position: fixed; top: 50%; left: 50%; width: 10px; height: 10px; margin: -5px 0 0 -5px; border: 2px solid rgba(0,255,0,0.7); border-radius: 50%; pointer-events: none; } #barrel { position: fixed; left: 50%; bottom: -30px; transform: translateX(-50%); width: 220px; height: 220px; image-rendering: pixelated; pointer-events: none; display: none; } #startScreen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.92); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 10; cursor: default; } h1 { margin: 0 0 16px 0; color: #0f0; font-size: 42px; letter-spacing: 3px; text-transform: uppercase; } p { max-width: 520px; text-align: center; color: #9f9; line-height: 1.5; } button { margin-top: 22px; padding: 14px 36px; font-size: 18px; background: #0a0; color: #000; border: none; cursor: pointer; font-weight: bold; } button:hover { background: #0f0; } .key { display: inline-block; background: #222; color: #0f0; padding: 2px 6px; border: 1px solid #0f0; margin: 0 2px; } </style> </head> <body> <canvas id="game"></canvas> <canvas id="barrel" width="110" height="110"></canvas> <div id="crosshair"></div> <div id="overlay"> <div id="hud"> <span>HEALTH: <span id="hp">100</span></span> <span>AMMO: <span id="ammo">25</span></span> <span>SCORE: <span id="score">0</span></span> </div> <div id="messages"></div> </div> <div id="startScreen"> <h1>Bunker Blaster</h1> <p> A first-person raycasting maze shooter made entirely with procedural textures.<br><br> <span class="key">W</span><span class="key">A</span><span class="key">S</span><span class="key">D</span> Move &nbsp;&nbsp; <span class="key">←</span><span class="key">→</span> or mouse Turn &nbsp;&nbsp; <span class="key">Click</span> / <span class="key">Ctrl</span> Shoot &nbsp;&nbsp; <span class="key">R</span> Reload </p> <button id="start">ENTER THE MAZE</button> </div> <script> /* ---------- constants ---------- */ const MAP_W = 24; const MAP_H = 24; const TEX_SIZE = 64; const FOV = 60 * Math.PI / 180; const SCREEN_W = 640; // internal resolution const SCREEN_H = 400; const MOVE_SPEED = 4.2; const ROT_SPEED = 2.2; const MOUSE_SENS = 0.002; /* ---------- procedural texture generation ---------- */ function createTexture(size, fn) { const c = document.createElement('canvas'); c.width = c.height = size; const ctx = c.getContext('2d'); const img = ctx.createImageData(size, size); const d = img.data; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const [r, g, b, a] = fn(x, y, size); const i = (y * size + x) * 4; d[i] = r; d[i + 1] = g; d[i + 2] = b; d[i + 3] = a; } } ctx.putImageData(img, 0, 0); return c; } const wallTex = createTexture(TEX_SIZE, (x, y) => { const brick = (Math.floor(x / 16) + Math.floor(y / 16)) % 2 === 0; const mortar = (x % 16 < 2) || (y % 16 < 2); if (mortar) return [90, 85, 80, 255]; const noise = (Math.sin(x * 12) + Math.cos(y * 9)) * 8; const r = brick ? 140 + noise : 90 + noise; const g = brick ? 70 + noise : 50 + noise; const b = brick ? 40 + noise : 30 + noise; return [Math.max(0, r), Math.max(0, g), Math.max(0, b), 255]; }); const wall2Tex = createTexture(TEX_SIZE, (x, y) => { const noise = Math.random() * 30; const stripe = Math.floor((x + Math.sin(y / 8) * 8) / 16) % 2; const r = stripe ? 60 + noise : 110 + noise; const g = stripe ? 60 + noise : 120 + noise; const b = stripe ? 70 + noise : 130 + noise; return [r, g, b, 255]; }); const doorTex = createTexture(TEX_SIZE, (x, y) => { const frame = x < 4 || x > 59 || y < 4 || y > 59; const rivet = ((x % 16 === 8) && (y % 16 === 8)); if (frame) return [70, 50, 30, 255]; if (rivet) return [180, 160, 80, 255]; const n = Math.random() * 25; return [50 + n, 35 + n, 20 + n, 255]; }); const enemyTex = []; function makeEnemyFrames() { for (let f = 0; f < 4; f++) { enemyTex.push(createTexture(TEX_SIZE, (x, y) => { const cx = TEX_SIZE / 2, cy = TEX_SIZE / 2 + 6; const dx = x - cx, dy = y - cy; const d = Math.sqrt(dx * dx + dy * dy); let r = 0, g = 0, b = 0, a = 0; if (d < 28) { // body armor green-brown const light = Math.max(0, 1 - d / 28); r = 80 + light * 80 + Math.random() * 10; g = 100 + light * 90 + Math.random() * 10; b = 40 + light * 40; a = 255; if (y < cy - 4 && Math.abs(dx) < 10 && Math.abs(dy + 10) < 8) { // helmet stripe r = 60; g = 60; b = 60; } if (Math.abs(dx) < 4 && y > cy - 8 && y < cy) { // visor r = 180; g = 200; b = 220; } if (f > 0 && y > cy + 4 && Math.abs(dx) < 12 && (f === 1 || f === 3)) { // gun arm swinging r = 120; g = 120; b = 130; } } return [r, g, b, a]; })); } } makeEnemyFrames(); const gunTexCanvas = document.createElement('canvas'); gunTexCanvas.width = 110; gunTexCanvas.height = 110; const gt = gunTexCanvas.getContext('2d'); function drawGun(fire) { gt.fillStyle = 'rgba(0,0,0,0)'; gt.clearRect(0, 0, 110, 110); // hand+gun shape gt.fillStyle = '#3a3a3a'; gt.fillRect(30, 60 + (fire ? -25 : 0), 50, 44); gt.fillStyle = '#222'; gt.fillRect(38, 30 + (fire ? -25 : 0), 34, 34); // barrel gt.fillStyle = '#111'; gt.fillRect(44, 14 + (fire ? -25 : 0), 22, 22); // muzzle flash if (fire) { gt.fillStyle = '#ffea00'; gt.beginPath(); gt.moveTo(55, 0); gt.lineTo(65, 18); gt.lineTo(45, 18); gt.fill(); gt.fillStyle = '#ff7700'; gt.beginPath(); gt.moveTo(55, 8); gt.lineTo(70, 22); gt.lineTo(40, 22); gt.fill(); } } drawGun(false); document.getElementById('barrel').getContext('2d').drawImage(gunTexCanvas,0,0,110,110,0,0,110,110); document.getElementById('barrel').style.display = 'block'; /* ---------- map ---------- */ const mapLayout = [ "111111111111111111111111", "100000011000000000000041", "101111011011111111111101", "101000000000000000000001", "101011111111011111111101", "100010000000000000000001", "111010111110111111101111", "100000100000000000100001", "101111101111111110101101", "100000000000000000000001", "111110111111111111111101", "100000100000000010000001", "101110111111011010111111", "100000000000000000000001", "111111111111111111111101", "100000000000000000000001", "101111111111111111011101", "100000000000000000000001", "111111111111111111111101", "100000000000000000000001", "101111112222111111111101", "100000000000000000000001", "111111111111111111111131", "111111111111111111111111" ]; const map = []; const startPos = { x: 2.5, y: 2.5 }; let playerX = startPos.x, playerY = startPos.y, playerDir = 0; const enemies = []; const items = []; // health / ammo pickups for (let y = 0; y < MAP_H; y++) { map[y] = []; for (let x = 0; x < MAP_W; x++) { const c = mapLayout[y][x]; map[y][x] = parseInt(c, 10) || 0; // start point if (c === 'S') { playerX = x + 0.5; playerY = y + 0.5; map[y][x] = 0; } } } function cellEmpty(x, y) { const ix = Math.floor(x), iy = Math.floor(y); if (ix < 0 || iy < 0 || ix >= MAP_W || iy >= MAP_H) return false; return map[iy][ix] === 0; } // place enemies in open rooms const rooms = [ {x:5,y:5}, {x:18,y:2}, {x:3,y:12}, {x:19,y:8}, {x:14,y:16}, {x:7,y:18}, {x:19,y:20}, {x:2,y:21}, {x:20,y:3}, {x:9,y:9} ]; for (const r of rooms) { if (cellEmpty(r.x, r.y)) { enemies.push({ x: r.x + 0.5, y: r.y + 0.5, hp: 3, dead: false, frame: 0, anim: 0, shootCd: 0, state: 'idle' }); } } function randOpen() { for (let t = 0; t < 200; t++) { const x = Math.floor(Math.random() * MAP_W); const y = Math.floor(Math.random() * MAP_H); if (map[y][x] === 0 && Math.hypot(x - playerX, y - playerY) > 4) return {x: x + 0.5, y: y + 0.5}; } return {x: 1.5, y: 1.5}; } // pickups for (let i = 0; i < 6; i++) { const p = randOpen(); items.push({ x: p.x, y: p.y, type: Math.random() < 0.5 ? 'health' : 'ammo' }); } /* ---------- canvas / buffers ---------- */ const canvas = document.getElementById('game'); canvas.width = SCREEN_W; canvas.height = SCREEN_H; const ctx = canvas.getContext('2d'); const screenImg = ctx.createImageData(SCREEN_W, SCREEN_H); const zbuf = new Float32Array(SCREEN_W); const texCtx = wallTex.getContext('2d'); const texData = texCtx.getImageData(0,0,TEX_SIZE,TEX_SIZE).data; const tex2Ctx = wall2Tex.getContext('2d'); const tex2Data = tex2Ctx.getImageData(0,0,TEX_SIZE,TEX_SIZE).data; const doorCtx = doorTex.getContext('2d'); const doorData = doorCtx.getImageData(0,0,TEX_SIZE,TEX_SIZE).data; const enemyCtx = enemyTex[0].getContext('2d'); const enemyDataCache = []; for (let i = 0; i < enemyTex.length; i++) { enemyDataCache[i] = enemyTex[i].getContext('2d').getImageData(0,0,TEX_SIZE,TEX_SIZE).data; } function sample(data, u, v) { const tx = (Math.floor(u * TEX_SIZE) & (TEX_SIZE - 1)); const ty = (Math.floor(v * TEX_SIZE) & (TEX_SIZE - 1)); const i = (ty * TEX_SIZE + tx) * 4; return [data[i], data[i+1], data[i+2], data[i+3]]; } /* ---------- input ---------- */ const keys = {}; let mouseX = 0, mouseY = 0; let pointerLocked = false; let firing = false, fireFlash = 0; window.addEventListener('keydown', e => { keys[e.code] = true; if (e.code === 'KeyR') reloadGun(); }); window.addEventListener('keyup', e => keys[e.code] = false); [350 more lines in file. Use offset=351 to continue.]

assistant
thinking
<|tool_calls_section_begin|><|tool_call_begin|>functions.read:4<|tool_call_argument_begin|>{"path": "index.html", "offset": 351, "limit": 250}<|tool_call_end|><|tool_calls_section_end|>

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

modelkimi-k2.7-code
vendorMoonshot · Kimi
harnesspi-agent
generated2026-07-13

EVALUATION

1/5 broken

Only a static start menu screen shown; clicking 'ENTER THE MAZE' never triggers the actual game, no raycasting visible.

off-prompt

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.

idle motion0.0%
on click0.0%
on drag2.2%
on wheel0.0%
on enter + space0.0%
on arrow keys0.0%
on w/a/s/d0.0%
frame spread16.7 / 255
console errors0
js errors none

METRICS

runtime541.6s
tokens in18k
tokens out12k
cached25k
cost / run$0.0576