Tactical Action · Architecture

AI, Perception & Stealth

A focused page within the Tactical Action Architecture documentation. The full map and every sibling page live in the Architecture hub.

8sections10 minread1diagram

On this page

Stealth is not a bonus mode in V4 — it is one of the launch cells, and the perception model that decides whether a guard sees you is the single most load-bearing AI system in the project. A Hitman waiter, a Commandos Wehrmacht patrol, a Splinter Cell mercenary, and a Wukong yaoguai all have to read the world with the same semantics: the same notion of a stimulus, the same 0-to-1 suspicion gauge, the same Calm → Curious → Alert → Hostile escalation, the same faction-and-disguise recognition. V4 achieves that by making AI data-oriented rather than tree-driven. There is no per-NPC Behavior Tree deciding what to think; there is a UV4PerceptionComponent that batches typed stimuli, an UV4SuspicionStateMachine that integrates them into a scalar, and a small library of pure functions (UV4PerceptionSenseLibrary) that turn geometry — a cone, a distance, a lux value, a stance — into stimulus strength. The stealth layer (V4Stealth) sits on top, modelling the player's side of the same equation: how much light is on you, how loud your footsteps are on this surface, whether your disguise survives a line-of-sight check.

This split is deliberate and it is what keeps the system honest across six very different cells. The perception math lives in V4/ue/Source/V4Perception; the player-facing stealth verbs live in V4/ue/Source/V4Stealth; dense NPC populations live in V4/ue/Source/V4Crowd. None of it is a TypeScript contract package — the source of truth is the Unreal C++ under those modules and the automation specs under V4/ue/Source/V4Tests. This page is the architecture-side companion for V4's AI and stealth, part of the AI, Stealth & Per-Cell Combat group; the section hub is ../V4_ARCHITECTURE.md.

What ships, honestly#

The perception and stealth logic is real, deterministic-by-construction, and value-tested. Six automation specs cover it — V4.Perception.Stimulus.Aggregates AndPropagates and V4.Perception.Suspicion.DecaysThroughStates, V4.Stealth.LightGauge.Sampling and a disguise spec, and V4.Crowd.Panic.PropagatesAcrossPopulation plus a perf spec — and they assert computed values, not shapes: that an enforcer's detection time is exactly half a civilian's, that two stimuli sum to a hand-derived suspicion amount, that a camera profile decays 0.8 → 0.55 in one second, that a panic signal reaches all 400 generated agents in under 8 ms. Those would fail against placeholders.

Four honest qualifications, so the rest of the page reads at face value. First, there is no Behavior Tree, Blackboard, EQS, or StateTree anywhere in V4 — a grep for BehaviorTree|Blackboard|EnvQuery|StateTree|UAISense over V4/ue/Source returns nothing. UV4PerceptionComponent extends UAIPerceptionComponent and the module links AIModule/NavigationSystem, but it registers no UAISense; the decision logic is V4's own stimulus loop. "AI behavior" in V4 means the suspicion state machine, the NPC schedules, and the crowd tiering — not Unreal's BT stack. Second, V4Crowd links the six Mass framework modules (MassEntity, MassNavigation, MassAIBehavior, MassMovement, MassReplication, MassSignals) and GetMassIntegrationStatus() honestly reports their loaded state via FModuleManager::IsModuleLoaded (not a struct of constant trues) — but the panic / tier / flee simulation runs on a plain TArray<FV4CrowdAgentRecord>, not a Mass processor graph. Each agent carries a real FMassEntityHandle, yet the per-frame work is array iteration that mirrors Mass concepts (signals, LOD tiers) rather than a live ECS pipeline. Third, the Splinter Cell light gauge samples a data-driven set of authored point lights (FV4StealthLuxSample summed with squared falloff in CalculateLuxAtCapsule), not a real Volumetric Lightmap query — SampleLuxAtCapsule is the seam where a true engine lux probe would plug in. Fourth, the enforcer suspicion multiplier field (EnforcerSuspicionMultiplier = 2.0) exists but is not referenced by ComputeSuspicionMultiplier; the enforcer mechanic that is wired is detection-time halving (in two places, below). The sections say where each claim is backed.

