Skip to content

Spatial

Spatial is a flat spatial-hash index: register any id at a world position, then ask “what’s near this point” without touching the DataModel or scanning a full roster. It’s the broad-phase in front of expensive exact tests — sensor cones, projectile threat radii, interest scans. Query the index first, spend engine raycasts and sight tests only on the survivors it returns.

There’s no tree, no pointers, no per-node allocation. Every id lives in exactly one of three flat tables (cell membership, current cell key, current position), and a move within the same cell is a plain position write — no rehash, no rebalancing.

Space is divided into cubes of CellSize studs (default 8). A position hashes to a cell by flooring each axis to a cell coordinate and packing all three into one integer key:

local function packKey(cellSize, position)
local X = math.clamp(math.floor(position.X / cellSize) + 65536, 0, 131071)
local Y = math.clamp(math.floor(position.Y / cellSize) + 65536, 0, 131071)
local Z = math.clamp(math.floor(position.Z / cellSize) + 65536, 0, 131071)
return (X * 131072 + Y) * 131072 + Z
end

Each axis’s cell coordinate is offset by 65536 so it’s non-negative, then clamped to 0..131071 — a 17-bit field. The three 17-bit fields are packed base-131072 into one number: (X * Span + Y) * Span + Z. The largest possible key is just under 131072^3 = 2^51, which sits well inside a double’s 53-bit exact-integer range, so the key never loses precision to floating rounding — it behaves exactly like an integer hash even though Luau numbers are doubles.

Because the pre-offset coordinate clamps to -65536..65535, world positions clamp to roughly ±524,288 studs at the default CellSize (65536 * 8). Positions further out than that still get inserted — they just collapse onto the boundary cell for hashing purposes (see the caution below).

Cells maps a packed key to a set of ids ({ [id]: true }). IdCell maps an id back to its current key, and IdPosition holds its exact, unclamped position — the position stored for exact-filtering is never the clamped-to-grid value, so distance and bounds checks stay exact even at the edge of the coordinate span.

set() is cheap when you don’t change cells

Section titled “set() is cheap when you don’t change cells”

set(id, position) is the only write path, and it’s also the move path — there’s no separate “move” call. It looks up the id’s current cell key: if the new position hashes to the same key, it’s a same-cell move and the call is just IdPosition[id] = position, nothing else touches the grid. If the key changed (or the id is new), it pulls the id out of its old cell (deleting the cell entry entirely once empty, so dead cells don’t accumulate), inserts it into the new cell (creating that cell’s set on demand), and updates IdCell/IdPosition. Size only increments when the id wasn’t already tracked.

set() is an upsert — you never need to remove() an id before calling set() on it again. Calling set() on an id that’s already indexed just relocates it.

Queries: broad-phase by cell, then exact-filter

Section titled “Queries: broad-phase by cell, then exact-filter”

Every query shape shares one internal walk (collect): given a world-space bounding box (min/max), it converts the box to a cell-coordinate range, clamps that range to the same -65536..65535 span the grid uses (so an out-of-range query still visits each boundary cell exactly once instead of re-scanning it per out-of-range step), and iterates every cell inside that box. For each id found in those cells, it calls a keep(position) predicate and only appends ids that pass. The cell walk is the broad-phase; the predicate is the exact filter. This is why every shape below returns exact results — cell membership only decides which ids are checked, never which ids are returned.

Result order is unspecified — a query’s ids come out however the cells and their internal sets happen to iterate.

Index:radius(position, radius) -- { id, ... }

Bounding box is position - radius to position + radius on every axis; the exact filter keeps ids where (candidate - position).Magnitude <= radius. An id sitting exactly on the boundary (distance == radius) is included — the spec asserts this at a cell boundary specifically, so the inequality is inclusive, not a near-miss trap.

Index:box(min, max) -- { id, ... }

No radius math — the bounding box is the query box, and the exact filter keeps ids where every axis independently falls between min and max (inclusive). A corner exactly on max counts as inside.

Index:cone(origin, direction, range, fovDegrees) -- { id, ... }

A horizontal field-of-view gate: the vertical axis is dropped entirely from the direction and the offset before either is compared, and the whole search is a range-limited disc test plus a dot-product angle test on the XZ plane.

