Skip to main content

How to Build an Atmospheric Horror FPS

v2 Written April 2026 Advanced

We've covered a lot of ground in the raycasting series: screen shake and view bobbing, distance fog and dithered lighting, monster spawning and BFS pathfinding, scene and level management, decals and props, and PNG-based level design. This tutorial brings all of it together.

We're building a corridor horror FPS. You wake up in a dark facility. The corridors are tight, the torchlight barely reaches the walls ahead, and something is hunting you. Your only goal is to find the exit before it finds you. There's no weapon — just movement, wits, and the hope that you hear the footsteps before you feel the damage.

Everything we need is already in our toolkit:

  • Raycasting renderer with a 24×24 PNG-defined map
  • Fog and dithered lighting for the oppressive atmosphere
  • LOS detection and BFS pathfinding driving enemy AI
  • Screen shake and health flash on damage
  • Torch flicker and view bob for physical immersion
  • A state machine managing the title screen, gameplay, and win/lose screens

By the end we'll have a complete, shippable horror game — and a solid sense of how to wire multiple systems into a single cohesive experience.

Setting the Scene

Before we add enemies, health, or atmosphere, we need the world. Let's get the dark corridors rendering first — the raycasting loop, fog, and torch falloff that everything else builds on.

The Map

The level is a 24×24 grid — 1 for walls, 0 for open space. Tight corridors, dead ends, and a single exit tucked in the far corner. Oppressive without being a pure maze.

