11.4 Perception & Spatial Reasoning

Phase 11 · Game AI · Study time: 15–25 h

Vision cones, hearing, memory and influence maps — how an agent comes to know about the world it reacts to.

An enemy in a game does not actually see or hear anything. It runs code that checks numbers and decides "the player is visible" or "the player is not visible." This lesson is about writing that code well. You already know the two math tools it leans on hardest: the dot product (chapter 2.1), which measures how well two directions line up, and the raycast (chapters 6.2 and 6.3), which asks "what does this line hit first?" Here you will combine both into a full perception system: vision with a field-of-view cone, hearing with sound events, a small memory so an agent does not forget you the instant you step behind a wall, and then a step up in difficulty — spatial reasoning, where an agent judges the shape of the level itself to find cover or a good flanking spot.

1. What Perception Means for an Agent

Picture the simplest possible enemy script: every frame, it reads player.transform.position directly and walks toward it. That enemy is not perceiving anything. It has perfect, instant, x-ray knowledge of the player at all times, through any wall, from any distance. Players notice this immediately — it feels like the enemy is cheating, because it is.

A believable agent should only know what it could plausibly sense. That means before an agent is allowed to react to the player, some piece of code has to answer three separate questions honestly: can I currently see it, can I currently hear it, and do I remember where it recently was? Only after those questions are answered does decision-making (the state machine from chapter 6.1, or a behavior tree in a later lesson) get to run. This is usually described as a loop:

World state (positions, sounds, everything that exists) | v SENSES (vision, hearing, ...) <-- this lesson, sections 2-6 | v PERCEPTS + MEMORY <-- what the agent currently believes | (this lesson, section 7) v DECISION MAKING (state machine, behavior tree, ...) | v ACTIONS (move, attack, take cover, ...)

Splitting the loop this way is not just about fairness to the player. It also keeps the code manageable: the vision code does not need to know anything about attacking, the state machine does not need to know how a raycast works, and you can test each piece on its own. The rest of this lesson builds the "senses" and "percepts + memory" boxes; section 8 wires a small state machine on top so you can see the whole loop running.

Tip "Perception" in this lesson means exactly this: turning raw world data into a small, believable, delayed, sometimes-wrong picture of what the agent currently knows. An agent that always knows the full truth instantly is not perceiving — it is cheating, and it plays like it.

2. Vision, Step 1: The Distance Check

A field of view (FOV) sensor answers one question: "can this agent currently see that target?" The honest answer depends on three things, checked in order: is the target close enough, is it within the angle the agent is looking, and is there a clear line of sight to it. This section covers the first and cheapest check: distance.

Every enemy has a view radius — a maximum distance beyond which it simply cannot make anything out, similar to how you cannot read a sign that is far enough away, no matter which direction you are facing.

public float viewRadius = 10f;   // meters

bool WithinRange(Vector3 myPosition, Vector3 targetPosition)
{
    float sqrDist = (targetPosition - myPosition).sqrMagnitude;
    return sqrDist <= viewRadius * viewRadius;
}

Notice this compares sqrMagnitude (length squared) against viewRadius * viewRadius, instead of computing the real distance with a square root and comparing that to viewRadius. Both give the same true-or-false answer, but sqrMagnitude skips the square root entirely. A single square root is not expensive on its own, but a vision check like this can run for every enemy, every frame, so skipping unnecessary square roots is a habit worth having from the start.

myPosition *-------- viewRadius --------* (edge of vision, distance-wise only) target inside this circle -> distance check passes, keep checking angle + LOS target outside this circle -> distance check fails, stop here, target is NOT visible

This is the cheapest of the three checks, so it always runs first: if the target is too far away, there is no reason to spend time computing an angle or firing a raycast at all. The next section adds the second check on top of this one.

3. Vision, Step 2: The Angle Check with the Dot Product

Passing the distance check only means the target is somewhere inside a full circle around the agent. Real eyes do not see in a full circle — they see inside a cone pointing forward, described by a view angle (the full width of that cone, in degrees). This is exactly the situation chapter 2.1 set up when it said the dot product "shows up constantly in games: lighting, AI vision cones, and steering."

Recall the formula from chapter 2.1: a . b = |a| * |b| * cos(theta), where theta is the angle between the two vectors. If both a and b are normalized (length 1), that formula simplifies to a . b = cos(theta) — the dot product of two unit vectors IS the cosine of the angle between them, with no square roots or trigonometry needed to get there.

