Skip to content

SpatialKit

SpatialKit turns “who is near this shape, filtered” into a sentence instead of a call-site ritual. Without it, every ability, AI scan, and loot radius hand-rolls the same three steps: get a bounding volume, walk a roster (characters, or Players:GetPlayers(), or a tag list), then check team/health/tag by hand. SpatialKit chains all three into one query:

local Victims = Kit:sphere(blastCenter, 15):players():alive():collect()

Every query reads as shape → population → filters → terminal: pick a volume, pick who’s considered, narrow with filters, then run a terminal to get results out. It sits on top of Spatial, the flat spatial-hash index — Spatial answers “what’s near this point” over arbitrary ids with no exact-shape opinions beyond its own radius/box/cone; SpatialKit is the query language that adds oriented boxes, true 3D cones, capsules, population selection, and cross-kit filters, optionally using a Spatial index as a broad-phase instead of re-implementing one.

SpatialKit.new(options) builds the kit — a thin holder for the collaborators a query needs: an optional Spatial index for broad-phasing players, an optional NPCKit for the npcs() population, an optional TeamKit for the team() filter, and an injectable GetPlayers (defaults to Players:GetPlayers(), overridable for specs). The kit itself only has four methods: the four shape constructors.

Calling a shape method (sphere, box, cone, capsule) returns a query — a separate object (its own metatable) holding the shape, an empty list of population selectors, an empty list of filters, and an empty exclusion set. Population, filter, and terminal methods live only on the query, not the kit. This isn’t just convention — it’s structural: Query has no sphere/box/cone/capsule methods, so a query can only ever have one shape, chosen first. And SpatialKit has no players/alive/collect methods, so you can’t run a terminal without first calling a shape method to get a query into existence. The grammar is enforced by which metatable has which methods, not by a runtime check.

Population and filter calls just append to the query’s Populations and Filters lists and return self — order between them doesn’t matter (:alive():players() and :players():alive() behave identically), since both lists are only read once, together, when a terminal runs the query.

Every terminal (collect, first, count, nearest) funnels through the same internal walk:

  1. Resolve the population list — the selectors that were chained, or a default if none were.
  2. For each population, resolve its subject list (candidate players, NPCKit:all(), or CollectionService:GetTagged(tag)).
  3. For each subject, dedupe against subjects already visited this query (Seen) and drop excluded subjects, then resolve a live position and test it against the shape.
  4. Run every filter against subjects that passed the shape test, short-circuiting on the first filter that fails or throws.
  5. Hand surviving (subject, distance-to-shape-origin) pairs to the terminal’s callback.

first() and count() just stop early or tally; collect() appends unconditionally; nearest() collects everything then sorts. This is why every terminal shares the same population/filter semantics — only what happens to a passing subject differs.

Every shape method lives on the kit and returns a query. Every shape’s containment test is also exported as a standalone pure function, independent of any kit instance:

Shape Kit method Pure test
Sphere Kit:sphere(position: Vector3, radius: number) SpatialKit.inSphere(point, center, radius) → boolean
Oriented box Kit:box(cframe: CFrame, size: Vector3) SpatialKit.inBox(point, cframe, size) → boolean
True 3D cone Kit:cone(origin: Vector3, direction: Vector3, range: number, fovDegrees: number) SpatialKit.inCone(point, origin, direction, range, fovDegrees) → boolean
Capsule Kit:capsule(from: Vector3, to: Vector3, radius: number) SpatialKit.inCapsule(point, from, to, radius) → boolean

These pure functions take no kit, no query, no Instances — just Vector3/CFrame/numbers — so tooling (a targeting-preview widget, a unit test for an ability’s blast shape) can reuse the exact containment math without constructing a kit.

(point - center).Magnitude <= radius. Inclusive at the boundary. The plainest shape — an AoE blast, a pickup radius, an aggro range.

function SpatialKit.inBox(point, cframe, size)
local Local = cframe:PointToObjectSpace(point)
return math.abs(Local.X) <= size.X / 2 and math.abs(Local.Y) <= size.Y / 2 and math.abs(Local.Z) <= size.Z / 2
end

PointToObjectSpace undoes the cframe’s position and rotation, so the test is a plain axis-aligned check in the box’s own local frame — which means the box in world space is oriented, not axis-aligned. Pass a rig’s HumanoidRootPart.CFrame and the search volume rotates with the rig; pass a room’s CFrame and it rotates with the room. An axis-aligned box would need conservative oversizing to cover every possible rotation of the thing it’s meant to track — the oriented test doesn’t pay that cost.

