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.
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 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.RangeDistance 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.
Attunement levels
Section titled “Attunement levels”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
Section titled “Vision”Vision is a three-gate pipeline, ordered cheap-to-expensive:
- Range — one magnitude compare, only if
maxRangeis given. - Cone —
Senses.inCone(eyePosition, facing, fovDegrees, targetPosition): a horizontal (Y-flattened) dot-product compare againstcos(fov / 2), avoiding anyacos. Three openings are deliberate:fovDegrees >= 360always 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. - Line of sight — the caller’s function.
Senses.canSeetakeshasLineOfSight(from, to)as an argument: a gridsightLine, 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.
Cadence and budgets
Section titled “Cadence and budgets”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
hearon sound events (Bus topics mapped to noise,emitSound), andcanSeefrom your brain tick — the README pattern runs the whole brain at ~0.15 s onkernel.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/HeardSourceper 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.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal 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)endFor 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.
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 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.
Design notes
Section titled “Design notes”- Wall occlusion is the caller’s job — by design. Baking a raycast into
hearwould pick one occlusion model for every game. Instead theDistanceoverride 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
inConevectors 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
Dullears). - Pass an
Rngwhen determinism matters. The shared module-levelRandomis fine for gameplay; specs and replays should inject their own seed.