Two inputs degrade the cone straight to a plain radius(origin, range) call: fovDegrees >= 360, and a direction whose horizontal component (Vector3.new(direction.X, 0, direction.Z)) has magnitude <= 1e-3 (a purely vertical or zero direction can’t define a horizontal facing).

Otherwise: Facing is the unit horizontal direction, and Threshold = cos(rad(fovDegrees / 2)) — the cosine of the half-angle. For each candidate inside the bounding box:

  1. If the full 3D offset’s magnitude exceeds range, reject — this is the range gate and it uses the true 3D distance, not the flattened one.
  2. Flatten the offset to XZ. If that flattened offset’s magnitude is <= 1e-3 (the candidate sits directly above or below origin, with no horizontal component to test), accept — there’s no horizontal angle to fail.
  3. Otherwise accept only if Facing:Dot(FlatOffset.Unit) >= Threshold — the angle between facing and the horizontal offset is within fovDegrees / 2 of dead-ahead.

The FOV is purely horizontal: pitch never enters the test. A target directly overhead or underfoot, within range, always passes step 2 regardless of which way direction points.

Index:nearest(position, maxRadius) -- (id?, distance?)

An expanding-radius search, not a fixed radius query: it re-runs radiusInto at a growing probe radius until it finds at least one id or exhausts maxRadius (default 512). The probe starts at CellSize and doubles each empty iteration (CellSize, 2 * CellSize, 4 * CellSize, …), clamped so the probe never exceeds maxRadius. The loop condition (Reach <= maxRadius * 2) guarantees the last probe always lands exactly on maxRadius before giving up, so a target sitting right at the limit is never missed by a doubling step overshooting past it.

Each probe is a full re-query from the origin — not an annulus of just the new ring — so a nearest call against a mostly-empty area costs one radiusInto per doubling, each scanning everything within its (growing) radius again. Once a probe returns any candidates, nearest linear-scans just that probe’s results for the minimum distance and returns (id, distance). Nothing at all inside maxRadius returns (nil, nil).

maxRadius is a hard cap, not a hint — if you don’t pass one, the effective search horizon is 512 studs, not “the whole map.”

Allocation-free queries: the Into variants

Section titled “Allocation-free queries: the Into variants”

radiusInto, boxInto, and coneInto take an extra out table and append to it instead of allocating a fresh result table — radius, box, and cone are thin wrappers that just call the Into form with a new {}.

function Spatial.radiusInto(self, position, radius, out)
-- ...
return collect(self, position - Reach, position + Reach, out, keep)
end
function Spatial.radius(self, position, radius)
return self:radiusInto(position, radius, {})
end

This exists for hot per-tick queries — a projectile simulation or an NPC target scan calling a query every frame for every actor shouldn’t allocate a new result table on every one of those calls just to throw it away a moment later. Pair the Into variants with a scratch table from TablePool: acquire a table once, table.clear() it, run the query with it as out, read the results, then release it back to the pool instead of letting the GC collect a fresh allocation every tick.

Character index: Spatial.characters(kernel)

Section titled “Character index: Spatial.characters(kernel)”
local Index = Spatial.characters(kernel, { CellSize = 8, Hz = 10 })

A convenience constructor that wires a self-refreshing index of player character roots into a kernel scheduler job, so you don’t hand-roll the join/leave/respawn bookkeeping yourself. It:

  1. Builds a plain Spatial.new({ CellSize }) internally.
  2. Registers a recurring job via kernel.Scheduler:every(1 / Hz, fn, kernel.Priority.Low) (Hz defaults to 10, so the index refreshes 10 times a second by default, at low scheduler priority so it never competes with gameplay-critical work).
  3. Each tick, it walks Players:GetPlayers(). For every player with a Character that has a HumanoidRootPart, it calls Index:set(player, root.Position) — the id is the Player instance itself, not the character model.
  4. After the sweep, it walks the index’s own tracked ids and Index:remove(id)s any Player id it didn’t see this tick — this is how a player who left, or whose character despawned between ticks (death, respawn gap), drops out of the index automatically rather than leaving a stale position behind.

The returned index is a normal Spatial instance — every query shape above works on it exactly the same way, keyed by Player — plus two extra members: Index.Handle (the scheduler TaskHandle) and Index:destroy(), which cancels that handle and clears the index. Call destroy() when you’re done with the index (round end, server shutdown) — the recurring job otherwise keeps running forever.

