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 run
  • chase — move toward last known player position
  • windup — telegraph before damage turns on
  • strike — short active frames with hitbox monitoring
  • recover — 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:

  • HurtArea2D on the enemy listens for the player weapon hitbox
  • triggers apply_hit on 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:

  1. Enemy never starts attacking from outside attack_range unless you intentionally allow leeway.
  2. Player can dodge during WINDUP if your design allows it; if not, shorten WINDUP until frustration drops.
  3. No double damage from a single STRIKE window (use monitoring toggles, not always-on damage).
  4. 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" or player_path unset.
  • Enemy slides through walls: using move_and_slide without collision layers set on CharacterBody2D.
  • Instant hits: skipping WINDUP or leaving hitbox monitoring always true.
  • Enemy ignores knockback: enemy apply_hit missing or velocity overwritten next frame in CHASE.
  • State machine freezes: await chain interrupted because the enemy freed mid-timer; guard with is_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.