Spatial
A flat spatial-hash grid over arbitrary ids with exact radius, box, cone and nearest queries, allocation-free variants, and an event-driven index of player character roots.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Spatial - Since
- v0.6.0
- Source
- Spatial.luau
Spatial is a flat spatial-hash index: register any id at a world position, then ask what is near a point without touching the DataModel or walking a whole roster. It is the broad phase in front of expensive exact tests (sensor cones, projectile capsules, interest scans): query the index first and spend engine raycasts only on what it returns. SpatialKit is the query language built on top of it, with oriented boxes, 3D cones, capsules and population filters.
There is no tree and no per-node allocation. Every id lives in three flat tables (cell membership, current cell key, current position), and a move within the same cell is one position write.
Mental model
Section titled “Mental model”The grid
Section titled “The grid”Space is divided into cubes of CellSize studs (default 8; Spatial.new asserts a finite positive size). A position hashes to a cell by flooring each axis to a cell coordinate and packing the three into one integer key:
local function packKey(cellSize: number, position: Vector3): number 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 + ZendEach cell coordinate is offset by 65,536 and clamped to 0..131,071, a 17-bit field, and the three fields pack into one number below 2^51. That is inside a double’s 53-bit exact integer range, so the key behaves like an integer hash. Cell coordinates clamp to -65,536..65,535, which is about 524,288 studs each way at the default CellSize. Positions beyond that are still indexed; they share the boundary cells.
Cells maps a key to a set of ids, IdCell maps an id to its key, and IdPosition holds the exact, unclamped position. Exact filters always use IdPosition, so distance and bounds checks stay exact even past the clamp.
set() is cheap when the cell does not change
Section titled “set() is cheap when the cell does not change”set(id, position) is the only write and also the move. If the new position hashes to the id’s current key, the call writes IdPosition[id] and returns. Otherwise it removes the id from its old cell (deleting the cell once empty, so dead cells do not accumulate), inserts it in the new one and updates both maps. set is an upsert; there is no separate insert and move.
A position with a NaN coordinate cannot key a cell, so set removes the id instead of indexing it. A NaN root on one character therefore drops that character from the index and leaves every other id untouched.
Queries: broad phase by cell, then exact filter
Section titled “Queries: broad phase by cell, then exact filter”Every shape shares one walk. Given a world-space box, it converts the box to a cell range (clamped to the grid’s span, so an out-of-range query visits each boundary cell once) and tests every id in those cells against the shape’s exact filter. Cell membership decides which ids are checked, never which are returned, so every result is exact.
When the cell range holds more cells than there are ids in the index, the walk skips the cells and scans every registered id instead. A 512-stud radius at 8-stud cells covers about 2.1 million cells; a server with 30 players has 30 ids, and the scan is the cheaper path. Result order is not defined either way.
The exact filters compare squared distances and read their parameters from module-level slots, so a query allocates no closure. The walk never yields, so the slots cannot be overwritten mid-query.
Query shapes
Section titled “Query shapes”radius
Section titled “radius”local Ids = Index:radius(position, radius)Keeps ids whose distance to position is at most radius, inclusive. A negative radius matches nothing.
local Ids = Index:box(min, max)Keeps ids whose position is between min and max on every axis, inclusive. A box whose min exceeds max on any axis returns nothing.
local Ids = Index:cone(origin, direction, range, fovDegrees)A horizontal field of view: a range test in 3D, then an angle test on the X/Z plane. fovDegrees >= 360, or a direction with no horizontal component (magnitude of its X/Z part at most 1e-3), degrades to radius(origin, range). Otherwise, for each candidate:
- Reject when the full 3D distance exceeds
range. - Accept when the candidate sits directly above or below the origin (no horizontal offset), since there is no horizontal angle to test.
- Accept when the angle between the flattened facing and the flattened offset is within
fovDegrees / 2.
Pitch never enters the test. SpatialKit’s cone is the true 3D alternative.
nearest
Section titled “nearest”local Id, Distance = Index:nearest(position, maxRadius)An expanding search: a radius query at CellSize, then twice that, doubling until a probe finds at least one id or reaches maxRadius (default 512). The last probe is exactly maxRadius, so a target at the limit is not skipped by a doubling that overshoots. Each probe is a full query from the origin, and the first probe with results is scanned for the closest id. Nothing within maxRadius, a negative or NaN maxRadius, or an empty index returns nil, nil.
Allocation-free queries: the Into variants
Section titled “Allocation-free queries: the Into variants”radiusInto, boxInto and coneInto append to a table you pass instead of allocating one; radius, box and cone call them with a fresh {}. For per-tick queries (a projectile step, an NPC target scan) pair them with a TablePool scratch table: acquire, query into it, read, release.
local Scratch = TablePool.acquire()Index:radiusInto(Origin, 30, Scratch)for _, Id in Scratch do -- exact tests on the survivorsendTablePool.release(Scratch)Character index: Spatial.characters(kernel)
Section titled “Character index: Spatial.characters(kernel)”local Characters = Spatial.characters(kernel, { CellSize = 8, Hz = 10 })Builds an index of player character roots, keyed by Player, that maintains itself from events:
PlayerAddedandPlayerRemovingadd and forget players; players already in the game are watched at construction.CharacterAddedfinds the character’sHumanoidRootPartand starts tracking it. A root that arrives afterCharacterAdded(streaming, custom rigs) is caught through the character’sChildAdded.CharacterRemovingdrops the player at once; a characterless player is never in the index.- While at least one root is tracked, a Scheduler job samples every tracked root
Hztimes per second (default 10) atkernel.Priority.Low. It cancels itself when no root is tracked, so an empty server has no job. A root whoseParentbecame nil is dropped and the player’s current character is watched again.
The result is a normal index, keyed by Player, plus Index:destroy(), which disconnects every event, cancels the job and clears the index. Call it when the owning service stops.
Arbitrary ids: insert, remove, move
Section titled “Arbitrary ids: insert, remove, move”id can be anything: strings, numbers, Instances, npc handles.
| Call | Effect |
|---|---|
Index:set(id, position) |
Insert or move; safe to repeat. A NaN position removes the id |
Index:remove(id) |
Drop an id; no-op when it is not indexed |
Index:position(id) |
Current stored position, or nil |
Index:count() |
Number of indexed ids |
Index:clear() |
Drop every id and cell |
Wired consumers
Section titled “Wired consumers”- Projectiles takes
Spatial = Characters(aSpatial.charactersindex) and queries capsule-mode candidates per shot instead of scanning every player each tick. - Sweeps takes a
Spatial.charactersindex and queries candidates within each attack’sReachof the rig’s root. - SpatialKit broad-phases its
players()population through aSpatial.charactersindex. - NPCKit takes a
Spatialindex and broad-phases target scans through it. By default itsupdate()refreshes the index from its own target roster and removes ids that are not in it. When several systems share one index that the game maintains, passfalseas the second argument (Npcs:update(nil, false)) so NPCKit leaves it alone. - Zones keeps a private
Spatialindex of occupant bounds in itsBoundsdetection mode.
Build one character index and hand the same instance to every consumer that needs “which players are near here”. One set of samples then backs every broad phase.
--!strict-- ArenaService: owns the shared character index and answers "who is near".
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Spatial = require(ReplicatedStorage.ChloeKernel.Spatial)
local ArenaService = { Name = "ArenaService",}
function ArenaService.init(self: any, kernel: any) self.Kernel = kernelend
function ArenaService.start(self: any) self.Characters = Spatial.characters(self.Kernel, { CellSize = 8, Hz = 10 })end
function ArenaService.nearestPlayer(self: any, position: Vector3): (Player?, number?) return self.Characters:nearest(position, 128)end
function ArenaService.stop(self: any) if self.Characters then self.Characters:destroy() endend
return ArenaServiceA plain index over props:
local Crates = Spatial.new({ CellSize = 16 })Crates:set("Crate1", Vector3.new(10, 0, 10))Crates:set("Crate2", Vector3.new(100, 0, 0))Crates:set("Crate1", Vector3.new(11, 0, 11)) -- same cell: one position writelocal Near = Crates:radius(Vector3.new(10, 0, 10), 5) -- { "Crate1" }Crates:remove("Crate2")API reference
Section titled “API reference”| Member | Description |
|---|---|
Spatial.new(options?) -> index |
options: { CellSize? = 8 }; errors on a size that is not finite and positive |
Spatial.characters(kernel, options?) -> index |
Event-driven index of player roots keyed by Player. options: { CellSize? = 8, Hz? = 10, Players? } (Players injects the player service for specs). Adds index:destroy() |
index:set(id, position) |
Insert or move; a same-cell move is one position write; a NaN position removes the id |
index:remove(id) |
Drop id; no-op when absent |
index:position(id) -> Vector3? |
Stored position, or nil |
index:count() -> number |
Number of ids |
index:clear() |
Drop every id and cell |
index:radius(position, radius) -> { any } |
Ids within radius, inclusive |
index:radiusInto(position, radius, out) -> { any } |
radius, appending to out |
index:box(min, max) -> { any } |
Ids inside the axis-aligned box, inclusive on every axis |
index:boxInto(min, max, out) -> { any } |
box, appending to out |
index:cone(origin, direction, range, fovDegrees) -> { any } |
Ids within range and within fovDegrees of the horizontal facing; degrades to radius at 360 degrees or a vertical direction |
index:coneInto(origin, direction, range, fovDegrees, out) -> { any } |
cone, appending to out |
index:nearest(position, maxRadius?) -> (any?, number?) |
Expanding search from CellSize to maxRadius (default 512); nil, nil when nothing is in range |
index:destroy() |
Character indexes only: disconnects events, cancels sampling and clears the index |
Bus topics
Section titled “Bus topics”None. Spatial publishes and consumes nothing on the Bus; the systems that query it publish their own events.