Spatial doesn’t only track characters — id is any, so strings, numbers, or non-character Instances all work the same as Player:

Call Effect
Index:set(id, position) Insert or move. Upsert — safe to call repeatedly on the same id, and safe to call on an id that was never inserted
Index:remove(id) Drop an id. Safe to call on an id that isn’t tracked — it’s a no-op, not an error
Index:position(id) Current stored position, or nil if untracked
Index:count() Number of tracked ids
Index:clear() Drop everything — all cells, all ids, count reset to 0

Two systems take a Spatial index directly at attach() and use it to skip a full-roster scan:

  • ProjectilesProjectiles.attach { Spatial = Index } replaces its default GetTargets (a full Players:GetPlayers() scan every tick) with a per-projectile radius query against the index, so each live projectile only exact-tests the roster members actually near its own position instead of the whole player list.
  • NPCKitNPCKit.attach { Spatial = Index } refreshes the index and broad-phases its target scans through a stale-padded radius query before running any sight test, so the expensive line-of-sight check only ever runs against candidates the grid already says are plausibly close.

The general pattern: build one Spatial index (often via Spatial.characters(kernel)) and hand the same index into every consumer that needs “who’s near X” — one refresh backs every consumer’s broad-phase instead of each one keeping its own roster scan.

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Spatial = require(Root.Spatial)
return function(kernel)
-- Plain index over arbitrary ids
local Index = Spatial.new({ CellSize = 8 })
Index:set("crate_1", Vector3.new(10, 0, 10))
Index:set("crate_2", Vector3.new(100, 0, 0))
Index:set("crate_3", Vector3.new(12, 0, 8))
-- Same-cell move: cheap position write, no rehash
Index:set("crate_1", Vector3.new(11, 0, 11))
-- Cross-cell move: pulled from the old cell, inserted into the new one
Index:set("crate_1", Vector3.new(-40, 0, -40))
local Nearby = Index:radius(Vector3.new(10, 0, 10), 5) -- { "crate_3" }
Index:remove("crate_2")
print(Index:count()) -- 2
-- Self-refreshing player-root index, wired into the scheduler
local Characters = Spatial.characters(kernel, { CellSize = 8, Hz = 10 })
kernel.Scheduler:every(1, function()
local Id, Distance = Characters:nearest(Vector3.new(0, 0, 0), 128)
if Id then
print(Id.Name, "is", Distance, "studs from origin")
end
end, kernel.Priority.Low)
-- Tear down the character index (round end, shutdown)
-- Characters:destroy()
end
Member Description
Spatial.new(options?) → index options: { CellSize? = 8 }
Spatial.characters(kernel, options?) → index Self-refreshing player-root index. options: { CellSize? = 8, Hz? = 10 }. Extra members: index.Handle (scheduler TaskHandle), index:destroy()
index:set(id, position) Insert or move id. Same-cell call is a position write only; cross-cell call rehashes. Upsert — no separate insert-vs-move distinction
index:remove(id) Drop id. No-op if id isn’t tracked
index:position(id) → Vector3? Current stored position, or nil if untracked
index:count() → number Number of tracked ids
index:clear() Drop every id and cell
index:radius(position, radius) → { id } Ids within radius studs of position, exact (<=)
index:radiusInto(position, radius, out) → { id } Same as radius, appends into caller’s out instead of allocating
index:box(min, max) → { id } Ids inside the axis-aligned box min..max, inclusive on every axis
index:boxInto(min, max, out) → { id } Same as box, appends into caller’s out
index:cone(origin, direction, range, fovDegrees) → { id } Ids within range studs and within fovDegrees of horizontal facing direction. fovDegrees >= 360 or a zero-length horizontal direction degrades to radius(origin, range)
index:coneInto(origin, direction, range, fovDegrees, out) → { id } Same as cone, appends into caller’s out
index:nearest(position, maxRadius? = 512) → (id?, distance?) Expanding-radius search from CellSize up to maxRadius, doubling each empty probe. (nil, nil) if nothing is within maxRadius

Spatial publishes and consumes nothing on the Bus — it’s a pure index, not an event source. Consumers like Projectiles and NPCKit own whatever events they build on top of its query results.