Skip to content
All projects
SystemsSolo Developer2025

Endless Onslaught

A 2D roguelike survivor built on TypeScript and HTML5 Canvas: hundreds of enemies at a rock-solid 60 FPS, powered by a spatial hash grid and zero game engines.

TypeScriptHTML5 CanvasElectronGame dev

The problem#

Every survivor-like starts the same way: for (const enemy of enemies) { if (distance(enemy, player) < range) ... }. It works beautifully with 40 enemies and collapses with 400. At wave 30 with twelve weapons firing, naive collision checks were eating the entire frame budget — the game shipped its own lag spikes as a difficulty mechanic I never intended.

The fix: a spatial hash grid#

Instead of checking every entity against every other, each frame buckets entities into a coarse grid by position. Queries only inspect the buckets that overlap the area of interest — typically 6–9 cells instead of hundreds of entities.

core/spatial-hash.ts
class SpatialHash<T extends { x: number; y: number }> {
  private cells = new Map<string, T[]>();
 
  constructor(private cellSize = 64) {}
 
  clear() { this.cells.clear(); }
 
  insert(entity: T) {
    const key = this.keyFor(entity.x, entity.y);
    const bucket = this.cells.get(key);
    if (bucket) bucket.push(entity);
    else this.cells.set(key, [entity]);
  }
 
  query(x: number, y: number, radius: number): T[] {
    const found: T[] = [];
    const min = this.keyFor(x - radius, y - radius);
    const max = this.keyFor(x + radius, y + radius);
    for (let cx = min.x; cx <= max.x; cx++) {
      for (let cy = min.y; cy <= max.y; cy++) {
        found.push(...(this.cells.get(`${cx}:${cy}`) ?? []));
      }
    }
    return found;
  }
 
  private keyFor(x: number, y: number) {
    return `${Math.floor(x / this.cellSize)}:${Math.floor(y / this.cellSize)}`;
  }
}

Collision, weapon targeting and enemy separation all query through the grid. The result:

WaveEntitiesBefore (ms/frame)After (ms/frame)
10~1506.21.1
20~32021.81.6
30+~50040+ (dropped frames)2.4

Other things that kept frames cheap#

  • Object pooling. Bullets and particles are recycled; garbage collection pauses were a real source of stutter.
  • Draw-call batching. Sprites sharing an atlas render in one pass; text and HUD live on a separate, rarely-invalidated canvas layer.
  • Fixed-timestep simulation with interpolation, so physics stayed deterministic while rendering ran free.
  • Profile first, always. I "optimised" my particle system once before profiling it. It was 3% of frame time. The collisions were 70%.

Shipped as#

An Electron build so it runs as a desktop app, with the whole game loop in a plain HTML5 Canvas — no engine, no framework, ~8k lines of TypeScript I can read end to end.