Wolfenstein raycaster · north-mini-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.
transcript · 6 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
The user wants me to build a first-person raycasting maze shooter in the Wolfenstein 3D style using canvas and JavaScript. This should be a single self-contained HTML file with CSS and JS inline, no external resources or network requests. Let me think about what needs to be implemented: 1. **Canvas Setup**: Create a canvas element 2. **Rendering**: Wolfenstein 3D style raycasting rendering with textured walls 3. **Maze Generation**: Procedural maze generation 4. **Player Movement**: WASD movement (forward, backward, left, right) 5. **Aiming**: Arrow keys or mouse turning 6. **Shooting**: Shooting mechanism 7. **Enemies**: Simple enemies to shoot at 8. **Game Loop**: Main game loop for animation and updates Key technical approaches: - Raycasting algorithm for 3D projection - Player state (position, angle, movement speed, turn speed) - Wall/column height calculation based on distance - Textured walls using procedural textures - Simple collision detection Let me start by creating the index.html file with all the components.
/workspace/index.html (27042 bytes)Successfully wrote 27042 bytes to /workspace/index.html
thinking
Great! I've created a complete Wolfenstein 3D style raycasting maze shooter game. Let me open the file to review it and make sure everything is correct before finishing. The game includes: - First-person raycasting with WASD movement - Mouse or arrow key turning - Textured walls (procedural stone texture) - Floor and ceiling rendering - Procedurally generated maze - Enemy AI with shooting - Player shooting mechanics - Health and ammo system - Score tracking - Game over screen with restart Let me check the file quickly to ensure it loads properly.
{"path":"/workspace/index.html"}<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Wolfenstein 3D Maze Shooter</title> <style> body { margin: 0; padding: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; overflow: hidden; font-family: Arial, sans-serif; } canvas { border: 2px solid #fff; image-rendering: pixelated; } #gameOver { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; font-size: 24px; text-align: center; display: none; } #restartBtn { margin-top: 20px; padding: 10px 20px; font-size: 18px; cursor: pointer; } #hud { position: absolute; top: 10px; left: 10px; color: white; font-size: 18px; } </style> </head> <body> <canvas id="gameCanvas" width="800" height="600"></canvas> <div id="hud">Score: 0 | Health: 100 | Ammo: <span id="ammoDisplay">30</span></div> <div id="gameOver"> Game Over<br> Your Score: <span id="finalScore">0</span><br> <button id="restartBtn">Restart</button> </div> <script> const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const gameOverDiv = document.getElementById('gameOver'); const finalScoreSpan = document.getElementById('finalScore'); const restartBtn = document.getElementById('restartBtn'); const ammoDisplay = document.getElementById('ammoDisplay'); const CANVAS_WIDTH = canvas.width; const CANVAS_HEIGHT = canvas.height; const FOV = Math.PI / 3; // 60 degrees const RAY_COUNT = 320; // Number of rays for rendering const PLAYER_SPEED = 0.05; const TURN_SPEED = 0.04; const ROTATION_SPEED = 0.05; const GRAVITY = 0.002; const JUMP_POWER = 0.08; const WALL_HEIGHT_SCALE = 1.8; const RAY_LENGHT = 100; let gameState = 'menu'; let score = 0; let health = 100; let ammo = 30; let isGameOver = false; // Procedural texture generators function generateWallTexture() { const texture = new ImageData(64, 64); const data = texture.data; // Generate random stone-like texture for (let y = 0; y < 64; y++) { for (let x = 0; x < 64; x++) { const gray = Math.floor(Math.random() * 55 + 55); const index = (y * 64 + x) * 4; data[index] = gray; // R data[index + 1] = gray; // G data[index + 2] = gray; // B data[index + 3] = 255; // A } } // Add some cracks and details for (let i = 0; i < 200; i++) { const cx = Math.floor(Math.random() * 64); const cy = Math.floor(Math.random() * 64); const index = (cy * 64 + cx) * 4; data[index] = Math.max(0, data[index] - 100); data[index + 1] = Math.max(0, data[index + 1] - 100); data[index + 2] = Math.max(0, data[index + 2] - 100); } return { texture, width: 64, height: 64 }; } function generateFloorTexture() { const texture = new ImageData(64, 64); const data = texture.data; for (let y = 0; y < 64; y++) { for (let x = 0; x < 64; x++) { const noise = Math.floor(Math.random() * 40 - 20); const base = 40 + noise; const gray = Math.max(20, Math.min(80, base)); const index = (y * 64 + x) * 4; data[index] = gray; // R data[index + 1] = gray; // G data[index + 2] = gray; // B data[index + 3] = 255; // A } } return { texture, width: 64, height: 64 }; } function generateCeilingTexture() { const texture = new ImageData(64, 64); const data = texture.data; for (let y = 0; y < 64; y++) { for (let x = 0; x < 64; x++) { const blue = Math.floor(Math.random() * 50); const gray = 20 + blue; const index = (y * 64 + x) * 4; data[index] = gray; // R data[index + 1] = gray; // G data[index + 2] = 200 + gray; // B data[index + 3] = 255; // A } } return { texture, width: 64, height: 64 }; } // Map generation function generateMap() { const map = [ "####################", "#..................#", "#.###.#######.#.###.#", "#.#..#.#.....#.#...#", "#.###.#.#.###..####.#", "#.#..#.#..#..#.....#", "#.###.###.###.######", "#.#...........#...#.", "#.#####.######.###.#", "#.#......#.....#...#", "#.#####.#.#####..###", "#.#.....#.......#...", "#.######.#######.###", "#.#.....#.....#....#", "#.#####.#.#####.###.", "#.#.......#....#...#", "#.######.#.######.#", "#.#.....#.#.....#.#", "###.######.#####..#", "#..............#...#", "####################" ]; return map; } // Enemy generation function generateEnemies() { const enemies = []; const map = generateMap(); for (let y = 0; y < map.length; y++) { for (let x = 0; x < map[y].length; x++) { if (map[y][x] === '.') { const rx = (x + 0.5) * cellSize; const ry = (y + 0.5) * cellSize; if (Math.random() < 0.1) { // 10% chance of enemy enemies.push({ x: rx, y: ry, life: 100, active: true, lastShot: 0 }); } } } } return enemies; } // Vector math utilities function degToRad(deg) { return deg * Math.PI / 180; } // Game object class class Game { constructor() { this.reset(); } reset() { this.map = generateMap(); this.enemies = generateEnemies(); this.player = { x: cellSize * 5 + cellSize / 2, y: cellSize * 5 + cellSize / 2, width: 0.4, height: 1.8, angle: 0, dx: 0, dy: 0, vel: PLAYER_SPEED, turnSpeed: TURN_SPEED, jumpVel: 0, onGround: true, health: 100, score: 0, lastShot: 0 }; this.time = 0; this.gameOver = false; this.keys = {}; this.mouseDown = false; this.mouseX = 0; this.mouseY = 0; this.lastMouseX = 0; this.lastMouseY = 0; this.wallTex = generateWallTexture(); this.floorTex = generateFloorTexture(); this.ceilingTex = generateCeilingTexture(); this.setupInput(); this.hideGameOver(); } setupInput() { // Keyboard input document.addEventListener('keydown', (e) => { this.keys[e.key.toLowerCase()] = true; if (e.key === ' ') e.preventDefault(); // Prevent space from scrolling }); document.addEventListener('keyup', (e) => { this.keys[e.key.toLowerCase()] = false; }); // Mouse input canvas.addEventListener('mousedown', (e) => { this.mouseDown = true; }); canvas.addEventListener('mouseup', (e) => { this.mouseDown = false; }); canvas.addEventListener('mousemove', (e) => { if (this.mouseDown) { const dx = e.clientX - this.lastMouseX; this.player.angle -= dx * 0.002; } this.lastMouseX = e.clientX; }); // Touch support for mobile canvas.addEventListener('touchstart', (e) => { e.preventDefault(); this.mouseDown = true; if (e.touches.length > 0) { this.lastMouseX = e.touches[0].clientX; } }); canvas.addEventListener('touchend', (e) => { this.mouseDown = false; }); canvas.addEventListener('touchmove', (e) => { e.preventDefault(); if (this.mouseDown && e.touches.length > 0) { const dx = e.touches[0].clientX - this.lastMouseX; this.player.angle -= dx * 0.002; this.lastMouseX = e.touches[0].clientX; } }); restartBtn.addEventListener('click', () => { this.reset(); this.updateHUD(); }); } updateHUD() { ammoDisplay.textContent = ammo; document.querySelector('#hud').innerHTML = `Score: ${this.player.score} | Health: ${this.player.health} | Ammo: ${ammo}`; } shoot() { if (ammo > 0 && performance.now() - this.player.lastShot > 500) { ammo--; this.player.lastShot = performance.now(); // Check if shooting hits an enemy const projX = this.player.x + Math.cos(this.player.angle) * 20; const projY = this.player.y + Math.sin(this.player.angle) * 20; this.enemies.forEach(enemy => { if (enemy.active) { const dist = Math.hypot(enemy.x - projX, enemy.y - projY); if (dist < 10) { enemy.life -= 50; if (enemy.life <= 0) { enemy.active = false; this.player.score += 100; } } } }); } } update() { if (this.gameOver) return; const dt = 0.016; // ~60fps const oldX = this.player.x; const oldY = this.player.y; // Movement this.player.dx = 0; this.player.dy = 0; if (this.keys['w']) { this.player.dx += Math.cos(this.player.angle) * this.player.vel; this.player.dy += Math.sin(this.player.angle) * this.player.vel; } if (this.keys['s']) { this.player.dx -= Math.cos(this.player.angle) * this.player.vel; this.player.dy -= Math.sin(this.player.angle) * this.player.vel; } if (this.keys['a']) { const strafeAngle = this.player.angle - Math.PI / 2; this.player.dx += Math.cos(strafeAngle) * this.player.vel; this.player.dy += Math.sin(strafeAngle) * this.player.vel; } if (this.keys['d']) { const strafeAngle = this.player.angle + Math.PI / 2; this.player.dx += Math.cos(strafeAngle) * this.player.vel; this.player.dy += Math.sin(strafeAngle) * this.player.vel; } // Turn with arrow keys if (this.keys['arrowleft']) { this.player.angle -= this.player.turnSpeed; } if (this.keys['arrowright']) { this.player.angle += this.player.turnSpeed; } // Jump if (this.keys[' '] && this.player.onGround) { this.player.jumpVel = JUMP_POWER; this.player.onGround = false; } // Apply gravity this.player.jumpVel += GRAVITY; // Update position let newX = this.player.x + this.player.dx; let newY = this.player.y + this.player.dy + this.player.jumpVel; // Collision detection if (this.canMove(newX, newY)) { this.player.x = newX; this.player.y = newY; } else { // Try moving separately to slide along walls const checkX = this.canMove(this.player.x + this.player.dx, this.player.y); const checkY = this.canMove(this.player.x, this.player.y + this.player.jumpVel); if (checkX) this.player.x += this.player.dx; if (checkY) this.player.y += this.player.jumpVel; } if (this.player.y == oldY) { this.player.onGround = true; } // Update enemies AI this.enemies.forEach(enemy => { if (enemy.active) { const angleToPlayer = Math.atan2( this.player.y - enemy.y, this.player.x - enemy.x ); const distToPlayer = Math.hypot( this.player.x - enemy.x, this.player.y - enemy.y ); if (distToPlayer < 200) { // Simple enemy AI enemy.angle = angleToPlayer; const moveX = Math.cos(enemy.angle) * 0.02; const moveY = Math.sin(enemy.angle) * 0.02; const newEx = enemy.x + moveX; const newEy = enemy.y + moveY; if (this.canMove(newEx, newEy)) { enemy.x = newEx; enemy.y = newEy; } } // Enemy shooting if (enemy.lastShot < performance.now() - 1000) { const enemyAngle = Math.atan2( this.player.y - enemy.y, this.player.x - enemy.x ); const projX = enemy.x + Math.cos(enemyAngle) * 20; const projY = enemy.y + Math.sin(enemyAngle) * 20; const dist = Math.hypot(this.player.x - projX, this.player.y - projY); if (dist < 5) { this.player.health -= 20; if (this.player.health <= 0) { this.player.health = 0; this.gameOver = true; this.showGameOver(); } enemy.lastShot = performance.now(); } } } }); this.updateHUD(); // Handle shooting if (this.keys[' '] && ammo > 0) { this.shoot(); } } canMove(x, y) { const gridX = Math.floor(x / cellSize); const gridY = Math.floor(y / cellSize); const midX = Math.floor((x + this.player.width / 2) / cellSize); const midY = Math.floor((y + this.player.height) / cellSize); if (gridX < 0 || gridX >= this.map[0].length || gridY < 0 || gridY >= this.map.length) { return false; } return this.map[gridY][gridX] !== '#'; } castRay(angle, isVertical) { const rayDirX = Math.cos(angle); const rayDirY = Math.sin(angle); const deltaDistX = isVertical ? 0 : Math.abs(1 / rayDirX); const deltaDistY = isVertical ? Math.abs(1 / rayDirY) : 0; let perpWallDist; let wallX; let textureX; let side; const mapX = isVertical ? Math.floor(this.player.x / cellSize) : Math.floor(this.player.x / cellSize); const mapY = isVertical ? Math.floor(this.player.y / cellSize) : Math.floor(this.player.y / cellSize); const stepX = isVertical ? 1 : (rayDirX < 0 ? -1 : 1); const stepY = isVertical ? (rayDirY < 0 ? -1 : 1) : 0; let hit = 0; const sideDistX = isVertical ? (this.player.x < mapX * cellSize ? (this.player.x - mapX * cellSize) / rayDirX : ((mapX + 1) * cellSize - this.player.x) / rayDirX) : (this.player.x < mapX * cellSize ? (this.player.x - mapX * cellSize) / rayDirX : ((mapX + 1) * cellSize - this.player.x) / rayDirX); const sideDistY = isVertical ? (this.player.y < mapY * cellSize ? (this.player.y - mapY * cellSize) / rayDirY : ((mapY + 1) * cellSize - this.player.y) / rayDirY) : (this.player.y < mapY * cellSize ? (this.player.y - mapY * cellSize) / rayDirY : ((mapY + 1) * cellSize - this.player.y) / rayDirY); while (hit === 0) { if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0; } else { sideDistY += deltaDistY; mapY += stepY; side = 1; } if (mapX < 0 || mapX >= this.map[0].length || mapY < 0 || mapY >= this.map.length) { hit = 2; break; } if (this.map[mapY][mapX] === '#') { hit = 1; // Calculate distance and texture coordinates if (side === 0) { perpWallDist = Math.abs((mapX + 0.5 - this.player.x) / rayDirX); wallX = mapX + 0.5; textureX = Math.floor(((this.player.y - mapY * cellSize) + 0.5) / cellSize * this.wallTex.width); } else { perpWallDist = Math.abs((mapY + 0.5 - this.player.y) / rayDirY); wallX = mapY + 0.5; textureX = Math.floor(((this.player.x - mapX * cellSize) + 0.5) / cellSize * this.wallTex.width); } if (side === 1) { perpWallDist *= -1; } break; } } return { hit: hit, perpWallDist: perpWallDist, wallX: wallX, textureX: textureX, side: side }; } render() { // Clear canvas ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // Render walls using raycasting for (let i = 0; i < RAY_COUNT; i++) { const rayAngle = this.player.angle - FOV / 2 + (i * FOV / RAY_COUNT); const ray = this.castRay(rayAngle, false); if (ray.hit) { const wallHeight = Math.floor((CANVAS_HEIGHT * WALL_HEIGHT_SCALE) / ray.perpWallDist); const wallTop = Math.floor((CANVAS_HEIGHT - wallHeight) / 2); const wallBottom = wallTop + wallHeight; // Draw wall column const shade = ray.side ? 0.5 : 1; ctx.fillStyle = `rgb(${shade * 255}, ${shade * 255}, ${shade * 255})`; ctx.fillRect(i, wallTop, 1, wallHeight); // Draw texture (simplified) const texX = ray.textureX % this.wallTex.width; for (let y = wallTop; y < wallBottom && y < CANVAS_HEIGHT; y++) { const ty = Math.floor(((y - wallTop) / wallHeight) * this.wallTex.height) % this.wallTex.height; ctx.fillStyle = `rgb(${ty * 255 / this.wallTex.height}, ${ty * 255 / this.wallTex.height}, ${ty * 255 / this.wallTex.height})`; ctx.fillRect(i, y, 1, 1); } } } // Render floor and ceiling const floorColor = 50; const ceilingColor = 100; // Draw floor for (let y = Math.floor(CANVAS_HEIGHT / 2); y < CANVAS_HEIGHT; y++) { for (let x = 0; x < CANVAS_WIDTH; x++) { const fy = 1 - (y - Math.floor(CANVAS_HEIGHT / 2)) / (CANVAS_HEIGHT / 2); const fx = (x / CANVAS_WIDTH) * 4; ctx.fillStyle = `rgb(${floorColor}, ${floorColor}, ${floorColor})`; ctx.fillRect(x, y, 1, 1); } } // Draw ceiling for (let y = 0; y < Math.floor(CANVAS_HEIGHT / 2); y++) { for (let x = 0; x < CANVAS_WIDTH; x++) { ctx.fillStyle = `rgb(${ceilingColor}, ${ceilingColor}, ${ceilingColor})`; ctx.fillRect(x, y, 1, 1); } } // Draw player const playerScreenX = Math.floor((this.player.x / cellSize - 1) * CANVAS_WIDTH / this.map[0].length); const playerScreenY = Math.floor((this.player.y / cellSize - 1) * CANVAS_HEIGHT / this.map.length); ctx.fillStyle = '#ff0'; ctx.fillRect(playerScreenX, playerScreenY, 3, 3); // Draw enemies this.enemies.forEach(enemy => { if (enemy.active) { const ex = Math.floor((enemy.x / cellSize - 1) * CANVAS_WIDTH / this.map[0].length); const ey = Math.floor((enemy.y / cellSize - 1) * CANVAS_HEIGHT / this.map.length); ctx.fillStyle = '#f00'; ctx.fillRect(ex, ey, 2, 2); } }); // Draw crosshair ctx.fillStyle = '#fff'; ctx.fillRect(CANVAS_WIDTH / 2 - 1, CANVAS_HEIGHT / 2 - 1, 2, 2); } showGameOver() { finalScoreSpan.textContent = this.player.score; gameOverDiv.style.display = 'block'; } hideGameOver() { gameOverDiv.style.display = 'none'; } start() { this.update(); requestAnimationFrame(() => this.start()); } } const game = new Game(); // Handle page visibility document.addEventListener('visibilitychange', () => { if (document.hidden) { // Pause game } else { // Resume game } }); // Start the game game.start(); </script> </body> </html>
thinking
The code exists but has several issues: 1. Undefined variables: `cellSize`, `gameState`, `isGameOver`, `score`, `health`, `ammo` are referenced but never initialized. 2. The map references use `cellSize` but it's not defined. 3. There are some logic issues with game state and scoring. Let me fix these issues by: 1. Defining missing global variables 2. Properly initializing the game state 3. Fixing some small bugs The code has a lot of logic so I need to be careful with my edits.
{"path":"/workspace/index.html","edits":[{"oldText":"<script>\n const canvas = document.getElementById('gameCanvas');\n const 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
Canvas stays completely black throughout due to a JS error (cellSize is not defined), no rendering occurs.
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.
- cellSize is not defined