Lesson 6: Enemy AI and Behavior Systems

In Lesson 5 you gave the player a combat system and weapons. Now those attacks need something to hit: enemies that move, react, and fight back.

This lesson focuses on one outcome: you will have a small set of enemy types driven by a simple state machine (idle, patrol, chase, attack) so they feel predictable to design and satisfying to fight. You will build one base enemy script, then create three variants with different stats and behaviors.


What You Will Build

By the end of this lesson you will have:

  • A reusable enemy base with a state machine (e.g. IDLE, PATROL, CHASE, ATTACK).
  • Behavior logic so enemies patrol, detect the player, chase when in range, and attack when close enough.
  • Three enemy types (e.g. slow tank, fast scout, medium bruiser) by tuning speed, health, and damage.
  • Clean integration with your existing health and combat systems from Lesson 5.

You will keep AI simple and readable so you can add more states (e.g. FLEE, STUNNED) later without rewriting everything.


Step 1 – Choose your state set

Before coding, decide the states your enemies can be in. A minimal set that works for most 2D action games:

  • IDLE – Standing still until the player is seen or a timer fires.
  • PATROL – Moving along a path or between points until the player is detected.
  • CHASE – Moving toward the player when they are in detection range.
  • ATTACK – Playing an attack animation and dealing damage when the player is in attack range.

Optional extras for later: FLEE (low health), STUNNED (after hit), DEAD (play death and remove).

Write down:

  • Which states you want for this project.
  • What triggers the transition (e.g. "player in detection radius" from PATROL to CHASE).
  • Whether you want patrol paths (waypoints) or random wandering.

You will encode this as an enum and a single _process or _physics_process that checks conditions and switches state.


Step 2 – Create the enemy scene and base script

Create a new scene with a CharacterBody2D (or RigidBody2D if you prefer physics-driven enemies) as the root. Name it Enemy. Add:

  • CollisionShape2D – So the enemy can collide with the world and the player.
  • Sprite2D or AnimatedSprite2D – Visual representation.
  • Area2D (optional) – For "detection zone" and "attack zone" if you want radius-based triggers.

Attach a script enemy.gd to the root. Start with a state enum and exported variables so you can tune each enemy type in the inspector:

extends CharacterBody2D

enum State { IDLE, PATROL, CHASE, ATTACK }

@export var state: State = State.IDLE
@export var move_speed: float = 80.0
@export var detection_radius: float = 200.0
@export var attack_radius: float = 40.0
@export var attack_damage: int = 1
@export var patrol_speed: float = 40.0

var _target: Node2D = null   # Usually the player

If you already have a health.gd component from Lesson 5, add it as a child node of Enemy or attach the same script so this enemy can take damage and emit died.


Step 3 – Implement state logic in _physics_process

In _physics_process, update the target (player) reference if needed, then run the behavior for the current state. Example structure:

func _physics_process(delta: float) -> void:
    if _target == null:
        _target = _get_player()
    if _target == null:
        return

    var dist_to_target := global_position.distance_to(_target.global_position)

    match state:
        State.IDLE:
            _idle_behavior(dist_to_target)
        State.PATROL:
            _patrol_behavior(dist_to_target)
        State.CHASE:
            _chase_behavior(dist_to_target)
        State.ATTACK:
            _attack_behavior(dist_to_target)

    move_and_slide()

