doc 4 of 5

Part 03 — Fair, readable, and still dangerous

You will build: A reaction delay between noticing and acting, a committed wind-up the player can respond to, an on-screen tell that exposes the enemy's belief about where you are, and two difficulty presets built only from perception numbers — no change to health, damage, or movement speed.

Your enemy now has honest senses and a memory. It sees you through a cone, it loses you behind walls, it holds a last-known position and searches it. Every part of that is correct. This part is about the gap between correct and playable, and it opens by making you lose the fight.

Give it a weapon and take your beating#

Add an attack to the enemy from Part 02. Nothing clever — when confidence is high and the player is inside attack range, deal damage and start a cooldown.

class_name Enemy
extends CharacterBody2D

@export var attack_range: float = 120.0
@export var attack_damage: int = 25
@export var attack_cooldown: float = 1.2

var _cooldown_left: float = 0.0

func _physics_process(delta: float) -> void:
	_update_memory(delta)   # from Part 02
	_cooldown_left = maxf(_cooldown_left - delta, 0.0)
	_try_attack()
	move_and_slide()

func _try_attack() -> void:
	if _cooldown_left > 0.0:
		return
	if memory.confidence < 1.0:
		return
	var target: Node2D = get_tree().get_first_node_in_group("player")
	if target == null:
		return
	if global_position.distance_to(target.global_position) > attack_range:
		return
	_fire_at(target)
	_cooldown_left = attack_cooldown

This is the same script Part 02 left you with, so memory is the EnemyMemory object already on the body and confidence is read off it. There is no bare confidence on the enemy — the belief lives in one place, and every line in this part reaches through memory to get at it. Write it any other way and the enemy ends up with two versions of what it thinks it knows.

_fire_at can be anything you already have — a hitscan using the sight ray you built in Part 01, a spawned projectile, a melee shape check. What matters is that it happens on the frame the conditions become true.

Now play it. Walk around a corner into the enemy's cone.

You die. Play again. You die again. At no point in either death did you learn anything.

Sit with that for a moment, because the feeling is the lesson. The enemy is not cheating. It has no wallhacks, no infinite range, no state you did not give it. It is doing exactly what a perfect reactor does: converting information into action with zero latency. And a perfect reactor is unbeatable, because to beat it you would have to act before it had the information, which means acting before you had it either.

The fix everyone tries first#

Halve the damage. attack_damage = 12. Play again.

You survive. You survive several hits, then you die anyway, and you still cannot say what you should have done differently. The fight now takes longer and teaches the same nothing it taught before. You have not made it fair. You have made it fair-ish and boring.

Name the diagnosis precisely, because the rest of this part is built on it:

The problem was never the size of the hit. The problem was that there was no window in which the information existed and the damage had not landed yet.

A player cannot react to an event that has already resolved. Every dodge, block, cover-dive, and panic-roll in every game you have ever played happened inside a window where the attack was knowable but not yet real. Your enemy has a window of zero frames. Tuning the number at the end of that window does not create one.

Difficulty lives in the information channel. Stats live somewhere else entirely, and confusing the two is the most common way a fight ends up unfair.

Dial one: reaction delay#

Sense. Wait. Then act.

The wait is not a hack to make the enemy worse. It is the enemy's nervous system, and it is a tunable you will adjust more than almost anything else in the fight. Give it a real name and an @export.

@export var reaction_delay: float = 0.35

var _reacting: bool = false
var _reaction_left: float = 0.0

The enemy notices, starts its clock, and only commits when the clock runs out.

func _update_reaction(delta: float) -> void:
	if memory.confidence < 1.0:
		# Lost the thread before it finished reacting — abandon the reaction.
		_reacting = false
		_reaction_left = 0.0
		return
	if not _reacting and not _winding_up and _cooldown_left <= 0.0:
		_reacting = true
		_reaction_left = reaction_delay
		_on_reaction_started()
		return
	if _reacting:
		_reaction_left -= delta
		if _reaction_left <= 0.0:
			_reacting = false
			_begin_windup()

