In Part 1, I covered the architecture decisions and procedural map generation. By the end of Part 1 I had a map, a player that moved around it, and a store that held game state cleanly. What I didn't have: enemies that could find me, fog of war that hid unexplored areas, or any sound.
This is the part where it starts feeling like a game.
A* Pathfinding: Getting Enemies to Chase You
Enemy AI needs to navigate toward the player's position across the tile map. The map has obstacles — walls, closed doors — so enemies can't just move in a straight line. They need to find a path.
A* is the textbook answer, and it's textbook for a reason. It finds the shortest path between two walkable tiles efficiently, using a heuristic (distance to destination) to prioritize which tiles to explore first.
function aStar(map: Tile[][], from: Point, to: Point): Point[] {
const open = new MinHeap<Node>()
const closed = new Set<string>()
open.push({ pos: from, g: 0, h: heuristic(from, to), parent: null })
while (!open.isEmpty()) {
const current = open.pop()
if (equals(current.pos, to)) return reconstructPath(current)
closed.add(key(current.pos))
for (const neighbor of getWalkableNeighbors(map, current.pos)) {
if (closed.has(key(neighbor))) continue
const g = current.g + 1
open.push({ pos: neighbor, g, h: heuristic(neighbor, to), parent: current })
}
}
return [] // no path found
}
The heuristic is Manhattan distance (sum of horizontal and vertical distance) — appropriate for a grid game where diagonal movement isn't allowed.
The performance problem. A* recalculates every time an enemy needs to move. With 8 enemies on a 50×50 map, running A* on every turn for every enemy is fast enough. With 20 enemies on a 100×100 map, it isn't — the first version dropped to 15fps during heavy combat.
I added two optimizations:
Path caching. Each enemy stores its current path. The path is only recalculated when the player moves more than 3 tiles from where the path was generated. Enemies walking along a cached path use almost no CPU. Recalculation only happens when the player makes a move that meaningfully changes the chase direction.
Staggered updates. Not all enemies recalculate on the same game turn. I distribute recalculations across turns with a simple round-robin: enemy 0 recalculates on even turns, enemy 1 on odd turns, etc. The player can't tell the difference — enemy movement still looks reactive — but the CPU cost is spread out.
After both optimizations: 60fps locked on all hardware I tested.
Bresenham Line-of-Sight and Fog of War
Fog of war is the system that makes unexplored areas dark and hides enemies outside the player's line of sight. Every tile is in one of three states: unseen (never explored), remembered (explored but not currently visible), or visible (currently in LOS).
Implementing this requires knowing which tiles the player can see from their current position. I trace a ray from the player to every tile within the vision radius and check whether the ray hits a wall before reaching the target. If it does, the target tile stays dark.
I use Bresenham's line algorithm to trace each ray. Bresenham's plots a line between two integer grid points using only integer arithmetic — no floating point, no trigonometry. For a tile grid, it's exactly right:
function bresenhamLine(x0: number, y0: number, x1: number, y1: number): Point[] {
const points: Point[] = []
let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1
let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1
let err = dx + dy
while (true) {
points.push({ x: x0, y: y0 })
if (x0 === x1 && y0 === y1) break
const e2 = 2 * err
if (e2 >= dy) { err += dy; x0 += sx }
if (e2 <= dx) { err += dx; y0 += sy }
}
return points
}
For each tile in the vision radius, I trace a Bresenham line from the player. I walk the points on the line. When I hit a wall tile, I stop — everything beyond the wall is occluded. Tiles I reach without hitting a wall are marked visible.
I run this for every tile in the vision radius on every player move. With a radius of 8 tiles, that's up to 200 rays per move. It's fast enough — Bresenham's integer arithmetic is cheap, and the vision radius is bounded.
The visual result: the player sees clearly down corridors, can't see around corners, and enemies that step into line-of-sight appear suddenly. That's the core fog-of-war experience, achieved entirely with 62-year-old algorithm.
Procedural Audio with Zero Dependencies
The game has sound: weapons firing, enemies taking hits, the extraction alarm. No audio files. Everything synthesized at runtime using the Web Audio API.
The Web Audio API lets you build signal graphs programmatically. You connect source nodes (oscillators, noise generators) through processing nodes (filters, gain envelopes) to the audio output. A sound effect is a graph that you construct, run for a short duration, and tear down.
Laser sound:
function playLaser(ctx: AudioContext) {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(900, ctx.currentTime)
osc.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.2)
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start()
osc.stop(ctx.currentTime + 0.2)
}
A sawtooth oscillator sweeping from 900Hz down to 200Hz over 0.2 seconds, with a gain envelope that decays to silence. It sounds like a retro laser. That's the whole thing.
Explosion sound:
function playExplosion(ctx: AudioContext) {
const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.4, ctx.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1 // white noise
const source = ctx.createBufferSource()
const filter = ctx.createBiquadFilter()
const gain = ctx.createGain()
filter.type = 'lowpass'
filter.frequency.value = 400
gain.gain.setValueAtTime(0.6, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4)
source.buffer = buffer
source.connect(filter)
filter.connect(gain)
gain.connect(ctx.destination)
source.start()
}
White noise through a lowpass filter with a gain decay. It sounds like a small explosion. The aesthetic matches the game — 8-bit terminal, derelict space station.
None of this sounds like a modern game. It sounds exactly like it should.
What I'd Tell Someone Starting Their First Browser Game
Get something ugly moving on screen before you touch WebGL. Prototype the game loop in a plain <canvas> tag. Add a player, add one enemy that moves toward you, add a wall they can't walk through. That's the whole game logic in embryonic form. Don't start with Three.js.
State management is the architecture decision. How you structure game state affects everything downstream. The wrong choice costs you days of refactoring. For React + game state, Zustand is the right call.
Instanced meshes are not optional at scale. If you have more than ~50 repeating geometry items, you need InstancedMesh or your performance is gone.
Zero-dependency audio is underrated. The Web Audio API can do everything a simple game needs. You don't need Howler.js. Learning to think in signal graphs is a useful mental model even outside games.
The game is live on my portfolio. Keyboard controls on desktop, touch on mobile.