Lesson Goal
In Lesson 4 you locked player movement, attack windows, and damage rules.
This lesson gives your test arena intentional opposition: enemies that notice the player, commit to readable actions, and fail in ways you can debug.
By the end of this lesson, you will:
- implement a compact finite state machine for a melee enemy
- separate perception, decision, and action so tuning does not collapse into spaghetti
- connect enemy attacks to the same damage contract the player already uses
- leave clear extension points for tilemaps, groups, and spawning in later lessons
Step 1 - Name States Before You Write Code
Start with five states only:
idle— stationary, cheap to runchase— move toward last known player positionwindup— telegraph before damage turns onstrike— short active frames with hitbox monitoringrecover— vulnerability window after a swing
Defer patrol splines, squad tactics, and animation trees until this loop feels fair in your validation room.
Mini task:
Write the state list on a sticky note and refuse to add a sixth state until you can beat this enemy three times in a row without “surprise hits.”
Step 2 - Track the Player Without Hard-Coupling
Give the enemy a @export var player_path: NodePath or resolve the player through a group such as "player" on ready.
extends CharacterBody2D
enum State { IDLE, CHASE, WINDUP, STRIKE, RECOVER }
@export var move_speed: float = 120.0
@export var aggro_radius: float = 220.0
@export var attack_range: float = 48.0
var state: State = State.IDLE
var player: CharacterBody2D
func _ready() -> void:
player = get_tree().get_first_node_in_group("player") as CharacterBody2D
Common mistake:
Calling get_node("/root/...") with a brittle absolute path. Groups or exported paths survive refactors.
Step 3 - Implement Perception as Cheap Distance Checks
In _physics_process, only run heavy logic if the player exists.
func _physics_process(delta: float) -> void:
if player == null:
return
var to_player := player.global_position - global_position
var dist := to_player.length()
match state:
State.IDLE:
if dist <= aggro_radius:
state = State.CHASE
State.CHASE:
if dist > aggro_radius * 1.15:
state = State.IDLE
elif dist <= attack_range:
state = State.WINDUP
else:
velocity = to_player.normalized() * move_speed
move_and_slide()
_:
pass # handled in timed transitions below
Hysteresis (aggro_radius * 1.15) stops enemies from flickering between idle and chase at the edge.
Pro tip:
Draw aggro_radius and attack_range with draw_arc in _draw() while tuning, then remove debug draws before shipping a build.
Step 4 - Telegraph With Timers, Not Magic Numbers in _process
When entering WINDUP, start a short timer, then move to STRIKE, then RECOVER, then back to CHASE or IDLE.
func enter_windup() -> void:
state = State.WINDUP
velocity = Vector2.ZERO
$TelegraphSprite.modulate = Color(1.2, 0.7, 0.7)
await get_tree().create_timer(0.18).timeout
await enter_strike()
func enter_strike() -> void:
state = State.STRIKE
$EnemyHitbox.monitoring = true
await get_tree().create_timer(0.12).timeout
$EnemyHitbox.monitoring = false
$TelegraphSprite.modulate = Color.WHITE
await enter_recover()
func enter_recover() -> void:
state = State.RECOVER
await get_tree().create_timer(0.35).timeout
state = State.CHASE if global_position.distance_to(player.global_position) <= aggro_radius else State.IDLE
Swap $TelegraphSprite for your real mesh or animation player call later. The point is a visible or audible cue during WINDUP.
Step 5 - Deliver Damage Through the Same Contract as the Player
Reuse the player-facing pattern from Lesson 4: call apply_hit on the body that owns health.
On the enemy Area2D hitbox:
func _on_enemy_hitbox_body_entered(body: Node2D) -> void:
if body == get_parent():
return
if body.has_method("apply_hit"):
body.apply_hit(1, global_position)
Keep damage low while tuning timing. Scale numbers after the loop feels honest.
Step 6 - Let the Player Hit Back Through a Hurtbox
Mirror the player setup:
HurtArea2Don the enemy listens for the player weapon hitbox- triggers
apply_hiton the enemy script with i-frames identical to the player rules
If you already emit health_changed signals from the player, emit the same from enemies so your HUD stays generic.
Step 7 - Validate in the Combat Room Before Spawning Hordes
Run this checklist in the Lesson 4 arena:
- Enemy never starts attacking from outside
attack_rangeunless you intentionally allow leeway. - Player can dodge during
WINDUPif your design allows it; if not, shortenWINDUPuntil frustration drops. - No double damage from a single
STRIKEwindow (use monitoring toggles, not always-on damage). - When the player leaves
aggro_radius, the enemy stops chasing without snapping across the map.
Pro tip:
Log state changes with print or a tiny on-screen label until you trust the FSM, then gate logs behind OS.is_debug_build().
Troubleshooting
- Enemy stands still: player not in group
"player"orplayer_pathunset. - Enemy slides through walls: using
move_and_slidewithout collision layers set on CharacterBody2D. - Instant hits: skipping
WINDUPor leaving hitbox monitoring always true. - Enemy ignores knockback: enemy
apply_hitmissing or velocity overwritten next frame inCHASE. - State machine freezes:
awaitchain interrupted because the enemy freed mid-timer; guard withis_instance_valid.
Common Mistakes to Avoid
- encoding AI rules inside animation callbacks before the FSM exists
- one mega-script that also handles loot drops, audio, and UI popups
- copying player movement code verbatim instead of simpler chase steering
- tuning aggro radius inside a huge level instead of the small validation room
Mini Challenge
Add a stagger state that triggers when the enemy takes a hit during WINDUP or RECOVER, cancels the attack, and forces a 0.4s stun. This single feature will teach you why clean state entry and exit matters.
FAQ
Do I need AnimationTree for this lesson?
No. Swap visual cues with a color flash, scale punch, or sound first. AnimationTree can read the same state enum later.
Should I use BehaviorTree or state machine?
For a single melee grunt, FSM is faster to ship. Promote to hierarchical FSM or BT when you add roles such as ranged, support, or boss phases.
How do I keep many enemies cheap?
Share a lightweight “brain tick” timer so not every enemy runs heavy checks every physics frame, or use PhysicsDirectSpaceState2D queries sparingly. Optimize after readability is proven.
Where does navigation fit?
Lesson 6 tilemaps will give you NavigationRegion2D. Until then, straight-line chase is enough to validate combat.
Quick Recap
You now have:
- a five-state enemy FSM with telegraphed melee
- aggro hysteresis to reduce edge flicker
- hitboxes that respect timing windows
- hooks aligned with the player damage pipeline
Next, you will build tilemap workflow and level blockout so encounters use space intentionally instead of an empty test box. Continue to Lesson 6: Tilemap Workflow and Level Blockout.
For deeper Godot patterns, revisit the Godot Game Development guide and keep your Lesson 4 combat notes open while tuning.
Bookmark this lesson when your AI starts to feel unfair—you will want the validation checklist nearby.