Call _update_reaction(delta) from _physics_process where _try_attack() used to sit, and delete _try_attack — the two paths do the same job and leaving both in place produces an enemy that attacks with no pause some of the time. The wind-up this hands off to arrives in the next section, and it needs its own call in _physics_process; there is a full listing of the final order further down.

Three things in there are deliberate. The reaction aborts if confidence drops, so ducking behind cover during the delay actually saves you — which is the entire point of having the delay. _on_reaction_started() exists so you have a hook for the tell you will build in a moment; the delay is invisible without one.

And _winding_up gates the start branch alongside _reacting. That one is worth staring at, because dropping it produces a failure that reads as anything but a missing condition. A wind-up is neither reacting nor on cooldown — the cooldown is only charged when the swing actually fires — so without that flag in the guard, the frame after _begin_windup() starts a second reaction on top of the wind-up already running. When that one finishes it calls _begin_windup() again, recapturing the target at wherever you are standing now and resetting the wind-up clock. With a reaction shorter than the wind-up, the clock is reset before it can ever reach zero: the attack never lands, and the committed target tracks you the whole time. Both symptoms point you at the wind-up code, and the bug is here.

Why this is an accumulator and not a Timer#

The obvious implementation is a Timer node with one_shot = true, started when the enemy notices, with timeout wired to the attack. That works, and for a 0.35 second delay it works fine. Two traps come with it, and both of them are quiet.

The first is which process callback the Timer runs on. Timer.process_callback defaults to Timer.TIMER_PROCESS_IDLE. A timeout handler therefore runs during idle processing, outside _physics_process. If that handler does a space-state query — an intersect_ray to confirm line of sight before firing, exactly the kind of confirmation a good attack does — you are querying physics space from outside the only callback where the docs say access is safe. Physics space can be locked, and you get an error that appears intermittently and makes no sense at the call site.

If you use a Timer for anything that touches physics, set the callback explicitly:

@onready var _reaction_timer: Timer = $ReactionTimer

func _ready() -> void:
	_reaction_timer.process_callback = Timer.TIMER_PROCESS_PHYSICS
	_reaction_timer.one_shot = true

The second is resolution. A Timer processes at most once per frame. The Godot docs say outright that for a wait time below roughly 0.05 seconds you should write your own code instead. So the moment you want a fast AI re-evaluation tick — "re-check the cone every 0.02 seconds" — a Timer cannot give you that, and it will not tell you it failed. It ticks at frame rate and your carefully chosen number does nothing.

There is a third, sharper edge if you go the Timer route. Timer.start(time_sec) does not take a one-off duration. If time_sec is greater than zero it overwrites wait_time for every cycle after it. Randomize the reaction delay by calling start(randf_range(0.2, 0.5)) and you have permanently retuned the node, not varied a single reaction.

The accumulator above has none of these problems, is four lines, and lives in the same function as the rest of the physics logic. Use a Timer when the delay is long, coarse, and unrelated to physics queries. Use an accumulator when it is short, precise, or feeds a raycast.

Dial two: the wind-up, and the rule that makes it honest#

The reaction delay buys the player a window. The wind-up is what fills it with information.

@export var windup_time: float = 0.45

var _winding_up: bool = false
var _windup_left: float = 0.0
var _committed_target: Vector2 = Vector2.ZERO

func _begin_windup() -> void:
	var target: Node2D = get_tree().get_first_node_in_group("player")
	if target == null:
		return
	_committed_target = target.global_position
	_winding_up = true
	_windup_left = windup_time
	_show_windup_tell()

func _update_windup(delta: float) -> void:
	if not _winding_up:
		return
	_windup_left -= delta
	if _windup_left <= 0.0:
		_winding_up = false
		_fire_at_position(_committed_target)
		_cooldown_left = attack_cooldown

Look at _committed_target. It is captured once, at the start of the wind-up, and never touched again. That single line is the honesty rule:

Once the wind-up starts, the attack is committed. It cannot be retargeted.

The temptation to break this is enormous, because retargeting is one line and it makes the enemy hit more often. Resist it. An attack that tracks you through its own wind-up is a lie — it shows you a tell and then ignores the response the tell asked for. Players detect this instantly and almost never articulate it correctly. They will tell you the enemy "feels cheap" or "the dodge doesn't work" or "the hitbox is huge", and every one of those reports is really this bug.

