A state machine puts a character’s active behavior in one place. This tutorial shows an enum version inside a character script and a node-based version with a script for each state. Start with the smaller version; separate state nodes are useful when their data and behavior become difficult to manage in one file.
What is a State Machine?
A finite state machine (FSM) is a pattern where an entity is in exactly one state at a time. A player might be IDLE, WALKING, JUMPING, or ATTACKING, never two at once. Each state owns the code that runs while it is active, and switching states goes through a single function so the entry and exit work always happens. The alternative most beginners write first, a set of flags like is_jumping and is_attacking checked all over _physics_process, breaks the moment two flags are true at the same time.
Choose based on how much behavior and data each state needs. An enum and a match statement work well for a few short states. Separate nodes can make larger states easier to edit and tune, especially when each needs its own timers or exported values.
Simple Enum-Based State Machine
Everything lives in the character script: an enum lists the states, a variable holds the current one, and a match statement runs the active state's logic every physics frame.
extends CharacterBody2D
enum State { IDLE, WALK, JUMP, FALL }
var current_state: State = State.IDLE
var speed = 200.0
var jump_force = -400.0
var gravity = 980.0
func _physics_process(delta):
# Apply gravity
velocity.y += gravity * delta
# Get input
var direction = Input.get_axis("move_left", "move_right")
# State machine
match current_state:
State.IDLE:
velocity.x = 0
if direction != 0:
change_state(State.WALK)
if Input.is_action_just_pressed("jump") and is_on_floor():
change_state(State.JUMP)
State.WALK:
velocity.x = direction * speed
if direction == 0:
change_state(State.IDLE)
if Input.is_action_just_pressed("jump") and is_on_floor():
change_state(State.JUMP)
if not is_on_floor():
change_state(State.FALL)
State.JUMP:
velocity.x = direction * speed
if velocity.y > 0:
change_state(State.FALL)
State.FALL:
velocity.x = direction * speed
if is_on_floor():
change_state(State.IDLE if direction == 0 else State.WALK)
move_and_slide()
func change_state(new_state: State):
if new_state == current_state:
return
# Exit current state
match current_state:
State.JUMP:
pass # Could stop jump animation
current_state = new_state
# Enter new state
match new_state:
State.JUMP:
velocity.y = jump_force
State.IDLE:
$AnimationPlayer.play("idle")
State.WALK:
$AnimationPlayer.play("walk")Read the match as "what does this state do every frame, and what would make it leave". IDLE stops horizontal movement and leaves when there is input or a jump. WALK moves and leaves when input stops, when the player jumps, or when the floor disappears. JUMP hands over to FALL the moment vertical velocity turns positive, which is the apex of the jump, and FALL returns to the ground states when is_on_floor() is true again, which is only valid after move_and_slide() has run in the previous frame.
The if new_state == current_state: return guard prevents change_state() from repeating entry work for the active state. Without it, setup such as playing a sound or spawning an effect may run again. Repeating $AnimationPlayer.play("walk") for the currently playing animation does not itself guarantee a restart; the guard is about avoiding repeated state-entry side effects.
Node-Based State Machine Pattern
When states carry their own data, an enum stops scaling: every state's timers and tuning values end up as variables on the character, and the match grows into hundreds of lines. The node-based pattern gives each state its own script under a StateMachine node. The machine forwards _process and _physics_process to whichever child is active and handles the enter and exit calls.
Select a node to inspect it.
A body controlled by your script. It supplies movement and collision methods for a 2D character.
enemy.gd: velocity, player_ref, can_see_player()
Displays a texture. It makes the character or coin visible, but does not define its collision boundary.
Child of Enemy
Gives its parent body or area a collision boundary. Assign a Shape resource to define that boundary.
Child of Enemy
A node in this example’s scene. Follow the setup note for its job here.
points down and ahead; detects ledges
Child of Enemy
A node in this example’s scene. Follow the setup note for its job here.
Child of Enemy
A node in this example’s scene. Follow the setup note for its job here.
state_machine.gd, initial_state = Idle
Child of Enemy
A node in this example’s scene. Follow the setup note for its job here.
enemy_idle_state.gd
Child of Enemy/StateMachine
A node in this example’s scene. Follow the setup note for its job here.
enemy_patrol_state.gd
Child of Enemy/StateMachine
A node in this example’s scene. Follow the setup note for its job here.
enemy_chase_state.gd
Child of Enemy/StateMachine
A node in this example’s scene. Follow the setup note for its job here.
enemy_attack_state.gd
Child of Enemy/StateMachine
One script per state; the StateMachine only ever talks to the active child.
# state.gd - Base class for all states
extends Node
class_name State
var state_machine: StateMachine
## The CharacterBody2D this state controls. Resolved once by the machine.
var actor: CharacterBody2D
func enter() -> void:
pass
func exit() -> void:
pass
func update(_delta: float) -> void:
pass
func physics_update(_delta: float) -> void:
pass# state_machine.gd - Attach to a Node called StateMachine
extends Node
class_name StateMachine
@export var initial_state: State
var current_state: State
var states: Dictionary = {}
func _ready():
# The machine sits directly under the actor, so owner-independent lookup
# is one get_parent(); states get the same reference.
var actor := get_parent() as CharacterBody2D
for child in get_children():
if child is State:
child.state_machine = self
child.actor = actor
states[child.name] = child
if initial_state:
change_state(initial_state)
func _process(delta):
if current_state:
current_state.update(delta)
func _physics_process(delta):
if current_state:
current_state.physics_update(delta)
## Switch by node name, e.g. transition_to("Chase"). Names are stable;
## paths like $"../Chase" break as soon as a state is renamed or nested.
func transition_to(state_name: String) -> void:
var next: State = states.get(state_name)
if next == null:
push_error("No state named '%s' under %s" % [state_name, name])
return
change_state(next)
func change_state(new_state: State):
if new_state == current_state:
return
if current_state:
current_state.exit()
current_state = new_state
current_state.enter()Two choices here save a lot of debugging later. The machine resolves the actor once and hands the same reference to every state, so each state can use that reference instead of get_parent().get_parent(). The actor lookup still needs to match the scene structure. And states switch by name through transition_to("Chase") rather than by path: reordering state nodes does not change the target, but renaming one requires updating its transition names, and a typo produces a clear push_error instead of a null-call crash.
Notice that the states do not implement _process themselves; they implement update() and physics_update(), and only the active state receives calls. That is why nothing needs process_mode toggling: an inactive state is simply never invoked. If you do put a real _process in a state script, it runs for every state every frame, which is the classic "my enemy is patrolling and chasing at the same time" bug.
// States defined in Animator Controller
// Transitions via parameters
animator.SetBool("isWalking", true);
animator.SetTrigger("attack");# States in code or as nodes
# Direct control over transitions
state_machine.transition_to("Walk")
state_machine.transition_to("Attack")Keep gameplay state decisions in code and use AnimationTree for animation transitions and blending. This gives you separate places to inspect behavior and its visual presentation.
The actor: what every state can use
States call actor.can_see_player() and check for ledges, so those helpers have to exist on the enemy script. The original version of this tutorial called them without defining them, which fails with Invalid call. Nonexistent function 'can_see_player'. Here is a minimal enemy script that provides both, using a RayCast2D child named FloorRay that points down and slightly ahead of the enemy.
# enemy.gd
extends CharacterBody2D
@export var sight_range: float = 240.0
@export var gravity: float = 980.0
@onready var floor_ray: RayCast2D = $FloorRay
var player_ref: Node2D
func _ready():
# The player is in the "player" group; see Node > Groups in the editor.
player_ref = get_tree().get_first_node_in_group("player")
func _physics_process(delta):
if not is_on_floor():
velocity.y += gravity * delta
func can_see_player() -> bool:
if player_ref == null:
return false
return global_position.distance_to(player_ref.global_position) < sight_range
func is_on_floor_ahead() -> bool:
return floor_ray.is_colliding()
func face_direction(direction: int) -> void:
# Flip the ledge ray with the sprite so it always looks ahead.
floor_ray.position.x = abs(floor_ray.position.x) * direction
$Sprite2D.flip_h = direction < 0Gravity is applied in the actor, not in every state, so no state can forget it. The player is found through a group instead of a hard-coded path, which keeps the enemy scene usable in any level. The example’s “can see” helper checks distance only and can detect through walls; if you need real line of sight, add a second RayCast2D aimed at the player and check that it hits the player and not a wall.
Enemy AI States
Attach the following scripts to Idle, Patrol, Chase, and Attack. Idle waits, Patrol walks and turns at walls or ledges, and Chase closes in. Attack stops the enemy briefly and emits a signal you can connect to your combat code. It does not apply damage by itself.
# enemy_idle_state.gd
extends State
@export var idle_time: float = 2.0
var timer: float = 0.0
func enter():
timer = idle_time
actor.velocity.x = 0.0
func physics_update(delta):
actor.move_and_slide() # keeps gravity applied while standing still
timer -= delta
if actor.can_see_player():
state_machine.transition_to("Chase")
elif timer <= 0.0:
state_machine.transition_to("Patrol")# enemy_patrol_state.gd
extends State
@export var patrol_speed: float = 100.0
var direction: int = 1
func enter():
direction = 1 if randf() > 0.5 else -1
actor.face_direction(direction)
func physics_update(_delta):
actor.velocity.x = direction * patrol_speed
actor.move_and_slide()
# Turn at walls or ledges. is_on_wall() is only valid after move_and_slide().
if actor.is_on_wall() or not actor.is_on_floor_ahead():
direction *= -1
actor.face_direction(direction)
if actor.can_see_player():
state_machine.transition_to("Chase")# enemy_chase_state.gd
extends State
@export var chase_speed: float = 150.0
@export var attack_range: float = 50.0
func physics_update(_delta):
var player: Node2D = actor.player_ref
if player == null or not actor.can_see_player():
state_machine.transition_to("Idle")
return
var direction := signf(player.global_position.x - actor.global_position.x)
actor.velocity.x = direction * chase_speed
actor.face_direction(int(direction))
actor.move_and_slide()
if actor.global_position.distance_to(player.global_position) < attack_range:
state_machine.transition_to("Attack")# enemy_attack_state.gd
extends State
signal attack_requested(target: Node2D)
@export var recovery_time: float = 0.6
var time_left: float = 0.0
func enter() -> void:
time_left = recovery_time
actor.velocity.x = 0.0
var player: Node2D = actor.player_ref
if is_instance_valid(player):
attack_requested.emit(player)
func physics_update(delta: float) -> void:
actor.move_and_slide()
time_left -= delta
if time_left <= 0.0:
state_machine.transition_to("Chase" if actor.can_see_player() else "Idle")Each state contains its own behavior and transition checks. Pay attention to the timing of the collision queries. is_on_wall() and is_on_floor() report the result of the last move_and_slide(), so check them after moving, as Patrol does. And @export values on state nodes appear in the Inspector per enemy instance, so a designer can give one guard a longer idle time without touching code.
Adding Pathfinding with NavigationAgent2D
Walking straight at the player fails as soon as there is a wall between them. NavigationAgent2D asks the level's navigation map for a path and hands you the next point to move toward each frame. The level needs a NavigationRegion2D with a baked NavigationPolygon covering the walkable area, and the enemy needs a NavigationAgent2D child.
# enemy_chase_state.gd (with navigation)
extends State
@export var chase_speed: float = 150.0
@export var attack_range: float = 50.0
var nav_agent: NavigationAgent2D
var ready_to_path := false
func enter():
nav_agent = actor.get_node("NavigationAgent2D")
nav_agent.path_desired_distance = 4.0
nav_agent.target_desired_distance = 4.0
ready_to_path = false
# The navigation map is synchronised at the end of the physics frame.
# Setting a target before the first sync returns an empty path.
call_deferred("_enable_pathing")
func _enable_pathing():
await actor.get_tree().physics_frame
ready_to_path = true
func physics_update(_delta):
var player: Node2D = actor.player_ref
if player == null or not ready_to_path:
return
nav_agent.target_position = player.global_position
if actor.global_position.distance_to(player.global_position) < attack_range:
state_machine.transition_to("Attack")
return
if nav_agent.is_navigation_finished():
return
var next_pos := nav_agent.get_next_path_position()
var direction := actor.global_position.direction_to(next_pos)
actor.velocity = direction * chase_speed
actor.move_and_slide()Wait for navigation synchronization before querying the initial path. Navigation regions register with the navigation map during the physics step, so a target set in _ready() or in the very first frame produces an empty path and the agent reports it is already finished. Waiting for one physics_frame before the first query avoids that. The attack check comes before the navigation check because is_navigation_finished() is also true when the target is unreachable, and you do not want an enemy that stares at the player from across a pit forever.
When to Use AnimationTree Instead
AnimationTree ships its own state machine node type, and it is the right tool for the animation half of the problem: which clip plays and how it blends into the next. Keep gameplay decisions in code and drive the tree from it. A Chase state that wants the run animation calls actor.get_node("AnimationTree").get("parameters/playback").travel("run"), and the tree handles the crossfade. Keeping gameplay decisions separate makes them easier to test independently of animation. Animation resources can still be inspected and compared in version control.
Check that it works
- Add a
Labelabove the enemy and set its text tostate_machine.current_state.nameeach frame. Watching the label is faster than reading print statements. - Stand still next to an idle enemy: it must switch to Chase within a frame of entering
sight_range, and back to Idle when you leave. - Patrolling enemies must turn at ledges without falling. If they walk off,
FloorRayis not pointing ahead of the feet or is not flipped byface_direction(). - Put a wall between you and a navigating enemy. It must walk around it; if it stops, check the navigation polygon covers the route and that the one-frame wait ran.
- Rename a state node. The
push_errorfromtransition_to()must name the missing state; a silent freeze means a path-based reference survived somewhere.
Key Takeaways
- Start with an enum FSM for simple behaviours, and guard
change_state()against re-entering the same state. - Move to node-based states when they need their own data; resolve the actor once and switch by name.
- Keep
enter()to setup, and make decisions inphysics_update(). - Wait one physics frame before the first NavigationAgent2D query.
- Let AnimationTree own animation blending, and let code own gameplay decisions.
The Real World Patterns guide shows the same state machine next to the other architecture patterns Godot projects lean on, and the platformer tutorial uses the enum version for its player controller if you want to see it in a full movement script.
Frequently asked questions
- What is a finite state machine in game development?
- A pattern where an object is in exactly one named state at a time (Idle, Walk, Jump, Attack), each state owns the code that runs while it is active, and transitions between states happen only through one function. It replaces piles of booleans like is_jumping and is_attacking that can contradict each other.
- Should I use an enum or a node-based state machine in Godot?
- Start with an enum and a match statement when a script has three to five states and little per-state data. Move to node-based states, one script per state under a StateMachine node, when states need their own exported variables, timers, or when two people need to edit different states without merge conflicts.
- How do I make an enemy chase the player in Godot 4?
- Give the enemy a Chase state that sets a NavigationAgent2D target to the player's global_position, reads get_next_path_position() every physics frame, and moves toward it with move_and_slide(). The level needs a NavigationRegion2D with a baked navigation polygon, and the first target must be set after one physics frame so the navigation map has synchronised.
- When should I use AnimationTree instead of a code state machine?
- Use AnimationTree's state machine for animation blending: which clip plays, and how it crossfades. Keep gameplay decisions in code. A common setup is both at once: the code FSM decides that the enemy is chasing, and calls playback.travel("run") on the AnimationTree.
Sources and revision context
Updated September 8, 2026. Typed the player reference in both Chase examples and supplied the missing Attack state with recovery and a combat signal. Clarified that a listener must implement damage. Selected checks passed in Godot 4.7.2, standard macOS build, headless, on September 8, 2026. Checked Idle, Patrol, Chase, Attack, recovery, and player removal. The NavigationAgent2D Chase variant only passed a parse check; pathfinding was not runtime-tested. These checks used the article scripts with supplied scene fixtures; they are not a full tutorial playtest.