cone(origin, direction, range, fovDegrees) — true 3D, unlike Spatial’s own cone

Section titled “cone(origin, direction, range, fovDegrees) — true 3D, unlike Spatial’s own cone”
function SpatialKit.inCone(point, origin, direction, range, fovDegrees)
local Offset = point - origin
local Distance = Offset.Magnitude
if Distance > range then
return false
end
if fovDegrees >= 360 or Distance < 1e-6 then
return true
end
if direction.Magnitude < 1e-6 then
return false
end
local Dot = Offset.Unit:Dot(direction.Unit)
return Dot >= math.cos(math.rad(fovDegrees / 2))
end

fovDegrees is the full aperture (same convention as Spatial’s cone) — the half-angle used for the cosine threshold is fovDegrees / 2. fovDegrees >= 360 degenerates to a plain range check, same as Spatial.

The difference from Spatial’s cone query is exact and worth stating precisely: Spatial’s cone flattens both the direction and the offset to the XZ plane before the angle test — it’s a horizontal-only FOV gate, and a target directly above or below the origin always passes the angle test regardless of facing, as long as it’s in range. SpatialKit’s inCone never flattens anything. Both the range gate (Distance, the full 3D magnitude) and the angle gate (Offset.Unit:Dot(direction.Unit), both full 3D unit vectors) use the true offset, vertical component included.

The spec proves this with a single case: a facing of (0, 0, -1), a 60-degree cone, and a candidate at (0, 8, -10) — 8 studs up, 10 studs forward. inCone returns false: the true 3D angle between the offset and the facing works out to about 38.7 degrees, past the 30-degree half-angle. Run the same origin/direction/candidate through Spatial’s cone query and it passes — Spatial drops the Y offset entirely, leaving a flat forward offset that sits dead-ahead in the horizontal test. Same inputs, different verdict, because one cone tests the full 3D angle and the other only ever tested the horizontal one. If a design calls for “can’t be sniped from directly above,” SpatialKit’s cone is the one that can express it; Spatial’s cannot.

capsule(from, to, radius) — no equivalent in Spatial

Section titled “capsule(from, to, radius) — no equivalent in Spatial”
function SpatialKit.inCapsule(point, from, to, radius)
local Axis = to - from
local LengthSquared = Axis:Dot(Axis)
local T = 0
if LengthSquared > 1e-9 then
T = math.clamp((point - from):Dot(Axis) / LengthSquared, 0, 1)
end
return (point - (from + Axis * T)).Magnitude <= radius
end

A point-to-segment distance test: project the point onto the from → to axis, clamp the projection parameter T to [0, 1] so it can’t slide past either end, then test the point against the sphere of radius centered at that clamped point. This is a swept-sphere shape — the region within radius studs of any point on the segment, including the rounded end caps. Spatial has no capsule query at all; its shape vocabulary stops at radius/box/cone. A melee swing’s reach, a beam weapon’s hit tube, or a tripwire between two points has no equivalent broad-phase shape in Spatial itself — SpatialKit adds it as an exact-filter-only shape (see the broad-phase section below for how it still gets index acceleration).

Population selectors decide who’s a candidate before any filter or shape test runs. Call none, one, or several — multiple populations union together (with dedup) rather than replacing each other.

Method Subjects
Query:players() The kit’s player roster — the Spatial index (if given) or GetPlayers()
Query:npcs() Kit.Npcs:all() — requires an Npcs kit at construction
Query:tagged(tag: string) CollectionService:GetTagged(tag)
(none called) players(), plus npcs() only if an Npcs kit was passed to SpatialKit.new

npcs() pulls a live roster reference, not a snapshot

Section titled “npcs() pulls a live roster reference, not a snapshot”

npcs() doesn’t scan anything itself — it calls self.Kit.Npcs:all() every time the query executes, where Kit.Npcs is whatever NPCKit instance was handed to SpatialKit.new({ Npcs = ... }). There’s no separate “tell SpatialKit about NPCKit” step beyond that constructor option — omit it, and calling npcs() throws ("npcs() needs an Npcs kit at SpatialKit.new") rather than silently returning nothing.

NPCKit:all() itself already filters to living npcs (if ActiveNpc.Alive then table.insert(...)) and returns them sorted by id — so an npcs() population is alive-by-construction before SpatialKit’s own alive() filter ever runs. Chaining .alive() after .npcs() is redundant for a real NPCKit roster (though harmless, and still meaningful for players() or duck npcs in a spec that don’t carry that guarantee).