Commitment is also what makes wind-up length the dial that decides whether an attack is dodgeable at all. A committed 0.45 second wind-up is a dodgeable attack. A committed 0.1 second wind-up is a coin flip. An uncommitted wind-up of any length is not an attack the player can beat, only one they can be lucky against.

If you want an enemy that adapts mid-swing, do it explicitly: let it cancel the wind-up and start a new one, paying the full reaction delay again. That reads as a feint, and a feint is fair because the cancel is visible.

Make the belief visible#

You built a belief system in Part 02 — a confidence value and a last-known position. Right now it exists only in the debugger. The player is being asked to play against a model they cannot see.

Surface it. Three channels, all cheap:

@onready var _alert_icon: Sprite2D = $AlertIcon
@onready var _voice: AudioStreamPlayer2D = $Voice

@export var detect_sound: AudioStream
@export var lost_sound: AudioStream

func _update_tell() -> void:
	_alert_icon.modulate.a = memory.confidence
	_alert_icon.scale = Vector2.ONE * lerpf(0.7, 1.1, memory.confidence)

The icon fades in as confidence rises, so a player edging along the wall watches it brighten and knows they are being noticed before they are noticed. That is a window, built out of nothing but an alpha channel.

Sound carries the edges. Fire on transitions, not every frame:

var _was_aware: bool = false

func _update_awareness_sound() -> void:
	var aware: bool = memory.confidence >= 1.0
	if aware and not _was_aware:
		_voice.stream = detect_sound
		_voice.play()
	elif not aware and _was_aware:
		_voice.stream = lost_sound
		_voice.play()
	_was_aware = aware

Two distinct sounds, and they matter for different reasons. The detection sting tells the player a fight started. The losing sting tells them a hiding place worked — without it, a player who successfully broke line of sight has no way to know it, so they keep running when they should have stopped. Half your stealth design is in that second sound.

_show_windup_tell() is the third channel and the loudest one. Flash the sprite, play an anticipation pose, drop a Line2D along the committed attack direction. Whatever you choose, it has to be legible at the edge of the player's attention, because that is where it will be.

The fork: do you draw the cone?#

A rendered vision cone gives the player perfect information about the enemy's senses. A sound-and-pose-only tell gives them a read on the enemy's state without exposing its geometry. Both are correct designs and neither is a compromise.

The deciding question: is the player expected to plan a route, or to react to a threat already on top of them?

Route-planning wants the cone drawn. The player is solving a spatial puzzle, and a puzzle with hidden rules is a guessing game. Stealth games draw the cone (or telegraph it with flashlights, floor markers, patrol paths) because the whole activity is reasoning about coverage before committing.

Reaction-based combat wants the cone hidden. If the player can see the exact arc, they will hover on its edge and play the geometry instead of the fight, and a tense encounter becomes a spreadsheet. Audio and pose carry enough for a reaction — awareness state and attack timing — without turning the enemy into a diagram.

Mixed games often split it: cone drawn while unaware, hidden once the fight starts. That is a real answer, not a fence-sit, because it matches which activity the player is doing at each moment.

The order everything runs in#

You have added four functions across this part and _physics_process still only calls one of them. Wire the rest in now, because three of the four do nothing until something ticks them:

func _physics_process(delta: float) -> void:
	_update_memory(delta)   # from Part 02
	_cooldown_left = maxf(_cooldown_left - delta, 0.0)
	_update_reaction(delta)
	_update_windup(delta)
	_update_tell()
	_update_awareness_sound()
	move_and_slide()

_update_windup(delta) is the one people leave out, and its symptom is silence. _begin_windup() sets the flag, the tell plays, and then nothing decrements _windup_left, so _fire_at_position is unreachable and the enemy poses forever without ever swinging. Nothing errors, because nothing is wrong with any single line.

The order is Part 02's rule extended: belief first, then anything that reads belief. _update_reaction sits ahead of _update_windup because it is what starts the wind-up, which costs a started wind-up one frame of its own duration — a sixtieth of a second out of 0.45, not worth restructuring for. The two display updates go last so they show this frame's state rather than the previous one, which is the same staleness problem the raycast had in Part 01, wearing different clothes.

