Skip to content

SpatialKit

A fluent query builder that chains a shape, a population, filters and a terminal, such as sphere, players, alive, collect, instead of hand-rolled radius scans.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.SpatialKit
Since
v0.7.2
Source
SpatialKit.luau

SpatialKit turns “who is in this shape, filtered” into one expression. Without it, every ability, AI scan and loot radius repeats the same steps: build a volume, walk a roster (characters, players, a tag list), then check team, health and tags by hand. SpatialKit chains them:

local Victims = Kit:sphere(BlastCenter, 15):players():alive():exclude(Caster):collect()

Every query reads shape, then population, then filters, then a terminal. It sits on top of Spatial, the flat hash index: Spatial answers “what is near this point” over arbitrary ids, and SpatialKit adds oriented boxes, true 3D cones, capsules, population selection and cross-kit filters, using a Spatial character index as its broad phase when you give it one.

SpatialKit.new(options) builds the kit, which holds the collaborators queries need: an optional Spatial character index for the players population, an optional NPCKit for npcs(), an optional TeamKit for team(), and an injectable GetPlayers (default Players:GetPlayers()). The kit has four methods: the shape constructors.

A shape method returns a query, a separate object with its own metatable holding the shape, a list of populations, a list of filters and an exclusion set. Populations, filters and terminals exist only on the query, and shapes exist only on the kit. A query therefore has exactly one shape, chosen first, and a terminal cannot run before a shape exists. The grammar is enforced by which metatable has which methods; there is no runtime check.

Population and filter calls append to their lists and return the query. Their relative order does not matter: :alive():players() equals :players():alive(), because both lists are read once, when a terminal runs. Queries are one-shot: build, chain, terminate.

Every terminal (collect, first, count, nearest) runs the same walk:

  1. Resolve the population list: the chained selectors, or the default when none were chained.
  2. For each population, get its subjects: candidate players, NPCKit:all(), or CollectionService:GetTagged(tag).
  3. For each subject, skip it if an earlier population already produced it or it was excluded, then resolve its live position and test it against the shape.
  4. Run every filter on subjects inside the shape, stopping at the first filter that returns false or throws.
  5. Hand each survivor and its distance to the shape’s origin to the terminal.

first() stops at the first survivor, count() tallies, collect() appends, and nearest() collects and sorts.

Every shape’s containment test is also exported as a pure function that needs no kit, no query and no Instances, so a targeting preview or an ability spec can reuse the exact math:

Member Description
Kit:sphere(position, radius) -> Query Pure test: SpatialKit.inSphere(point, center, radius)
Kit:box(cframe, size) -> Query Oriented box. Pure test: SpatialKit.inBox(point, cframe, size)
Kit:cone(origin, direction, range, fovDegrees) -> Query True 3D cone. Pure test: SpatialKit.inCone(point, origin, direction, range, fovDegrees)
Kit:capsule(from, to, radius) -> Query Capsule. Pure test: SpatialKit.inCapsule(point, from, to, radius)

(point - center).Magnitude <= radius, inclusive. Blast radii, pickup ranges, aggro ranges.

function SpatialKit.inBox(point: Vector3, cframe: CFrame, size: Vector3): boolean
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 axis-aligned in the box’s own frame and the box is oriented in the world. Pass a rig’s root CFrame and the volume turns with the rig; an axis-aligned box would need oversizing to cover every rotation.

cone(origin, direction, range, fovDegrees): true 3D

Section titled “cone(origin, direction, range, fovDegrees): true 3D”
function SpatialKit.inCone(point: Vector3, origin: Vector3, direction: Vector3, range: number, fovDegrees: number): boolean
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
return Offset.Unit:Dot(direction.Unit) >= math.cos(math.rad(fovDegrees / 2))
end

fovDegrees is the full aperture, the same convention as Spatial’s cone, and 360 or more degrades to a range check. Below 360, a zero direction matches only a point at the origin.

