Skip to main content

๐Ÿ‘๏ธ Lesson 6.4: Sensing the Player โ€” A Patrolling Guard (Mini-Project)

Your enemy FSM works, but it cheats: its CanSeePlayer() is stubbed to always return true, so it "sees" through walls and behind its own head. In this mini-project you'll replace that stub with a real vision cone โ€” distance, angle, and a line-of-sight ray โ€” then drop it straight into the FSM to finish a proper patrolling guard that spots you, gives chase, and returns to its route when you slip away.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Build a three-part vision test: distance, Vector3.Angle (field of view), and a Raycast line-of-sight check
  • Understand why all three checks are needed and the order to run them in (cheap first)
  • Add an optional hearing sense as a fallback trigger
  • Wire the sensor into the Lesson 6.3 FSM by replacing the stub
  • Assemble the complete mini-project: a guard that patrols, senses, chases, and gives up

Estimated Time: 50 minutes  ยท  Prerequisite: Lesson 6.3 (An Enemy State Machine)

In This Lesson

What "Seeing" Really Means

To a human, "the guard can see the player" is obvious. To code, it's three separate questions that must all be yes:

  1. Is the player close enough? โ€” within the guard's sight range. (A distance check.)
  2. Is the player in front of the guard? โ€” inside the field-of-view cone, not behind its head. (An angle check.)
  3. Is the view unobstructed? โ€” no wall or crate between them. (A raycast.)

Miss any one and the AI feels broken: skip the angle and the guard has eyes in the back of its head; skip the raycast and it sees through solid walls. Together, these three give the fair, readable stealth behaviour players expect.

๐Ÿ“– Definition

Field of view (FOV): the angular width of what a character can see, centred on its forward direction. A 90ยฐ FOV means the guard notices anything within 45ยฐ either side of straight ahead. Combined with a range, it forms a vision cone.

The Vision Cone

Picture the guard from above. Its vision is a wedge: a maximum radius (the range) and an angular spread (the FOV), pointing wherever the guard faces. The player is only "seen" when they're inside that wedge and a straight line to them isn't blocked. Here's the whole idea in one top-down diagram:

Top-down vision cone detecting the player A top-down view of a guard at the apex of a translucent yellow vision cone pointing right, bounded by a range arc. One player inside the cone is connected by a clear green line-of-sight ray and labelled SEEN. A second player is inside the cone's angle and range but hidden behind a wall, with a red blocked ray and labelled HIDDEN. A third player is outside the cone angle, labelled OUT OF VIEW. forward ยท range FOV wall guard P SEEN P HIDDEN P OUT OF VIEW
Figure 1: The vision cone (recreated top-down). Only the top player passes all three tests โ€” in range, inside the FOV angle, and with a clear ray. The wall blocks the middle player; the third is outside the cone's angle entirely.

Building the Sensor

Now the code. Run the checks cheapest-first: a distance compare is nearly free, the angle is cheap, and the Raycast is the most expensive โ€” so bail out early before you ever fire a ray.

using UnityEngine;

public class VisionSensor : MonoBehaviour
{
    [SerializeField] Transform player;
    [SerializeField] float sightRange = 12f;
    [SerializeField] float fieldOfView = 90f;      // total cone angle in degrees
    [SerializeField] float eyeHeight = 1.6f;       // raise the ray to "eye" level
    [SerializeField] LayerMask obstacleMask;       // what counts as a sight blocker

    public bool CanSeePlayer()
    {
        if (player == null) return false;

        Vector3 toPlayer = player.position - transform.position;

        // 1) Distance โ€” cheapest. Compare squared to skip a sqrt.
        if (toPlayer.sqrMagnitude > sightRange * sightRange) return false;

        // 2) Angle โ€” is the player inside the FOV cone?
        //    Vector3.Angle returns 0..180 between forward and the direction to the player.
        float angle = Vector3.Angle(transform.forward, toPlayer);
        if (angle > fieldOfView * 0.5f) return false;

        // 3) Line of sight โ€” raycast from eye level; anything on obstacleMask blocks.
        Vector3 eye = transform.position + Vector3.up * eyeHeight;
        Vector3 dir = (player.position + Vector3.up * eyeHeight) - eye;
        if (Physics.Raycast(eye, dir.normalized, out RaycastHit hit, sightRange, obstacleMask | (1 << player.gameObject.layer)))
        {
            // we saw the player only if the ray hit the player first (nothing in the way)
            return hit.transform == player;
        }
        return false;
    }
}

