Building a more advanced state machine in Godot


Sample project

Well, I didn’t set out to make an entire series based around state machines, but here we are. We’ve looked at the theory behind state machines and the state pattern, and we’ve implemented one in code using a somewhat simplified and opinionated take on the state pattern. Now, let’s look at a few options we have to expand upon that design.

Overview

If you’ll recall, the original design called for a hierarchy of nodes, with the player delegating to a state manager for state-dependent logic, which then delegated to the active state while handling the transition between states. Additionally, all the state code was built upon the BaseState class.

# base_state.gd
class_name BaseState
extends Node

enum State {
	Null,
	Idle,
	Walk,
	Fall,
	Jump
}

export (String) var animation_name

var player: Player

func enter() -> void:
	player.animations.play(animation_name)

func exit() -> void:
	pass

# Enums are internally stored as ints, so that is the expected return type
func input(event: InputEvent) -> int:
	return State.Null

func process(delta: float) -> int:
	return State.Null

func physics_process(delta: float) -> int:
	return State.Null

And this method works well enough for fairly simple use cases (a character in a simple run-and-jump platformer, for instance), but it quickly falls apart if we need to do anything more advanced:

  • What if we want a walk and run state? They require essentially the same code, but need separate input processing methods so that the walk state transitions to the run state when the right button is pressed and run transitions to walk when that same button is released. We don’t want to maintain two sets of code that’s otherwise identical to handle this single difference.
  • Similarly, what if we want to add a dash state? It also handles horizontal movement, but the dash probably shouldn’t be canceled by the player letting go of a button unlike the walk or run states. We’ll again need to make an extremely similar state to handle that difference.
  • Lastly, and perhaps most importantly, the state_manager has hardcoded references to its child states, which means we’ll have to whip up a new manager for a different object if it has a different collection of states. Plus, state reusability becomes a bit complicated since one enumerator is shared by all states.

There are two techniques that can be used to fix these issues: hierarchical state machines and dependency injection. Both of these actually already exist to a degree in the original implementation, but they were underutilized and not directly discussed to keep things simpler for the introductory post. Now that we’re comfortable with a basic state machine, though, let’s formally discuss these techniques and refactor the state machine implementation to make better use of them.

Hierarchical state machines

First, let’s discuss hierarchical state machines. The idea behind this type of state machine is that there’s a hierarchy of states available at any time. When a logical input is received, we move through the hierarchy, from most specific to most generic, until one of those states handles it. Observant readers may notice that this is exactly what our BaseState class is already doing as it provides base functions to be called in case the child classes don’t implement them.

Let’s take that as an example of this technique and expand on it a bit to see how we can use it to handle the above issue of wanting to have walk, run, and dash states. Really, these are all variations on horizontal movement, so let’s create a class to hold some generic movement code and subclass each individual movement state from it. Our hierarchy will then become the following:

  • BaseState
    • MoveState
      • WalkState
      • RunState
      • DashState

As for the code, let’s first create our MoveState class, which will handle movement for the player and apply the horizontal movement based on the output of the get_movement_input() function . With the exception of that new method and some extra code in the input() function so we can enter the DashState while walking / running, this code is essentially the original WalkState code.

class_name MoveState
extends BaseState

export (float) var move_speed = 60

func input(event: InputEvent) -> int:
	if Input.is_action_just_pressed("jump"):
		return State.Jump

	if Input.is_action_just_pressed("dash"):
		return dash_state

	return State.Null

func physics_process(delta: float) -> int:
	if !player.is_on_floor():
		return State.Fall

	var move = get_movement_input()
	if move < 0:
		player.animations.flip_h = true
	elif move > 0:
		player.animations.flip_h = false
	
	player.velocity.y += player.gravity
	player.velocity.x = move * move_speed
	player.velocity = player.move_and_slide(player.velocity, Vector2.UP)
	
	if move == 0:
		return State.Idle

	return State.Null

func get_movement_input() -> int:
	if Input.is_action_pressed("move_left"):
		return -1
	elif Input.is_action_pressed("move_right"):
		return 1
	
	return 0

Now we can make walk and run states by simply inheriting from this script, adding a bit of conditional logic to handle toggling between running and walking, and adjusting the animation name and movement speed to our liking (remember that these are exported so we can just change them from within the Godot editor).

# walk_state.gd
extends MoveState

func input(event: InputEvent) -> int:
	# First run parent code and make sure we don't need to exit early
	# based on its logic
	var new_state = .input(event)
	if new_state != State.Null:
		return new_state
	
	if Input.is_action_just_pressed("run"):
		return State.Run

	return State.Null
# run_state.gd
extends MoveState

func input(event: InputEvent) -> int:
	# First run parent code and make sure we don't need to exit early
	# based on its logic
	var new_state = .input(event)
	if new_state != State.Null:
		return new_state
	
	if Input.is_action_just_released("run"):
		return State.Walk

	return State.Null