The AI core: stimulus in, suspicion out#

Every hostile NPC carries a UV4PerceptionComponent (V4Perception/Public/V4PerceptionComponent.h). It does not evaluate every frame. It accumulates real time into EvaluatorAccumulatorSeconds and only fires when it crosses GetEvaluatorIntervalSeconds()1.0f / max(1, EvaluatorTickRateHz), default 10 Hz (background NPCs can be set to 2 Hz). On each evaluation it does one of two mutually exclusive things: if PendingStimuli is non-empty it FlushV4Stimuli, otherwise it DecaySuspicion. That one-or-the-other rule is important — a guard actively perceiving you never decays on the same tick.

The integration target is UV4SuspicionStateMachine (V4Perception/Private/V4SuspicionStateMachine.cpp), a UObject holding a single CurrentSuspicion in [0, 100]. Stimuli become suspicion through BuildStimulusAggregate, whose core line is domain-specific, not generic:

text
StimulusAmount = (Strength * 100 * DeltaSeconds) / DetectionTimeSeconds

and DetectionTimeSeconds comes from CalculateDetectionTimeSeconds: a base time divided by a proximity scale clamp(1 - Distance/MaxDistance, 0.05, 1), then halved if the source is an enforcer, floored at 0.05 s. Closer targets are detected faster (smaller divisor → larger per-second gain); an enforcer who has clocked your disguise detects you twice as fast. Stimuli past MaxDistance are dropped, and the aggregate also records StrongestStimulus, bSawEnforcer, and the LastKnownLocation that the search behaviour reads.

CurrentSuspicion maps to EV4SuspicionState { Calm, Curious, Alert, Hostile } through thresholds CuriousThreshold = 25, AlertThreshold = 60, HostileThreshold = 90, with a HysteresisMargin = 5. The state is not a naive comparison: CalculateStateWithHysteresis requires suspicion to fall a full margin below an entry threshold before de-escalating, so a value hovering at 59 does not strobe between Curious and Alert. Crossing a boundary resets TimeInStateSeconds to zero and broadcasts OnSuspicionStateChanged. Decay is DecayRatePerSecond (default 12.5/s) applied only when no stimulus arrived, and a FV4NpcSuspicionProfile lets each NPC class override both the decay rate and per-EV4PerceptionStimulusKind weights — a security camera weights Sight at 1.5× and Hearing at 0.25×, verified in StimulusSpec.cpp.

stateDiagram-v2 [*] --> Calm Calm --> Curious : suspicion ≥ 25 Curious --> Alert : ≥ 60 Alert --> Hostile : ≥ 90 Hostile --> Alert : < (90 − 5) Alert --> Curious : < (60 − 5) Curious --> Calm : < (25 − 5) note right of Curious per-tick: flush stimuli OR decay, never both. Hysteresis margin = 5 gates every downward edge. end note

Perception senses: sight, hearing, faction, disguise#

UV4PerceptionSenseLibrary (V4Perception/Private/V4PerceptionSenseLibrary.cpp) is the pure-function layer that produces stimuli from geometry.

Sight. EvaluateSight gates on Cone.RangeCentimeters, then takes the dot-product of the observer's forward and the to-target direction, converts to a half-angle test against Cone.HalfAngleDegrees (default 60°, range 12 m via RangeCentimeters = 1200). Inside the cone it computes a CenterWeight and a DistanceWeight; if either clears InnerInstantDetectionFraction (default 0.5) the target is in the inner cone with a 0.05 s base detection time (near- instant), otherwise 1.5 s. Crucially, low light scales the result: LowLightMultiplier = lerp(LowLightFalloff, 1.0, clamp(lux, 0, 1)), so a target in shadow yields a weaker sight stimulus — the hook the stealth layer drives.

