State Machines & AI Patterns

31.1 Enum-based state machine

Perfect for small actors:

extends CharacterBody2D

enum State { IDLE, PATROL, CHASE, ATTACK, DEAD }

var state: State = State.IDLE:
	set(value):
		if state == value: return
		_exit_state(state)
		state = value
		_enter_state(state)

@export var speed := 80.0
var player: Node2D

func _physics_process(delta: float) -> void:
	match state:
		State.IDLE:
			if can_see_player(): state = State.CHASE
		State.PATROL:
			patrol(delta)
			if can_see_player(): state = State.CHASE
		State.CHASE:
			velocity = global_position.direction_to(player.global_position) * speed
			move_and_slide()
			if global_position.distance_to(player.global_position) < 24.0:
				state = State.ATTACK
			elif not can_see_player():
				state = State.PATROL
		State.ATTACK, State.DEAD:
			pass

func _enter_state(s: State) -> void:
	match s:
		State.ATTACK: $AnimationPlayer.play("attack")
		State.DEAD: $AnimationPlayer.play("die")

func _exit_state(_s: State) -> void:
	pass

31.2 Node-based state machine

For complex actors, each state is a node with its own script:

Scene: Player → StateMachine → Idle, Run, Jump, Fall. Variations: hierarchical state machines (states with sub-machines), pushdown automata (a stack of states for pause/menus).

# state.gd
class_name State
extends Node

signal transition_requested(to_state: StringName)

var actor: CharacterBody2D

func enter(_previous: StringName) -> void: pass
func exit() -> void: pass
func handle_input(_event: InputEvent) -> void: pass
func update(_delta: float) -> void: pass
func physics_update(_delta: float) -> void: pass
# state_machine.gd
class_name StateMachine
extends Node

@export var initial_state: State
var current: State
var states: Dictionary = {}       # StringName → State

func _ready() -> void:
	await owner.ready                # ensure the actor is fully ready
	for child in get_children():
		if child is State:
			states[child.name] = child
			child.actor = owner
			child.transition_requested.connect(_transition)
	current = initial_state
	current.enter(&"")

func _unhandled_input(event: InputEvent) -> void: current.handle_input(event)
func _process(delta: float) -> void: current.update(delta)
func _physics_process(delta: float) -> void: current.physics_update(delta)

func _transition(to_state: StringName) -> void:
	if not states.has(to_state):
		push_error("No state " + to_state)
		return
	var previous := current.name
	current.exit()
	current = states[to_state]
	current.enter(previous)
# states/idle.gd
extends State

func enter(_previous: StringName) -> void:
	actor.get_node("AnimatedSprite2D").play("idle")

func physics_update(_delta: float) -> void:
	if Input.get_axis("move_left", "move_right") != 0.0:
		transition_requested.emit(&"Run")
	elif Input.is_action_just_pressed("jump"):
		transition_requested.emit(&"Jump")

31.3 Vision and line of sight

@export var view_distance := 300.0
@export var view_angle_deg := 60.0

func can_see_player() -> bool:
	if player == null: return false
	var to_player := player.global_position - global_position
	if to_player.length() > view_distance: return false
	var facing := Vector2.RIGHT.rotated(rotation)
	if rad_to_deg(facing.angle_to(to_player)) > view_angle_deg / 2.0 \
			or rad_to_deg(facing.angle_to(to_player)) < -view_angle_deg / 2.0:
		return false
	var query := PhysicsRayQueryParameters2D.create(global_position, player.global_position, 1)  # world layer
	return get_world_2d().direct_space_state.intersect_ray(query).is_empty()

31.4 Steering behaviors

Combine forces (separation, alignment, cohesion) for boids/flocking.

func seek(target: Vector2, max_speed: float) -> Vector2:
	return (target - global_position).normalized() * max_speed - velocity

func flee(threat: Vector2, max_speed: float) -> Vector2:
	return (global_position - threat).normalized() * max_speed - velocity

func arrive(target: Vector2, max_speed: float, slow_radius: float) -> Vector2:
	var offset := target - global_position
	var dist := offset.length()
	var desired_speed := max_speed * minf(dist / slow_radius, 1.0)
	return offset.normalized() * desired_speed - velocity

func _physics_process(delta: float) -> void:
	var steering := seek(player.global_position, 200.0).limit_length(400.0 * delta)
	velocity = (velocity + steering).limit_length(200.0)
	move_and_slide()

31.5 Other AI techniques

Finite state machines Enum/node FSMs, AnimationTree state machines Complex, modular decisions

Addons: LimboAI (BTs + HSMs, GDExtension), Beehave Choose the best-scoring action (needs, sims) Score functions in GDScript Planning toward goals NavigationAgent, AStarGrid2D Grids + Packed arrays Utility AI sketch:

func choose_action() -> StringName:
	var scores := {
		&"eat": (100.0 - hunger) * -1.0 + 100.0,
		&"sleep": 100.0 - energy,
		&"flee": 200.0 if danger_nearby else 0.0,
	}
	var best: StringName = &"idle"
	var best_score := 10.0
	for action in scores:
		if scores[action] > best_score:
			best = action
			best_score = scores[action]
	return best