# Organic Enemy Sensing **Target:** Unity 6000.5 and above (Unity 6). Uses only built-in physics, plus AI Navigation 2.x for the enemy. Plugs into [[Enemy AI State Machine]] by implementing its `IEnemySenses` interface. Pattern background: Observer / Event Queue in [[Game Programming Patterns (Review)]] and [[Architecture and Code Patterns]]. ## Overview 1. What makes sensing feel organic 2. Stimuli: visibility (light level) and noise events 3. Vision: focal and peripheral cones with line-of-sight rays 4. Hearing: falloff and occlusion 5. Awareness meter with decay and thresholds 6. Memory, reaction delay and randomness 7. Group alerting 8. Wiring into the state machine, and gizmos ## Step 1 - The idea Simple AI uses a yes/no check: "in range and in view, so attack." That feels robotic. Real guards: - **notice gradually**. A shape at the edge of vision makes them curious, not instantly hostile. - see better **straight ahead** than at the edges, and much worse in the **dark**. - **hear** noises that get quieter with distance and are muffled by walls. - **remember** where you were, and **forget** over time. - take a moment to **react**, and don't all react the same. - **talk to each other**. So every sense adds to one **awareness** value from 0 to 1. Thresholds on that value drive the state machine: | Awareness | Level | State machine | |---|---|---| | < 0.3 | Unaware | Idle / Patrol | | 0.3 - 0.99 | Suspicious | Search (investigate) | | 1.0 | Alerted | Chase / Attack | ## Step 2 - Stimuli ### Player visibility (light and stealth) ```csharp using System.Collections.Generic; using UnityEngine; /// Put on the player. Estimates how visible they are (0..1) from nearby /// lights, crouching and movement. Enemies multiply sight gain by Visibility. public class StealthTarget : MonoBehaviour { public static readonly List<StealthTarget> All = new(); [Tooltip("Lights that can reveal the player. Leave empty to find all Lights in the scene at start.")] [SerializeField] List<Light> lights = new(); [SerializeField] float ambient = 0.15f; // base visibility in total darkness [SerializeField] LayerMask lightBlockers = ~0; [SerializeField] float crouchMultiplier = 0.5f; public float LightLevel { get; private set; } public float Visibility { get; private set; } public bool Crouching { get; set; } public Vector3 AimPoint => transform.position + Vector3.up * (Crouching ? 0.6f : 1.2f); void OnEnable() => All.Add(this); void OnDisable() => All.Remove(this); void Start() { if (lights.Count == 0) lights.AddRange(FindObjectsByType<Light>(FindObjectsSortMode.None)); } void Update() { float sum = ambient; Vector3 p = AimPoint; foreach (Light l in lights) { if (!l || !l.isActiveAndEnabled) continue; if (l.type == LightType.Directional) { // Sun/moon: lit unless something blocks the ray toward the light. if (!Physics.Raycast(p, -l.transform.forward, 200f, lightBlockers, QueryTriggerInteraction.Ignore)) sum += 0.5f * l.intensity; continue; } Vector3 toPlayer = p - l.transform.position; float d = toPlayer.magnitude; if (d > l.range) continue; if (l.type == LightType.Spot && Vector3.Angle(l.transform.forward, toPlayer) > l.spotAngle * 0.5f) continue; if (Physics.Raycast(l.transform.position, toPlayer / d, d - 0.1f, lightBlockers, QueryTriggerInteraction.Ignore)) continue; float falloff = 1f - d / l.range; sum += l.intensity * falloff * falloff * 0.5f; } LightLevel = Mathf.Clamp01(sum); Visibility = LightLevel * (Crouching ? crouchMultiplier : 1f); } } ``` This is a cheap estimate, not a real lighting measurement. It ignores baked GI, emissive surfaces and shadow maps. For accurate values you'd render a small probe camera or sample light probes. Most stealth games tune a fake like this anyway, because the player has to be able to read it. ### Noise events ```csharp using System; using UnityEngine; /// Global noise bus. Footsteps, gunshots, thrown bottles call Emit. /// Listeners (enemy ears) subscribe. Observer pattern. public static class NoiseSystem { public readonly struct Noise { public readonly Vector3 Position; public readonly float Loudness; // radius in metres at which it is just audible, open air public readonly GameObject Source; public Noise(Vector3 pos, float loudness, GameObject src) { Position = pos; Loudness = loudness; Source = src; } } public static event Action<Noise> NoiseMade; public static void Emit(Vector3 position, float loudness, GameObject source = null) => NoiseMade?.Invoke(new Noise(position, loudness, source)); } /// Example emitter: footsteps scale with speed; crouching is quieter. [RequireComponent(typeof(StealthTarget))] public class FootstepNoise : MonoBehaviour { [SerializeField] float stepInterval = 0.45f; [SerializeField] float walkLoudness = 6f, runLoudness = 12f, runSpeed = 5f; StealthTarget stealth; Vector3 lastPos; float timer; void Awake() { stealth = GetComponent<StealthTarget>(); lastPos = transform.position; } void Update() { float speed = (transform.position - lastPos).magnitude / Mathf.Max(Time.deltaTime, 1e-5f); lastPos = transform.position; if (speed < 0.5f) return; timer -= Time.deltaTime; if (timer > 0f) return; timer = stepInterval; float loud = Mathf.Lerp(walkLoudness, runLoudness, speed / runSpeed); if (stealth.Crouching) loud *= 0.35f; NoiseSystem.Emit(transform.position, loud, gameObject); } } ``` ## Step 3 to 7 - The senses component One component does vision, hearing, the awareness meter, memory, reaction delay and group alerts. They all feed the same awareness value, so keeping them together keeps the logic easy to follow. Split it up later if you need to. ```csharp using System.Collections.Generic; using UnityEngine; /// Organic perception for one enemy. Implements IEnemySenses from [[Enemy AI State Machine]]. public class EnemySenses : MonoBehaviour, IEnemySenses { public enum AwarenessLevel { Unaware, Suspicious, Alerted } [Header("Vision")] [SerializeField] Transform eyes; // head bone or child at eye height [SerializeField] float sightRange = 18f; [SerializeField] float focalAngle = 40f; // full-strength cone (total degrees) [SerializeField] float peripheralAngle = 140f; // weak outer cone [SerializeField, Range(0, 1)] float peripheralStrength = 0.25f; [SerializeField] LayerMask sightBlockers = ~0; [SerializeField] float sightGainPerSecond = 1.2f; // at point blank, full light, focal [SerializeField] int losSamples = 3; // rays to head/chest/feet: partial cover = partial seen [Header("Hearing")] [SerializeField] float hearingMultiplier = 1f; [SerializeField] float wallMuffle = 0.4f; // loudness multiplier per blocking wall [SerializeField] LayerMask soundBlockers = ~0; [SerializeField] float noiseAwarenessGain = 0.35f; [Header("Awareness")] [SerializeField] float suspiciousThreshold = 0.3f; [SerializeField] float decayPerSecond = 0.08f; [SerializeField] float decayDelay = 2f; // grace period after last stimulus [SerializeField] float memoryDuration = 15f; [Header("Personality (randomised per enemy)")] [SerializeField] Vector2 reactionDelayRange = new(0.25f, 0.7f); [SerializeField] Vector2 perceptionScaleRange = new(0.85f, 1.15f); [Header("Group")] [SerializeField] float shoutRadius = 15f; [SerializeField, Range(0, 1)] float shoutAwareness = 0.8f; static readonly List<EnemySenses> allEnemies = new(); // --- runtime --- public float Awareness { get; private set; } public AwarenessLevel Level { get; private set; } public Transform Target { get; private set; } public Vector3 LastKnownPosition { get; private set; } public bool HasMemory => Time.time - lastStimulusTime < memoryDuration && hasEverSensed; public bool CanSeeTarget => Level == AwarenessLevel.Alerted && seenThisFrame; public bool IsSuspicious => Level == AwarenessLevel.Suspicious; float lastStimulusTime = -999f; bool hasEverSensed, seenThisFrame, alertedRaised; float reactionDelay, perceptionScale, pendingAlertTime = -1f; readonly Vector3[] sampleOffsets = new Vector3[3]; void OnEnable() { allEnemies.Add(this); NoiseSystem.NoiseMade += OnNoise; } void OnDisable() { allEnemies.Remove(this); NoiseSystem.NoiseMade -= OnNoise; } void Awake() { if (!eyes) eyes = transform; reactionDelay = Random.Range(reactionDelayRange.x, reactionDelayRange.y); perceptionScale = Random.Range(perceptionScaleRange.x, perceptionScaleRange.y); } void Update() { float dt = Time.deltaTime; seenThisFrame = false; foreach (StealthTarget t in StealthTarget.All) Look(t, dt); // Decay after a grace period, so a brief break doesn't wipe suspicion. if (Time.time - lastStimulusTime > decayDelay) Awareness = Mathf.Max(0f, Awareness - decayPerSecond * dt); UpdateLevel(); } // ---------- Vision ---------- void Look(StealthTarget t, float dt) { Vector3 eye = eyes.position; Vector3 to = t.AimPoint - eye; float dist = to.magnitude; if (dist > sightRange) return; float angle = Vector3.Angle(eyes.forward, to); float coneFactor; if (angle <= focalAngle * 0.5f) coneFactor = 1f; else if (angle <= peripheralAngle * 0.5f) { // Smoothly fade from focal to peripheral strength toward the edge. float k = Mathf.InverseLerp(focalAngle * 0.5f, peripheralAngle * 0.5f, angle); coneFactor = Mathf.Lerp(1f, peripheralStrength, k); } else return; float visibleFraction = LineOfSight(t, eye); if (visibleFraction <= 0f) return; // Distance falloff: quadratic feels right; close = fast. float distFactor = 1f - (dist / sightRange) * (dist / sightRange); float gain = sightGainPerSecond * coneFactor * distFactor * visibleFraction * t.Visibility * perceptionScale; if (gain <= 0.0001f) return; // Moving targets are easier to spot (simple: compare to last frame not tracked; use velocity if available). if (t.TryGetComponent(out Rigidbody rb)) gain *= 1f + Mathf.Clamp01(rb.linearVelocity.magnitude / 5f); Awareness = Mathf.Min(1f, Awareness + gain * dt); Register(t.transform, t.transform.position); if (Awareness >= 1f) seenThisFrame = true; } /// Fraction of sample points (head, chest, feet) with a clear line. Partial cover = slower detection. float LineOfSight(StealthTarget t, Vector3 eye) { Vector3 basePos = t.transform.position; sampleOffsets[0] = t.AimPoint + Vector3.up * 0.5f; sampleOffsets[1] = t.AimPoint; sampleOffsets[2] = basePos + Vector3.up * 0.2f; int n = Mathf.Clamp(losSamples, 1, 3), clear = 0; for (int i = 0; i < n; i++) { Vector3 d = sampleOffsets[i] - eye; if (!Physics.Raycast(eye, d.normalized, out RaycastHit hit, d.magnitude, sightBlockers, QueryTriggerInteraction.Ignore) || hit.transform == t.transform || hit.transform.IsChildOf(t.transform)) clear++; } return clear / (float)n; } // ---------- Hearing ---------- void OnNoise(NoiseSystem.Noise noise) { if (noise.Source == gameObject) return; Vector3 ear = eyes.position; float dist = Vector3.Distance(ear, noise.Position); float loud = noise.Loudness * hearingMultiplier * perceptionScale; // Occlusion: each blocking surface between source and ear muffles it. Vector3 dir = (noise.Position - ear).normalized; int walls = Physics.RaycastNonAlloc(ear, dir, wallHits, dist, soundBlockers, QueryTriggerInteraction.Ignore); for (int i = 0; i < walls; i++) if (noise.Source == null || !wallHits[i].transform.IsChildOf(noise.Source.transform)) loud *= wallMuffle; if (dist >= loud) return; // not audible float strength = 1f - dist / loud; // 1 at source, 0 at the audible edge // Noise alone caps at "very suspicious": you must SEE to become fully alerted. Awareness = Mathf.Min(0.95f, Awareness + noiseAwarenessGain * strength); // Hearing is imprecise: remember a fuzzy position, fuzzier when faint. Vector3 fuzz = Random.insideUnitSphere * (1f - strength) * 3f; fuzz.y = 0; Register(noise.Source ? noise.Source.transform : null, noise.Position + fuzz); } readonly RaycastHit[] wallHits = new RaycastHit[8]; // ---------- Memory / levels / reaction ---------- void Register(Transform source, Vector3 position) { if (source && source.GetComponent<StealthTarget>()) Target = source; LastKnownPosition = position; lastStimulusTime = Time.time; hasEverSensed = true; } void UpdateLevel() { AwarenessLevel wanted = Awareness >= 1f ? AwarenessLevel.Alerted : Awareness >= suspiciousThreshold ? AwarenessLevel.Suspicious : AwarenessLevel.Unaware; if (wanted == AwarenessLevel.Alerted && Level != AwarenessLevel.Alerted) { // Reaction delay: the "!" moment before acting. Gives the player a beat to break LOS. if (pendingAlertTime < 0f) pendingAlertTime = Time.time + reactionDelay; if (Time.time < pendingAlertTime) return; } pendingAlertTime = -1f; if (wanted == AwarenessLevel.Alerted && !alertedRaised) { alertedRaised = true; Shout(); } if (wanted == AwarenessLevel.Unaware) alertedRaised = false; // Alerted enemies don't drop straight back to calm: stay Suspicious (Search) while awareness decays. if (Level == AwarenessLevel.Alerted && wanted != AwarenessLevel.Alerted && Awareness > 0.5f) wanted = AwarenessLevel.Suspicious; Level = wanted; } // ---------- Group alerting ---------- void Shout() { foreach (EnemySenses other in allEnemies) { if (other == this) continue; float d = Vector3.Distance(transform.position, other.transform.position); if (d > shoutRadius) continue; // Shouts are heard through walls but muffled; ally ends up suspicious-to-alerted at our info. other.ReceiveAlert(LastKnownPosition, Target, shoutAwareness * (1f - 0.5f * d / shoutRadius)); } } public void ReceiveAlert(Vector3 position, Transform target, float amount) { Awareness = Mathf.Max(Awareness, amount); if (target) Target = target; LastKnownPosition = position; lastStimulusTime = Time.time; hasEverSensed = true; } public void ForgetTarget() { Awareness = 0f; hasEverSensed = false; Level = AwarenessLevel.Unaware; alertedRaised = false; } // ---------- Gizmos ---------- void OnDrawGizmosSelected() { Transform e = eyes ? eyes : transform; DrawCone(e, focalAngle, sightRange, new Color(1f, 0.2f, 0.2f, 0.9f)); DrawCone(e, peripheralAngle, sightRange * 0.8f, new Color(1f, 0.8f, 0.2f, 0.6f)); if (Application.isPlaying && hasEverSensed) { Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(LastKnownPosition, 0.4f); Gizmos.DrawLine(e.position, LastKnownPosition); } Gizmos.color = new Color(0.3f, 0.6f, 1f, 0.3f); Gizmos.DrawWireSphere(transform.position, shoutRadius); } static void DrawCone(Transform e, float angle, float range, Color c) { Gizmos.color = c; Vector3 l = Quaternion.AngleAxis(-angle * 0.5f, e.up) * e.forward * range; Vector3 r = Quaternion.AngleAxis(angle * 0.5f, e.up) * e.forward * range; Gizmos.DrawLine(e.position, e.position + l); Gizmos.DrawLine(e.position, e.position + r); Vector3 prev = e.position + l; for (int i = 1; i <= 12; i++) { Vector3 p = e.position + Quaternion.AngleAxis(-angle * 0.5f + angle * i / 12f, e.up) * e.forward * range; Gizmos.DrawLine(prev, p); prev = p; } } } ``` ### What each piece does, and why - **Focal vs peripheral:** inside `focalAngle` you're seen at full speed. Toward the edge of `peripheralAngle` it drops to 25%. A guard notices movement in the corner of their eye slowly, and you can still slip past their side. - **Several LOS rays:** standing half behind a crate lets 1 or 2 of 3 rays through, so you're detected at 33 to 66% speed instead of all-or-nothing. - **Light level:** `Visibility` scales the gain. In darkness (0.15 ambient) a guard takes about 7x longer to see you, which is the core of stealth. - **Gradual awareness:** the gain is per second, so detection builds up over time. Show it to the player as a "?" that fills up and turns into a "!". - **Decay with a grace period:** stepping out of view briefly doesn't reset the meter. - **Noise caps at 0.95:** sound makes them investigate but never confirms you. Only sight sends them into Chase. - **Fuzzy memory:** faint sounds give an inaccurate last-known position, so Search sweeps the area instead of heading straight to you. - **Reaction delay plus per-enemy scale:** each guard rolls its own 0.25-0.7 s delay and a ±15% perception multiplier, so a group doesn't react in lockstep. - **Shout:** the first guard to become alerted tells nearby allies. They come in suspicious-to-alert with its information, and the reaction delay still gives each of them a short beat. ## Step 8 - Wire into the state machine 1. On the **Player**: add `StealthTarget` and `FootstepNoise`. Set `Crouching` from your input script. Add a Rigidbody if you want the "movement is easier to spot" bonus. If you use a CharacterController, drop that line or feed in its velocity yourself. 2. On each **Enemy**: remove `SimpleSenses` and add `EnemySenses`, with `eyes` set to a head child. `EnemyBrain` picks it up through `GetComponent<IEnemySenses>()`. 3. The existing transitions now behave organically: - `Senses.IsSuspicious`: Patrol/Idle → **Search** (investigate the fuzzy last-known position) - `Senses.CanSeeTarget`: → **Chase** only after the meter fills *and* the reaction delay passes - Search's `GaveUp` calls `ForgetTarget()`, resetting the meter 4. Throw a distraction: `NoiseSystem.Emit(bottleHitPoint, 14f, bottle);` pulls guards away to investigate. 5. Optional UI: show `Awareness` as a world-space meter over the enemy's head (see [[UI Toolkit and Diegetic UI]]). ## Tuning checklist | Feels like... | Adjust | |---|---| | Spotted instantly | Lower `sightGainPerSecond`, narrow `focalAngle` | | Guards are blind | Raise `ambient` or `peripheralStrength` | | Guards forget too fast | Lower `decayPerSecond`, raise `decayDelay` | | Whole group reacts at once | Widen `reactionDelayRange` | | Sound travels through walls too easily | Lower `wallMuffle` | ## Performance notes - Each enemy casts up to 3 rays per target per frame. For dozens of enemies, run `Look` every 0.1-0.2 s with a staggered offset and multiply the gain by that interval instead of `dt`. You can also batch the rays with `RaycastCommand` (Jobs). - The hearing raycast only runs when a noise is made. - `FindObjectsByType` runs once, at Start.