spatial-grid.ts
PURPOSE
Spatial hash grid that reduces broad-phase collision lookups from O(n^2) to O(n * k), where k is the average number of entities in a queried cell neighborhood. Divides the world into fixed-size square cells; entities are inserted into every cell their position+radius overlaps; queries return the union of entities in the cells intersected by the query area. Exports a SpatialGrid class plus a singleton enemyGrid used as the canonical broad-phase index for alive, tangible enemies.
OWNS
class SpatialGrid— the grid data structure.cellSize(readonly) — world units per cell, supplied at construction.invCellSize(readonly) — precomputed reciprocal ofcellSize, used to replace division with multiplication in the hot path.cells—Map<number, any[]>from packed cell key to the array of entities currently in that cell.arrayPool— pool of reusable arrays so per-frameclear()does not allocate fresh arrays each frame.cellKey(cx, cy)— packs two 16-bit signed cell coordinates into a single integer key:(cx & 0xFFFF) | ((cy & 0xFFFF) << 16).getCell(cx, cy)— private; returns the cell array for(cx, cy), creating one from the pool (or[]) if missing.clear()— empties every cell, returns the cleared arrays toarrayPool, then clears thecellsmap.insert(entity, x, y, radius)— addsentityto every cell whose footprint overlaps the AABB[x-r, x+r] x [y-r, y+r].insertPoint(entity, x, y)— addsentityto the single cell containing(x, y)with no radius expansion; intended for entities whoseradius < cellSize/4.query(x, y, radius)— broad-phase: returns candidates in every cell overlapping the AABB around(x, y, radius). Uses a shared module-level result array.countNear(x, y, radius)— returns the total entity count across the cells overlapping the AABB; does not allocate or expose entities.
- Module-level
_queryResults: any[]— singleton, pre-allocated result buffer reused by everyquery()call across everySpatialGridinstance. - Module-level
enemyGrid = new SpatialGrid(CFG.GRID_CELL_SIZE)— exported singleton grid used by combat, weapons, bullets, artifacts, effects, and AI separation.
READS FROM
./config— importsCFGand readsCFG.GRID_CELL_SIZEwhen constructing theenemyGridsingleton.- Entity inputs supplied by callers: only
(x, y)coordinates and aradiusscalar. The grid does not inspect any other entity field; entities can be any object shape (any).
PUSHES TO
- Stores entity references inside its internal
cellsmap. Returns aliases to those references viaquery(). query()returns the shared_queryResultsarray directly; the buffer is reused on the nextquery()call on anySpatialGridinstance.- Does not emit events, mutate entities, or write to other systems.
DOES NOT
- Does not perform precise (narrow-phase) collision tests. Returned candidates may be outside the query radius; callers must do an exact distance check.
- Does not deduplicate. An entity inserted via
insert()into multiple cells will appear multiple times inquery()results if those cells fall inside the query AABB. Callers that need uniqueness rely on their own bookkeeping (for example, thebullet.hitsSet in collision-resolver). - Does not update positions automatically. The grid is rebuilt from scratch each frame; there is no
move()orremove(entity)API. - Does not own or run an update loop.
clear()+insert()calls are driven externally (currently bycollision-resolver.ts). - Does not support concurrent queries. The shared
_queryResultsarray assumes one query is consumed before the next begins. - Does not validate cell coordinate bounds. Coordinates outside the 16-bit signed range will alias with other cells because
cellKeymasks with0xFFFF. - Does not handle the
bulletGriduse case in code — the file’s header comment notes a future-usebulletGridslot, but onlyenemyGridis instantiated and exported.
Signals
None. The grid produces no events or telemetry. All output is via return values from query() and countNear().
Entry points
new SpatialGrid(cellSize)— construct a grid with a chosen cell size.grid.clear()— call once per frame before re-inserting.grid.insert(entity, x, y, radius)— radius-aware insertion, places entity in every overlapping cell.grid.insertPoint(entity, x, y)— single-cell point insertion, no radius expansion.grid.query(x, y, radius)— broad-phase lookup of candidate entities near a point.grid.countNear(x, y, radius)— density count without returning entity references.enemyGrid(exported singleton) — the project-wide broad-phase index for enemies; consumed bycombat/collision-resolver.ts,combat/damage.ts,weapons/weapons.ts,weapons/bullets.ts,world/artifacts.ts,effects/custom-handlers.ts, andbridge.ts.
Pattern notes
- Cell size.
enemyGridusesCFG.GRID_CELL_SIZE(currently 200 world units). The header notes the heuristic thatcellSizeshould be at least twice the largest entity radius so most entities fall in a single cell. - Packed integer keys. Cell coordinates are folded into a single 32-bit integer (
cxin the low 16 bits,cyin the high 16 bits) so the cell map can be keyed by a primitive number rather than a string. - Inverse cell size cached.
invCellSize = 1 / cellSizeis computed once in the constructor; insert/query/count all multiply byinvCellSizeinstead of dividing bycellSizein their inner loops. - AABB cell range. Both
insert()andquery()compute[minCx..maxCx] x [minCy..maxCy]fromMath.floor((coord +/- radius) * invCellSize)and iterate that 2D range. For an entity small relative tocellSize, this typically reduces to a single cell on insert and a 3x3 block on query. - Per-frame rebuild contract. Lifecycle is
clear()→ manyinsert()→ manyquery()→clear()on the next frame. The grid has no incremental update path; callers must rebuild after entity movement. - Allocation discipline. Two pooling strategies avoid per-frame garbage:
clear()recycles emptied cell arrays viaarrayPool, andquery()writes into the module-level_queryResultsbuffer instead of allocating a new array. - Shared result buffer.
query()returns a reference to_queryResults, which is reused by every grid instance. Callers must consume (read or copy) the result before callingquery()again; otherwise the prior result is overwritten. - Duplicates by design. An entity inserted into multiple cells (radius spanning more than one) will appear multiple times if the query AABB covers more than one of its cells. The header documents this and notes that callers using
bullet.hitsSet already handle it naturally. - Broad-phase only. The grid eliminates candidates by cell-membership; precise circle/circle or circle/segment tests stay in the caller (
collision-resolver.ts,damage.ts,bullets.ts, etc.). any-typed entities. The grid is intentionally untyped on the entity payload to avoid coupling to enemy / bullet / pickup shapes; the only requirement is that the caller supplies(x, y, radius)numbers at insert and query time.- Singletons live with the class.
enemyGridis constructed at module load usingCFG.GRID_CELL_SIZE. The header reserves abulletGridslot for future use; nobulletGridis currently instantiated.