Hearing. GetHearingRadiusForStance is a real table keyed to the source's stance: Prone 200, Crouch 400, Walk 800, Run 1600, Sprint 2500 cm; gunfire overrides to 5000 cm, or 800 cm when silenced. EvaluateHearing returns strength from the distance falloff, tagging the stimulus Gunfire or Hearing accordingly.

Faction and disguise. RecognizeFaction(OtherFactionId, PresentedDisguise) returns EV4FactionDisposition { Friendly, Neutral, Hostile, Disguised }: an accepted disguise short-circuits to Disguised, then own/friendly factions are Friendly, listed hostiles are Hostile, everything else Neutral. CanTreatAsAlly collapses Friendly-or-Disguised to true, and that gate governs alert spread: PropagateAlertToAllies only adds suspicion to allies within a radius (default AlertPropagationRadius = 400 cm = 4 m), while PropagateGunfireToAllies uses GunfirePropagationRadius = 2500 cm = 25 m. This is how one guard going Hostile pulls his squad up the suspicion curve without a global broadcast.

Presentation. BuildSuspicionPresentation maps state to an animation tag (Perception.Calm/Curious/Alert/Hostile), a bShowHUDSignal, a bWeaponReady (true from Alert up), and a vision-cone colour. Rendering is mode-aware: UV4VisionConeDecalRendererComponent projects a colour-coded floor decal for the top-down RTST cell (EV4VisionConeRenderMode::FloorDecal), while first-person cells leave the cone invisible and lean on animation tells — the same green / yellow / orange / red FV4VisionCone colours drive both.

Stealth detection: light, sound, disguise, marks#

The V4Stealth module models the player's exposure — the inputs the perception layer consumes.

Light / shadow gauge. UV4LightGaugeComponent collapses lux into EV4LightExposureState { Dark, Dim, Lit } at thresholds Dark < 0.3, Dim < 0.65, else Lit, over an AmbientLux = 0.02 floor. CalculateLuxAtCapsule sums each enabled FV4StealthLuxSample's contribution with a squared distance falloff measured to the capsule's axis (a vertical segment, not a point), so a crouched silhouette under a light is lit correctly. The Splinter Cell rule is the payoff: CanBeSightDetected returns false in Dark unless the observer is within DarkCloseDetectionDistanceCentimeters = 150 cm with line of sight — darkness hides you at range but not at arm's length. Shooting a bulb calls ApplyLightOut(LightId) (toggling bEnabled off, reversible via RestoreLight), which drops the next capsule sample below the dark threshold. LightGaugeSpec.cpp walks this end to end: lit overhead light → light-out → dark → close-LOS gate.