As for the dash state, we can use a similar script, but since we don’t want movement to be cancelled by the player, we’ll simply override the input() function so we can’t cancel early based on player input. We’ll also override the get_movement_input() function to always return the direction the player was facing when the state was entered. Once the desired amount of time has passed, we’ll automatically return back to an idle or moving state.

# dash_state.gd
extends MoveState

export (float) var dash_time = 0.4

var current_dash_time: float = 0
var dash_direction: int = 0

# Upon entering the state, set dash direction to either current input or the direction the player is facing if no input is pressed
func enter() -> void:
	.enter()
	
	current_dash_time = dash_time
	
	if player.animations.flip_h:
		dash_direction = -1
	else:
		dash_direction = 1

# Override MoveState input() since we don't want to change states based on player input
func input(event: InputEvent) -> int:
	return State.Null

# Move in the dash_direction every frame
func get_movement_input() -> int:
	return dash_direction

# Track how long we've been dashing so we know when to exit
func process(delta: float) -> int:
	current_dash_time -= delta

	if current_dash_time > 0:
		return State.Null
	
	if Input.is_action_pressed("move_left") or Input.is_action_pressed("move_right"):
		if Input.is_action_pressed("run"):
			return State.Run
		return State.Walk
	return State.Idle

And that’s a look at hierarchical state machines. There are other ways of implementing them that can be useful, such as creating a stack of states that can be popped to and pushed from rather than using inherited classes, but as usual I’m just going to stick to showing my preferred method of implementation so you don’t get overwhelmed by options. You could also go even further with this concept and make a lot of micro classes to really keep things small and reusable for a large project, but that’s left as an exercise for those interested:

  • BaseState - Base class
    • InputState - Reads inputs
      • GravityState - Applies force of gravity
        • MoveState - Applies horizontal forces
          • IdleState - Zero movement
          • WalkState - Slow movement
          • RunState - Fast movement
          • TimedMovementState - Movement state with a limited time to live
            • DashState - Fast movement that can’t be canceled

Object decoupling and dependency injection

Now let’s discuss dependency injection. Don’t be scared by those words, though. As James Shore puts it:

“Dependency Injection” is a 25-dollar term for a 5-cent concept.

  • James Shore

All dependency injection means is that we’re going to pass in the objects we need at runtime instead of at compile time (ie hardcoding them). We’re actually already doing this in the Player and StateManager classes when we let the player pass a reference of itself to the state manager, which then passes the reference to its child states so that the states don’t have to know where in the application the player lives.

# player.gd
class_name Player
extends KinematicBody2D

...

func _ready() -> void:
	# Initialize the state machine, passing a reference of the player to the states,
	# that way they can move and react accordingly
	states.init(self)

...

# state_manager.gd
extends Node

...

# Initialize the state machine by giving each state a reference to the objects
# owned by the parent that they should be able to take control of
# and set a default state
func init(player: Player) -> void:
	for child in get_children():
		child.player = player

	# Initialize with a default state of idle
	change_state(BaseState.State.Idle)
	
...

So let’s do more of this and completely decouple the states and the state machine from the player to improve code flexibility and allow states to be more easily reused on other objects, especially now that we’ve seen how to easily reuse code with hierarchical states. Currently, one of the biggest couplings in the entire project is the state enumerator in BaseState and the dictionary lookup in the state manager.

# base_state.gd
class_name BaseState
extends Node

enum State {
	Null,
	Idle,
	Walk,
	Fall,
	Jump
}
# state_manager.gd
extends Node

# Using enums for state names that way every script has the same interface
# while being more robust and less error prone than using strings
onready var states = {
	BaseState.State.Idle: $idle,
	BaseState.State.Walk: $walk,
	BaseState.State.Fall: $fall,
	BaseState.State.Jump: $jump,
}

This works fine for a single object (and I certainly like them better than hardcoding the path to a specific script), but it makes our code pretty inflexible since we either need a different enumerator and state machine for each object with different possible states, or one giant enumerator holding every possible state any object could need. So instead, let’s have each state export variables to hold a reference to the states they can transition to. At runtime, instead of passing an enum to the state machine representing the state we want to transition to, we’ll just pass this reference directly to the state machine to handle the transition.

So, after removing the enum and changing our expected return types, BaseState now looks like this:

class_name BaseState
extends Node

export (String) var animation_name

var player: Player

func enter() -> void:
	player.animations.play(animation_name)

func exit() -> void:
	pass

func input(event: InputEvent) -> BaseState:
	return null

func process(delta: float) -> BaseState:
	return null

func physics_process(delta: float) -> BaseState:
	return null

And, as an example, IdleState looks like this:

extends BaseState

