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.
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.
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:
| Wave | Entities | Before (ms/frame) | After (ms/frame) |
|---|---|---|---|
| 10 | ~150 | 6.2 | 1.1 |
| 20 | ~320 | 21.8 | 1.6 |
| 30+ | ~500 | 40+ (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.