The default population is conditional, not a fixed union

Section titled “The default population is conditional, not a fixed union”

The changelog phrase “no population = players plus npcs” is accurate only when an Npcs kit was passed to SpatialKit.new. The source is exact about this:

if #Populations == 0 then
Populations = { { Kind = "Players" } }
if self.Kit.Npcs then
table.insert(Populations, { Kind = "Npcs" })
end
end

Calling zero population methods always includes players. It includes npcs in addition only if the kit was constructed with Npcs = someNpcKit. Build a SpatialKit without an Npcs option and every unselected query is players-only, not “players plus npcs” — there’s no npc roster to add.

Pulls straight from CollectionService:GetTagged(tag) — any tagged Instance with a resolvable position (a part, a model with a pivot) is a candidate, independent of whether it’s a player, an npc, or neither. This is how a loot pickup or an interactable becomes queryable the same way a character is.

Filters run after the shape test passes, in the order chained, and all must pass — the first filter that returns falsy (or throws, via pcall) drops the subject.

Method Checks
Query:alive() See below — layered health check
Query:team(name: string) Teams:teamOf(subject) == name — requires a Teams kit at construction
Query:hasTag(tag: string) CollectionService:HasTag(body, tag) (or a duck Tags table) on the subject’s body
Query:where(fn: (subject, position) -> boolean) Arbitrary predicate, given the subject and its live position
Query:exclude(subject: any | {any}) Drops one subject, or every subject in a list — see below

alive() is a layered duck-typed check, not a single Humanoid read

Section titled “alive() is a layered duck-typed check, not a single Humanoid read”
function Query.alive(self)
table.insert(self.Filters, function(subject, _position)
if type(subject) == "table" and subject.Alive ~= nil then
return subject.Alive == true
end
local Body = bodyOf(subject)
if typeof(Body) == "Instance" then
local Humanoid = Body:FindFirstChildWhichIsA("Humanoid")
return if Humanoid then Humanoid.Health > 0 else true
end
if type(Body) == "table" and Body.Humanoid then
return Body.Humanoid.Health > 0
end
return true
end)
return self
end

In order: if the subject itself is a table carrying a non-nil .Alive field (an npc handle, or a duck player in a spec), that field is authoritative. Otherwise it resolves the subject’s body — the character Model for a Player, .Model or .Character for a table subject, the Instance itself for a tagged Instance — and looks for a Humanoid child. If one exists, alive() means Humanoid.Health > 0. If the body is an Instance with no Humanoid, alive() defaults to true rather than rejecting it — a tagged prop or loot pickup with no humanoid passes alive() by default instead of being filtered out.

team(name) genuinely integrates with TeamKit, npc Factions included

Section titled “team(name) genuinely integrates with TeamKit, npc Factions included”
function Query.team(self, name)
local Teams = self.Kit.Teams
assert(Teams ~= nil, "team() needs a Teams kit at SpatialKit.new")
table.insert(self.Filters, function(subject, _position)
return Teams:teamOf(subject) == name
end)
return self
end

SpatialKit doesn’t special-case players vs. npcs here at all — it forwards the raw subject to Teams:teamOf(subject) and compares the result. The integration lives entirely in TeamKit’s own teamOf, which is confirmed (reading TeamKit.luau directly) to resolve four subject shapes: a direct player/session assignment, a character Model (resolved back to its player), a session table (.Player), and — the case that matters here — an npc handle carrying .Archetype, in which case it returns subject.Archetype.Faction directly. So a team("Blue") filter passes an npc whose Archetype.Faction == "Blue" exactly the same way it passes a player TeamKit has assigned to "Blue" — the changelog’s claim that “npc Factions count as teams” holds up precisely, not just in spirit, because TeamKit’s teamOf treats a Faction as a team name with no translation layer in between.

Reads the same “body” concept as alive() — for a Player that’s the character Model, for a table subject .Model or .Character, for anything else the Instance itself — and checks CollectionService:HasTag(body, tag), or a duck Tags table for spec doubles. A “Carrier” tag on a flag-holder’s character, checked without knowing whether the holder is a player or an npc.

function Query.exclude(self, subject)
if type(subject) == "table" and subject[1] ~= nil then
for _, Entry in subject do
self.Excluded[Entry] = true
end
else
self.Excluded[subject] = true
end
return self
end

