Hide & Seek
Detection isn't a flag — it's a continuous suspicion float with
per-state hysteresis, six behaviours implemented as self-contained
states in a generic StateMachine, and a static noise event bus that decouples every sound source from
every listener.
Gameplay preview — suspicion build-up, patrol → investigate → chase sequence
Making Detection Feel Like Pressure, Not a Trap
Binary detection — seen or unseen — removes player agency the moment
it fires. A continuous float (0–100) fixes this: the player can see the meter rising, duck
behind cover, and let it bleed back down before the enemy commits.
Hysteresis makes that bleed-down reliable — each SeekState has separate rising and falling thresholds, so the enemy doesn't
flicker between states at the boundary. You need to genuinely break
line of sight to pull the meter back, not just graze the threshold.
Keeping Six Behaviours Independent of Each Other
Idle, Patrol, Investigate, Alert, Chase, Search — each state needs
to read from the suspicion meter, drive the NavMesh, play
animations, and trigger camera responses. None of those concerns
belong in a single Update() loop. A generic StateMachine (plain C# class, no Unity dependency) paired with an IState interface makes each state a self-contained class that owns its
own enter, tick, and exit logic. Adding a state or changing a
transition touches one file, not the whole controller.
Decoupling Noise Sources from Listeners
Anything that makes noise — a sprinting player, a knocked-over
prop — needs to alert every enemy within range. Neither the emitter
nor the enemy should hold a reference to the other: the set of
listening enemies changes at runtime, and emitters shouldn't need to
know what's listening. A static NoiseEmitter event bus with a readonly struct payload solves this. Emitters fire-and-forget. Each enemy's NoiseListener filters by _hearingRadius before raising its own local event. No allocation, no coupling, no
broadcast-to-all overhead.
Architecture
IState + StateMachine — Plain Classes, Not MonoBehaviours
StateMachine and BaseState live in Infrastructure/StateMachine/ with no Unity dependency — they're plain C# classes that could
run in a test harness without an editor. StateMachine holds the current IState, calls Exit() then Enter() on transition, and exposes Tick(). EnemyController owns one instance, calls Tick() from Update(), and constructs each concrete state with a reference to
itself. The states get everything they need through that owner
reference — no singletons, no FindObjectOfType.
// Infrastructure/Interfaces/IState.cs
public interface IState
{
void Enter();
void Tick();
void Exit();
}
// Infrastructure/StateMachine/StateMachine.cs
public class StateMachine
{
IState _currentState;
public IState CurrentState => _currentState;
public void ChangeState(IState newState)
{
_currentState?.Exit();
_currentState = newState;
_currentState?.Enter();
}
public void Tick() => _currentState?.Tick();
}
// Infrastructure/StateMachine/BaseState.cs
public abstract class BaseState : IState
{
public virtual void Enter() { }
public virtual void Tick() { }
public virtual void Exit() { }
}
// AI/Enemy/EnemyController.cs (simplified)
public class EnemyController : MonoBehaviour
{
StateMachine _stateMachine = new();
void Start() => _stateMachine.ChangeState(new EnemyPatrolState(this));
void Update() => _stateMachine.Tick();
public void ChangeState(IState newState) => _stateMachine.ChangeState(newState);
} Six concrete states in AI/Enemy/States/
EnemyIdleState Stops navigation, waits a timer, transitions to Patrol EnemyPatrolState Walks a waypoint loop, transitions to Alert or Chase as suspicion rises EnemyInvestigateState Moves to a specified position, transitions back to Patrol after a look-around EnemyAlertState Stops and scans toward the last-known position, transitions to Chase or Search EnemyChaseState Pursues with throttled nav updates, transitions to Search on LoS loss EnemySearchState Three-phase sweep: LKP → rotate through N directions → nearest patrol waypoints SuspicionMeter — Continuous Float with Hysteresis
SuspicionMeter is a MonoBehaviour on the enemy that accumulates suspicion each
FixedUpdate. The visual delta from EnemyDetection.ComputeVisualDelta() applies three modifiers: distance falloff, angle falloff, and
a crouch stealth multiplier. The result is a per-frame rate —
not a spike — so suspicion rises smoothly and the player has
time to react.
ComputeTargetState() uses separate rising and falling thresholds per state. The gap
between them is the hysteresis band: you need to lose suspicion
past the falling threshold to revert a state, not just dip below
the threshold that triggered it. Without that gap, a player
hovering at the boundary would flicker the enemy between states
on every frame.
// Detection/SuspicionMeter.cs
public class SuspicionMeter : MonoBehaviour
{
public event Action<float> OnSuspicionChanged;
public event Action<SeekState, SeekState> OnStateChanged;
public event Action OnPlayerCaught;
public float Suspicion { get; private set; } // 0–100
public float SuspicionNormalized => Suspicion / 100f;
public SeekState State { get; private set; }
public float Phase2SuspicionFloor { get; set; } // set by LevelPhaseManager
// Called each FixedUpdate by EnemyDetection
public void Tick(float visualDeltaPerSec, float audioSpike,
bool hasLoS, float proximityDeltaPerSec,
bool playerInCatchRadius) { /* ... */ }
SeekState ComputeTargetState()
{
// Rising thresholds (from EnemyData ScriptableObject):
// Unaware → Alert: suspicion >= alertThreshold (25f)
// Alert → Searching: suspicion >= searchingThreshold (60f)
// Searching → Chase: suspicion >= chaseThreshold (85f)
//
// Falling thresholds (hysteresis gap prevents flickering):
// Chase → Searching: suspicion < chaseRevertThreshold (75f)
// Searching → Alert: suspicion < searchingRevertThreshold (50f)
// Alert → Unaware: suspicion < alertRevertThreshold (15f)
// ...
}
}
// AI/Enemy/EnemyDetection.cs — visual delta per GDD formula F2
float ComputeVisualDelta(float distance, float angleToPlayer)
{
// distanceFactor: falloff using distanceFalloffExponent (2f) over detectionRange (15f)
// angleFactor: falloff using angleFalloffExponent (2f) over fieldOfViewAngle/2 (45°)
// crouchModifier: crouchStealthMultiplier (0.4f) when player is crouching
// stateMultiplier: searchingVisualDetectionMultiplier (1.5f) when Searching
return data.baseDetectionRate * distanceFactor * angleFactor
* crouchModifier * stateMultiplier;
} EnemyNavigation — NavMeshAgent Behind an Interface
States never touch NavMeshAgent directly. EnemyNavigation wraps it and exposes only what states need: SetDestination(), Stop(), IsNear(), and RotateToward(). That last one is the non-obvious method — EnemyAlertState uses it to smoothly scan toward the last-known position, and
it returns true when the rotation is within 1° of the target so the state
knows when to stop waiting. Wrapping the agent means you can
swap the navigation backend or mock it in tests without touching
any state logic.
NoiseEmitter — Static Event Bus with Zero Allocation
NoiseEmitter is a static class that fires a readonly struct — NoiseEvent — carrying world position, intensity, and a source tag.
Emitters call NoiseEmitter.Emit() and forget it. Each enemy's NoiseListener subscribes on OnEnable, unsubscribes on OnDisable, and filters incoming events by _hearingRadius before raising its own local event. The struct allocates
nothing on the heap: no boxing, no list allocation per
broadcast. Adding a new noise source — a door slam, a
grenade — means one Emit() call. Nothing else changes.
// AI/Enemy/EnemyNavigation.cs
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyNavigation : MonoBehaviour
{
NavMeshAgent _agent;
public bool IsAtDestination { get; }
public void SetDestination(Vector3 position) => _agent.SetDestination(position);
public void SetSpeed(float speed) => _agent.speed = speed;
public void Stop() => _agent.ResetPath();
public bool IsNear(Vector3 position, float threshold) =>
Vector3.Distance(transform.position, position) <= threshold;
// Used by EnemyAlertState to scan toward last-known-position
public bool RotateToward(Vector3 target, float degreesPerSecond) { /* returns true when < 1° */ }
}
// Detection/NoiseSystem/NoiseEvent.cs — heap-free event payload
public readonly struct NoiseEvent
{
public Vector3 WorldPosition { get; init; }
public float Intensity { get; init; }
public string SourceTag { get; init; }
}
// Detection/NoiseSystem/NoiseEmitter.cs — static fire-and-forget bus
public static class NoiseEmitter
{
public static event Action<NoiseEvent> OnNoiseEmitted;
public static void Emit(NoiseEvent e) => OnNoiseEmitted?.Invoke(e);
}
// Detection/NoiseSystem/NoiseListener.cs — per-enemy filtered subscriber
public class NoiseListener : MonoBehaviour
{
[SerializeField] float _hearingRadius;
public event Action<NoiseEvent> OnNoiseHeard;
void OnEnable() => NoiseEmitter.OnNoiseEmitted += HandleNoise;
void OnDisable() => NoiseEmitter.OnNoiseEmitted -= HandleNoise;
void HandleNoise(NoiseEvent e)
{
if (Vector3.Distance(transform.position, e.WorldPosition) <= _hearingRadius)
OnNoiseHeard?.Invoke(e);
}
} LevelBuilder — Nine Custom Editor Tools
The Editor/LevelBuilder/ directory is nine tools designed to hand off to each other
across the full layout workflow. Placement (SurfaceDropTool, BoundsSnapTool, PrefabPaletteWindow), alignment and distribution (BoundsAlignTool, DistributeTool, ScatterTool), and auditing (OverlapChecker, ComponentAuditTool) — you scatter props with ScatterTool, snap them to geometry with SurfaceDropTool, then run OverlapChecker to catch collider intersections before hitting play. LevelBuilderUtility provides the shared selection queries, undo helpers, and gizmo
drawing that all nine tools depend on.
SurfaceDropTool Raycasts selection onto underlying geometry — snaps props to floor/ceiling in one click BoundsSnapTool Snaps object bounds edges to target surfaces, avoiding gap-between-mesh artefacts BoundsAlignTool Aligns selection to a reference object's bounds (left/right/top/bottom/front/back) DistributeTool Distributes selected objects evenly along X, Y, or Z with configurable spacing ScatterTool Random-placement with overlap avoidance — configurable radius, count, and normal alignment OverlapChecker Highlights objects whose collider bounds intersect — catches layout errors before play PrefabPaletteWindow Click-to-place prefab palette with preview — eliminates drag from Project window ComponentAuditTool Scans scene for missing scripts, empty serialized fields, or incorrect layer assignments LevelBuilderUtility Shared helpers used by all tools — selection queries, undo helpers, gizmo drawing // Editor/LevelBuilder/ — all tools live here as EditorWindow or static utility classes
// Example: SurfaceDropTool drops selected transforms onto the nearest surface below
// Representative pattern (actual implementation details [VERIFY]):
public class SurfaceDropTool : EditorWindow
{
[MenuItem("Level Builder/Surface Drop Tool")]
static void Open() => GetWindow<SurfaceDropTool>("Surface Drop");
void OnGUI()
{
if (GUILayout.Button("Drop to Surface"))
{
Undo.RecordObjects(Selection.transforms, "Surface Drop");
foreach (var t in Selection.transforms)
LevelBuilderUtility.DropToSurface(t);
}
}
} Screenshots
Top-down view — patrol route and suspicion meter visible
Chase state — EnemyChaseState active, suspicion at max
Retrospective
- — State transitions allocate.
Each
ChangeState(new EnemyChaseState(this))creates a new heap object. With six states and frequent transitions, this generates measurable GC. Pre-allocating all states atAwake()and passing them by reference would eliminate that allocation entirely. - — Phase 2 escalation is three loose properties, not a profile.
LevelPhaseManagerescalates the enemy by settingPhase2SpeedMultiplier,Phase2SuspicionFloor, andPhase2SkipAlertScandirectly onEnemyController. There's already anEscalationProfileScriptableObject in theData/folder — it should drive all phase transitions instead of the three ad-hoc setters. - — SuspicionMeter has four separate timer floats.
_detectionCooldownTimer,_chaseNoInputTimer,_catchDwellTimer, and_caughtFiredare all managed inline. A small reusableCountdownTimerstruct that ticks and fires a callback would cut this down significantly and make the intent explicit.
What's Next
- › SFX pass on the noise system. Footstep intensity varies by stance — walk, sprint, crouch — via PlayerNoiseEmitter. The wiring is in place; it needs audio assets and an alert sting on SeekState transitions.
- › Hiding spots. The wardrobe interaction is wired up. What's missing is the tension layer: a slow creeping suspicion drain rate while inside — distinct from normal falloff — and a VFX pass to sell the dread.
- › Win condition. RoundTimer expiry already calls WinConditionEvaluator. The evaluation logic is done; the win screen and score display aren't.
- › Phase 2 escalation. LevelPhaseManager and the EscalationProfile ScriptableObject both exist. The remaining work is connecting the profile to the game loop through the LevelPhase enum so phase transitions are data-driven rather than the three ad-hoc setters called out in the Retrospective.
- › More level layouts. The LevelBuilder toolset makes iteration low-effort — scatter, drop to surface, audit. The constraint is art assets, not tooling.