Walk through the three gates. The distance check uses sqrMagnitude against sightRange squared โ€” no square root needed. The angle check uses Vector3.Angle between the guard's forward and the direction to the player; if that exceeds half the FOV, the player is off to the side or behind. Only if both pass do we fire the Raycast from eye height; if the first thing it hits is the player, the view is clear.

โš ๏ธ Raycast from the eyes, not the feet

If you cast from the guard's pivot (usually at the floor), a low wall or a step can block a ray that a standing guard would clearly see over โ€” or vice-versa. Offset the origin to eyeHeight and aim at the player's chest/head. Also make sure the obstacleMask excludes the guard's own collider, or the ray hits itself and always reports "blocked."

โœ… Pro Tip

Draw the cone in the Scene view with OnDrawGizmosSelected โ€” a couple of Gizmos.DrawLine calls for the FOV edges and a Gizmos.DrawWireSphere for the range. Being able to see the sensor's shape while you tune fieldOfView and sightRange turns guesswork into a two-minute job.

Optional: Hearing

Sight alone can feel unfair when the player sneaks directly behind a guard. A cheap second sense fixes it: hearing. The player emits a "noise" (louder when sprinting, near-silent when crouching); the guard notices if that noise is within a hearing radius, regardless of angle or walls.

[SerializeField] float hearingRange = 6f;

// Called by the player (or a movement script) when it makes noise.
// loudness 0..1 scales how far the sound carries.
public bool CanHearPlayer(float loudness)
{
    if (player == null) return false;
    float range = hearingRange * loudness;
    float sqrDist = (player.position - transform.position).sqrMagnitude;
    return sqrDist <= range * range;
}

Hearing doesn't need an angle or a ray โ€” sound goes around corners. Treat it as an alternative trigger into Chase (or into an intermediate "Investigate" state that walks toward the last-heard position). It's optional for this mini-project, but it's what turns a guard from "sees you" into "notices you," which feels much smarter.

Wiring It Into the FSM

Here's the payoff. In Lesson 6.3 you called a stubbed CanSeePlayer(). Now you delete the stub and delegate to the real sensor. Two small edits to EnemyAI:

[RequireComponent(typeof(NavMeshAgent), typeof(VisionSensor))]
public class EnemyAI : MonoBehaviour
{
    VisionSensor vision;

    void Awake()
    {
        agent   = GetComponent<NavMeshAgent>();
        animator = GetComponent<Animator>();
        vision  = GetComponent<VisionSensor>();   // NEW
    }

    // Replace the old stub with the real thing.
    bool CanSeePlayer() => vision.CanSeePlayer();   // (add "|| vision.CanHearPlayer(...)" for hearing)
}

That's the entire integration. Because the FSM was written against a CanSeePlayer() method from the start, swapping a fake implementation for a real one changes nothing else. This is the decoupling lesson from Module 2 paying off in miniature: the FSM depends on the question ("can I see the player?"), not on how it's answered.

๐Ÿ’ก The give-up timer now makes sense. With real vision, CanSeePlayer() flicks to false the instant the player ducks behind a wall. The giveUpTime from Lesson 6.3 is what keeps the guard chasing for a few seconds after losing sight โ€” hunting toward where you were โ€” instead of instantly forgetting you. That short memory is the difference between a dumb guard and a tense one.

The Sense โ†’ Decide โ†’ Act Loop

Zooming out, every frame the guard runs the same three-beat loop. The sensor is the sense step; the FSM's Decide and Act are the other two. Seeing it whole clarifies where each piece of code lives:

