Excerpt: Collision checks become expensive when every game object is compared with every other object. A spatial hash grid limits work to nearby cells, keeping broad-phase collision detection predictable as an HTML5 game grows.
A simple HTML5 game can start with a nested loop: compare every bullet with every enemy, every player with every pickup, and every moving object with every obstacle. That approach is easy to understand, but its cost grows quickly. With 1,000 objects, a naive all-pairs pass can approach half a million comparisons per frame. Most of those objects are nowhere near each other.
A spatial hash grid solves the broad-phase problem by grouping objects according to their position. Instead of asking whether every pair might collide, the game asks which objects share the same cell or a neighboring cell. The narrow-phase collision test then runs only on plausible candidates.
What a spatial hash grid does
Imagine the world divided into equal square cells. Each object is inserted into one or more cells based on its bounding box. A query for an object examines only the cells overlapped by that object, plus any neighbor cells required by your insertion strategy.
The grid does not replace accurate collision geometry. It is a broad-phase index. It cheaply rejects distant pairs, then hands nearby candidates to circle, axis-aligned bounding box, polygon, or swept collision tests.
- Insert: map an object's bounds to cell coordinates and add its ID to those buckets.
- Query: collect IDs from the relevant buckets.
- Filter: remove duplicates, self-pairs, and incompatible collision layers.
- Narrow phase: perform the exact collision test only for remaining candidates.
- Rebuild or update: refresh membership as objects move.
Choose a useful cell size
Cell size is the most important tuning decision. If cells are too large, many unrelated objects share a bucket and the grid loses its advantage. If cells are too small, large objects occupy many buckets and insertion overhead rises.
A practical starting point is a cell edge close to the typical collidable object's diameter. For a game where most enemies and projectiles are 32 to 48 pixels wide, try 64-pixel cells. Measure candidate counts and frame time, then adjust. There is no universal best value because object density, movement, and size distribution matter more than canvas dimensions.
Games with a few very large objects and many small objects may use separate grids, collision layers, or a different structure for static geometry. Do not force a boss covering half the screen into hundreds of tiny buckets if it can be checked separately.
Build stable cell keys
Convert a world position into integer cell coordinates:
const cellX = Math.floor(worldX / cellSize);
const cellY = Math.floor(worldY / cellSize);
const key = cellX + "," + cellY;
A string key is readable and sufficient for many games. At higher object counts, a packed integer key can reduce allocations, but it requires careful handling of negative coordinates and range limits. Optimize only after profiling shows key creation is material.
Keep world coordinates independent from camera position. The spatial grid represents the simulation, not the viewport. Scrolling or zooming the camera should not move objects between cells.
Insert complete bounds, not only centers
Inserting an object only by its center works when every object is smaller than a cell and neighbor queries are expanded correctly. It is safer to insert every cell overlapped by the object's axis-aligned bounding box:
const minX = Math.floor(bounds.left / cellSize);
const maxX = Math.floor(bounds.right / cellSize);
const minY = Math.floor(bounds.top / cellSize);
const maxY = Math.floor(bounds.bottom / cellSize);
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
addToBucket(x, y, objectId);
}
}
This ensures that objects crossing a cell boundary can find each other. Because one object may appear in multiple buckets, queries must deduplicate candidate IDs before narrow-phase testing.
A compact implementation pattern
class SpatialHash {
constructor(cellSize) {
this.cellSize = cellSize;
this.cells = new Map();
}
clear() {
this.cells.clear();
}
key(x, y) {
return x + "," + y;
}
insert(id, bounds) {
const minX = Math.floor(bounds.left / this.cellSize);
const maxX = Math.floor(bounds.right / this.cellSize);
const minY = Math.floor(bounds.top / this.cellSize);
const maxY = Math.floor(bounds.bottom / this.cellSize);
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const key = this.key(x, y);
let bucket = this.cells.get(key);
if (!bucket) {
bucket = [];
this.cells.set(key, bucket);
}
bucket.push(id);
}
}
}
query(bounds, out) {
out.clear();
const minX = Math.floor(bounds.left / this.cellSize);
const maxX = Math.floor(bounds.right / this.cellSize);
const minY = Math.floor(bounds.top / this.cellSize);
const maxY = Math.floor(bounds.bottom / this.cellSize);
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const bucket = this.cells.get(this.key(x, y));
if (!bucket) continue;
for (const id of bucket) out.add(id);
}
}
return out;
}
}
This version rebuilds the grid and uses a reusable output set. It favors clarity. A production game can reuse bucket arrays, encode keys differently, or update only moved objects, but those changes should follow measurements.
Rebuild the grid at a predictable point
For dynamic objects, rebuilding once per simulation step is often simpler and fast enough. Clear the buckets, insert active colliders, then resolve queries. This avoids stale membership and complicated removal logic.
Coordinate the rebuild with a stable simulation loop. The techniques in a fixed-timestep HTML5 game loop keep collision behavior consistent even when rendering frame rate changes. Build and query the grid inside simulation updates, not at arbitrary rendering times.
Static colliders can live in a grid that is built only when a level changes. Dynamic and static grids can then be queried together. This reduces work without complicating moving-object updates.
Avoid duplicate pair checks
When object A finds object B, object B may later find object A. Multiple shared buckets can also produce the same pair. Use stable numeric IDs and process a pair only when idA < idB, or store a compact pair key for the current step.
Apply collision layers before the narrow phase. A player projectile may collide with enemies but not with other player projectiles. Filtering layers early reduces exact checks and makes rules easier to audit.
Manage allocations carefully
Creating new arrays, sets, and key objects for every query can erase much of the performance gain. Reuse candidate sets and temporary buffers. Clear them between uses. If buckets churn heavily, pool bucket arrays or keep a generation counter instead of deleting every object each frame.
Object pooling can help, but use it deliberately. The guide to object pooling in HTML5 games explains deterministic resets, bounded growth, and the risks of retaining stale state. Pool only after profiling identifies allocation pressure.
Support fast-moving objects
A spatial grid based only on the object's final position can miss a projectile that crosses several cells in one update. Query the swept bounds from the previous position to the new position, then use continuous or segment-based collision tests in the narrow phase.
Very fast projectiles may use raycasts or dedicated projectile queries instead of ordinary overlap checks. The grid still helps identify which colliders intersect the swept region.
Measure the right signals
Do not judge the grid only by average frames per second. Track simulation time, broad-phase candidate count, exact collision checks, bucket count, maximum bucket occupancy, and garbage-collection pauses. Add the data to the same workflow used for frame-time telemetry.
| Metric | What it reveals |
|---|---|
| Candidates per object | Whether cells are filtering distant objects effectively |
| Maximum bucket size | Dense hotspots or an unsuitable cell size |
| Duplicate candidates | Large objects spanning many cells |
| Broad-phase milliseconds | Total indexing and query cost |
| Allocation rate | Temporary collections that can trigger pauses |
Test realistic worst cases: crowded spawn points, particle-heavy battles, paused screens with many pickups, and lower-powered mobile devices. A grid that performs well in an empty test level may still struggle when hundreds of objects occupy one cell.
Keep gameplay behavior deterministic
Maps and sets may not produce the ordering your gameplay implicitly expects. If collision resolution order affects outcomes, sort candidate IDs or define explicit priority rules before resolving contacts. Stable ordering is especially important for replays, lockstep simulations, and reproducible debugging.
Collision detection should also cooperate with player input timing. If actions are queued around simulation steps, the approach in HTML5 game input buffering helps prevent missed actions without tying logic to rendering frames.
Implementation checklist
- Profile the naive collision loop before changing it.
- Choose an initial cell size near the typical collider diameter.
- Insert every cell overlapped by each collider's bounds.
- Deduplicate candidates and filter collision layers early.
- Prevent reversed and repeated pair checks.
- Use swept bounds for fast-moving objects.
- Separate static and dynamic grids when it simplifies updates.
- Reuse temporary collections to control allocations.
- Measure bucket occupancy, candidate counts, and frame time.
- Test dense scenarios on representative mobile hardware.
Final takeaway
A spatial hash grid is effective because it matches collision work to locality. The best implementation is not the cleverest one; it is the smallest structure that produces stable candidate counts, predictable frame time, and correct collisions under real game conditions. Start with a clear rebuild-per-step design, profile it, and add complexity only where the measurements justify it.