public float viewAngle = 90f;   // FULL cone width in degrees, not the half-angle

bool WithinAngle(Vector3 forward, Vector3 myPosition, Vector3 targetPosition)
{
    Vector3 dirToTarget = (targetPosition - myPosition).normalized;
    float cosHalfAngle = Mathf.Cos(viewAngle * 0.5f * Mathf.Deg2Rad);
    float cosActual = Vector3.Dot(forward, dirToTarget);
    return cosActual >= cosHalfAngle;
}

Walking through this: forward and dirToTarget are both unit vectors (length 1), so cosActual is exactly the cosine of the real angle between where the agent is looking and where the target is. cosHalfAngle is the cosine of half the view cone (a 90-degree cone reaches 45 degrees to either side of forward). Cosine gets SMALLER as the angle gets BIGGER, so "the real angle is small enough" turns into "the real cosine is large enough" — which is why the comparison is >= and not <=.

forward ^ \ | / \ | / A (dot=0.97) | / A: nearly straight ahead, dot close to 1, PASSES \ | / cone edge -> \ | / <- cone edge (viewAngle/2) \ | / (viewAngle/2) \|/ -------o------- o = agent position B (dot=0.0) B: exactly to the side, dot = 0, FAILS (perpendicular, same as chapter 2.1's example)

This is the same sign-and-size pattern from chapter 2.1's dot product lesson, just reused for a new purpose: a target dead ahead gives a dot product near 1, a target to the side gives a dot product near 0, and a target behind gives a negative dot product. Comparing against cosHalfAngle instead of converting back to a real angle (which would need acos, the inverse of cosine) is deliberately the cheaper path — the same reasoning as skipping the square root in section 2. You already saw Vector3.Angle used for slope checks in chapter 6.2, which does call acos internally; that is fine for a single check per frame, but for a cone test the direct dot-product comparison avoids that extra work.

Tip Both vectors MUST be normalized before you compare their dot product to a fixed cosine threshold — otherwise the dot product also carries the vectors' lengths, not just the angle between them, and the comparison stops meaning what you think it means. Exercise 1 at the end of this lesson asks you to find exactly this bug.

4. Vision, Step 3: Line of Sight with a Raycast

Distance and angle only describe an empty cone in space. A target can be inside that cone and still be completely hidden behind a wall, a crate, or another character. The third check is a raycast — the same tool from chapters 6.2 and 6.3, fired from the agent's eyes toward the target, asking "what is the first solid thing this line touches?"

public LayerMask targetMask;     // what counts as "a target" (e.g. the Player layer)
public LayerMask obstacleMask;   // what can block sight (e.g. walls, crates)

bool HasLineOfSight(Vector3 eyePosition, Vector3 dirToTarget, float distToTarget)
{
    int mask = obstacleMask | targetMask;
    if (Physics.Raycast(eyePosition, dirToTarget, out RaycastHit hit, distToTarget, mask))
    {
        bool hitIsTarget = ((1 << hit.collider.gameObject.layer) & targetMask) != 0;
        return hitIsTarget;   // true only if the FIRST thing hit is the target itself
    }
    return true;   // raycast found nothing at all in the way -- clear line
}

The raycast is fired against a mask that includes BOTH obstacles and targets together, so whichever one is physically closer to the agent's eyes is the one the ray reports back. If that first hit belongs to the target layer, the line is clear. If it belongs to anything else (a wall, a crate), that thing is standing in the way and the target is not visible, no matter how good the distance and angle looked.

Now put all three checks together, in cheapest-first order, into one method:

public class FieldOfViewSensor : MonoBehaviour
{
    public float viewRadius = 10f;
    [Range(0, 360)] public float viewAngle = 90f;
    public LayerMask targetMask;
    public LayerMask obstacleMask;

    public bool CanSeeTarget(Transform target)
    {
        Vector3 toTarget = target.position - transform.position;

        // 1) distance -- cheapest, do it first
        float sqrDist = toTarget.sqrMagnitude;
        if (sqrDist > viewRadius * viewRadius)
            return false;

        // 2) angle -- still cheap, no physics involved
        Vector3 dirToTarget = toTarget.normalized;
        float cosHalfAngle = Mathf.Cos(viewAngle * 0.5f * Mathf.Deg2Rad);
        float cosActual = Vector3.Dot(transform.forward, dirToTarget);
        if (cosActual < cosHalfAngle)
            return false;

        // 3) line of sight -- a physics query, the most expensive, so it runs last
        float dist = Mathf.Sqrt(sqrDist);
        int mask = obstacleMask | targetMask;
        if (Physics.Raycast(transform.position, dirToTarget, out RaycastHit hit, dist, mask))
        {
            bool hitIsTarget = ((1 << hit.collider.gameObject.layer) & targetMask) != 0;
            if (!hitIsTarget)
                return false;
        }
        return true;
    }

    public List<Transform> FindVisibleTargets()
    {
        List<Transform> visible = new List<Transform>();
        Collider[] nearby = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
        foreach (Collider col in nearby)
        {
            if (CanSeeTarget(col.transform))
                visible.Add(col.transform);
        }
        return visible;
    }
}

Worked trace, with the agent at the origin facing (0, 0, 1), viewRadius = 10, viewAngle = 90 (so cosHalfAngle = cos(45 deg) = 0.707):

Target A at (0, 0, 5): distance 5 (<=10, passes). dirToTarget=(0,0,1). dot = 1.0 (>=0.707, passes). Raycast to it is clear -> CanSeeTarget = true. Target B at (8, 0, 0): distance 8 (<=10, passes). dirToTarget=(1,0,0). dot = 0.0 (<0.707, FAILS) -> CanSeeTarget = false. No raycast even fired. Target C at (0, 0, 5), same as A, but a wall's collider sits at (0, 0, 2): distance and angle both pass, same as A. Raycast now hits the wall first, at distance 2, before reaching the target at distance 5. hitIsTarget = false -> CanSeeTarget = false.
forward ^ \ | / \ (A) / A: inside radius, inside cone, ray clear -> VISIBLE \ | / cone \ | / cone edge \ | / edge \ | / \ | / -----( o )----- o = agent (eye position) / | \. / ###|###\. / ## C ##\ wall blocks the ray to C -> NOT VISIBLE / ###|### \ even though C is inside radius + cone / | \. B B: inside radius, OUTSIDE the cone (angle check fails, ray never fired) -> NOT VISIBLE
Common mistake Building the raycast mask from ONLY obstacleMask and leaving targetMask out of it. The ray then either hits nothing (if it passes straight through the target's own collider) or, worse, treats the target's own collider as an obstacle and reports the target as blocked by itself. Always raycast against the combined mask and check what layer the first hit belongs to, exactly as CanSeeTarget does above.

5. Hearing: Sound Events, Radius, and Loudness

Vision is directional and needs a clear line — hearing is neither. A sound spreads outward in every direction, and whether an agent notices it usually depends on two things: how loud the sound was at its source, and how far away the agent is. Model that as a small event, broadcast whenever something makes noise.

public struct SoundEvent
{
    public Vector3 position;
    public float loudness;      // radius, in meters, this sound can be heard at
    public GameObject source;
}

public static class AudioPerception
{
    public static event Action<SoundEvent> OnSoundEmitted;

    public static void EmitSound(Vector3 position, float loudness, GameObject source)
    {
        OnSoundEmitted?.Invoke(new SoundEvent { position = position, loudness = loudness, source = source });
    }
}

AudioPerception is a static event bus: anything that makes a sound calls EmitSound without needing a reference to any particular enemy, and any enemy that wants to listen subscribes to OnSoundEmitted without needing a reference to whatever might make a sound. A footstep controller is a typical emitter:

public class Footsteps : MonoBehaviour
{
    public bool isRunning;

    public void OnFootstep()   // called by an Animation Event on the footstep frame
    {
        float loudness = isRunning ? 15f : 6f;
        AudioPerception.EmitSound(transform.position, loudness, gameObject);
    }
}

And a listener, following the same OnEnable/OnDisable subscribe pattern chapter 6.7 used for the Observer pattern:

public class HearingSensor : MonoBehaviour
{
    public float hearingSensitivity = 1f;   // multiplier; 1 = normal hearing

    public event Action<Vector3> OnHeardSound;   // reports WHERE the sound came from

    void OnEnable()  { AudioPerception.OnSoundEmitted += HandleSound; }
    void OnDisable() { AudioPerception.OnSoundEmitted -= HandleSound; }

    void HandleSound(SoundEvent sound)
    {
        float dist = Vector3.Distance(transform.position, sound.position);
        float hearingRadius = sound.loudness * hearingSensitivity;
        if (dist <= hearingRadius)
        {
            OnHeardSound?.Invoke(sound.position);
        }
    }
}

Worked trace: an enemy stands 10 meters from a footstep. A running footstep has loudness 15, and 10 is not more than 15, so HandleSound raises OnHeardSound. A walking footstep has loudness 6, and 10 is greater than 6, so nothing is raised — the same footstep at the same distance is heard only if the player was running.

Tip This hearing model treats sound as if it always travels in a straight line at full strength. A more realistic version adds a Physics.Raycast between the listener and the sound, exactly like the line-of-sight check in section 4, and reduces the effective loudness (or ignores the sound entirely) if a wall is in the way. That extra step is left as a natural extension once you are comfortable with both raycasts and this event pattern.

6. Decoupling Senses from Decisions: Percepts and a Perception System

Right now, seeing the player and hearing the player are two unrelated pieces of code with two different shapes: CanSeeTarget is a method you call, OnHeardSound is an event with a Vector3. Whatever makes decisions for the enemy would have to know about both shapes individually, which ties the decision code tightly to exactly these two senses. If you later add a "smell" sense, or replace vision with something else for a boss, every place that reacts to senses would need editing.

The fix is the same idea chapter 6.7 used for the Observer pattern: introduce one small, shared data shape — a percept (the AI term for "one piece of sensed information") — and make every sense report through that same shape.

public enum PerceptType { Sight, Sound }

public struct Percept
{
    public PerceptType type;
    public Vector3 position;
    public float timestamp;
}

public class PerceptionSystem : MonoBehaviour
{
    public FieldOfViewSensor sight;
    public HearingSensor hearing;
    public Transform player;

    public event Action<Percept> OnPercept;

    void OnEnable()  { hearing.OnHeardSound += HandleHeard; }
    void OnDisable() { hearing.OnHeardSound -= HandleHeard; }

    void Update()
    {
        if (sight.CanSeeTarget(player))
        {
            Raise(PerceptType.Sight, player.position);
        }
    }

    void HandleHeard(Vector3 soundPosition)
    {
        Raise(PerceptType.Sound, soundPosition);
    }

    void Raise(PerceptType type, Vector3 position)
    {
        OnPercept?.Invoke(new Percept { type = type, position = position, timestamp = Time.time });
    }
}
FieldOfViewSensor.CanSeeTarget() --\. +--> PerceptionSystem --> OnPercept event --> anything listening HearingSensor.OnHeardSound --/ (turns both senses into one shape: Percept) Memory (section 7) and the Brain/state machine (section 8) both subscribe to OnPercept. Neither one needs to know a raycast or a sound radius exists.

Nothing downstream of PerceptionSystem ever calls Physics.Raycast or reads a LayerMask again. It only ever sees small Percept values arrive through one event. That is the whole point of decoupling: memory and decision-making become testable and reusable on their own, and swapping how vision or hearing works internally never touches them.

Tip This is the same shape most commercial engines use under different names (Unreal calls its version "AI Perception," with "Senses" feeding "Stimuli"). The names differ, the idea is identical: senses produce a small, uniform unit of sensed information, and everything else consumes that uniform unit instead of touching the senses directly.

7. Memory: Remembering Where the Target Was

With only PerceptionSystem so far, an enemy "forgets" the player the instant CanSeeTarget returns false — one frame chasing, the next frame standing still with no idea where to go, because nothing is keeping a copy of the last thing it perceived. That looks robotic and cheap. A small memory fixes it: keep the last known position, and let confidence in it fade out over a few seconds instead of vanishing instantly.

public class PerceptionMemory : MonoBehaviour
{
    public PerceptionSystem perception;
    public float decayPerSecond = 0.2f;     // confidence lost per second while unseen
    public float forgetThreshold = 0.05f;

    public Vector3 LastKnownPosition { get; private set; }
    public float Confidence { get; private set; }   // 1 = certain, 0 = forgotten

    void OnEnable()  { perception.OnPercept += HandlePercept; }
    void OnDisable() { perception.OnPercept -= HandlePercept; }

    void HandlePercept(Percept p)
    {
        LastKnownPosition = p.position;
        Confidence = 1f;
    }

    void Update()
    {
        if (Confidence <= 0f)
            return;

        Confidence -= decayPerSecond * Time.deltaTime;
        if (Confidence < forgetThreshold)
            Confidence = 0f;   // forgotten -- LastKnownPosition is no longer trustworthy
    }
}

Every time a percept arrives, Confidence snaps back up to 1 and LastKnownPosition is refreshed. Every frame with no new percept, Confidence ticks down. Once it drops below forgetThreshold, it is clamped to exactly 0 — fully forgotten — rather than trickling toward zero forever.

Worked trace with decayPerSecond = 0.2, last percept at t = 0.0 with Confidence = 1, and no new percept after that:

t = 0.0s Confidence = 1.0 (just perceived) t = 1.0s Confidence = 0.8 t = 2.0s Confidence = 0.6 t = 3.0s Confidence = 0.4 t = 4.0s Confidence = 0.2 t = 4.75s Confidence would be 0.05 -> below forgetThreshold -> clamped to 0.0 (forgotten) t = 5.0s Confidence = 0.0 (still forgotten, LastKnownPosition no longer trusted)

A state machine reading Confidence > 0 can now tell the difference between "I know exactly where it is right now" (freshly refreshed, close to 1) and "I have a rough idea but it is getting stale" (decaying toward 0) — which is precisely what lets an agent walk to the last known spot and search around for a few seconds instead of either knowing everything forever or forgetting instantly.

Common mistake Never clearing Confidence when the tracked target is destroyed or despawned (for example, the player respawns somewhere else after dying). LastKnownPosition would keep pointing at a location that has nothing to do with the player anymore, and the agent would confidently search an empty spot. Reset the memory explicitly whenever the target itself becomes invalid, not only when sight is lost.

8. Putting Perception Into a State Machine

Sections 2 through 7 built senses, a shared percept shape, and memory. None of it moves the enemy by itself — it only produces information. Decision-making is a separate job, and you already have the tool for it: the State pattern from chapter 6.1 and chapter 6.7. Here it reacts to Percept events and to PerceptionMemory.Confidence instead of to raw input.

public enum AIState { Idle, Investigating, Chasing, Searching }

public class EnemyBrain : MonoBehaviour
{
    public PerceptionSystem perception;
    public PerceptionMemory memory;
    public Transform player;
    public float searchDuration = 4f;

    public AIState state = AIState.Idle;
    private Vector3 investigateTarget;
    private float searchTimer;

    void OnEnable()  { perception.OnPercept += HandlePercept; }
    void OnDisable() { perception.OnPercept -= HandlePercept; }

    void HandlePercept(Percept p)
    {
        if (p.type == PerceptType.Sight)
        {
            state = AIState.Chasing;
        }
        else if (p.type == PerceptType.Sound && state == AIState.Idle)
        {
            state = AIState.Investigating;
            investigateTarget = p.position;
        }
    }

    void Update()
    {
        switch (state)
        {
            case AIState.Chasing:
                if (memory.Confidence <= 0f)
                {
                    state = AIState.Idle;
                }
                else if (!perception.sight.CanSeeTarget(player))
                {
                    state = AIState.Searching;
                    searchTimer = searchDuration;
                }
                break;

            case AIState.Searching:
                searchTimer -= Time.deltaTime;
                // MoveTo(memory.LastKnownPosition) would go here
                if (memory.Confidence <= 0f || searchTimer <= 0f)
                {
                    state = AIState.Idle;
                }
                break;

            case AIState.Investigating:
                // MoveTo(investigateTarget) would go here
                break;
        }
    }
}
[Idle] --hear sound--> [Investigating] --see player--> [Chasing] ^ | | | lose sight | search timer runs out, v | or memory confidence hits 0 [Searching] +--------------------------------------------------------+ (walks toward LastKnownPosition while searching; seeing the player again jumps straight back to Chasing)

Every transition here reads either a Percept that just arrived or the memory's Confidence — never player.transform.position directly. That is section 1's loop, fully wired: world state feeds senses, senses feed percepts and memory, and only now does decision-making run.

9. Spatial Reasoning and Influence Maps

Everything so far answers "what do I currently know?" A second, different question matters just as much for a good agent: "given what I know, where in this space should I go?" Knowing the player is 8 meters away and visible does not by itself say whether standing still is safe, whether there is a better spot to shoot from, or where cover is. That is spatial reasoning — judging the level's geometry itself, not just tracking individual targets.

The classic tool for this is an influence map: overlay the level with a grid, and let each cell hold a single number describing something about that spot — commonly called danger (how threatened that spot is) or ally presence (how much friendly support is nearby). Every relevant source — each enemy, each ally — adds influence to the cells around it, strongest at its own position and weaker with distance, and the whole grid is then smoothed so the numbers form a soft field instead of a blocky mess.

influence map (top-down grid over the level, one enemy as the only source) . . . . . . . . . . 1 . . . . . 2 3 2 . . . 1 3 E 3 1 . E = enemy (source of "danger" influence) . . 2 3 2 . . numbers = danger value contributed to that cell . . . 1 . . . . . . . . . . each cell holds one float. Section 10 builds the code that fills this grid (AddInfluence) and smooths it out (Blur).

An agent can then ask the grid a question in one cheap lookup — "how dangerous is THIS spot" — instead of re-deriving that judgment from scratch (which enemies can see it, how close each one is, and so on) every single time it needs an answer. That is the same trade every earlier optimization in this lesson made: do the expensive reasoning once, store a small summary, and read the summary repeatedly.

Tip Grid resolution is a tradeoff. A coarse grid (large cells) is cheap to update and query but gives blocky, imprecise answers. A fine grid (small cells) is precise but costs more memory and more time to fill and blur every update. Most games use a grid far coarser than the visual level geometry — a cell might cover a couple of meters, not a few centimeters.

10. Building and Blurring an Influence Map

An influence map needs three operations: convert a world position into a grid cell, add influence from a source into nearby cells with distance falloff, and blur the whole grid so values spread smoothly instead of stopping in a hard-edged blob.

public class InfluenceMap
{
    private float[,] cells;
    private readonly int width;
    private readonly int height;
    private readonly float cellSize;
    private readonly Vector3 origin;

    public InfluenceMap(int width, int height, float cellSize, Vector3 origin)
    {
        this.width = width;
        this.height = height;
        this.cellSize = cellSize;
        this.origin = origin;
        cells = new float[width, height];
    }

    Vector2Int WorldToCell(Vector3 worldPos)
    {
        int cx = Mathf.FloorToInt((worldPos.x - origin.x) / cellSize);
        int cz = Mathf.FloorToInt((worldPos.z - origin.z) / cellSize);
        return new Vector2Int(cx, cz);
    }

    public void Clear()
    {
        System.Array.Clear(cells, 0, cells.Length);
    }

    // adds influence at worldPos, falling off linearly to 0 at radiusCells
    public void AddInfluence(Vector3 worldPos, float value, int radiusCells)
    {
        Vector2Int center = WorldToCell(worldPos);
        for (int dx = -radiusCells; dx <= radiusCells; dx++)
        {
            for (int dz = -radiusCells; dz <= radiusCells; dz++)
            {
                int x = center.x + dx;
                int z = center.y + dz;
                if (x < 0 || x >= width || z < 0 || z >= height)
                    continue;

                float dist = Mathf.Sqrt(dx * dx + dz * dz);
                if (dist > radiusCells)
                    continue;

                float falloff = 1f - (dist / radiusCells);
                cells[x, z] += value * falloff;
            }
        }
    }

    // box blur: replace every cell with the average of itself and its neighbors
    public void Blur()
    {
        float[,] result = new float[width, height];
        for (int x = 0; x < width; x++)
        {
            for (int z = 0; z < height; z++)
            {
                float sum = cells[x, z];
                int count = 1;
                if (x > 0)          { sum += cells[x - 1, z]; count++; }
                if (x < width - 1)  { sum += cells[x + 1, z]; count++; }
                if (z > 0)          { sum += cells[x, z - 1]; count++; }
                if (z < height - 1) { sum += cells[x, z + 1]; count++; }
                result[x, z] = sum / count;
            }
        }
        cells = result;
    }

    public float GetValue(Vector3 worldPos)
    {
        Vector2Int c = WorldToCell(worldPos);
        if (c.x < 0 || c.x >= width || c.y < 0 || c.y >= height)
            return 0f;
        return cells[c.x, c.y];
    }
}

AddInfluence visits every cell within radiusCells of the source and adds a value that is strongest at distance 0 and fades to exactly 0 at the edge of the radius — the same falloff idea as a light's range, just written by hand into a grid instead of handled by a lighting engine. Blur replaces every cell with the average of itself and its up-to-four neighbors (fewer at the grid's edges, where some neighbors do not exist), which is exactly what a simple photo blur does to pixels.

Worked trace: a 5x5 grid, one AddInfluence call at the center cell with value = 10 and radiusCells = 2, before and after one Blur() pass:

BEFORE Blur() (raw AddInfluence splat): 0.0 0.0 0.0 0.0 0.0 0.0 2.9 5.0 2.9 0.0 0.0 5.0 10.0 5.0 0.0 0.0 2.9 5.0 2.9 0.0 0.0 0.0 0.0 0.0 0.0 AFTER one Blur() pass: 0.0 0.7 1.3 0.7 0.0 0.7 2.6 4.2 2.6 0.7 1.3 4.2 6.0 4.2 1.3 0.7 2.6 4.2 2.6 0.7 0.0 0.7 1.3 0.7 0.0

The sharp peak of 10.0 at the center softens to 6.0, and cells that were exactly 0.0 before (like the corners' neighbors) pick up small nonzero values afterward, because they now average in a bit of their neighbors' influence. Run Blur() more than once and the field spreads and smooths further each pass, at the cost of one more full grid pass each time.

Common mistake Calling Clear(), AddInfluence() for every source, and Blur() every single frame on a large grid. That is a lot of nested loops running constantly for information that usually does not change much frame to frame. A common fix is to rebuild the influence map on a timer (a few times per second) or spread the rebuild across several frames, instead of doing it fresh every Update().

11. Tactical Queries: Cover and Flanking

An influence map is only useful once something asks it a question. Two of the most common questions are "where is a safe spot to hide" (cover) and "where is a good spot to attack from without being seen coming" (a flanking position). Both combine the influence map with the two tools from earlier in this lesson: raycasts and the dot product.

Cover means a spot that is currently NOT visible to the threat — checked with exactly the raycast idea from section 4, just fired from the threat's eyes toward each candidate spot instead of from the agent's own eyes toward a target:

public Vector3 FindBestCover(Vector3 selfPos, Vector3 threatEyePos,
                              List<Vector3> candidates, InfluenceMap dangerMap,
                              LayerMask obstacleMask)
{
    Vector3 best = selfPos;
    float bestScore = float.MaxValue;

    foreach (Vector3 candidate in candidates)
    {
        Vector3 toCandidate = candidate - threatEyePos;
        bool blocked = Physics.Raycast(threatEyePos, toCandidate.normalized,
                                        toCandidate.magnitude, obstacleMask);
        if (!blocked)
            continue;   // the threat could still see this spot -- not cover

        float danger = dangerMap.GetValue(candidate);
        float travelCost = Vector3.Distance(selfPos, candidate);
        float score = danger * 10f + travelCost;   // lower is better

        if (score < bestScore)
        {
            bestScore = score;
            best = candidate;
        }
    }
    return best;
}

A raycast fired FROM the threat's eyes catches candidates that are hidden right now, which is different from checking whether the candidate is merely far away or in a low-danger cell — a spot can sit right next to a wall in a low-danger cell and still be in plain sight if there is no actual geometry breaking the line, which is exactly why the raycast check happens before anything from the influence map is even used.

Flanking means a spot that is roughly to the side or behind the threat, so the agent approaches from outside the threat's own forward cone — checked with the exact same dot-product comparison from section 3, just applied to the threat's forward direction instead of the agent's own:

public Vector3 FindFlankPosition(Vector3 enemyPos, Vector3 enemyForward,
                                  List<Vector3> candidates, InfluenceMap dangerMap)
{
    Vector3 best = enemyPos;
    float bestScore = float.MinValue;

    foreach (Vector3 candidate in candidates)
    {
        Vector3 dirFromEnemy = (candidate - enemyPos).normalized;
        float facingDot = Vector3.Dot(enemyForward, dirFromEnemy);

        if (facingDot > 0.3f)
            continue;   // still inside the enemy's forward cone -- not a flank

        float danger = dangerMap.GetValue(candidate);
        float flankScore = -facingDot - danger;   // reward "behind", penalize danger

        if (flankScore > bestScore)
        {
            bestScore = flankScore;
            best = candidate;
        }
    }
    return best;
}

facingDot close to 1 means the candidate sits in front of the enemy (easily noticed), close to 0 means directly to the side, and close to -1 means directly behind. Skipping anything above 0.3 keeps candidates out of most of the enemy's forward cone; among what is left, -facingDot rewards positions further behind, and subtracting danger still avoids spots the influence map marks as heavily watched by OTHER threats.

forward ^ \ | / \ 6 3|1 / numbers = danger contributed by this \ 3 8|E 3 / one threat's influence map (closer to \ | \ / E and inside its cone reads higher) ----E---- /|\. / | \. ,----+ | +-----, | wall | wall | '-------' ' COVER* *FLANK (blocked from E's (behind E: facingDot < 0.3, eye position by low danger, and reachable the raycast check) without crossing the cone)
Common mistake Running FindBestCover against only ONE threat's eye position when several enemies can see the area. A spot hidden from one attacker can be in full view of another standing somewhere else. With multiple threats, either run the raycast check against every threat and require all of them to be blocked, or fold every threat's visibility into the influence map itself (each visible cell gets extra danger from that threat) so a single GetValue lookup already reflects the combined picture.

12. Glossary

13. Exercises

Exercise 1 The method below compiles and sometimes works, but gives inconsistent results: a target dead ahead at 2 meters passes the angle check, while the same direction at 8 meters can fail it, even though the actual angle to the target never changed. Find the bug and fix it.
public bool WithinAngle(Vector3 forward, Vector3 myPosition, Vector3 targetPosition, float viewAngleDegrees)
{
    Vector3 toTarget = targetPosition - myPosition;
    float cosHalfAngle = Mathf.Cos(viewAngleDegrees * 0.5f * Mathf.Deg2Rad);
    float cosActual = Vector3.Dot(forward, toTarget);
    return cosActual >= cosHalfAngle;
}
Show answer

The bug is that toTarget is never normalized before the dot product. Recall from chapter 2.1: a . b = |a| * |b| * cos(theta). forward has length 1, but toTarget has whatever length the distance to the target happens to be, so cosActual actually equals |toTarget| * cos(theta), not cos(theta) by itself. Comparing that distance-scaled number against a fixed cosine threshold only works by coincidence at some distances and fails at others — exactly the symptom described (2 meters passes, 8 meters in the same direction fails, because the same true angle produces two very different cosActual values).

public bool WithinAngle(Vector3 forward, Vector3 myPosition, Vector3 targetPosition, float viewAngleDegrees)
{
    Vector3 dirToTarget = (targetPosition - myPosition).normalized;
    float cosHalfAngle = Mathf.Cos(viewAngleDegrees * 0.5f * Mathf.Deg2Rad);
    float cosActual = Vector3.Dot(forward, dirToTarget);
    return cosActual >= cosHalfAngle;
}

Normalizing toTarget first makes both vectors in the dot product length 1, so cosActual is exactly cos(theta) regardless of how far away the target is, matching what cosHalfAngle assumes it is being compared against.

Exercise 2 An enemy's PerceptionMemory has decayPerSecond = 0.25 and forgetThreshold = 0.1. The last percept arrived at t = 10.0s, setting Confidence = 1. No new percept arrives after that. Compute Confidence at t = 12.0s, t = 13.5s, and t = 14.0s, and state at which of these times (if any) the memory counts as forgotten.
Show answer

Confidence decays linearly by decayPerSecond for every second since the last percept, clamped to 0 once it drops below forgetThreshold.

t = 12.0s: 2.0s elapsed. Confidence = 1 - 0.25*2.0 = 0.5 0.5 is not below 0.1 -> not forgotten. t = 13.5s: 3.5s elapsed. Confidence = 1 - 0.25*3.5 = 0.125 0.125 is not below 0.1 -> not forgotten (close, but still above threshold). t = 14.0s: 4.0s elapsed. Confidence = 1 - 0.25*4.0 = 0.0 0.0 is below 0.1 -> forgotten, clamped to exactly 0.0.

The memory survives until roughly 3.6 seconds after the last percept (where 1 - 0.25*T = 0.1 gives T = 3.6), and is fully forgotten by 4.0 seconds. This is why decayPerSecond and forgetThreshold together are really just a knob for "how many seconds does this enemy remember a lost target" — smaller decayPerSecond or smaller forgetThreshold both mean a longer memory.

Exercise 3 Write a method bool IsBehindTarget(Vector3 targetForward, Vector3 targetPosition, Vector3 myPosition) that returns true if myPosition is in the back half (not the front half) of space relative to targetForward. Use the dot product; do not use Mathf.Acos or any trigonometry function.
Show answer
bool IsBehindTarget(Vector3 targetForward, Vector3 targetPosition, Vector3 myPosition)
{
    Vector3 dirFromTarget = (myPosition - targetPosition).normalized;
    float dot = Vector3.Dot(targetForward, dirFromTarget);
    return dot < 0f;
}

This reuses the sign rule from chapter 2.1 directly: a dot product of two normalized vectors is positive when the angle between them is less than 90 degrees (in front), exactly zero at 90 degrees (directly to the side), and negative when the angle is more than 90 degrees (behind). Checking dot < 0 is exactly "is the angle more than 90 degrees" without ever computing the angle itself — the same shortcut section 3 used for the whole view cone, narrowed down to a plain front/back split.

← Back to all chapters