03 — The 3D Enemy
Run the 3D demo, then open EnemyAI3D.cs and TargetSensor.cs side by side. Select the Enemy in the Hierarchy while playing: you'll see the yellow vision sphere with its cone edges, the red reach sphere, and the cyan patrol route — the whole design, drawn live.
The two detectors
The enemy carries two TargetSensor components, configured differently:
| Vision ("the eyes") | Attack ("the reach") | |
|---|---|---|
| Radius | 12 | 2.2 |
| Cone | 110° | 360° |
| Line of sight | required | off |
| Question it answers | "Can I see the player?" | "Is the player within reach?" |
One reusable component, two Inspector configurations — no code duplication. CanDetect() runs the three classic tests from 01 - Enemy AI Concepts cheapest-first: range (a distance compare), FOV (Vector3.Angle against the cone), then line of sight (Physics.Linecast from eye height to the player's chest, blocked by anything solid that isn't the player).
Sneak test: walk straight at the enemy's back — nothing happens until you cross into the cone or touch the reach sphere. That's the FOV doing its job.
The state machine, state by state
Patrol — walks the waypoint loop at patrolSpeed, idling waitAtPoint seconds at each. Movement is just agent.SetDestination(...); arrival is !agent.pathPending && agent.remainingDistance <= agent.stoppingDistance. Transition out: visionSensor.CanDetect(player) → Chase.
Chase — agent.speed = chaseSpeed and re-SetDestination(player.position) every frame (the NavMesh reroutes around walls automatically — you never wrote pathfinding). Transitions out: reach sensor detects → Attack; or the lose-sight timer fills → back to Patrol. The timer only accumulates while vision fails, and resets the moment the enemy glimpses you again — that 3-second "memory" is what makes hiding behind a wall a real mechanic instead of an instant off-switch.
Attack — agent.ResetPath() (stand still), turn to face the player, and Strike() every attackCooldown seconds. Strike() is deliberately just a Debug.Log plus a red DamageFlash on the player — it's the marked hook where a real game deals damage. Transition out: the reach sensor loses the player → Chase.
Returning to patrol is not a fourth state — EnterPatrol() picks the nearest waypoint and the agent simply walks back. A nice class discussion: when would you want a distinct ReturnHome state? (Answer: when returning has its own rules — holstering weapons, different speed, ignoring the player entirely.)
Design details worth pointing out to students
- Enter/Tick method pairs (
EnterChase()/TickChase()) keep one-time setup (speed change, tint, log) separate from per-frame logic. Most FSM spaghetti comes from mixing those. - The enemy tints yellow in Chase and red in Attack. Debug color = free animation placeholder and the telegraphing principle in action.
- Everything tunable is a serialized field with a range chosen to be safe to hand to students: no hidden constants.
Exercises
- Add a second enemy (duplicate, move it, give it its own patrol points). Notice everything just works — the FSM has no global state.
- Give the vision sensor a 60° cone and 20 radius: congratulations, it's a sniper sentry. Then 360°/6: a motion detector. Sensors-as-data means enemy variety is Inspector work, not code work.
- Add a Search state: on losing sight, walk to the last seen position and wait 2 s before giving up (store
player.positioneach frame that vision succeeds). This is the single most impactful upgrade — see 06 - Tuning and Extending. - Wire
Strike()into a real health system (combine with the UI Toolkit kit'sHealthScoreSystemif you have it).