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.
Mental model
Section titled “Mental model”Two objects: the kit and the query
Section titled “Two objects: the kit and the query”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.
Execution: one walk per query
Section titled “Execution: one walk per query”Every terminal (collect, first, count, nearest) funnels through the same internal walk:
- Resolve the population list — the selectors that were chained, or a default if none were.
- For each population, resolve its subject list (candidate players,
NPCKit:all(), orCollectionService:GetTagged(tag)). - 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. - Run every filter against subjects that passed the shape test, short-circuiting on the first filter that fails or throws.
- 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.
Shapes
Section titled “Shapes”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.
sphere(position, radius)
Section titled “sphere(position, radius)”(point - center).Magnitude <= radius. Inclusive at the boundary. The plainest shape — an AoE blast, a pickup radius, an aggro range.
box(cframe, size) — oriented
Section titled “box(cframe, size) — oriented”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 / 2endPointToObjectSpace 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))endfovDegrees 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 <= radiusendA 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).
Populations
Section titled “Populations”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" }) endendCalling 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.
tagged(tag)
Section titled “tagged(tag)”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
Section titled “Filters”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 selfendIn 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 selfendSpatialKit 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.
hasTag(tag)
Section titled “hasTag(tag)”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.
exclude(subject)
Section titled “exclude(subject)”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 selfendOne 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.
Terminals
Section titled “Terminals”| 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 |
first() is iteration order, not proximity
Section titled “first() is iteration order, not proximity”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 FoundendThat 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 countendThe 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.
Live positions, always
Section titled “Live positions, always”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()endWithout 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").ChloeKernelServerlocal 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() endendCombatKit-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.
API reference
Section titled “API reference”Constructor
Section titled “Constructor”| 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 |
Pure shape tests (no kit required)
Section titled “Pure shape tests (no kit required)”| 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 |
Populations (query methods)
Section titled “Populations (query methods)”| Method | Signature | Requires |
|---|---|---|
| Players | Query:players() → Query |
— |
| NPCs | Query:npcs() → Query |
Npcs kit at construction |
| Tagged | Query:tagged(tag: string) → Query |
— |
Filters (query methods)
Section titled “Filters (query methods)”| 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 |
— |
Terminals (query methods)
Section titled “Terminals (query methods)”| 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 |
Bus topics
Section titled “Bus topics”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.