Senses
Pure perception math for NPCs, with hearing graded by attunement and blurred along the sound's bearing, cone-gated vision and a line-of-sight test the caller supplies.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Senses - Since
- v0.3.0
- Source
- Senses.luau
Senses is the perception model: hearing with graded attunement, and cone-gated vision. The defining decision is that it is pure functions with no state and no loop. It answers “would this listener hear that sound” and “can this eye see that point”. Who listens, when to ask, what to do with the answer and what counts as a wall belong to the caller. That is why one set of ears serves NPCKit brains and hand-rolled agents the same way. Spatial and SpatialKit are the tools for finding the candidates to test.
Mental model
Section titled “Mental model”Hearing
Section titled “Hearing”Senses.hear(listenerPosition, attunement?, sound, options?) returns the heard position, or nil when the sound is out of range. The heard position is wrong in proportion to how poor the listener’s ears are and how far away the sound is: keen ears pinpoint a distant shot, dull ears hear “somewhere over there”.
Range gate. The effective audible range is:
local Effective = (sound.Range or 40) * (sound.Loudness or 1) * Attunement.RangeDistance defaults to the straight line from listener to sound. options.Distance replaces it with a caller-measured value. Pass a route length (from a Pathfinding distance field) and sound travels around obstacles. Wall occlusion is the caller’s job; Senses compares a distance with a range and nothing else.
Blur. The position error scales linearly with distance. BlurStuds is the error at the edge of range and zero at the listener’s feet:
local Blur = Attunement.BlurStuds * (Distance / Effective)The blur is horizontal and anisotropic: the offset is sampled up to Blur studs either way along the listener-to-sound axis and up to 0.35 * Blur sideways. Ears judge direction better than distance, so the error spreads along the bearing. An NPC investigating a blurred shot searches along the right line at the wrong depth, which reads as searching instead of wandering. When the sound sits within a millimeter horizontally of the listener, the axis falls back to world X. Pass options.Rng for deterministic samples (specs, replays); otherwise a shared module Random is used.
Attunement levels
Section titled “Attunement levels”An attunement is { Range, BlurStuds }: a multiplier on every sound’s range and the blur at the edge of range. Five presets ship, and any custom table works:
| Level | Range | BlurStuds | Reads as |
|---|---|---|---|
Keen |
1.5 | 0 | Hears half again as far and pinpoints exactly |
Sharp |
1.2 | 4 | Slightly extended range, near pinpoint |
Average |
1.0 | 10 | The default when attunement is nil |
Dull |
0.75 | 20 | Short range and vague |
Oblivious |
0.5 | 36 | Half range; “it came from around here somewhere” |
An unknown level name errors (unknown attunement "X" (Keen|Sharp|Average|Dull|Oblivious)) instead of falling back to a default. NPCKit builds a custom table from each difficulty: HearingMultiplier becomes Range and HearingBlurStuds (default 8) becomes BlurStuds.
Vision
Section titled “Vision”Vision is three gates, cheapest first:
- Range: one distance compare, only when
maxRangeis given. - Cone:
Senses.inCone(eyePosition, facing, fovDegrees, targetPosition), a horizontal dot product againstcos(fov / 2)with both vectors flattened to X/Z, so noacos. It passes whenfovDegrees >= 360, whenfacingis nil, whenfacinghas no horizontal component, and when the target is within a millimeter of the eye horizontally. An eye with unknown orientation is never blind, which keeps headless rigs and specs working. - Line of sight: your function.
Senses.canSeetakeshasLineOfSight(from, to)as an argument: a grid sight line, a world raycast or a spec fake. It runs only for targets that passed range and cone, and the spec checks that rejected targets never reach it.
Senses.canSee(Eye, Facing, FovDegrees, TargetPosition, hasLineOfSight, MaxRange)Taking line of sight as an argument keeps Senses pure: it never touches workspace, so it runs the same under specs, on grids and against real geometry.
Cadence and budgets
Section titled “Cadence and budgets”Senses has no loop. A hearing call is one distance and two random samples; a vision call is one dot product plus whatever your line-of-sight test costs. The budget belongs to the calling loop:
- NPCKit calls
hearwhen a sound is emitted (Bus topics mapped to noise,emitSound) andcanSeefrom the brain tick your game schedules. - The cone gate is cheap enough for every NPC every tick. The line-of-sight ray is the cost, which is why it runs last.
- Memory (last known positions, search timers, detection charge) lives in the consumer. NPCKit keeps
State.HeardPosition,HeardAtandHeardSourceper NPC and shares intel through its squads.
A guard post that hears shots, sees intruders in a cone and does not see through walls:
--!strict-- GuardService: stationary guards that hear WeaponKit shots and watch a cone.
local Players = game:GetService("Players")local ServerScriptService = game:GetService("ServerScriptService")
local Senses = require(ServerScriptService.ChloeKernelServer.Senses)
local GuardService = { Name = "GuardService",}
type Guard = { Position: Vector3, Facing: Vector3, HeardPosition: Vector3?, Spotted: Player? }
function GuardService.init(self: any, kernel: any) self.Kernel = kernel self.Guards = {} :: { Guard } -- One params object for every sight ray. RespectCanCollide: trigger volumes do not block sight self.SightParams = RaycastParams.new() self.SightParams.RespectCanCollide = trueend
function GuardService._hasLineOfSight(self: any, from: Vector3, to: Vector3, target: Model): boolean local Hit = workspace:Raycast(from, to - from, self.SightParams) return Hit == nil or Hit.Instance:IsDescendantOf(target)end
function GuardService._look(self: any) for _, Guard in self.Guards :: { Guard } do local Eye = Guard.Position + Vector3.new(0, 2, 0) for _, Player in Players:GetPlayers() do local Character = Player.Character local Root = Character and Character:FindFirstChild("HumanoidRootPart") local Seen = Root ~= nil and Senses.canSee(Eye, Guard.Facing, 120, (Root :: BasePart).Position, function(from, to) return self:_hasLineOfSight(from, to, Character :: Model) end, 80) if Seen then Guard.Spotted = Player Guard.HeardPosition = nil -- sight beats hearing end end endend
-- Vision runs five times a second, from the first guard to the lastfunction GuardService.addGuard(self: any, position: Vector3, facing: Vector3): Guard local Guard: Guard = { Position = position, Facing = facing } table.insert(self.Guards, Guard) if not self.LookHandle then self.LookHandle = self.Kernel.Scheduler:every(0.2, function() self:_look() end, self.Kernel.Priority.Low) end return Guardend
function GuardService.removeGuard(self: any, guard: Guard) local Index = table.find(self.Guards, guard) if Index then table.remove(self.Guards, Index) end if #self.Guards == 0 and self.LookHandle then self.LookHandle:cancel() self.LookHandle = nil endend
function GuardService.start(self: any) self.ShotConnection = self.Kernel.Bus:subscribe( "Weapon.Fired", function(_topic: string, _session: any, _weaponId: number, origin: Vector3) for _, Guard in self.Guards :: { Guard } do local Heard = Senses.hear(Guard.Position, "Dull", { Position = origin, Range = 60 }) if Heard then Guard.HeardPosition = Heard -- walk there and search the area end end end )end
function GuardService.stop(self: any) if self.ShotConnection then self.ShotConnection:disconnect() end if self.LookHandle then self.LookHandle:cancel() self.LookHandle = nil endend
return GuardServiceThe sight ray aims at the target’s root, inside its body, so it usually stops on the target’s own torso: a hit on a descendant of target counts as seen, and so does a ray that reaches the root without hitting anything.
For kit NPCs none of this is yours to write. npc:canSee(target) applies eye height, the difficulty’s FieldOfViewDegrees, the rig’s facing and the kit’s line of sight (a grid sight line, or a world ray that respects CanCollide and skips NPC bodies) in one call, and the kit’s hearing resolves its occlusion mode (Through, Blocked or Path) before handing Senses a distance.
API reference
Section titled “API reference”| Member | Description |
|---|---|
Senses.Attunement |
Preset table: Keen, Sharp, Average, Dull, Oblivious, each { Range, BlurStuds } |
Senses.hear(listenerPosition, attunement?, sound, options?) -> Vector3? |
Heard (blurred) position, or nil out of range. attunement: a level name, a custom table or nil (Average). sound: { Position, Range? = 40, Loudness? = 1 }. options: { Distance?, Rng? } |
Senses.inCone(eyePosition, facing?, fovDegrees, targetPosition) -> boolean |
Horizontal cone gate; a nil or vertical facing, a fov of 360 or more, and a target at the eye all pass |
Senses.canSee(eyePosition, facing?, fovDegrees, targetPosition, hasLineOfSight, maxRange?) -> boolean |
Range, then cone, then the caller’s line-of-sight test |
Senses publishes and consumes no Bus topics. The perception events (Npc.Heard, Npc.Suspicious) belong to NPCKit, which publishes them after consulting Senses.
Design notes
Section titled “Design notes”- Wall occlusion is the caller’s job. Baking a raycast into
hearwould pick one occlusion model for every game. TheDistanceoverride composes with whatever the caller measures: a straight line (Through), a line-of-sight veto (Blocked) or a route length (Path, where no route means fully blocked). - The cone is horizontal. Both vectors are flattened, so a target on a ledge straight ahead is never hidden by the vertical offset. Gate on height difference before calling if your game needs vertical limits, or use SpatialKit’s true 3D cone.
- A line-of-sight ray that ignores CanCollide stops on trigger volumes and zone parts. Set
RespectCanCollide = trueon sight rays; since limbs do not collide by default, count a clear ray as seeing the target, or run a separate pass for bodies. - Pass an
Rngwhen determinism matters. The shared moduleRandomis fine for gameplay; specs and replays should seed their own.