const Map = [
  [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
  [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
  [1,0,1,1,0,1,1,1,0,1,1,0,1,0,1,1,1,0,1,1,0,1,0,1],
  // ... (full 24×24 grid)
];

We spawn the player at { x: 1.5, y: 1.5 } — the centre of the first open cell.

Constants

Everything tunable lives in one object:

const Constants = {
  Fov: Math.PI / 3,
  MoveSpeed: 0.04,
  RotSpeed: 0.03,
  SpawnX: 1.5,
  SpawnY: 1.5,
  FogStart: 0.2,
  MaxDist: 1.5,
  TorchStrength: 0.65,
  TorchFlicker: 0.08,
};

FogStart and MaxDist are intentionally tight. Fog starts at 0.2 and is complete by 1.5. The darkness is the point.

The Raycasting Loop

For each vertical column of pixels, we cast a ray and find where it hits a wall:

for (let x = 0; x < viewW; x++) {
  const cameraX = (2 * x) / viewW - 1;
  const rdx = dirX + planeX * cameraX;
  const rdy = dirY + planeY * cameraX;

  // DDA — step through the grid until we hit a wall
  let side;
  while (true) {
    if (sideDistX < sideDistY) {
      sideDistX += deltaDistX;
      mapX += stepX;
      side = 0;
    } else {
      sideDistY += deltaDistY;
      mapY += stepY;
      side = 1;
    }
    if (Map[mapY]?.[mapX] === 1) break;
  }

  const perpDist = side === 0 ? sideDistX - deltaDistX : sideDistY - deltaDistY;
  const stripeH = Math.floor(viewH / perpDist);
}

perpDist is the corrected wall distance. A straight Euclidean distance would cause fisheye distortion — perpDist avoids that. stripeH is how tall the wall stripe is at that depth.

Fog and Torch Falloff

Fog is a linear ramp from FogStart to MaxDist:

function fogAmount(dist) {
  return Math.min(1, Math.max(0, (dist - Constants.FogStart) / (Constants.MaxDist - Constants.FogStart)));
}

Raw distance isn't the whole story, though. The torch doesn't reach the edges of the screen as well as the centre — so we reduce the effective distance based on column position before running it through fogAmount:

const colOffset = Math.abs(x - viewW / 2) / (viewW / 2);
const torchReduce = Constants.TorchStrength * (1 - colOffset * colOffset);
const effectiveDist = Math.max(0, perpDist - torchReduce);
const fogFactor = fogAmount(effectiveDist);

colOffset * colOffset gives us a quadratic curve — smooth circular falloff instead of a harsh linear edge.

Vignette

Once the walls are drawn, we apply a radial vignette to darken everything outside the torch circle. Instead of fading it, we use the Bayer 4×4 matrix to threshold it — the same dithering trick from the fog and lighting tutorial:

for (let sy = 0; sy < viewH; sy++) {
  for (let sx = 0; sx < viewW; sx++) {
    const r2 = cx * cx + cy * cy;
    const vignette = Math.min(1, Math.max(0, (r2 - 0.4) / 0.6));
    if (vignette > Bayer[(sy % 4) * 4 + (sx % 4)]) pset(sx, sy, 0);
  }
}

Dithering to hard black rather than blending gives the edge a grainy, organic feel. It's what makes the torch look real.

Atmospheric Horror FPS: Setting the Scene
Navigate the dark corridors. Arrow keys to move and look.

Player Health & Damage

Horror lives and dies by consequence. Without a health system, the corridors are just a maze. With one, every sound becomes a threat. Let's add three hit points, a HUD to show them, and a physical response when the player takes damage.

Health State

Health is an array of booleans — true for full, false for lost:

let health = [true, true, true];
let flashTimer = 0;
let shakeTimer = 0;

An array makes the HUD trivial to render — iterate and draw a filled or dimmed square per slot. Taking damage finds the last true and flips it:

function takeDamage() {
  const idx = health.lastIndexOf(true);
  if (idx === -1) return;
  health[idx] = false;
  flashTimer = 12;
  shakeTimer = 8;
}

lastIndexOf works from the right, so the HUD empties left-to-right.

Screen Shake

Shake works by offsetting the entire scene with camera() for a few frames. The tricky bit is when to call it:

function draw() {
  creset();
  cls(0);

  if (shakeTimer > 0) {
    camera((rnd(2) - 1) * 2, (rnd(2) - 1) * 2);
    shakeTimer--;
  }

  // ... raycasting and vignette ...

  creset(); // reset before drawing the HUD
  // ... HUD drawing ...
}

We call creset() at the top of draw() to clear any leftover offset from last frame. Then camera() applies the random jitter before the raycasting runs. We call creset() again before the HUD — so the health squares stay pinned to the screen no matter how much we're shaking.

Damage Flash

A dithered red overlay does more to sell a hit than almost anything else. The intensity fades over 12 frames using the Bayer matrix as a threshold:

if (flashTimer > 0) {
  const intensity = flashTimer / 12;
  for (let fy = 0; fy < viewH; fy++) {
    for (let fx = 0; fx < viewW; fx++) {
      if (intensity > Bayer[(fy % 4) * 4 + (fx % 4)]) pset(fx, fy, 8);
    }
  }
  flashTimer--;
}

At frame 1 of 12, almost every pixel is red. By frame 12, it's just a faint dither pattern. The result feels physical — like something clearing from your eyes — rather than a clean dissolve.

The HUD

We draw the HUD after the second creset() so shake doesn't drag it around:

for (let i = 0; i < Constants.PlayerMaxHealth; i++) {
  rectfill(4 + i * 8, 4, 9 + i * 8, 9, health[i] ? 8 : 5);
}

Full slots are red (color 8), empty slots are dark grey (color 5). Present but hollow. No icons needed.

Atmospheric Horror FPS: Player Health & Damage
Press Z to take damage. Watch the health display and screen response.

Monsters & Dread

An empty maze is a puzzle. Put something in it and it becomes a horror game. Let's add enemies — they spawn in fixed positions, detect us using line-of-sight, hunt using BFS pathfinding, and deal damage on contact.

Enemy State

Each monster is a plain object. We track position, whether it's chasing, the current path, and a hit cooldown to prevent instant multi-hit:

let monsters = EnemySpawns.map(s => ({
  x: s.x, y: s.y,
  chasing: false,
  seenPlayer: false,
  path: [],
  pathAge: 0,
  hitCooldown: 0,
  dead: false,
}));

EnemySpawns is an array of { x, y } positions distributed around the map.

Line-of-Sight Detection

Before a monster can chase us, it needs to see us. Line-of-sight uses the same DDA step as the raycasting loop — it marches from the monster toward the player and stops as soon as it either arrives or hits a wall:

function hasLineOfSight(fromX, fromY, toX, toY) {
  const rdx = dx / dist;
  const rdy = dy / dist;

  // DDA step toward the target
  while (true) {
    if (sdx < sdy) { sdx += ddx; mx += stepX; }
    else { sdy += ddy; my += stepY; }
    if (mx === tx && my === ty) return true;  // reached target cell
    if (Map[my]?.[mx] === 1) return false;    // hit a wall
  }
}

If LOS is clear and the monster hasn't spotted us before, it plays a detection sound and sets chasing:

if (los) {
  if (!m.seenPlayer) {
    m.seenPlayer = true;
    sfx('clunk');
  }
  m.chasing = true;
}

The sound itself is a short synthesised clunk registered in sounds:

const sounds = { clunk: [5, [8, 0.8, 1], [5, 0.5, 1], [0, 0, 0]] };

BFS Pathfinding

When LOS breaks — we ducked around a corner — the monster falls back to BFS:

function bfs(startX, startY, goalX, goalY) {
  const visited = new Set();
  const queue = [{ x: sx, y: sy, path: [] }];

  while (queue.length > 0) {
    const { x, y, path } = queue.shift();
    for (const [dx, dy] of [[0,-1],[1,0],[0,1],[-1,0]]) {
      const nx = x + dx;
      const ny = y + dy;
      if (visited.has(key) || Map[ny]?.[nx] !== 0) continue;
      const newPath = [...path, { x: nx + 0.5, y: ny + 0.5 }];
      if (nx === gx && ny === gy) return newPath;
      queue.push({ x: nx, y: ny, path: newPath });
    }
  }
  return [];
}

We don't run BFS every frame — that'd be expensive. Every 30 frames is frequent enough to feel reactive:

if (m.path.length === 0 || m.pathAge >= 30) {
  m.path = bfs(m.x, m.y, px, py);
  m.pathAge = 0;
}

The monster pops waypoints off the front of the path as it gets close to each one.

Collision & Damage

When a monster closes to within 0.4 units with a clear line of sight, it deals damage and removes itself:

if (dist < 0.4 && los && m.hitCooldown <= 0) {
  takeDamage(m.x, m.y);
  m.dead = true;
  continue;
}

takeDamage also shoves us slightly away from the monster. Combined with screen shake, the hit feels like an actual impact.

Rendering Monsters in 3D

Each monster renders as a billboard — a flat 8×8 sprite that always faces the camera. We project it using the same camera plane as the raycasting loop:

const transformX = invDet * (dirY * relX - dirX * relY);
const transformY = invDet * (-planeY * relX + planeX * relY);
if (transformY <= 0) continue; // behind the camera

const screenX = Math.floor((viewW / 2) * (1 + transformX / transformY));
const sprH = Math.floor(viewH / transformY);

For each column of the sprite, we check transformY against zBuffer[stripe] — the wall depth we stored during the main pass. If a wall's closer, we skip that column. Monsters disappear behind corners correctly:

if (transformY >= zBuffer[stripe]) continue;

-1 in the sprite array means transparent — we skip those pixels. Fog and vignette apply to monster pixels the same way they apply to walls.

Atmospheric Horror FPS: Monsters & Dread
Monsters are watching. They'll hunt you down if they see you.

Atmosphere

The mechanics work. Now let's make them feel like something. The torch flickers, movement has physical weight, and the audio reacts to what's nearby. None of this changes the rules — but all of it changes how the game feels.

Torch Flicker

The torch strength used to be a constant. Now it varies slightly every frame:

const torchStrength = Constants.TorchStrength + (rnd(2) - 1) * Constants.TorchFlicker;

rnd(2) gives us a float in [0, 2), so (rnd(2) - 1) is [-1, 1). Multiplied by Constants.TorchFlicker (0.08), that's ±8% per frame. We pass torchStrength into the falloff calculation instead of the fixed constant — every wall stripe and monster sprite now breathes with it.

It's recalculated fresh each frame, so there's no pattern to detect. That's what makes it feel like a real flame rather than an animation.

View Bob

Walking should feel like walking. We track a bobTimer that increments while the player's moving and drives a sine offset on the camera:

if (isMoving) bobTimer += 0.12;
const bobOffset = isMoving ? Math.sin(bobTimer) * 1.5 : 0;

We combine the bob with screen shake in a single camera() call:

camera(shakeX, shakeY + bobOffset);

When we stop moving, bobOffset snaps to zero. A polished game would ease that out — but at this movement speed, the snap actually feels fine.

Heartbeat Tension

When a monster gets within half the detection radius, we play a heartbeat sound every 60 frames — about once a second:

const tensionDist = Constants.DetectionRadius / 2;
const nearMonster = monsters.some(m => {
  const dx = m.x - px;
  const dy = m.y - py;
  return Math.sqrt(dx * dx + dy * dy) < tensionDist;
});

if (nearMonster && heartbeatTimer >= 60) {
  sfx('heartbeat');
  heartbeatTimer = 0;
}

It's a short synthesised pulse:

const sounds = {
  clunk: [5, [8, 0.8, 1], [5, 0.5, 1], [0, 0, 0]],
  heartbeat: [3, [3, 0.6, 2], [1, 0, 0], [0, 0, 0]],
};

The heartbeat plays whether the monster has line-of-sight or not. We can hear the danger before we can see it. That gap between audio and visual is where the dread lives.

Atmospheric Horror FPS: Atmosphere
The atmosphere is alive now. Listen for what's nearby.

The Complete Game

Every mechanic is in place. Now we wire them together with a state machine — a title screen, a win condition, a lose condition, and a clean restart.

The State Machine

Game state is a single string — 'title', 'playing', 'win', or 'lose'. Both update() and draw() branch on it:

let gameState = 'title';

In update():

if (gameState === 'title') {
  if (btn('z')) {
    resetGame();
    gameState = 'playing';
  }
  return;
}

if (gameState === 'win' || gameState === 'lose') {
  if (btn('z')) gameState = 'title';
  return;
}

// ... all the playing logic

In draw(), non-playing states get a black fill and a couple of text lines, then return early — nothing leaks through:

if (gameState === 'title') {
  cls(0);
  caption('ATMOSPHERIC HORROR FPS', 9, 46, 8);
  caption('Press Z to start', 24, 62, 7);
  return;
}

The early return keeps each state's rendering isolated. No health HUD bleeding onto the title screen.

Resetting the Game

All mutable state lives in resetGame():

function resetGame() {
  px = Constants.SpawnX;
  py = Constants.SpawnY;
  pa = 0;
  health = [true, true, true];
  flashTimer = 0;
  shakeTimer = 0;
  bobTimer = 0;
  isMoving = false;
  heartbeatTimer = 0;
  monsters = EnemySpawns.map(s => ({
    x: s.x, y: s.y,
    chasing: false,
    seenPlayer: false,
    path: [],
    pathAge: 0,
    hitCooldown: 0,
    dead: false,
  }));
}

We call it once at startup so the game's ready immediately, and again each time the player presses Z from the title screen. Monster positions, chase state, health — everything resets.

Win and Lose Conditions

We check both at the end of the playing update:

if (isDead()) {
  gameState = 'lose';
  return;
}

const edx = px - ExitPos.x;
const edy = py - ExitPos.y;
if (Math.sqrt(edx * edx + edy * edy) < 0.6) {
  gameState = 'win';
  return;
}

ExitPos is a world-space coordinate in the far corner of the map. Walk within 0.6 units of it and you win — no button press, just getting there.

The Exit Door

The exit isn't marked on a minimap or labelled with text — you have to find it. But it does leave a clue: the walls adjacent to the exit position glow green (color 11) instead of the standard stone:

const isExitWall = (mapX === exitWallX && mapY === exitWallY - 1)
  || (mapX === exitWallX - 1 && mapY === exitWallY);

let color = isExitWall ? 11 : sprites.wall[ty * 16 + tx];

Fog still applies, so you won't see the glow until you're close. That's intentional. The reward for exploring rather than panicking is catching that glimpse of green in the dark.

Atmospheric Horror FPS: The Complete Game
Use arrow keys to move. Find the exit before they find you.