TECH DETAILS

Doom Shooter - Day 21

Advanced Level

Table of Contents

01 / Raycasting Algorithm

Raycasting is the technique that made Wolfenstein 3D and Doom possible on 1990s hardware. We cast rays from the player's position and find where they hit walls to create a 3D illusion.

The Core Loop

For each vertical column of the screen, we cast a ray at a slightly different angle within our field of view (FOV).

const FOV = Math.PI / 3; // 60 degrees field of view const NUM_RAYS = 320; // One ray per 2 pixels (640/2) const MAX_DEPTH = 20; // Maximum ray distance for (let i = 0; i < NUM_RAYS; i++) { // Calculate ray angle for this column const rayAngle = player.angle - FOV / 2 + (i / NUM_RAYS) * FOV; const ray = castRay(rayAngle); // Draw the wall strip at this column drawWallStrip(i, ray); }

Ray Marching

We step along the ray in small increments until we hit a wall or reach maximum distance.

function castRay(angle) { let sin = Math.sin(angle); let cos = Math.cos(angle); // Step through the ray for (let depth = 0; depth < MAX_DEPTH; depth += 0.02) { let x = player.x + cos * depth; let y = player.y + sin * depth; let mapX = Math.floor(x); let mapY = Math.floor(y); // Check if we hit a wall if (map[mapY][mapX] > 0) { // Determine which side was hit (for shading) let side = 0; let prevX = player.x + cos * (depth - 0.02); if (Math.floor(prevX) !== mapX) side = 1; return { depth: depth, wallType: map[mapY][mapX], side: side }; } } return { depth: MAX_DEPTH, wallType: 0, side: 0 }; }
Performance Tip

The step size (0.02) is a trade-off: smaller = more accurate but slower. For better performance, use DDA (Digital Differential Analyzer) algorithm which steps exactly to cell boundaries.

02 / Fisheye Correction

Raw raycasting produces a "fisheye" distortion where walls curve outward. This happens because rays at the edges travel further to reach the same wall distance.

The Problem

When looking at a wall straight ahead, rays on the sides are at an angle, making them report longer distances even though the wall is at a constant distance.

The Solution

Multiply the ray distance by the cosine of the angle difference between the ray and the player's facing direction.

// Without correction: wall appears curved const rawDepth = ray.depth; // With fisheye correction: wall appears flat const angleDiff = rayAngle - player.angle; const correctedDepth = ray.depth * Math.cos(angleDiff); // Use corrected depth for wall height calculation const wallHeight = canvas.height / correctedDepth;
    Wall
    |
    |  <-- All rays hit at same distance
    |      after fisheye correction
    |
   /|\
  / | \   <-- Rays fan out from player
 /  |  \
    P
 (Player)
                

Fisheye correction ensures parallel walls render as straight lines

03 / Wall Rendering & Shading

Walls are rendered as vertical strips with height inversely proportional to distance. Different wall types and sides get different colors for a 16-bit aesthetic.

Wall Height Calculation

// Farther walls appear shorter const wallHeight = Math.min( canvas.height * 2, // Cap maximum height canvas.height / correctedDepth // Inverse relationship ); // Center the wall vertically const wallTop = (canvas.height - wallHeight) / 2;

16-Bit Color Palette

const wallColors = { 1: { base: '#8B0000', dark: '#5C0000' }, // Dark red brick 2: { base: '#2F4F4F', dark: '#1C2F2F' }, // Dark slate 3: { base: '#8B4513', dark: '#5C2E0D' }, // Brown wood 4: { base: '#4B0082', dark: '#2E004F' } // Purple stone }; // Side shading: vertical walls (side=1) are darker let color = ray.side === 1 ? colors.dark : colors.base; // Distance shading: farther walls are dimmer const shade = Math.max(0.2, 1 - correctedDepth / MAX_DEPTH); color = shadeColor(color, shade);

Texture Lines for 16-Bit Style

// Add horizontal lines for retro texture effect if (wallHeight > 20) { ctx.fillStyle = `rgba(0,0,0,${0.3 - shade * 0.2})`; for (let t = 0; t < wallHeight; t += 8) { ctx.fillRect(x, wallTop + t, stripWidth, 1); } }

04 / Sprite Rendering & Z-Buffer

Enemies are 2D sprites that always face the player (billboarding). We use a z-buffer to handle occlusion by walls.

Z-Buffer Creation

// Store wall distance for each screen column const zBuffer = []; for (let i = 0; i < NUM_RAYS; i++) { const ray = castRay(rayAngle); zBuffer[i] = ray.depth * Math.cos(rayAngle - player.angle); // ... draw wall ... }

Sprite Screen Position

function drawEnemies(zBuffer) { // Sort enemies far to near (painter's algorithm) const sorted = enemies.sort((a, b) => b.dist - a.dist); for (const enemy of sorted) { // Calculate angle from player to enemy const dx = enemy.x - player.x; const dy = enemy.y - player.y; const angle = Math.atan2(dy, dx); // Relative angle (where on screen) let relAngle = angle - player.angle; // Normalize to [-PI, PI] while (relAngle > Math.PI) relAngle -= Math.PI * 2; while (relAngle < -Math.PI) relAngle += Math.PI * 2; // Check if in field of view if (Math.abs(relAngle) < FOV / 2 + 0.1) { // Screen X position based on angle const screenX = canvas.width / 2 + (relAngle / FOV) * canvas.width; // Size based on distance const spriteHeight = (canvas.height / enemy.dist) * enemy.size; // Check z-buffer for occlusion const col = Math.floor((screenX / canvas.width) * NUM_RAYS); if (enemy.dist < zBuffer[col]) { // Draw sprite (not occluded by wall) drawSprite(screenX, spriteHeight, enemy); } } } }
Billboarding

Sprites always face the player - we don't need to rotate them. This classic technique from Doom makes enemies look 3D while being simple 2D images.

05 / Collision Detection

The player needs to collide with walls but not get stuck. We check X and Y movement separately with a margin around the player.

Wall Check Function

function isWall(x, y) { let mapX = Math.floor(x); let mapY = Math.floor(y); // Bounds check if (mapX < 0 || mapX >= MAP_SIZE || mapY < 0 || mapY >= MAP_SIZE) { return true; } return map[mapY][mapX] > 0; }

Sliding Collision

const margin = 0.2; // Player "radius" // Try X movement first const testX = player.x + moveX + (moveX > 0 ? margin : -margin); if (!isWall(testX, player.y)) { player.x += moveX; } // Then try Y movement const testY = player.y + moveY + (moveY > 0 ? margin : -margin); if (!isWall(player.x, testY)) { player.y += moveY; } // Result: player slides along walls instead of stopping

Projectile Collision

// Check projectile against enemies for (const enemy of enemies) { const dx = projectile.x - enemy.x; const dy = projectile.y - enemy.y; const dist = Math.sqrt(dx * dx + dy * dy); // Simple circle collision if (dist < enemy.size * 0.5) { enemy.health -= projectile.damage; enemy.hitTimer = 10; // Flash white // Remove projectile... } }

06 / Enemy AI & Combat

Enemies chase the player using simple pathfinding and attack when close enough. Different enemy types have different stats.

Enemy Types

const ENEMY_TYPES = { imp: { health: 30, speed: 0.02, damage: 10, color: '#00aa00', // Green size: 0.6, score: 100 }, demon: { health: 60, speed: 0.015, damage: 20, color: '#aa0000', // Red size: 0.8, score: 200 }, skull: { health: 20, speed: 0.04, damage: 15, color: '#ffaa00', // Orange size: 0.4, score: 150 } };

Chase AI

function updateEnemy(enemy) { // Vector toward player const dx = player.x - enemy.x; const dy = player.y - enemy.y; const dist = Math.sqrt(dx * dx + dy * dy); // Only move if not too close if (dist > 0.8) { // Normalize and apply speed const moveX = (dx / dist) * enemy.speed; const moveY = (dy / dist) * enemy.speed; // Move with wall collision if (!isWall(enemy.x + moveX, enemy.y)) { enemy.x += moveX; } if (!isWall(enemy.x, enemy.y + moveY)) { enemy.y += moveY; } } // Attack when close if (dist < 1 && enemy.attackCooldown <= 0) { health -= enemy.damage; enemy.attackCooldown = 60; // 1 second at 60fps } }
Advanced AI

For smarter enemies, implement A* pathfinding to navigate around obstacles. The current direct-chase AI works well for open areas but enemies can get stuck on corners.