The difference from Spatial’s cone is the vertical axis. Spatial flattens the facing and the offset to X/Z, so a target directly above the origin passes whatever the facing. SpatialKit keeps the full 3D offset in both the range and the angle test. With a facing of (0, 0, -1), a 60-degree cone and a candidate at (0, 8, -10), inCone returns false: the 3D angle is about 38.7 degrees, past the 30-degree half-angle. Spatial’s cone accepts the same candidate because it ignores the 8 studs of height. Use SpatialKit’s cone when “cannot be hit from directly above” matters.

function SpatialKit.inCapsule(point: Vector3, from: Vector3, to: Vector3, radius: number): boolean
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

Project the point onto the segment, clamp to its ends, and test the distance: everything within radius of any point on the segment, rounded end caps included. A melee swing’s reach, a beam, a tripwire between two points. Spatial has no capsule; SpatialKit tests it exactly and broad-phases it with a containing sphere (below). Sweeps is the tool for capsules that move with a rig over a swing.

Populations decide who is a candidate before any shape test runs. Chain none, one or several; several union together with duplicates removed.

Member Description
Query:players() -> Query The player roster: the kit’s Spatial index when given, else GetPlayers()
Query:npcs() -> Query Npcs:all() from the NPCKit passed to SpatialKit.new; errors without one
Query:tagged(tag) -> Query CollectionService:GetTagged(tag): any tagged instance with a position

With no population chained, the default is players(), plus npcs() only when the kit has an Npcs kit. A kit built without Npcs runs player-only default queries.

npcs() calls Npcs:all() each time the query runs. NPCKit’s all() returns living NPCs that are not dying (between Npc.Died and despawn), sorted by id, so an npcs() population is alive by construction; alive() after it is harmless. Calling npcs() on a kit without Npcs errors when the terminal runs (npcs() needs an Npcs kit at SpatialKit.new) instead of returning nothing.

Pulls straight from CollectionService:GetTagged(tag): any tagged BasePart or Model with a position is a candidate, whether it is a player’s character, an NPC or neither. Loot pickups and interactables become queryable the same way as characters.

Filters run on subjects inside the shape, in chain order, and all must pass. A filter that returns falsy or throws (each runs under pcall) drops the subject.

Member Description
Query:alive() -> Query Layered health check (below)
Query:team(name) -> Query Teams:teamOf(subject) == name; team() errors at once without a Teams kit
Query:hasTag(tag) -> Query body:HasTag(tag) on the subject’s body (a duck Tags set for spec doubles)
Query:where(predicate) -> Query predicate(subject, position) -> boolean, given the live position
Query:exclude(subject) -> Query Drops one subject, or every subject in a list
  1. A table subject with a non-nil Alive field (an npc handle) uses that field.
  2. Otherwise it resolves the subject’s body: the character for a Player, .Model or .Character for a table, the Instance itself for a tagged instance.
  3. A body with a Humanoid passes when Health > 0. A body with no Humanoid passes: a tagged prop is “alive” unless you filter it another way.

team() forwards the raw subject to TeamKit:teamOf, which resolves a directly assigned subject, a session (through .Player), a character Model (through its player), and an npc handle (through Archetype.Faction). team("Blue") therefore passes a Blue player and an NPC whose archetype’s Faction is "Blue", with no translation layer.

Reads the same body as alive() and calls Instance:HasTag(tag) on it. A “Carrier” tag on a flag holder’s character works whether the holder is a player or an NPC.

Takes one subject or an array of subjects (detected by a non-nil index [1]). The common use is excluding the caster from their own blast:

Kit:sphere(Origin, 15):players():exclude(Caster):collect()
Kit:sphere(Origin, 15):players():exclude({ Caster, Decoy }):collect()
Member Description
Query:collect() -> { any } Every survivor, in walk order (not defined)
Query:first() -> any? The first survivor the walk reaches, which is not the nearest
Query:count() -> number Number of survivors
Query:nearest(count?) -> { any } Survivors sorted by distance to the shape’s origin, capped at count