Implement each behavior function so that it:

  • IDLE: After a short timer or when player is within detection_radius, switch to PATROL or CHASE.
  • PATROL: Move along a simple path (e.g. two points, or a path2D). If dist_to_target <= detection_radius, set state to CHASE.
  • CHASE: Set velocity toward the target. If dist_to_target <= attack_radius, set state to ATTACK. If player leaves detection, return to PATROL or IDLE.
  • ATTACK: Play attack animation, apply damage to the player (via a signal or direct call to player's health), then after a short cooldown switch back to CHASE or IDLE.

Helper to find the player (assuming it's in a group):

func _get_player() -> Node2D:
    var players = get_tree().get_nodes_in_group("player")
    return players[0] if players.size() > 0 else null

Ensure your player node is in the player group so enemies can find it.


Step 4 – Add detection and attack radius (optional but recommended)

Using Area2D nodes makes it easy to know when the player enters or leaves detection and attack range. Add two Area2D children to the enemy:

  • DetectionZone – CollisionShape2D with radius detection_radius. Connect body_entered / body_exited to set _target or clear it.
  • AttackZone – Smaller radius attack_radius. Use body_entered to know when to transition to ATTACK.

Alternatively, you can skip Area2D and use distance_to in _physics_process (as in the snippet above). Both approaches are valid; choose one and stick with it.


Step 5 – Define three enemy types with different stats

Duplicate the Enemy scene three times and name them, for example:

  • EnemyTank – Low move_speed, high health, high attack_damage. Feels slow and heavy.
  • EnemyScout – High move_speed, low health, low damage. Good for pressure and chase.
  • EnemyBruiser – Medium speed, medium health, medium damage. Your "default" foe.

For each, change only the exported variables in the inspector (or in a minimal inherited script). No need for three full scripts unless you want different behavior logic (e.g. Scout runs away at low health).

Place one of each in your test level and verify:

  • They detect the player and chase.
  • They stop and attack when in range.
  • They take damage from the player's weapon and die when health reaches zero.

Mini challenge – Add three different enemy types

Your mini-task for this lesson:

  1. Create EnemyTank, EnemyScout, and EnemyBruiser (or names that fit your game).
  2. Tune move_speed, detection_radius, attack_radius, and attack_damage so each feels distinct.
  3. Place at least one of each in a test scene and play through: patrol, chase, attack, and death for all three.

Share your enemy lineup in the community and describe how you balanced their difficulty.


Troubleshooting

Enemy does not move.
Check that velocity is set in PATROL and CHASE (e.g. velocity = direction * move_speed) and that you call move_and_slide() once per frame. Ensure the enemy's collision layers allow movement where you expect.

Enemy never sees the player.
Confirm the player is in the player group and that _get_player() returns a valid node. If using Area2D, ensure the player's collision layer is in the detection area's mask.

Enemy attacks but does no damage.
Ensure the player has a script or node that receives damage (e.g. a health.gd that takes a damage(amount) call or a signal). From the enemy's ATTACK state, call that API or emit a signal with the damage value.

Enemy gets stuck on walls.
Use get_slide_collision_count() and nudge velocity or state (e.g. flip patrol direction) when blocked. For patrol, simple waypoint flipping is often enough.


Pro tips

  • One state machine, many types. Keep a single enemy.gd with an enum and exported values. Different scenes (Tank, Scout, Bruiser) just override the numbers; you avoid copy-pasting logic.
  • Signals for attack. Emit a signal like attacking(damage) from the enemy and let the player (or a game manager) connect and apply damage. That keeps the enemy from needing a direct reference to the player's health.
  • Debug draw. In _draw(), use draw_arc(Vector2.ZERO, detection_radius, 0, TAU, 32, Color.RED) so you can see detection range in the editor; disable in release.

Recap

  • You defined enemy states (IDLE, PATROL, CHASE, ATTACK) and implemented them in a single script.
  • You created an Enemy scene with optional detection and attack zones and wired it to your existing health and combat systems.
  • You built three enemy variants by tuning speed, radius, and damage so your action game has variety and clear difficulty tuning.

Next lesson

In Lesson 7: Level Design and Scene Management you will design levels and implement scene transitions, checkpoints, and level progression so your game has structure beyond a single test room.


Cross-links and further reading

Bookmark this lesson and share your three enemy types when you are done; next you will use them inside real levels.


FAQ

Do I have to use CharacterBody2D for enemies?
No. You can use RigidBody2D for physics-driven movement or even Area2D for simple "trigger" enemies. CharacterBody2D is recommended for predictable, controller-style movement that matches most 2D action games.

How do I add a patrol path?
Store an array of Vector2 waypoints and an index. In PATROL, move toward waypoints[current_index]; when close enough, advance the index and wrap at the end. You can expose waypoints in the inspector with @export var patrol_points: PackedVector2Array.

Should enemy AI run in _process or _physics_process?
Use _physics_process for movement and collision-related logic so velocity and move_and_slide() stay in sync with the physics tick. Pure visual or UI updates can go in _process.

How do I make an enemy that only attacks from behind?
In ATTACK (or before entering it), check the angle between the enemy's forward direction and the direction to the player. If the player is not in a "front" cone, switch back to CHASE or add a new state like "repositioning."