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.
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, 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.
Execution: one walk per query
Section titled “Execution: one walk per query”Every terminal (collect, first, count, nearest) runs the same walk:
- Resolve the population list: the chained selectors, or the default when none were chained.
- For each population, get its subjects: candidate players,
NPCKit:all(), orCollectionService:GetTagged(tag). - 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.
- Run every filter on subjects inside the shape, stopping at the first filter that returns false or throws.
- 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.
Shapes
Section titled “Shapes”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) |
sphere(position, radius)
Section titled “sphere(position, radius)”(point - center).Magnitude <= radius, inclusive. Blast radii, pickup ranges, aggro ranges.
box(cframe, size): oriented
Section titled “box(cframe, size): oriented”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 / 2endPointToObjectSpace 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))endfovDegrees 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.
capsule(from, to, radius)
Section titled “capsule(from, to, radius)”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 <= radiusendProject 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
Section titled “Populations”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() reads the live roster
Section titled “npcs() reads the live roster”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.
tagged(tag)
Section titled “tagged(tag)”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
Section titled “Filters”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 |
alive() is layered
Section titled “alive() is layered”- A table subject with a non-nil
Alivefield (an npc handle) uses that field. - Otherwise it resolves the subject’s body: the character for a
Player,.Modelor.Characterfor a table, the Instance itself for a tagged instance. - A body with a
Humanoidpasses whenHealth > 0. A body with noHumanoidpasses: a tagged prop is “alive” unless you filter it another way.
team(name) covers npc factions
Section titled “team(name) covers npc factions”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.
hasTag(tag)
Section titled “hasTag(tag)”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.
exclude(subject)
Section titled “exclude(subject)”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()Terminals
Section titled “Terminals”| 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 walk order
Section titled “first() returns walk order”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.
Live positions, always
Section titled “Live positions, always”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()endWithout 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.
--!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 = kernelend
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 includedfunction 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 swingerfunction 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 deepfunction 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() endend
return AbilityServiceLoot near a player needs no filter: Query:sphere(Root.Position, 10):tagged("Loot"):collect().
Bus topics
Section titled “Bus topics”None. SpatialKit publishes and consumes nothing on the Bus. Whatever acts on a result (damage, healing, aggro) publishes its own events.