Part 02 — What it remembers after you hide
You will build: A memory object the enemy carries between frames — last known position, time since last seen, and a confidence value that rises while it senses you and decays while it does not. When you break line of sight, the enemy walks to where you were, searches, gives up, and returns to patrol. A thrown rock writes to that same memory from across the room.
Part 01 gave you perception that does not lie: a cone test, an occlusion ray, correct layers. That code answers exactly one question, and it answers it about exactly one frame.
This part is about the gap between what the enemy senses and what the enemy believes.
Wire it straight through first#
Do the naive thing. It takes four lines and it is worth every second you spend on it.
Part 01's perception lives on the enemy body. Move it onto a child now: a Node2D called Perception, with its own script holding facing, the three gates, and the SightRay. Give that script a class_name, because the body is about to be typed against it.
class_name EnemyPerception
extends Node2D
One change to the interface while you move it. can_see_player() returned bool, and from here on the enemy wants the node it saw, not a yes. get_visible_target() runs the same three gates and returns the player node on success, null on failure.
The type on the reference is not decoration. Write @onready var perception: Node = $Perception and every call below it fails before the game runs — Node has no get_visible_target, no facing, and the analyser reports each one as not found in base Node. A statically typed reference has to name a type that actually carries the members you call on it.
extends CharacterBody2D
@export var chase_speed: float = 140.0
@export var patrol_speed: float = 60.0
@onready var perception: EnemyPerception = $Perception
func _physics_process(delta: float) -> void:
var target: Node2D = perception.get_visible_target()
if target != null:
_chase(target.global_position, delta)
else:
_patrol(delta)
move_and_slide()
Run it. Walk into the cone. The enemy chases. Correct.
Now walk behind a pillar.
The enemy stops mid-stride. Not slows — stops, on the exact frame the ray got blocked, and turns back toward its patrol route. It does not look at the pillar. It does not check the other side. Whatever it was doing a sixteenth of a second ago is gone, and the thing on screen reads as a machine that got switched off.
Now stand at the exact edge of the cone and shuffle a few pixels back and forth. The enemy vibrates. Chase, patrol, chase, patrol, sixty times a second, feet skidding, animation restarting every frame.
Two symptoms, one cause. get_visible_target() is an instantaneous truth about this physics frame, and you have handed it the job of deciding behaviour, which is a thing that has to persist over time. Sensing and believing are being treated as the same thing. They are not, and the difference is where the illusion of a mind lives.
Two different jobs#
Sensing is a measurement. It is true only for the frame it was taken, it has no history, and it has no opinion.
Belief is what the enemy carries. It persists, it decays, and — this is the part that matters — it can be wrong.
A player who watches an enemy act on information the player knows is stale reads that as thought. It is the whole trick. An enemy that always knows the truth is not smart, it is omniscient, and omniscience reads as cheating. An enemy that walks confidently to the wrong crate because that is where it last saw you reads as something with an inner life.
So build a place for belief to live.
enemy_memory.gd:
class_name EnemyMemory
extends RefCounted
## What the enemy believes about its target. Owned by the enemy body,
## read by every state, destroyed by nothing.
var last_known_position: Vector2 = Vector2.ZERO
var has_last_known: bool = false
## Seconds since the target was last actually sensed. Large means stale.
var time_since_seen: float = INF
## 0.0 = oblivious, 1.0 = certain. Rises while sensing, decays while not.
var confidence: float = 0.0
## The live node, valid ONLY while confidence is being fed by sight.
## Never path to this. See "Commit to the belief" below.
var sensed_target: Node2D = null
RefCounted rather than Node on purpose: this is data, not a thing in the tree. It has no _process, it draws nothing, it emits nothing. It exists so that several states can read the same beliefs without owning them.
Where it lives matters and Part 01 did not settle it: the memory belongs to the enemy body, not to any state. A state that owns the memory destroys it on exit, which means every transition gives the enemy amnesia. The memory outlives transitions. States read it and write to it; none of them own it.
Confidence, and why it fixes the flicker for free#
Add rise and fall rates and update the memory once per physics frame, before anything decides anything.
@export_group("Awareness")
## Seconds of unbroken sight to go from 0.0 to full confidence.
@export var confidence_rise_time: float = 0.45
## Seconds of no sight to fall from full confidence back to 0.0.
@export var confidence_fall_time: float = 3.5
var memory: EnemyMemory = EnemyMemory.new()
func _update_memory(delta: float) -> void:
var target: Node2D = perception.get_visible_target()
if target != null:
memory.sensed_target = target
memory.last_known_position = target.global_position
memory.has_last_known = true
memory.time_since_seen = 0.0
memory.confidence = minf(1.0, memory.confidence + delta / confidence_rise_time)
else:
memory.sensed_target = null
memory.time_since_seen += delta
memory.confidence = maxf(0.0, memory.confidence - delta / confidence_fall_time)
Dividing by a time rather than exporting a raw per-second rate is worth the extra character. confidence_rise_time = 0.45 means "half a second of clear sight and this enemy is sure", which is a sentence you can tune against. A rate of 2.22 is a number you tune by guessing.
Two separate rates, not one, because attention is asymmetric in every game that feels good: notice fast, forget slow. A guard that forgets in the same 0.45 seconds it took to notice is back to being a switch.
Now change the decision to read belief instead of sensation, with two thresholds instead of one:
@export var alert_threshold: float = 0.75
@export var calm_threshold: float = 0.25
Enter chase when confidence climbs past alert_threshold. Leave it only when confidence falls below calm_threshold. The gap between them is hysteresis, and it kills the cone-edge vibration outright — at the cone's edge, confidence oscillates in a narrow band, and a narrow band cannot cross two thresholds that are half a unit apart. You did not add flicker-suppression code. You added a belief with inertia, and the flicker was a symptom of not having one.
You also now have a 0..1 ramp with a meaning, which Part 03 puts on screen so the player can read the enemy's mind before it acts.
Now raise calm_threshold from 0.25 to 0.5 and change nothing else. Same rise time, same fall
time, same alert threshold.
That is the tuning knob that decides whether an enemy feels like it is looking for you or like it gave up the moment you broke line of sight. It is not the rise time, and it is not the fall time. It is the distance between two thresholds.
The fork: smooth meter or discrete stages#
Two designs are both sound here, and shipped games use both.
Continuous confidence is what is written above. One float, thresholds sit wherever you want, and the value maps directly onto a fill bar or an eye that opens. Tuning is a matter of turning two knobs and feeling the result.
Discrete stages replace the float with an enum — UNAWARE, SUSPICIOUS, ALERT — and explicit transition rules with dwell times. Harder to tune smoothly, but every stage is a thing you can hang a distinct pose, bark, and animation on, and the player learns three shapes instead of estimating a bar.
The deciding question is not "which is more sophisticated". It is: does the player need to read a smooth meter, or a small number of distinct enemy poses? If your enemies are pixel-art blobs eight frames tall, three unmistakable poses beat a bar the player squints at. If you are drawing a detection meter above the head anyway, the float already is the design. Pick from what the player sees, not from the data structure.
If you go discrete, note that you have rebuilt a small state machine and should say so — /systems/state-machine/02-enum-machine is that shape, and the awareness enum stays separate from the behaviour enum.
Commit to the belief#
This is the section the whole part exists for.
When the enemy loses sight, it must path to memory.last_known_position. Not to the target's live global_position. Not to a "well, it was probably heading that way" projection unless you deliberately design one.
func _process_search(delta: float) -> void:
if not memory.has_last_known:
_enter_patrol()
return
var to_goal: Vector2 = global_position.direction_to(memory.last_known_position)
velocity = to_goal * search_speed
Note what the enemy is not allowed to touch here: memory.sensed_target is null right now, by construction. There is no live position available to accidentally use. That is not an accident of the code — the memory is shaped that way so the mistake is hard to make.
Why this sells: the player hid behind the left crate, then crawled to the right crate. The enemy walks to the left crate. The player knows the enemy is wrong. The player watches something act, at cost, on a belief that is false — and watching something act on outdated information is what human observers read as a mind. Nothing else in your AI will buy as much perceived intelligence per line of code.
The inverse is the tell. An enemy that walks straight to your actual hiding place has announced that it never lost you. Everything the player thought was stealth was theatre. One frame of using the live position undoes the entire system.
Searching, then giving up#
Arriving at a stale position and immediately shrugging is nearly as bad as never going. The enemy needs to visibly look.
The cheapest version that reads correctly: sweep the facing direction across a few offsets, pausing at each, then quit. Since your Part 01 cone is driven by a facing vector, rotating that vector is a real search — the perception code does not need to know a search is happening.
@export_group("Search")
@export var search_speed: float = 70.0
@export var look_pause: float = 0.9
@export var look_offsets: PackedFloat32Array = PackedFloat32Array([-1.0, 1.0, 0.0])
@export var arrive_radius: float = 8.0
var _look_index: int = 0
var _look_timer: float = 0.0
var _base_look_angle: float = 0.0
func _search_step(delta: float) -> void:
var distance: float = global_position.distance_to(memory.last_known_position)
if distance > arrive_radius:
velocity = global_position.direction_to(memory.last_known_position) * search_speed
_base_look_angle = velocity.angle()
_look_index = 0
_look_timer = 0.0
return
velocity = Vector2.ZERO
_look_timer += delta
if _look_timer >= look_pause:
_look_timer = 0.0
_look_index += 1
if _look_index >= look_offsets.size():
_look_index = 0
_look_timer = 0.0
memory.has_last_known = false
_enter_patrol()
return
var sweep: float = _base_look_angle + look_offsets[_look_index]
perception.facing = Vector2.from_angle(sweep)
_look_index gets cleared on the way out, and that is not tidiness. The only other place it resets is the travel branch above, which the enemy skips entirely when the new last_known_position is already inside arrive_radius — a rock that lands at its feet, an ally's report from two steps away. Skip the travel branch with the counter still parked at its exhausted value from the last search and the give-up fires on the first frame: no sweep, no pause, an enemy that walks nowhere and shrugs. Reset it in whatever runs when the search state is entered as well, because a search interrupted by re-acquiring the target never reaches this branch at all and leaves the counter wherever it stopped.
Clearing has_last_known when the search fails is the give-up. Without it the enemy re-triggers on a position it has already exhausted and paces the same spot forever, which reads as a bug even though every individual rule fired correctly.
Vector2.from_angle(sweep) matters because Vector2.normalized() returns (0, 0) when the input has zero length. If you were deriving facing from velocity.normalized() while standing still to search, your cone would silently collapse to nothing — every dot product returns 0, the enemy detects no one, and no error is ever printed. A standing enemy needs an explicit facing vector, not one derived from motion.
Two Timer traps, if you reach for a Timer instead#
The accumulator above avoids both, but you will be tempted by a Timer node for "lose interest after three seconds", so know what is waiting.
one_shot defaults to false. A give-up timer you forgot to set to one-shot re-fires every three seconds for the rest of the level. The symptom is an enemy that gives up correctly the first time and then randomly gives up again while chasing you, twenty seconds later.
start(time_sec) is not a temporary override. The docs are explicit: if time_sec is greater than 0, that value is used for wait_time. Calling timer.start(0.4) once to shorten a single search pause permanently rewrites the property, and every subsequent cycle uses 0.4. Nothing warns you. The enemy's whole cadence quietly retunes and you spend an evening looking for the bug in your state logic.
Two more, while the class is open. Timer.process_callback defaults to TIMER_PROCESS_IDLE, so a timeout handler that runs a space-state query fires outside _physics_process — where space is locked and the query errors. Set process_callback = Timer.TIMER_PROCESS_PHYSICS for any timer that drives a physics query, or have the handler set a flag _physics_process reads. And below roughly 0.05 seconds the docs tell you to write your own code, because a Timer processes at most once per frame: a wait_time of 0.016 does not give you a reliable 60 ticks per second. A fast AI re-evaluation tick is an accumulator, not a Timer.
Hearing writes to the same memory#
Here is the payoff for building the memory as a separate object.
A sound gives you a position and no target. That is exactly the shape of last_known_position. So hearing requires no new behaviour whatsoever — the search, the arrival, the sweep, the give-up all already exist. Hearing is a second writer to a field you already have.
## Called when a sound is heard. `origin` is global. `strength` is 0..1.
func hear_sound(origin: Vector2, strength: float) -> void:
if memory.confidence >= sound_confidence_cap:
return
memory.last_known_position = origin
memory.has_last_known = true
memory.time_since_seen = 0.0
memory.confidence = maxf(memory.confidence, strength * sound_confidence_cap)
sound_confidence_cap around 0.6 — below alert_threshold, above calm_threshold. That single number is the difference between a game where noise is a tactic and a game where noise is a death sentence. A sound produces investigation. Only eyes produce a charge. The early return also stops a rock from lowering the confidence of an enemy that currently has you dead in its sights.
Model the sound as an event, not an Area2D#
The tempting shortcut is an Area2D on the player that enemies detect. Resist it, because it welds the sound to the thing that made it. A thrown rock is not the player. A gunshot is not the player either, and it should be heard from four times as far.
Model the sound as an event carrying an origin and a radius:
func emit_noise(origin: Vector2, radius: float, strength: float) -> void:
for enemy: Node2D in get_tree().get_nodes_in_group("enemies"):
if not enemy.has_method("hear_sound"):
continue
var distance: float = enemy.global_position.distance_to(origin)
if distance > radius:
continue
var falloff: float = 1.0 - (distance / radius)
enemy.call("hear_sound", origin, strength * falloff)
Two lines in there are negotiating with the type system, and both spellings are load-bearing. Node2D rather than Node on the loop variable, because global_position belongs to Node2D and a loop variable typed Node fails the parse rather than the frame. And call("hear_sound", ...) rather than enemy.hear_sound(...), because a direct call on a statically typed variable is checked against that type — Node2D does not declare hear_sound, so the analyser rejects the line no matter what the has_method guard says about it at runtime. Routing through call is what hands the decision back to the guard, which is where you wanted it: any node that answers to hear_sound gets told, and nothing else has to know what an enemy is.
A rock is emit_noise(rock.global_position, 220.0, 0.7). A gunshot is emit_noise(muzzle.global_position, 900.0, 1.0). They differ by one number, and no scene changes.
The falloff is what makes distance legible: an enemy at the edge of a gunshot's radius gets a faint nudge that decays before it acts, while one standing next to it investigates immediately. Distance becomes a design parameter rather than a boolean.
Delivering the alert#
The loop above — get_nodes_in_group("enemies") and a direct call — is the traceable version. You can put a breakpoint in it and see exactly who was told what.
get_tree().call_group("enemies", "hear_sound", origin, strength) is terser, but it broadcasts identically to everyone regardless of distance, which throws away the falloff, and the docs flag that call_group "acts immediately on all selected nodes at once, which may cause stuttering in some performance-intensive situations." When an alert genuinely goes to every enemy and does not need to land this frame, use call_group_flags with SceneTree.GROUP_CALL_DEFERRED so it processes at the end of the frame instead of interrupting the middle of your physics step.
Two group details that bite. add_to_group() takes persistent as its second argument and it defaults to false, so a group added from code is runtime-only and is never written into the .tscn — assign enemy groups in the editor, or re-add them in _ready(). And when you want a single node rather than all of them, get_first_node_in_group("player") avoids allocating an entire array to read index zero.
If the project already has a bus, /systems/event-bus/01-building-the-bus is the shape to follow, and the past-tense rule from that shelf applies exactly here. The signal is noise_made(origin, radius, strength) — an announcement that a sound happened. It is not investigate_position(...), which would be the emitter issuing orders to enemies it should know nothing about. The rock does not decide whether anyone cares.
Where the memory sits relative to the state machine#
The seam is worth stating plainly, because this is where the two shelves compose instead of fighting.
- The memory is data. It lives on the enemy body, it is updated once per physics frame before any decision runs, and it survives every transition.
- The states read it.
Chasereadssensed_targetwhen it exists andlast_known_positionwhen it does not.Searchreadslast_known_positionandtime_since_seen.Patrolreads nothing. - Transitions fire on confidence crossing thresholds, not on raw perception results.
Order inside _physics_process is not negotiable:
func _physics_process(delta: float) -> void:
_update_memory(delta)
_update_state(delta)
move_and_slide()
Sense, then update belief, then decide. Decide before updating belief and the enemy acts on data one frame old — usually invisible, occasionally the reason a hit registers wrong.
When per-state search logic grows past a couple of branches — a sweep state, a check-nearby-cover state, a return-to-post state — that is the signal to give each one its own node. /systems/state-machine/03-state-nodes is that refactor, and the memory object moves across it untouched, because it never belonged to a state in the first place.
The Area2D detail that makes alerts intermittent#
If any part of this uses an Area2D — a hearing radius on the enemy, a proximity trigger, a "player is close" flag — one behaviour will eventually cost you an evening.
body_entered is an edge event. It fires once, at the moment of entry. An enemy spawned already overlapping the player never receives it. An area whose monitoring was toggled on while the player stood inside it never receives it. That enemy is not slow to react — it is permanently blind, in a way that looks like a random bad spawn.
Anything that must be true every frame ("the player is currently inside my hearing radius") is not signal-shaped. It is either a bool you flip on body_entered / body_exited, or a polled check:
func _physics_process(_delta: float) -> void:
if hearing_area.has_overlapping_bodies():
for body: Node2D in hearing_area.get_overlapping_bodies():
if body.is_in_group("player"):
_on_player_near(body)
is_in_group() is the cheap identity check here. It costs you no class_name dependency between the enemy and the player, which is exactly what you want when the enemy scene should be droppable into a level that has a different player.
The polling has its own trap, and it is the reason "works on the second frame but not the first" shows up in your notes. The overlap list is rebuilt once per physics step, not the instant something moves — the docs say it plainly. Poll it in the same frame you spawned or teleported something and you get an empty array with no error. Wait a frame (await get_tree().physics_frame) before trusting an overlap query on something you just placed.
And the layer relationship is directional, as Part 01 established: the overlapping body's collision_layer must be in the area's collision_mask. Setting it on the wrong side produces silent non-detection with no warning anywhere.
Checkpoint — definition of done#
- Walking behind an obstacle does not stop the enemy — it continues to where you were and arrives there.
- Standing at the exact edge of the vision cone and shuffling produces no chase/patrol flicker; confidence rises and falls smoothly and the state changes at most a couple of times.
- Hide behind crate A, then move to crate B while unseen. The enemy goes to crate A. It does not drift toward B.
- On arrival the enemy visibly sweeps its facing across at least two directions before quitting.
- After giving up, the enemy returns to patrol and does not immediately walk back to the same spot.
- A thrown rock landing across the room sends the enemy to the rock, not to you, at a walk rather than a charge.
- A rock thrown while the enemy already has clear sight of you does not reduce its confidence or redirect it.
- Printing
memory.confidenceeach frame shows it climbing to 1.0 in roughlyconfidence_rise_timeseconds and decaying to 0.0 in roughlyconfidence_fall_time. - An enemy placed in the level already overlapping the player still detects the player.
- You can explain: why pathing to the last known position — rather than to the player's real position — makes an enemy look like it is thinking, when it is objectively the less accurate behaviour.
Stretch (no instructions)#
- Have a searching enemy that finds nothing at
last_known_positioncheck one or two nearby cover positions before quitting, chosen by the direction the target was last moving. - Let one enemy's memory seed another's: an alerted guard that spots you calls
hear_soundon nearby allies with a reduced strength, and cover propagation gets you a squad without any squad code. - Give confidence a floor that persists after a first sighting, so a guard who has seen you once this level notices you faster the second time.
- Decay
last_known_positiontoward the enemy's patrol route astime_since_seengrows, so an old belief fades geographically instead of vanishing at a threshold.
If you get stuck#
- Enemy still snaps back to patrol the instant sight breaks → the decision is reading the perception call instead of
memory.confidence. Search the enemy script forget_visible_target()outside_update_memory— there should be exactly one call site. - Enemy walks precisely to your current hiding spot every time → something is still reading a live node position. Check that
memory.sensed_targetis set tonullin the else-branch of_update_memory, and that the search path useslast_known_position. - Confidence never reaches 1.0 →
confidence_rise_timeis longer than the time you actually stay visible, or Part 01's cone is dropping frames. Print the perception result alongside confidence and look for gaps. - Cone-edge flicker survived →
alert_thresholdandcalm_thresholdare too close, or the code enters and leaves chase on the same threshold. They must be different numbers with a real gap. - Enemy paces the last known position forever →
has_last_knownis never cleared on give-up, so the search restarts the frame after it ends. - Enemy detects nobody while standing still to search → facing came from
velocity.normalized(), which returns(0, 0)at zero velocity, so every dot product is 0. No error is printed. Set facing explicitly withVector2.from_angle(). - Search pauses got faster (or slower) than the export says → a
Timer.start(seconds)call somewhere permanently overwrotewait_time. Search for.start(with an argument. - "Give up" fires repeatedly during a later chase → a
Timerleft at the defaultone_shot = false. - Space-locked error from a timer handler → that Timer is on the default
TIMER_PROCESS_IDLE. Setprocess_callback = Timer.TIMER_PROCESS_PHYSICS, or set a flag the physics step reads. - Thrown rock alerts nobody → the enemy is not in the
"enemies"group in the saved scene. Anadd_to_group()from code defaults topersistent = falseand is not stored in the.tscn. - Rock alerts enemies through solid walls → intended, until you add an occlusion check to the noise loop. Sound routing around geometry is a separate problem from sight; decide deliberately rather than by omission.
- One specific enemy never reacts at all → it was spawned overlapping its trigger area, so
body_enteredfired before anything connected, or never fired. Replace the signal-only logic with a polled overlap check. - Overlap check returns nothing immediately after spawning → the overlap list updates once per physics step.
await get_tree().physics_framebefore querying.
Next: Part 03 — Fair, readable, and still dangerous. Read it once the enemy behaves correctly but players still complain it feels unfair — Part 03 is about putting the confidence ramp you just built where the player can see it.