flowchart TD subgraph SENSE S1["Distance in range?"] --> S2["Inside FOV angle?"] S2 --> S3["Raycast line-of-sight clear?"] S3 --> S4["canSee = true / false"] end S4 --> D{"DECIDE:
given state + canSee + dist,
should we transition?"} D -- "Patrol & canSee" --> C1["โ†’ Chase"] D -- "Chase & dist < attackRange" --> C2["โ†’ Attack"] D -- "Chase & lost > giveUpTime" --> C3["โ†’ Patrol"] D -- "no change" --> C4["stay"] C1 --> A["ACT: run current state
(drive agent + Animator)"] C2 --> A C3 --> A C4 --> A A --> S1

Figure 2: The full sense โ†’ decide โ†’ act loop. The VisionSensor produces canSee; the FSM consumes it to pick transitions, then acts by driving the NavMeshAgent and Animator.

Mini-Project: The Patrolling Guard

Time to bring the whole module together into one deliverable. You'll assemble a guard that patrols a route, catches the player with a real vision cone, chases, attacks at close range, and returns to its patrol when it loses you.

๐Ÿ—๏ธ Build: A guard that patrols, spots, chases, and returns

Objective: A complete, playable stealth encounter using everything from Module 6.

  1. Scene: reuse your baked room from Lesson 6.1 (or bake a fresh one with a few walls to hide behind). Add a player capsule you can move with the Input System from Module 3.
  2. Guard: a capsule with a NavMeshAgent, the EnemyAI FSM (Lesson 6.3), and the new VisionSensor. Put your level's walls on an Obstacles layer and assign it to the sensor's obstacleMask.
  3. Patrol: place 3โ€“4 waypoints and assign them; confirm the guard walks the loop.
  4. Vision: tune sightRange and fieldOfView. Add the gizmo drawing so you can see the cone while tuning.
  5. Encounter: step into the cone โ€” the guard should switch to Chase and pursue. Duck behind a wall and count: after giveUpTime it returns to patrol. Get close and it enters Attack.
  6. Animator (optional): if you have a rigged character from Module 1, feed SetFloat("Speed", agent.velocity.magnitude) so it idles, walks, and runs correctly through the encounter.
๐Ÿ’ก Hint: the guard sees me through walls

Your obstacleMask probably doesn't include the wall layer, so the raycast never hits anything and reports a clear view. Put walls on a dedicated layer and tick it in the mask. Also confirm the guard's own collider isn't on that mask, or the ray hits itself.

๐Ÿ’ก Hint: it spots me the instant I'm anywhere near, even behind it

Your angle check is likely missing or wrong. Verify you compare Vector3.Angle(transform.forward, toPlayer) against fieldOfView * 0.5f (half, because the FOV spreads both sides of forward). If you compare against the full FOV, the cone is twice as wide as intended.

โœ… Success check

Standing behind the guard or behind a wall, you're invisible. Step into the cone with a clear line and it immediately gives chase. Break line-of-sight and it keeps coming briefly, then peels off back to its patrol loop and resumes the circuit. All three states are reachable and it never flickers.

๐ŸŽฏ Quick Quiz

Question 1: Why run the distance check before the angle check, and the angle check before the Raycast?

Question 2: What does Vector3.Angle(transform.forward, toPlayer) give you, and what do you compare it against?

Question 3: Replacing the stubbed CanSeePlayer() with the real sensor required no other changes to the FSM. Why?

Summary

๐ŸŽ‰ Key Takeaways

  • "Seeing" is three tests that must all pass: distance, FOV angle (Vector3.Angle vs half the cone), and a Raycast line-of-sight.
  • Run them cheapest-first and cast the ray from eye height with an obstacleMask that excludes the guard itself.
  • Hearing (a distance-only, angle-free check scaled by loudness) is a cheap, fair second sense.
  • Because the FSM was written against a CanSeePlayer() method, swapping the stub for the real sensor changed nothing else โ€” decoupling in action.
  • The whole guard is one sense โ†’ decide โ†’ act loop: the sensor senses, the FSM decides and acts on the NavMeshAgent and Animator.
  • You built a complete stealth encounter โ€” patrol, spot, chase, attack, give up โ€” from the pieces of this module.

๐Ÿš€ What's Next?

Your guard can catch the player and swing at them โ€” but nothing happens on a hit yet. Module 6 gave enemies a brain; Module 7 gives the whole cast consequences. In Lesson 7.1: Health & Damage with Interfaces you'll build an IDamageable interface so the guard's attack (and the player's) can hurt anything that implements it โ€” the clean, decoupled way to do combat.

๐Ÿ‘๏ธ You built a thinking, seeing enemy

Bake, move, decide, sense โ€” four lessons, one guard that patrols its beat and hunts you when you slip up. Every AI you build from here is a variation on this exact spine.