One call, two accepted shapes: a single entity, or an array of entities (detected by checking whether index [1] is populated). Kit:sphere(origin, 15):players():exclude(caster):collect() and :exclude({caster, decoy}) both work off the same method. The most common use is excluding the caster from their own blast — a self-heal shouldn’t need a where(fn) subject ~= caster predicate when exclude says the same thing directly.

Method Returns Order
Query:collect() { subject, ... } Unspecified — whatever order populations/rosters/index cells iterate in
Query:first() subject? Not nearest — the first subject the walk happens to reach; see below
Query:count() number
Query:nearest(count: number?) { subject, ... } Sorted ascending by distance to the shape’s origin point; capped at count if given, otherwise every match

It’s tempting to read :first() as “the nearest one,” but the source doesn’t sort anything for it — visit calls the terminal’s callback the moment a subject passes every filter, and first()’s callback records the subject and returns false to stop the walk immediately:

function Query.first(self)
local Found = nil
visit(self, function(subject)
Found = subject
return false
end)
return Found
end

That means “first” is whichever population was chained first, in whatever order that population’s subject list happens to iterate — GetPlayers()’s order, NPCKit:all()’s id-sorted order, CollectionService:GetTagged’s order, or a Spatial:radius() broad-phase’s unspecified cell-iteration order. If you need the closest match, use nearest(1)first() gives you a match, not the closest one.

nearest(count) sorts by distance to the shape’s origin point

Section titled “nearest(count) sorts by distance to the shape’s origin point”
function Query.nearest(self, count)
local Hits = {}
visit(self, function(subject, distance)
table.insert(Hits, { Subject = subject, Distance = distance })
return true
end)
table.sort(Hits, function(A, B)
return A.Distance < B.Distance
end)
-- ...capped at count
end

The distance sorted on is (position - self.Shape.Origin).Magnitude, computed once per hit during the walk. Shape.Origin is shape-specific: the sphere’s center, the cone’s origin, the box’s CFrame.Position (the box’s center point — not corrected for its rotation), and the capsule’s segment midpoint (from + to) / 2 — not the closest point on the segment. For a sphere or cone this is exactly “distance from the natural reference point.” For a long capsule or a large box, two subjects that are equally “inside” the shape can rank in an order that doesn’t match intuitive proximity to the near edge, because the sort key is always distance to one fixed point, not distance to the shape’s nearest surface.

Every subject’s position is resolved fresh, at query time, through positionOf: a Player’s position is its current Character.HumanoidRootPart.Position, read at the moment the query walks it — not cached from when the query was built or from an earlier tick. A BasePart reads .Position live; a Model reads :GetPivot().Position live; an npc handle calls its own :position() method live. A subject that can’t resolve a position at all right now — no character, no root part — is silently dropped from the query rather than erroring. This matters for anything hitting moving targets: build the query once, and every terminal call against it still sees where things actually are right now, never a stale snapshot from construction.

Spatial broad-phase: only for the players population, only when given

Section titled “Spatial broad-phase: only for the players population, only when given”

The players() population is the only one that can use a Spatial index to skip a full roster scan, and only if one was passed to SpatialKit.new({ Spatial = characterIndex }):

local function candidatePlayers(self)
local Index = self.Kit.Spatial
if Index then
return Index:radius(self.Shape.Origin, broadRadius(self.Shape) + 8)
end
return self.Kit.GetPlayers()
end

Without a Spatial index, players() is a full GetPlayers() scan every time the query runs — every connected player becomes a shape-test candidate. With one, it queries the index for everything within a conservative radius around the shape and only shape-tests those candidates. The + 8 studs of extra pad exists because an index’s stored positions can lag one refresh interval behind reality (see Spatial’s character index, which refreshes at a configurable Hz, default 10) — the pad absorbs that staleness so a player who moved since the last refresh still gets pulled in as a candidate — that index refreshes on its own scheduler job at a configurable Hz (default 10), independent of when any given query runs. The exact shape test still runs against each candidate’s live position afterward, so correctness never depends on the index being perfectly fresh — only how many candidates get exact-tested does.

The radius handed to the index is shape-specific, computed by broadRadius as the smallest sphere guaranteed to contain the shape:

Shape Broad-phase radius
Sphere Radius
Box Size.Magnitude / 2 — half the box’s diagonal, so any rotation is covered
Cone Range
Capsule (To - From).Magnitude / 2 + Radius — half the segment length plus the capsule’s own radius, measured from the segment midpoint

npcs() and tagged() never consult the Spatial index — they always pull their full source list (NPCKit:all(), CollectionService:GetTagged) and rely on the shape test alone to narrow it. Broad-phasing only exists for the players population because that’s the one roster large enough, and volatile enough per-tick, for a full scan to matter.