Difficulty as information, not inflation#

Now the payoff. Build two presets that touch only perception numbers.

Every field here has to be a property you actually built, spelled the way you built it. A preset full of invented names is six lines that look right and refuse to compile, so copy these off parts 01 and 02 rather than off the concept.

class_name PerceptionPreset
extends Resource

@export_range(0.0, 180.0, 1.0) var vision_angle_degrees: float = 70.0
@export var chase_range: float = 320.0
@export var reaction_delay: float = 0.35
@export var confidence_fall_time: float = 3.0
@export var look_pause: float = 0.9
@export var windup_time: float = 0.45

Save two .tres files from this — guard_normal.tres and guard_alert.tres:

NormalAlert
vision_angle_degrees70100
chase_range320460
reaction_delay0.450.18
confidence_fall_time3.07.0
look_pause0.92.0
windup_time0.50.35

Apply one on ready:

@export var preset: PerceptionPreset

func _apply_preset() -> void:
	if preset == null:
		return
	perception.vision_angle_degrees = preset.vision_angle_degrees
	perception.chase_range = preset.chase_range
	reaction_delay = preset.reaction_delay
	confidence_fall_time = preset.confidence_fall_time
	look_pause = preset.look_pause
	windup_time = preset.windup_time

The first two go through perception because that is where they live — the cone and the range moved onto the EnemyPerception node in Part 02, and the body only holds the numbers it uses itself. The split is a nuisance to write once and tells you something worth knowing: a preset is not one object's settings, it is a description of a fight assembled from wherever the pieces happen to sit.

Play both. Not once each — several runs, and pay attention to what changes about how you play rather than how long the enemy survives.

Health is identical. Damage is identical. Movement speed is identical. The alert guard is harder and it is a different kind of hard: you cannot peek from where you used to peek, cover you used to reach in time you no longer reach, and hiding for two seconds no longer works because it remembers for seven.

That is the whole argument for building difficulty this way:

An enemy with more health is the same fight for longer. An enemy that reacts faster and remembers longer is a different fight.

More health means your existing plan still works and takes more repetitions. Faster reaction means your existing plan stops working and you need a new one. One of those is content and the other is padding, and they cost the same to implement.

If you want a Resource refresher before wiring the presets, the item definitions in /systems/inventory/01-item-definitions cover the same pattern in a calmer context.

The audit that keeps you honest#

One question, asked after every death:

Can the player name the mistake?

Not "was it hard", not "was it close" — can they say the specific thing they did, and say what they will do instead. "I peeked from the same crate twice." "I stopped moving during the wind-up." "I sprinted into the room I already knew was patrolled."

If the answer is no, the enemy acted on information the game never handed over, and no amount of stat tuning fixes that. A player who cannot name the mistake cannot improve, so the fight is not difficult — it is random with extra steps.

Now run the audit on your alert preset. Honestly.

At reaction_delay = 0.18 with windup_time = 0.35, you have 0.53 seconds between the enemy noticing and the damage landing, and 0.35 of those seconds carry a visible tell. That is defensible. Push reaction down to 0.05 and wind-up down to 0.15 and try again — the guard is now measurably deadlier and you will not be able to name a single mistake, because there was no moment where knowing anything would have helped.

Be willing to fail your own build here. The point of the audit is that it can come back negative on a setting you liked.

Two forks worth stating rather than deciding#

Fixed reaction delay, or a randomized range?

A fixed delay per enemy type is learnable. Fight the same guard twenty times and you internalize its rhythm, and mastery feels earned because it is. The cost is exploitability — a player who knows the number can dance on it forever.

A small randomized range (randf_range(0.3, 0.45), sampled once per reaction, not per frame) removes the exploitable rhythm and makes the enemy harder to read. The cost is that "harder to read" is exactly what you spent this part fighting, so keep the range tight enough that the tell still covers it.

The deciding question: is the player meant to master this specific enemy, or to stay uneasy around it? A boss you fight for ten minutes should be masterable. A patrolling guard you meet forty times over a campaign should probably not be perfectly solvable.

