Skip to content

Senses

Senses is the perception model: hearing with graded attunement, and cone-gated vision. The defining decision is that it is pure functions, no state, no loop — it answers “would this listener hear that sound?” and “can this eye see that point?”, and nothing else. Who listens, when to ask, what to do with the answer, and what counts as a wall are all the caller’s job. That is why one set of ears serves NPCKit brains and hand-rolled agents identically.

Senses.hear(listenerPosition, attunement?, sound, options?) returns the heard position — or nil when the sound is out of range. The heard position is deliberately wrong in proportion to how bad the listener’s ears are and how far away the sound is: a keen listener pinpoints a distant gunshot, a dull one hears “somewhere over there”.

Range gate. The effective audible range is:

Effective = (sound.Range or 40) × (sound.Loudness or 1) × attunement.Range

Distance defaults to the straight line from listener to sound. options.Distance replaces it with a caller-measured value — pass a route length (pair with the Pathfinding distance field) and sound goes around obstacles, maze-correct. Wall occlusion is explicitly the caller’s job; Senses only compares a distance to a range.

Blur. The positional error scales linearly with distance — BlurStuds is the error at the edge of range, zero at the listener’s feet:

Blur = attunement.BlurStuds × (Distance / Effective)

The blur is anisotropic and horizontal: the offset is sampled up to ±Blur along the listener→sound axis but only ±0.35 × Blur sideways. Ears are better at judging direction than distance, so the error ellipse points away from the listener — an NPC investigating a blurred gunshot searches along the right bearing at the wrong depth, which reads as natural searching instead of random wandering. Pass options.Rng for deterministic sampling (specs, replays); otherwise a shared Random is used.

An attunement is { Range, BlurStuds } — a range multiplier and an edge-of-range blur. Five presets ship, and any custom table slots anywhere between:

Level Range BlurStuds Reads as
Keen 1.5× 0 Hears half again as far, pinpoints exactly
Sharp 1.2× 4 Slightly extended, near-pinpoint
Average 1.0× 10 The default when attunement is nil
Dull 0.75× 20 Short-eared and vague
Oblivious 0.5× 36 Half range, “it came from around here somewhere”

An unknown level name errors loudly (unknown attunement) rather than silently defaulting. NPCKit maps its difficulty knobs straight into a custom table: HearingMultiplier becomes Range and HearingBlurStuds becomes BlurStuds, so a Perfect npc and a Keen guard use the same math.

Vision is a three-gate pipeline, ordered cheap-to-expensive:

  1. Range — one magnitude compare, only if maxRange is given.
  2. ConeSenses.inCone(eyePosition, facing, fovDegrees, targetPosition): a horizontal (Y-flattened) dot-product compare against cos(fov / 2), avoiding any acos. Three openings are deliberate: fovDegrees >= 360 always passes, a nil facing always passes (an eye with unknown orientation is never blind — headless rigs and specs stay functional), and a target within a millimeter of the eye passes.
  3. Line of sightthe caller’s function. Senses.canSee takes hasLineOfSight(from, to) as an argument: a grid sightLine, a workspace raycast, or a spec fake. The expensive test only runs for targets that already passed range and cone — the spec verifies rejected targets never consult it.
Senses.canSee(Eye, Facing, FovDegrees, TargetPosition, hasLineOfSight, MaxRange?)

This inversion is what keeps Senses pure: it never touches workspace, so it runs identically under specs, on grids, and against real geometry.

Senses has no loop of its own — every call is a handful of vector math (hearing: one distance, two random samples; vision: one dot product plus whatever your line-of-sight test costs). The budget question belongs to the calling loop:

  • NPCKit calls hear on sound events (Bus topics mapped to noise, emitSound), and canSee from your brain tick — the README pattern runs the whole brain at ~0.15 s on kernel.Priority.Low.
  • The cone gate is cheap enough to run every tick for every npc; the line-of-sight raycast is the cost center, and the pipeline ordering exists precisely so it only runs for in-frame, in-range targets.
  • Memory (last-known positions, search timers, detection charge) lives in the consumer — NPCKit keeps State.HeardPosition/HeardAt/HeardSource per npc and squad-shared intel on the blackboard. Senses stays stateless.

A guard that hears shots and investigates, sees intruders in a cone, and never cheats through walls:

-- src/Server/Bootstrap.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Senses = require(Root.Senses)
return function(kernel)
local Guards = {} -- { { Position: Vector3, Facing: Vector3, HeardPosition: Vector3? } }
local function hasLineOfSight(from: Vector3, to: Vector3): boolean
local Params = RaycastParams.new()
Params.FilterType = Enum.RaycastFilterType.Exclude
Params.FilterDescendantsInstances = {} -- exclude characters as needed
return workspace:Raycast(from, to - from, Params) == nil
end
-- Gunshots alert every guard, with per-guard accuracy
kernel.Bus:subscribe("Weapon.Fired", function(_, session, weaponId, origin, direction)
for _, Guard in Guards do
local Heard = Senses.hear(Guard.Position, "Dull", {
Position = origin,
Range = 60,
})
if Heard then
Guard.HeardPosition = Heard -- walk there and comb the area
end
end
end)
-- The vision tick
kernel.Scheduler:every(0.2, function()
for _, Guard in Guards do
for _, Player in game:GetService("Players"):GetPlayers() do
local Character = Player.Character
local TargetRoot = Character and Character.PrimaryPart
if TargetRoot and Senses.canSee(
Guard.Position + Vector3.new(0, 2, 0), -- eye height
Guard.Facing,
120, -- fovDegrees
TargetRoot.Position,
hasLineOfSight,
80 -- maxRange
) then
Guard.HeardPosition = nil -- sighted beats heard
print("guard spotted", Player.Name)
end
end
end
end, kernel.Priority.Low)
end

For kit npcs none of this wiring is yours to write — npc:canSee(target) runs eye height, the difficulty’s FieldOfViewDegrees, the rig’s actual facing, and the kit’s line-of-sight in one call, and the kit’s hearing path resolves its occlusion mode (Path/Blocked/Through) before handing Senses the distance. See NPCKit.

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 when out of range. attunement: level name, custom table, or nil (Average). sound: { Position, Range? = 40, Loudness? = 1 }. options: { Distance?, Rng? }
Senses.inCone(eyePosition, facing?, fovDegrees, targetPosition) → boolean Horizontal cone gate; nil facing, 360°+ fov, or 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 no Bus topics and consumes none — it is a leaf module. The perception events (Npc.Heard, Npc.Suspicious) belong to NPCKit, which publishes them after consulting Senses.

  • Wall occlusion is the caller’s job — by design. Baking a raycast into hear would pick one occlusion model for every game. Instead the Distance override composes with whatever the caller measures: straight line (Through), a line-of-sight veto (Blocked), or a route length (Path — sound bends around a maze correctly, and no route means fully blocked).
  • The cone is horizontal. Both inCone vectors are Y-flattened, so vertical offset never blinds an npc to a target on a ledge directly ahead. If your game needs vertical vision limits, gate on height difference before calling.
  • Blur favors bearing over depth on purpose. The 0.35 side-axis factor is the difference between “searches along the right line” and “wanders randomly” — spec-verified along with distance scaling (near sounds land near-pinpoint even for Dull ears).
  • Pass an Rng when determinism matters. The shared module-level Random is fine for gameplay; specs and replays should inject their own seed.