Doom Shooter - Day 21
Advanced LevelRaycasting 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.
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);
}
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 };
}
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.
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.
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.
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
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.
// 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;
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);
// 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);
}
}
Enemies are 2D sprites that always face the player (billboarding). We use a z-buffer to handle occlusion by walls.
// 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 ...
}
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);
}
}
}
}
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.
The player needs to collide with walls but not get stuck. We check X and Y movement separately with a margin around the player.
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;
}
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
// 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...
}
}
Enemies chase the player using simple pathfinding and attack when close enough. Different enemy types have different stats.
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
}
};
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
}
}
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.