Sound footprint. UV4SoundFootprintComponent is the emission side, separate from NPC hearing. Per-stance base radii are Sprint 1200, Run 900, Walk 500, Crouch 250, Crawl 120 cm, scaled by a surface multiplier resolved from the physical material: Concrete 1.0, Wood 1.15, Metal 1.35, Gravel 1.45, Water 1.6, Carpet 0.55. EvaluateHearingRange answers whether a listener is inside that radius and with what strength. (Note the two complementary stance enums — EV4PerceptionStance for the listener's read of a source's stance, EV4StealthMovementStance including a Crawl for the player's emission.)

Disguise. UV4DisguiseComponent enforces the Hitman/Commandos fantasy. BeginDisguisePickupSwap fails loud — returns false — unless the target was knocked out and dragged and hidden in a container and the swap takes ≥ 2 s; only then is the disguise worn. ComputeSuspicionMultiplier is the live knob: an allowed zone scales suspicion by 0.3 × lerp(1.0, 0.5, Stealthiness), a restricted zone by 1.75×, a suspicious action by an extra , an obvious weapon by 1.5×. The enforcer interaction is detection-time, not multiplier: ComputeEnforcerDetectionTimeSeconds shortens the base detection time by a clamped scale, mirroring the perception layer's bEnforcer 0.5× — so a disguise is a liability precisely around the NPCs who wear it.

Mark and execute. UV4MarkAndExecuteComponent is the Splinter Cell tag-then-fire verb: AwardMarkSlot earns capacity (capped at MaxMarkSlots = 4), TryMarkTarget consumes a slot, and ExecuteMarkedTargets fires the queue and clears it — a small, honest slot economy, not a hardcoded list.

Crowds and panic — the Mass-adjacent layer#

UV4CrowdSubsystem drives dense Hitman/Commandos populations. EvaluateTierForAgent assigns EV4CrowdTier { Background, Medium, Hero } from squared distance (HeroRadius 600, MediumRadius 1800 cm) and a relevance heuristic (Hero ≥ 0.85, Medium ≥ 0.35), and GetTierBehavior declares what each tier runs — Background is path-following only, Medium adds cheap perception and schedule ticks, Hero adds full perception. Panic is a FV4CrowdMassSignal: HandlePanicMassSignal applies a distance-falloff panic amount to every agent in radius, promotes panicked Background agents to Medium so they don't freeze, and calls AssignFleeTargetsForPanic, which routes each agent to the nearest exfil node with remaining capacity (spilling to a neighbour when full) over a default four-way exfil graph. AdvanceFleeingAgents then steps them at FleeSpeedCentimetersPerSecond = 400 (= 4 m/s, matching ComputePanicArrivalSeconds). A disguise line-of-sight signal runs the same faction recognition as the perception layer and flips bDisguiseCompromised. EnforcePerformanceBudget sorts by relevance and demotes tiers until the estimated Mass tick fits MassTickBudgetMilliseconds = 8 — the perf spec proves 400 agents panic in under that budget.

Edge cases and failure modes#

  • Suspicion can't strobe. The HysteresisMargin forces a full drop below an entry threshold before de-escalating; a value parked at a boundary holds state.
  • Decay never races a sighting. The evaluator flushes or decays per tick, never both, so a guard with a live stimulus this interval keeps all of it.
  • Out-of-range stimuli are discarded, not clamped to zero — they never enter the aggregate, so distant noise can't slow-cook a guard to Hostile.
  • Dark ≠ invisible. CanBeSightDetected still trips inside 150 cm with LOS; hugging a shadow next to a guard does not work.
  • Disguise pickups fail loud. Miss any precondition (not knocked out, not dragged, not hidden, sub-2 s) and BeginDisguisePickupSwap returns false rather than half-applying a disguise.
  • Enforcers cut both ways. They halve detection time, so the disguise that fools civilians is your fastest tell near the people who wear it.
  • Fleeing agents stay simulated. Panicked agents are pinned to at least Medium tier even when the player walks 100 m away, so a crowd mid-flight doesn't pop to Background and stop moving.
  • The un-enforced seams. Lux is summed from authored light samples, not a real Volumetric Lightmap probe; the crowd's panic/flee model is array logic that mirrors Mass rather than a live processor graph. Both are correct and tested — but they are the documented places where deeper engine integration would land.

How it connects#

Perception is the cross-cell hub the rest of V4's AI reads. The suspicion gauge, faction recognition, and vision-cone presentation feed the cell-specific subsystems — Hitman crowds and schedules, Splinter Cell light gating, the Desperados/Commandos time-stop planning loop — detailed in ./per-cell-deep-dives.md. The stealth verbs (GA_BodyDrag, GA_LightKill, disguise) and NPC attribute blocks mount on the shared Ability System described in ./gas-layout.md, and the server-authoritative replication of suspicion, schedule clocks, and crowd state — distinct from V2's rollback model — is covered in ./networking-determinism.md. The section hub is ../V4_ARCHITECTURE.md.