Should reaction delay scale with proximity?

A guard you walk directly into at point-blank range should probably not stand there for 0.45 seconds. Real reaction time drops when the stimulus is overwhelming, and an enemy that ignores a player standing on its toes reads as broken rather than fair.

But scaling the delay down with distance quietly eats the fairness window exactly where the player has the least room to use it, so run the audit again after you add it. If a point-blank ambush kills without a nameable mistake, the answer might not be "faster reaction" — it might be that the player should never have been able to reach point-blank undetected in the first place, which is a level design problem wearing an AI costume.

Checkpoint — definition of done#

  • Walking into the enemy's cone produces a visible tell, then a pause, then an attack — you can watch all three happen as separate events.
  • Breaking line of sight during the reaction delay cancels the attack entirely; no damage lands.
  • Sidestepping after the wind-up starts causes the attack to miss and hit where you were standing, not where you are now.
  • The alert indicator fades in gradually as confidence rises, before the enemy is fully aware.
  • Two distinct sounds fire: one when the enemy first detects you, another when it loses you — and neither repeats every frame while the state holds.
  • Swapping the preset resource in the inspector changes the fight without touching health, damage, or speed anywhere in the code.
  • The alert preset is measurably harder — time yourself crossing the same room — and you can still name your mistake after each death.
  • You have tried at least one setting that fails the audit, and reverted it.
  • You can explain: why does capturing the target position at the start of the wind-up make the enemy feel more fair than tracking the player through it, even though tracking would be more realistic?

Stretch (no instructions)#

  • Give the enemy a distinct tell for "suspicious" versus "certain", so a player can tell the difference between being investigated and being hunted.
  • Make the wind-up cancellable into a feint at a cost, and see whether the audit still passes.
  • Expose reaction delay as an accessibility option and decide what its floor should be.

If you get stuck#

  • Attack fires with no visible pause_update_reaction(delta) is not being called from _physics_process, or _begin_windup() is being reached from an old _try_attack() call you forgot to remove. Both paths can coexist silently.
  • Attack always lands even when you dodge_committed_target is being reassigned during _update_windup, or _update_reaction is missing not _winding_up from its start guard and calling _begin_windup() over and over. Check the guard first; it recaptures the target every time it re-enters.
  • The tell plays and the attack never comes → nothing is calling _update_windup(delta), so _windup_left never decrements. Check the _physics_process listing above against yours.
  • Identifier not declared in the current scope: confidence → confidence lives on the memory object from Part 02, not on the body. Every read is memory.confidence, and there are four of them in this part.
  • Reaction never completes → confidence is dipping below 1.0 for a single frame somewhere in the perception update, resetting _reacting. Print confidence every frame and look for a one-frame notch, usually from a raycast read before force_raycast_update().
  • Timer-based delay errors about locked space → the timeout handler is running a space-state query on the default TIMER_PROCESS_IDLE callback. Set process_callback = Timer.TIMER_PROCESS_PHYSICS, or set a flag and do the query in _physics_process.
  • The delay changes on its own after the first attack → you called Timer.start(some_value), which permanently overwrote wait_time. Set wait_time explicitly before each start(), or move to the accumulator.
  • A short tick interval does nothing → any Timer below roughly 0.05 seconds is capped at one tick per frame. That tick has to be an accumulator.
  • Detection sound plays every frame → you are checking confidence >= 1.0 instead of comparing against the previous frame's value. The _was_aware bool is the whole mechanism.
  • Losing sound never plays → confidence is not actually decaying, or it decays to a floor above your threshold. Check the memory logic from Part 02 rather than the sound code.
  • Preset resource swaps but nothing changes_apply_preset() runs in _ready() but a value is being overwritten afterward by an @export default, or you edited the .tres while the scene held a cached copy. Reload the scene.
  • Alert icon invisible at all confidence levelsmodulate.a is being set on a node whose parent is already fully transparent, or the sprite has no texture assigned. Check the parent chain first.

Next: Part 04 — Five enemies, one fight. Read it once a single enemy passes the audit — group behaviour multiplies whatever fairness problems you have not fixed yet.