A caster casts a heal that hits allies in a sphere, excluding no one but restricted to their own team:

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local SpatialKit = require(Root.SpatialKit)
local Spatial = require(Root.Spatial)
return function(kernel, npcKit, teamKit)
local CharacterIndex = Spatial.characters(kernel, { CellSize = 8, Hz = 10 })
local Kit = SpatialKit.new({
Spatial = CharacterIndex,
Npcs = npcKit,
Teams = teamKit,
})
-- AoE heal: allies of the caster's team, in a 20-stud sphere, alive only
local function castHeal(caster: Player, center: Vector3)
local Allies = Kit:sphere(center, 20)
:players()
:team(teamKit:teamOf(caster))
:alive()
:collect()
for _, Ally in Allies do
-- apply heal to Ally
end
end
-- Melee swing: everyone in a capsule along the blade, excluding the caster
local function meleeHit(caster: Player, bladeFrom: Vector3, bladeTo: Vector3)
local Victims = Kit:capsule(bladeFrom, bladeTo, 3)
:players()
:npcs()
:alive()
:exclude(caster)
:collect()
for _, Victim in Victims do
-- apply damage to Victim
end
end
-- NPC aggro scan: nearest living player in a forward-facing cone
local function scanForTarget(npc: any, origin: Vector3, facing: Vector3)
local Target = Kit:cone(origin, facing, 40, 70)
:players()
:alive()
:nearest(1)
return Target[1]
end
-- Loot radius: tagged pickups near a player, no team/alive filter needed
local function nearbyLoot(player: Player)
local Root = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
if not Root then
return {}
end
return Kit:sphere(Root.Position, 10):tagged("Loot"):collect()
end
end

CombatKit-style abilities, AI scans, loot radii, and win-condition objective checks all reduce to the same shape of call: pick a volume, pick who’s considered, filter, terminate. A blast radius, a cone-shaped attack, and a capsule-shaped melee swing are three shapes feeding the identical players():alive():exclude(caster):collect() tail.

Member Description
SpatialKit.new(options?) → kit options: { Spatial: any?, Npcs: any?, Teams: any?, GetPlayers: (() -> { any })? }

Spatial enables index broad-phasing of the players() population (a Spatial.characters(kernel) index, typically). Npcs (an NPCKit instance) enables npcs() and folds it into the default population. Teams (a TeamKit instance) enables team(). GetPlayers overrides the player roster function (default Players:GetPlayers()); mainly for specs that need a duck roster instead of the live Players service.

Shapes (kit methods — start every query here)

Section titled “Shapes (kit methods — start every query here)”
Method Signature
Sphere Kit:sphere(position: Vector3, radius: number) → Query
Oriented box Kit:box(cframe: CFrame, size: Vector3) → Query
True 3D cone Kit:cone(origin: Vector3, direction: Vector3, range: number, fovDegrees: number) → Query
Capsule Kit:capsule(from: Vector3, to: Vector3, radius: number) → Query
Function Signature
Sphere SpatialKit.inSphere(point: Vector3, center: Vector3, radius: number) → boolean
Oriented box SpatialKit.inBox(point: Vector3, cframe: CFrame, size: Vector3) → boolean
True 3D cone SpatialKit.inCone(point: Vector3, origin: Vector3, direction: Vector3, range: number, fovDegrees: number) → boolean
Capsule SpatialKit.inCapsule(point: Vector3, from: Vector3, to: Vector3, radius: number) → boolean
Method Signature Requires
Players Query:players() → Query
NPCs Query:npcs() → Query Npcs kit at construction
Tagged Query:tagged(tag: string) → Query
Method Signature Requires
Alive Query:alive() → Query
Team Query:team(name: string) → Query Teams kit at construction
Has tag Query:hasTag(tag: string) → Query
Predicate Query:where(fn: (subject: any, position: Vector3) -> boolean) → Query
Exclude Query:exclude(subject: any | { any }) → Query
Method Signature Order
Collect Query:collect() → { any } Unspecified iteration order
First Query:first() → any? Iteration order — not nearest
Count Query:count() → number
Nearest Query:nearest(count: number?) → { any } Ascending distance to the shape’s origin point, capped at count

None. SpatialKit publishes nothing on the Bus and consumes nothing from it — it’s a pure query builder over live state and whatever kits you inject, the same design stance as Spatial itself. Anything built on top of a query result (damage, healing, aggro state changes) publishes its own events.