Skip to content
Aman SinghWorkshop
Mini-JavaScript-Projects / Ray Casting Engine

Ray Casting Engine

A Wolfenstein-style first-person shooter in plain JavaScript and Canvas 2D: DDA ray casting, textured walls, z-buffered sprites, projectiles and a green CRT tactical HUD. Every texture, the pistol and the face icon are drawn in code, with no image files and no dependencies. The renderer is correct and fast.

3,780Lines of JS · 22 files
1,533Lines of CSS
16 × 16Map grid
66.8°Field of view
0.84 msWorld pass, 1920 rays
0Image assets
Demo

Clearing sector one

Scripted run in headless Chromium. Pointer lock was stubbed so mouse turns and clicks work without a real cursor. The route turns 90° at a time, fires at the three enemies and ends on the "Sector cleared" popup.
First-person view down a brick corridor with a bright green projectile orb in flight
Firing. A green projectile at 8 units/s. Ammo drops from 50 to 49 and one block empties in the grid.
The enemy is gone, a white hit marker and a +100 popup at the crosshair
Kill feedback. Hit marker and "+100". Enemies die from one hit.
Dimmed game view with a SECTOR CLEARED popup and a NEXT SECTOR button
Level complete. Next sector spawns min(2 + level, 8) enemies on the same map.
HUD showing health 25 in red and a hurt face icon with blood drips
Damage state, triggered through the debug hook testDamage(75). Nothing in the game can hurt the player yet.
Clean render of a long brick corridor with one enemy, no HUD
Screenshot mode (F12) hides the HUD and leaves the ray-cast view and the CSS scanlines.
Corridor view with an enemy, minimap top-left, vitals and armament panels
The full HUD. Minimap with red enemy dots, vitals with a procedural face, and a 50-block ammo grid.
Controls

What the player can do

InputAction
W A S D, arrowsMove, strafe, turn · 3 units/s, 103°/s
Left ShiftSprint × 1.8
Mouse (pointer locked)Look, 0.002 rad per pixel
Click / right-clickLock the pointer, then shoot (100 ms cooldown)
R R R within 500 msWeapon inspect animation
H / M / FToggle HUD / minimap / fullscreen
P / F12Profiler / screenshot mode

One map, one weapon, one enemy type. The 16 × 16 map uses a single wall type, so the procedural stone texture is generated and never drawn. Enemies are red billboards with yellow eyes. They don't move or attack, and there is no line-of-sight check.

Where the work went. The renderer core, Raycaster.js plus sprite.js, is 334 lines, about 9% of the JavaScript. Most of the rest is the HUD, weapon animation, effects and infrastructure around it.

JavaScript lines by subsystem
Grey is UIManager.js, which is imported but never used.
Architecture

One loop, five canvases and CSS on top

GameLooprAF · dt · FPS 1 UPDATE(dt) · main.js input state → yaw (dir and plane rotated together)move and strafe · axis-separated grid collision, r 0.25projectiles · wall test, hit test < 0.5, deferred removalweapon recoil and bob · crosshair · HUD barslevel complete when enemies = 0 2 RENDER · per frame Raycaster.renderW rays · DDA · textured columns · shade zBuffer[W] renderSprites · sort far→near, depth test Radar Weapon Layers, bottom to top #screen · walls + spritesz 1 #minimap · FPSz 5–6 #weapon-canvasz 12 #effects-canvas · own rAFz 15 .crt-overlay · CSS onlyz 200 crosshair · DOMz 250 #hud-container · siblingz 100, but on top Also running separately: screen shake, and one tween loop per status bar. 5 rAF loops total.
Frame loop and layer stack. Mouse look and shooting run directly in their event handlers, outside the loop. The HUD sits in a different stacking context from the viewport, so it draws above the CRT overlay and the crosshair even though its z-index is lower.
How it works

Digital differential analysis, one column at a time

player WALL · side 0 ● each dot is one DDA step: cross the nearer grid line ≤ ~22 steps per ray on a 16 × 16 map
One ray. sideDistX and sideDistY track the distance to the next vertical and horizontal grid line. The ray always advances whichever is closer, so it never skips a cell.
// Raycaster.js:49-104 · one ray per screen column
const cameraX = 2 * x / width - 1;
const rayDirX = player.dirX + player.planeX * cameraX;
const rayDirY = player.dirY + player.planeY * cameraX;
while (hit === 0) {
  if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0; }
  else { sideDistY += deltaDistY; mapY += stepY; side = 1; }
  if (worldMap[mapX] && worldMap[mapX][mapY] > 0) hit = 1;
}
// perpendicular distance: no fisheye, no cosine
const perpWallDist = side === 0 ? sideDistX - deltaDistX
                               : sideDistY - deltaDistY;
// then, per column
lineHeight = floor(H / perpWallDist)            // projection
texX = floor(frac(hit point) * 64)               // un-mirrored per side
ctx.drawImage(tex, texX, 0, 1, 64, x, top, 1, lineHeight)
fog = min(dist, 20) / 20;  if (side) shade *= 0.7
zBuffer[x] = perpWallDist                        // for sprites

Columns are 1-pixel drawImage slices, not per-pixel writes. That is why the whole world pass stays under a millisecond.

Sprites are projected with the inverse of the 2 × 2 camera matrix [plane dir], sorted far to near, and drawn column by column only where they are closer than zBuffer[x]. Collision tests the X and Y moves separately against the grid with a 0.25 radius, which gives wall sliding for free. The CRT look is pure CSS: a 2 px scanline gradient, tinted edge gradients and an inset shadow vignette, with no per-frame JavaScript.

Measurements

Fast renderer, slow HUD budget

World render time against the documented budget
Profiler averages over about 2.5 s in headless Chromium on a Mac. Rays = canvas width.

docs/PERFORMANCE.md sets a budget of 8 ms for the world pass and 2 ms for all UI. The world pass measured 0.57 ms at 1280 rays and 0.84 ms at 1920. The frame rate sat at the 60 FPS cap at every size.

The profiler's own frame total is broken: mark() resets the frame start, so totalFrame reports about 0.002 ms and its 16.67 ms warning can never fire.