Enum State Machines

Godot Version

4.3

Background

I’m a game dev newbie and lately I’ve been trying to wrap my head around state machines. It’s a topic that I see coming up at lot as something everyone should try to learn early on. It’s something I find really interesting and want to master but every article I read and every video I watch seems to do handle it differently, and I’m just finding it all a bit overwhelming.

I’m creating this topic as a discussion in the hope that some of the more experienced devs here might be able to offer some advice.

Genre

My main interest is 2D RPGs/platformers etc. So when I talk about state machines I’m typically talking about:

  • Object State (e.g. the state of a door)
  • Player State (your controllable character)
  • Enemy State (enemy AI)

State machine

I want to wrap my head around the enum state machine first. I appreciate there are other and potentially better ways of implementing a state machine but I think the enum state machine is the simpler option and fits most of my use cases right now.

My implementation

So my current implementation for a basic enum state machine is to first define the states:

enum PlayerState {
    IDLE,
    WALK,
    JUMP,
    FALL
}

Then add a variable to store the current state and set the default state:

var _state: PlayerState = PlayerState.IDLE

I handle input via separate functions using the InputMap. So I then then use velocity and other tools to help work out which state the player is currently in:

if is_on_floor():
	if velocity.x == 0:
		set_state(PlayerState.IDLE)
	else:
		set_state(PlayerState.RUN)
else:
	if velocity.y > 0:
		set_state(PlayerState.FALL)
	else:
		set_state(PlayerState.JUMP)

Finally I have a function that sets the new state and matches the current state to a set of behaviours:

func set_state(new_state: PlayerState) -> void:
	if new_state == _state:
		return

	_state = new_state
	
	match _state:
		PlayerState.IDLE:
			animation_player.play("idle")
		PlayerState.RUN:
			animation_player.play("run")
		PlayerState.JUMP:
			animation_player.play("jump")
		PlayerState.FALL:
			animation_player.play("fall")

Questions

Firstly, does this work?

  • I mean it works but does it fit the state machine design pattern? Is it a proper state machine or am I breaking the rules?

Where should I implement Gravity?

  • Currently I apply gravity separately from the state as I want to apply regardless of the state. However there may be situations where I either want no gravity or I want to behave differently such as floating or swimming. Should gravity be handled independently by each state?

How should I handle input?

  • As mentioned above, currently I do this via a function using the InputMap that’s separate from the state but should I handle it within the state itself? For example, below is my jump code. It’s constantly being checked but only runs when both the jump button is pressed and the player is on a surface. Is it more fitting of the state pattern to code this into the states where jump is allowed? So for example, it only checks for the jump input from the IDLE or RUN state (likely means some code duplication)?
func jump() -> void:
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = JUMP_VELOCITY
  • The same applies to movement. Should I just check for left & right input and and handle movement individually in each state? So in this case, all of them? Or should I stick to one global function that happens regardless of the state?

How should I handle animations?

  • Here I’m keeping things simple with an AnimationPlayer and just playing the animation based on state. When handling the input I also flip the sprite based on the direction.
  • Should I consider using an AnimationTree for this? I’ve done a bit of work in unity using a similar component that would change animation based on a variable (e.g. is_falling). I appreciate that this is a separate topic entirely but I’m still keen to hear thoughts on whether or not you think this is something I should be exploring at the same time.

Summary

Apologies that turned into a bit of a wall of text. Any help anyone can provide would be hugely appreciated. I really want to master this subject but I’m finding it a bit overwhelming so I’m trying to break it down into smaller chunks.

1 Like

When the discussion of game programming patterns come up, I always recommend this amazing book by Robert Nystrom, and in this instance, the chapter where he explains state machines:

https://gameprogrammingpatterns.com/state.html

1 Like

Thanks @tibaverus! That’s a page I keep coming back to and re-reading as it seems to be considered a standard on the state design pattern.

I’ve been playing around and doing some experiments. I think my above implementation is fine though the biggest problem is that I haven’t separated state logic outside of the animations. I use the velocity to define the state whereas I should be defining state transitions within each state? Right now if I were to add a “Crouch” state for example, it would be messy to block movement and jump from this state.

So I think what I “should” do, is something more like this:

func _physics_process(delta: float) -> void:
	handle_gravity(delta)
	handle_state(delta)
	
	move_and_slide()

func handle_gravity(delta) -> void:
	if is_on_floor() or velocity.y > MAX_FALL:
		return
	
	velocity.y += GRAVITY * delta

func handle_state(delta) -> void:
	match _state:
		PlayerState.IDLE:
			animation_player.play("idle")
			handle_idle(delta)
		PlayerState.RUN:
			animation_player.play("run")
			handle_run(delta)
		PlayerState.JUMP:
			animation_player.play("jump")
			handle_jump(delta)
		PlayerState.FALL:
			animation_player.play("fall")
			handle_fall(delta)

func set_state(new_state: PlayerState) -> void:
	if new_state == _state:
		return
	
	_state = new_state

handle_gravity() sits on its own only at the moment simply because it applies to all states.

The main change is that all state logic is now kept within induvial handle_ functions. It is this logic that defines which states it can switch to within the current state and how it does it. As opposed to be previous attempt where the state was chosen based on the global input.

func handle_idle(delta) -> void:
	if not is_on_floor():
		set_state(PlayerState.FALL)
		return
	
	if Input.get_axis("left", "right") != 0:
		set_state(PlayerState.RUN)
		return
	
	handle_jump_input()

func handle_run(delta) -> void:
	handle_left_right_input()
	
	if velocity.x == 0:
		set_state(PlayerState.IDLE)
	
	handle_jump_input()

func handle_jump(delta) -> void:
	handle_left_right_input()
	handle_jump_input()
	
	if velocity.y > 0:
		set_state(PlayerState.FALL)

func handle_fall(delta) -> void:
	if is_on_floor():
		set_state(PlayerState.IDLE)
		return
	
	handle_left_right_input()
	handle_jump_input()

func handle_left_right_input():
	_direction = Input.get_axis("left", "right")
	velocity.x = _direction * RUN_SPEED
	if _direction != 0:
		sprite_2d.scale.x = _direction

func handle_jump_input():
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = JUMP_VELOCITY
		set_state(PlayerState.JUMP)

Each state then sets the conditions for transitioning to other states as well as any logic it needs to carry out whilst in the state. Where actions such as movement and jump are treated the same in multiple states I’ve given those their own functions to avoid duplication.

This makes it pretty easy to add a double jump just by modifying the handle_jump_input function:

var _can_double_jump: bool = true

func handle_jump_input():
	if Input.is_action_just_pressed("jump") and (is_on_floor() or _can_double_jump):
		velocity.y = JUMP_VELOCITY
		set_state(PlayerState.JUMP)
		_can_double_jump = false
	
	if is_on_floor():
		_can_double_jump = true

Conclusion

I think this is better? and more in line with a “State pattern”. Thoughts? If anything it just feels like its starting to get a bit messy but I guess that’s where I need to start looking at class and node based state machines?