export (NodePath) var jump_node
export (NodePath) var fall_node
export (NodePath) var walk_node
export (NodePath) var run_node
export (NodePath) var dash_node

onready var jump_state: BaseState = get_node(jump_node)
onready var fall_state: BaseState = get_node(fall_node)
onready var walk_state: BaseState = get_node(walk_node)
onready var run_state: BaseState = get_node(run_node)
onready var dash_state: BaseState = get_node(dash_node)

func enter() -> void:
	.enter()
	player.velocity.x = 0

func input(event: InputEvent) -> BaseState:
	if Input.is_action_just_pressed("move_left") or Input.is_action_just_pressed("move_right"):
		if Input.is_action_pressed("run"):
			return run_state
		return walk_state
	elif Input.is_action_just_pressed("jump"):
		return jump_state
	elif Input.is_action_just_pressed("dash"):
		return dash_state
	return null

func physics_process(delta: float) -> BaseState:
	player.velocity.y += player.gravity
	player.velocity = player.move_and_slide(player.velocity, Vector2.UP)

	if !player.is_on_floor():
		return fall_state
	return null

There’s a bit more boilerplate code that has to be written this way, since each state will need to have its own exported NodePaths and calls to get_node(), but it gives us a lot more flexibility in where and how these states are used.

And lastly, after removing the dict and changing our expected types, StateManager will now be:

extends Node

export (NodePath) var starting_state

var current_state: BaseState

func change_state(new_state: BaseState) -> void:
	if current_state:
		current_state.exit()

	current_state = new_state
	current_state.enter()

# Initialize the state machine by giving each state a reference to the objects
# owned by the parent that they should be able to take control of
# and set a default state
func init(player: Player) -> void:
	for child in get_children():
		child.player = player

	# Initialize with a default state of idle
	change_state(get_node(starting_state))
	
# Pass through functions for the Player to call,
# handling state changes as needed
func physics_process(delta: float) -> void:
	var new_state = current_state.physics_process(delta)
	if new_state:
		change_state(new_state)

func input(event: InputEvent) -> void:
	var new_state = current_state.input(event)
	if new_state:
		change_state(new_state)

func process(delta: float) -> void:
	var new_state = current_state.process(delta)
	if new_state:
		change_state(new_state)

And now, we can reuse the states and associated state machine wherever we want. The state machine no longer has any specific knowledge of the states available to it while the states can still change their transition targets as needed.

Options to decouple physics and graphics from state

The last thing that may stand out to you is that we hold a reference to player in each state and specifically make calls to move_and_slide(), which is a KinematicBody2D method, and animation.play(), which is an AnimatedSprite method. I’m generally ok with this since the player is such a unique object in most games that you’re most likely going to have at least some states that have some amount of coupling to the player, but you do have options for removing this coupling.

First, hierarchical state machines can alleviate a lot of the most egregious instances of coupling. Consider, for example, removing any input reading from MoveState so it only handles movement based on the inputs given to it and instead creating a subclass, InputMoveState, for the player that reads keyboard or controller inputs and feeds this information to the super class’ movement code. For NPCs, an AI controller can simply feed the appropriate inputs into MoveState.

Another option is to do more dependency injection and, instead of calling direct methods on the parent object (and therefore making assumptions about its structure), pass functions to the states that they can call when they want to do something. That way, any physics body type and any animation implementation can be used within the states. FuncRefs are the thing to lookup for this, and I’ve written about them previously if you want to know more. For now, I’ll just leave you with a small code example to point you in the right direction if this interests you:

class_name Player
extends KinematicBody2D

...

# Create a reference to move_and_slide that child states can call
var move_ref = funcref(self, 'move_and_slide')

func _ready() -> void:
	states.init(self, move_ref)

...
# state_manager.gd
...

# Give each child state the reference to move_and_slide
func init(player: Player, move_func: FuncRef) -> void:
	for child in get_children():
		child.player = player
		child.move_function = move_func

	# Initialize with a default state of idle
	change_state(get_node(starting_state))

...
class_name MoveState
extends BaseState

var move_function: FuncRef

...

func physics_process(delta: float) -> BaseState:
	if !player.is_on_floor():
		return fall_state

	var move = get_movement_input()
	if move < 0:
		player.animations.flip_h = true
	elif move > 0:
		player.animations.flip_h = false
	
	player.velocity.y += player.gravity
	player.velocity.x = move * move_speed
	# Old version of code
	# player.velocity = player.move_and_slide(player.velocity, Vector2.UP)

	# New version, using a FuncRef so we don't have to know how movement logic is implemented
	move_function.call_func(player.velocity)
	
	if move == 0:
		return idle_state

	return null

Conclusion

And that’s a few more options you have when coding a state machine in Godot. To sum it up: hierarchical state machines are a great way to reduce code duplication while using dependency injection, whether via FuncRefs or exported variables, can make your states more flexible and reusable in other state machines.