Stealth detection

Usage guide of stealth.detection: guards, creatures and cameras that see through a vision cone, light and cover and hear noises, with suspicion, four awareness states, light and cover volumes, settings, service, events and hooks.

Source: module-kits:kits/stealth_detection/usage/en.mdView Markdown source

Also read offline — CLI: grip-forge docs stealth-detectionMCP tool: gripforge_docs { "topic": "stealth-detection" }

Stealth perception (stealth.detection) #

What it is for #

The perception side of an infiltration or horror game. Any entity that carries a perceiver component watches and listens for the player: it sees through a vision cone, only along a clear line of sight, better in light than in shadow, less a crouching player than a running one, and it hears footsteps, thrown objects and gunshots. Each perceiver fills a suspicion meter and moves through four states: unaware, suspicious, searching, alert. The kit decides what is noticed; what a guard does while patrolling stays with its NPC document (see NPC guide), and the player's side (takedowns, hiding) is in Stealth takedowns.

Turn it on #

gripforge_gamekit_install   { project_id: "gkp_…", id: "stealth.detection" }
gripforge_gamekit_configure { project_id: "gkp_…", id: "stealth.detection",
                              config: { "ambient": 0.25, "sightRate": 60, "alertRadius": 12 } }

It requires npc.behavior, npc.locomotion, input.actions and game.engine, so installing it brings them. Give an entity the perceiver component in an archetype:

{ "id": "guard", "kind": "npc", "components": { "npc": { "doc": "guard" }, "perceiver": { "fov": 100, "focus": 50, "range": 16, "group": "guards" } } }

First example #

<!-- test: kits/game_engine/host/tests/stealth_detection.rs::a_filter_answering_false_blinds_the_perceiver_and_leaves_with_its_remover --> Another kit hands a perception filter to the service with addFilter(owner, fn). While the filter answers false, visibility(perceiver, target) returns a factor of 0 and los: false; removing the filter restores the normal factor:

visibility ["e1", "e2"]            -> { factor: > 0, distance: 5, los: true, ... }
(filter answers false)
visibility ["e1", "e2"]            -> { factor: 0, distance: 5, los: false, ... }
removeFilter ["test.giver", fn]    -> the factor is back above 0

How a perceiver sees #

visibility = distance x angle x light x posture.

  • distance: 1 up to near, then falling linearly to 0 at range.
  • angle: 1 inside the focus cone, peripheral inside the fov cone, 0 behind.
  • light: minLight + (1 - minLight) x level. The level is ambient, raised by light_zone volumes, lamps of the world lighting and lights other kits add, then multiplied by 1 - depth inside a shadow_zone.
  • posture: the factor of postures for crouch, walk or run.
  • 0 when an occluder stands between the eye (eye) and the target, when the target is hidden, or when it crouches in cover farther than reveal away.

Seen, suspicion grows by sightRate x visibility per second; not seen, it falls by decayRate. A noise adds loudness x (1 - d / (radius x hearing)).

StateEntered whenDoes
unawarestart, end of a searchits NPC behaviour (patrol)
suspicioussuspicion reaches suspiciousAtstops and stares at the last known position
searchingnothing perceived for calmSeconds; lost the target for loseSeconds; called by an ally; a body seenwalks to the point, looks around for searchSeconds, then returns to its patrol
alertsuspicion 100chases; allies within alertRadius search the spot; within its catch distance it catches the target

Settings and defaults #

