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.
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:
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.
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.
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.
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 <=.
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.
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):
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.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.
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.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 });
}
}
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.
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:
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.
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.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;
}
}
}
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.
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.
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.
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:
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.
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().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.
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.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;
}
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.
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.Confidence decays linearly by decayPerSecond for every second since the last percept, clamped to 0 once it drops below forgetThreshold.
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.
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.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.