← Projects
In Development Unity 6 · URP

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 structNoiseEvent — 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

Hide and Seek — top-down gameplay view: patrol route and suspicion meter visible

Top-down view — patrol route and suspicion meter visible

Hide and Seek — enemy chase state: EnemyChaseState active with suspicion meter at maximum

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 at Awake() and passing them by reference would eliminate that allocation entirely.
  • Phase 2 escalation is three loose properties, not a profile. LevelPhaseManager escalates the enemy by setting Phase2SpeedMultiplier, Phase2SuspicionFloor, and Phase2SkipAlertScan directly on EnemyController. There's already an EscalationProfile ScriptableObject in the Data/ folder — it should drive all phase transitions instead of the three ad-hoc setters.
  • SuspicionMeter has four separate timer floats. _detectionCooldownTimer, _chaseNoInputTimer, _catchDwellTimer, and _caughtFired are all managed inline. A small reusable CountdownTimer struct 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.