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.
| Current state | Condition | Next state |
|---|---|---|
| Idle | Valid target within detection distance | Chase |
| Chase | Target missing or outside detection distance | Idle |
| Idle or Chase | disable_enemy() called | Disabled |
| Disabled | Any target condition | Disabled 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.
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 syntaxWhy 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 referenceHow 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.
@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.