SettingDefaultMeaning
ambient0.25light level where nothing says otherwise (0 dark, 1 daylight)
minLight0.2visibility factor in total darkness
lampFull2intensity of a lamp that lights fully at its foot
sightRate60suspicion per second at visibility 1
decayRate15suspicion lost per second while nothing is perceived
suspiciousAt30suspicion from which a perceiver stares
calmSeconds2a suspicious perceiver perceiving nothing this long goes to check
searchSeconds8time spent looking around the last known position
searchTimeout20a search that cannot reach its point gives up after this long
loseSeconds3an alert perceiver that lost the target searches after this long
catchCooldown3seconds before the same perceiver can catch again
bodySight0.15visibility above which a downed body is noticed
alertRadius12allies within it search when a perceiver goes alert or finds a body
posturescrouch 0.5, walk 1, run 1.5visibility factor by posture
footstepscrouch 0, walk 4, run 10footstep noise radius by posture
stepSeconds0.5seconds between two footsteps
footstepLoudness25suspicion a footstep adds at its source
runAbove5.5player speed (m/s) from which the posture is run
throwItememptyitem spent by a throw; empty: free
throwRange, throwRadius, throwLoudness, throwCooldown8, 10, 60, 1.5where a thrown object lands, its noise radius and loudness, its cooldown
noiseEventscombat.fired, radius 30, loudness 100, kind gunshotbus events that make a noise where they happen
defaultsemptydefaults of every perceiver (fov 100, focus 50, range 16, near 4, peripheral 0.5, hearing 1, catch 1.2)

The per-entity components are perceiver (fov, focus, range, near, peripheral, hearing, eye, group, catch, enabled) and the volumes light_zone, shadow_zone, cover, occluder and noise_surface (a radius or a box size; level, depth, crouch, reveal, solid or factor).

Services, events, variables #

  • stealth.detection (service): perceivers(), perceiver(entity), visibility(perceiver, target?), lightAt(point), noise(at, radius, opts), alert(entity, at?), posture(), crouched(), setCrouch(on), throwDistraction(), setHidden(entity, on, owner), hidden(entity), addLight(owner, fn), addFilter(owner, fn), enable(entity, on), indicators(), awareness().
  • Inputs: stealth.crouch (Z) and stealth.throw (O).
  • Events: stealth.state (a perceiver changed state), stealth.noise (a noise was heard by some perceivers), stealth.alerted (an ally was called), stealth.body_found, stealth.caught, stealth.crouched.
  • Variables: stealth.state, stealth.suspicion, stealth.light, stealth.posture, stealth.hidden, stealth.seen, stealth.caught.
  • Script verbs: actions make_noise and alert_perceivers, condition stealth_state, NPC behaviours stealth_watch, stealth_search and stealth_chase.

Hooks and modding #

Without a tap nothing changes. A game or a mod taps these decisions with a hooks document:

HookPayloadEffect
stealth.detectperceiver, target, distance, angle, light, posture, multipliermutable multiplier; cancelled: visibility 0 (filters are taps of this hook)
stealth.hearx, y, z, radius, loudness, kind, sourcemutable radius and loudness; cancelled: nobody hears it
stealth.ally.callentity, by, x, y, zcancelled: this ally is not called
stealth.body.findentity, bodycancelled: the body stays unfound for now
stealth.catchentity, target, distancecancelled: no catch, asked again next tick
stealth.crouchoncancelled: the posture does not change
{ "id": "dim_guards", "hook": "stealth.detect", "match": { "posture": "crouch" }, "mul": { "multiplier": 0.5 } }

A mod may override every setting above.

Engines #

The same Rust rules run in the web player and the native kit host. The line of sight and the throw are ray casts through the physics port, and an occluder is a static box on it. When audio.acoustics is available, hearing uses rooms, doors and obstacles; otherwise the legacy hearing applies. The kit draws an awareness HUD: one arrow per suspicious perceiver, filled by its suspicion.

When it does not do what you expect #

  • A guard never reacts: the entity has no perceiver, it is disabled (enable), or ambient and minLight make the target nearly invisible.
  • The player is seen through a wall: the wall has no occluder component.
  • A camera never searches: an entity without npc only raises the alarm.

Limits #

  • It decides perception only: patrols, chases and attacks belong to the NPC and combat kits.
  • Perceivers are checked against the player.

Versions #

Kit 1.2.0, service contract 1.0.0. 1.2.0: optional audio.acoustics hearing; 1.1.0: the game.engine hook contract; 1.0.0: first release.