GDSense how-to guide

Godot State Machines: A Small GDScript Enemy Example

Replace conflicting enemy flags with explicit idle, chase, and disabled states, one transition function, and repeatable boundary checks.

Published Updated

All documentation guides

The short answer

A small Godot state machine can use an enum for mutually exclusive states, a match block for per-frame behavior, and one function for transitions. Put transition-only side effects in that function rather than repeating them every frame. Start with a short state table and test the boundary conditions before splitting the design into separate state scripts.

When is a state machine useful in Godot?

Use a state machine when combinations of flags allow contradictory behavior, such as an enemy being idle and chasing at once. A single active state makes that rule explicit. Do not introduce a framework just to replace one uncomplicated condition; the table should clarify the gameplay before the code grows.

Our example is a top-down CharacterBody2D enemy with an exported Node2D target. It idles outside detection range, chases inside range, and stays stopped after disable_enemy(). Add an appropriate CollisionShape2D and assign the target in the Inspector. This is direct pursuit, not navigation or platformer movement.

The entire transition contract
Current stateConditionNext state
IdleValid target within detection distanceChase
ChaseTarget missing or outside detection distanceIdle
Idle or Chasedisable_enemy() calledDisabled
DisabledAny target conditionDisabled until explicitly reset by your game

How do I implement a small enum-based state machine?

Keep state selection separate from state behavior. The code first chooses the next state, then updates velocity and moves once. set_state() ignores repeated requests for the same state and emits only real transitions. A listener can react to the transition without being called on every physics frame.

res://scripts/state_enemy.gd — attach to CharacterBody2D
extends CharacterBody2D

enum State { IDLE, CHASE, DISABLED }
signal state_changed(previous: State, current: State)

@export var target: Node2D
@export var speed: float = 120.0
@export var detection_distance: float = 200.0
var state: State = State.IDLE

func _ready() -> void:
    motion_mode = CharacterBody2D.MOTION_MODE_FLOATING

func _physics_process(_delta: float) -> void:
    if state != State.DISABLED:
        var can_chase: bool = is_instance_valid(target)
        if can_chase:
            can_chase = global_position.distance_to(target.global_position) <= detection_distance
        set_state(State.CHASE if can_chase else State.IDLE)

    match state:
        State.CHASE:
            velocity = global_position.direction_to(target.global_position) * speed
        State.IDLE, State.DISABLED:
            velocity = Vector2.ZERO
    move_and_slide()

func set_state(next_state: State) -> void:
    if next_state == state:
        return
    var previous: State = state
    state = next_state
    state_changed.emit(previous, state)

func disable_enemy() -> void:
    set_state(State.DISABLED)

Keep in mind: Keep state_changed listeners observational in this small example: update presentation or record transitions, but do not free the target or trigger another transition synchronously inside the callback. More complex event-driven transitions need an explicit queue or ordering policy.

Godot GDScript enums and match syntax

Why keep movement and transition side effects separate?

Per-frame code answers “what does this state do now?” Transition code answers “what happens once when the state changes?” Starting a cooldown or replaying an animation in an unconditional chase block can restart it every frame. Put those one-time actions behind a real transition.

The example sets CharacterBody2D.velocity in units per second and calls move_and_slide() in the physics callback. Do not multiply that velocity by delta for this API. This controller has no gravity, avoidance, attack range, or pathfinding; add each as a separate requirement and test instead of assuming pursuit already solves them.

Godot CharacterBody2D movement reference

How do I test state-machine edge cases?

Test the transition table, including repeated frames where no transition should occur. Log previous and current state in a signal listener while testing. The exact detection boundary belongs in the contract: this example treats distance equal to the threshold as in range.

  • With no assigned target, the enemy stays idle without a null error.
  • Moving the target inside range starts chase; leaving range stops it.
  • Staying inside range for many frames emits only the first transition.
  • Removing the target between frames returns the enemy to idle.
  • Calling disable_enemy() while chasing stops movement on the next physics update.
  • A disabled enemy does not resume when the target moves back into range.

Keep in mind: If the target jitters around the boundary, consider separate enter and exit distances. That hysteresis changes the table and needs new tests; it is not a cosmetic refactor.

When should I split states into separate scripts?

Split when individual states have enough independent behavior that the shared script becomes hard to review. Define who owns movement, timers, enter/exit actions, and transitions first. Separate nodes or Resources are implementation choices, not automatic improvements over a readable enum.

When using GDSense, supply the current controller and scene plus the transition table. Ask it to preserve the table while changing structure, and reject a proposal that adds new attack, animation, or navigation behavior without an explicit requirement.

State-machine review request
@openscript
Review this enemy controller against the Idle/Chase/Disabled transition table.
Find paths that can move while disabled, emit duplicate transitions, or use a missing target.
Do not introduce a state framework. Suggest the smallest correction and a test for each finding.

Try this workflow inside Godot

Use GDSense to ask questions, attach the context you choose, and review proposed changes without leaving the editor.