Skip to content

State machines

Charles edited this page Jun 12, 2026 · 3 revisions

Introduction

State machines are the foundation of many game features including menus, player controllers, basic enemy AI, and more. Most implementations differ only by minor details so ProtoJam provides a standard node-based implementation to help get your games running without the boilerplate code.

Using NodeStateMachine

NodeStateMachine is the root of your state machine and handles executing and transitioning between the states under it. By default, the state machine will tick the active state every process frame but it can be configured to tick during the physics frame instead or manually with a call to tick(delta) (ideal for logic that doesn't need to run every frame).

To use the state machine, simply add an instance to your scene, add any states you want it to run as children, and set the initial active state with a call to change_state(state).

Using AbstractNodeState

An AbstractNodeState is the base class which all states must inherit. It provides the enter, execute, and exit functions used by NodeStateMachine to transition and execute state logic. Additionally, it provides an optional get_state_name function which may be used for debugging purposes.

The enter function contains the logic that should be run every time the state is transition to. The execute function contains the logic which will be run each time the state machine ticks. Finally, the exit function contains the logic to be run before the state is transitioned out. Typically, enter and exit will contain setup and cleanup code like connecting and disconnecting signals used by the state.

When a state change is needed, the active state may queue the new state with a call to change_state from the state_machine instance passed to enter, execute, and exit.

Note

A state's _ready, _process, _physics_process, and other built-in functions will continue to run even while the state is not currently active. Users should be careful if they choose to leverage these functions.

Related, users should be careful to disconnect any signals connected during enter or execute to ensure the signal does not active logic within the state after it is no longer active (unless this is desired behavior).

Best practices

This implementation is optimized for the state-design pattern which simply dictates that states decide when to transition and what state to transition to instead of the state machine. Consequently, this pattern requires states to have a reference to all the state they want to transition to.

Rather than hard-coding the refences to each state, users are strongly advised to make these references export variables so they may be assigned through the editor. This helps make the states more reusable and easier to test during debugging.

Examples

Example 1 - Basic enemy AI

This example demonstrates a basic enemy with idle and attack states created through code.

Note

This example showcases adding and configuring states through code for brevity; however, it's recommended to add and configure states through the scene editor for simplicity.

enemy.gd

class_name Enemy
extends Node3D

var _state_machine: NodeStateMachine = NodeStateMachine.new()
var _idle_state: EnemyIdleState = EnemyIdleState.new()
var _attack_state: EnemyAttackState = EnemyAttackState.new()
@onready var _player_detector: PlayerDetector = %PlayerDetector
@onready var _blaster: Blaster = %Blaster

func _ready() -> void:
	# Adding/configuring states is better done through the editor but can be done through code
	add_child(_state_machine)
	
	_idle_state.player_seen_state = _attack_state
	_idle_state.player_detector = _player_detector
	_state_machine.add_child(_idle_state)
	
	_attack_state.player_lost_state = _idle_state
	_attack_state.player_detector = _player_detector
	_attack_state.blaster = _blaster
	_state_machine.add_child(_attack_state)

	# Set the initial state
	_state_machine.change_state(_idle_state)

enemy_idle_state.gd

class_name EnemyIdleState
extends AbstractNodeState

@export var player_seen_state: AbstractNodeState = null
@export var player_detector: PlayerDetector = null

func _execute(state_machine: NodeStateMachine) -> void:
	if player_detector.is_player_detected():
		state_machine.change_state(player_seen_state)

enemy_attack_state.gd

class_name EnemyAttackState
extends AbstractNodeState

@export var player_lost_state: AbstractNodeState = null
@export var player_detector: PlayerDetector = null
@export var blaster: Blaster = null

func _execute(state_machine: NodeStateMachine) -> void:
	if player_detector.is_player_detected():
		blaster.shoot_at(player_detector.get_player_position())
	else:
		state_machine.change_state(player_lost_state)

Clone this wiki locally