first() returns the first survivor the walk reaches: the first chained population, in that source’s order (GetPlayers(), NPCKit’s id order, GetTagged’s order, or the Spatial index’s undefined order). Use nearest(1) when proximity matters.

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

Section titled “nearest(count) sorts by distance to the shape’s origin”

The sort key is the distance from each survivor’s position to the shape’s origin: the sphere’s center, the cone’s origin, the box’s CFrame.Position, and the capsule’s segment midpoint (from + to) / 2. For a long capsule or a large box, two subjects equally inside the shape can rank against intuition, because the key is one fixed point, not the nearest surface.

Every subject’s position is resolved when the walk reaches it: a Player reads its character’s HumanoidRootPart.Position, a BasePart its Position, a Model its pivot, and an npc handle calls npc:position(). A subject with no resolvable position (no character, no root) is skipped. A terminal always sees where things are now.

Spatial broad phase: players only, when given

Section titled “Spatial broad phase: players only, when given”

Only the players population can use an index, and only when one was passed as Spatial (a Spatial.characters index):

local function candidatePlayers(self: any): { any }
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 an index, players() shape-tests every connected player. With one, it tests only players within a sphere that contains the shape, plus 8 studs because the index samples roots at its own rate (10 Hz by default) and can lag a moving player. The exact shape test always runs on the live root, so correctness never depends on index freshness; only the candidate count does.

The containing sphere per shape:

Shape Broad-phase radius
Sphere Radius
Box Size.Magnitude / 2, half the diagonal, which covers any rotation
Cone Range
Capsule (To - From).Magnitude / 2 + Radius, from the segment midpoint

npcs() and tagged() never use the index; they take their full lists and rely on the shape test.

src/Server/Game/Services/AbilityService.luau
--!strict
-- AbilityService: heals, cleaves and aggro scans as SpatialKit queries.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Spatial = require(ReplicatedStorage.ChloeKernel.Spatial)
local SpatialKit = require(ReplicatedStorage.ChloeKernel.SpatialKit)
local AbilityService = {
Name = "AbilityService",
Dependencies = { "TeamService" }, -- owns the game's TeamKit as .Teams
}
function AbilityService.init(self: any, kernel: any)
self.Kernel = kernel
end
function AbilityService.start(self: any)
self.Teams = self.Kernel:getService("TeamService").Teams
self.Characters = Spatial.characters(self.Kernel)
self.Query = SpatialKit.new({ Spatial = self.Characters, Teams = self.Teams })
end
-- Allies of the caster's team within 20 studs, alive, caster included
function AbilityService.healTargets(self: any, caster: Player, center: Vector3): { any }
local Team = self.Teams:teamOf(caster)
if not Team then
return {}
end
return self.Query:sphere(center, 20):players():team(Team):alive():collect()
end
-- Everyone the blade's capsule touches, never the swinger
function AbilityService.cleaveTargets(self: any, caster: Player, bladeFrom: Vector3, bladeTo: Vector3): { any }
return self.Query:capsule(bladeFrom, bladeTo, 3):players():alive():exclude(caster):collect()
end
-- The closest living player in a 70-degree cone, 40 studs deep
function AbilityService.aggroTarget(self: any, origin: Vector3, facing: Vector3): Player?
return self.Query:cone(origin, facing, 40, 70):players():alive():nearest(1)[1]
end
function AbilityService.stop(self: any)
if self.Characters then
self.Characters:destroy()
end
end
return AbilityService

Loot near a player needs no filter: Query:sphere(Root.Position, 10):tagged("Loot"):collect().

None. SpatialKit publishes and consumes nothing on the Bus. Whatever acts on a result (damage, healing, aggro) publishes its own events.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.