We’ve been adding more and more features to our player character, and while everything works, our code is prone to get messier and more difficult to maintain as time goes on. That’s why we’re going to introduce a common programming pattern that will help us organize our character’s behavior: state machines.
Every game developer talks about them. There are countless definitions of them floating around (especially on Wikipedia). But at their core, state machines help us isolate different behaviors into distinct “states” and control which states we can transition to from any given state.
What is a state machine?Jump to 1:20
Think of a state machine as a way to organize behavior. Instead of having one massive script with endless if-statements checking “Am I jumping? Am I running? Am I falling?”, we break those behaviors into separate, self-contained pieces1. In the case of Godot and GDScript, each state will be its own class/file that knows how to handle that specific behavior.
The most common type you’ll hear about is called a finite state machine, often abbreviated as FSM. “Finite” just means you’re typically in one state at a time with a finite number of possible states (and transitions between them). There are fancier versions with hierarchies of states, stacks of states, multiple states running simultaneously — the list goes on. But we’re keeping things simple because that’s all our character needs right now.
State machines aren't just for animation
In Godot, Unity, or Unreal, you’ll often see animation state machines built into the engine. Because this is the only place we often see state machines show up as a ready-made solution in a game engine, people often think of them only in that context.
But state machines are a general programming pattern that can be applied to any kind of behavior management. You can use them for AI behavior, game flow, door mechanics, game mode(s), or pretty much anything that has distinct states.
Everything can be a state machine. A door? That’s a state machine with locked, closed, and open states. A light switch? On and off. Your player character’s movement? Perfect candidate. If we want to be really pedantic, you can easily make the argument that computers themselves are just a giant state machine.
Visualizing our statesJump to 2:33
Before we jump into code, let’s visualize what we’re building using a tool called Excalidraw. It runs right in your browser and is great for planning out systems like this.
We can outline what we want our player character can do as “states” (boxes) and then draw arrows between them (transitions) to show what states can lead to other states, along with the conditions that cause the changes in state to happen. Take a look at the screenshot below to see the total map of what we’re aiming to represent with our state machine:
A quick diagram made in Excalidraw showing the states and transitions for our player character’s movement system. The jump state is separated out for clarity as the graph was already getting very busy.
It's a bit different than the video
When we record our episodes, we have a general outline of what we want to cover but we kinda figure out the details as we go. So the image above represents a more “finalized” version of what we want to achieve, whereas the video was a looser description of the idea. It’s also important to note that created these kind of diagrams don’t need to get the details perfected, they just have to convey the desired intent to make implementation easier.
State transitions (or conditions, really)Jump to 4:52
To move between these states, we need something called transitions. Personally, I don’t love this term because “transition” often means something different when we’re talking in spoken language rather than in the context of state machines.
For example, the “transition” of your character grabbing onto a ladder could itself be a state with an animation, timing, and rules that allow the transition to be cancelled2. In state machine terminology, however, a “transition” is simply the arrow that connects two states together, indicating that you can move from one state to another when certain conditions are met.
Since the arrow is really a predicate, I prefer to think of these arrows between states purely as conditions. So when moving from idle to walking, the condition might be: “we’re on the ground AND our speed is greater than zero.”
Going from walk to run? That happens when “we’re on the ground AND our speed is greater than our run speed threshold” (which we’ve set to 5 in our case).
We’ve already kind of implemented a state machine with our previous conditions, but the challenge is that if we kept going down that path, our code would become an unmaintainable mess. That’s why we’re refactoring it now.
Creating our base state classJump to 9:50
Okay, enough of what state machines are all about, let’s make one. We’ll begin by creating a new script called base_player_state.gd in our scripts folder. This will be the foundation that all our specific states inherit from. We’re going to create a new folder called player_states to keep things organized.
1class_name BasePlayerState
2extends RefCountedWe’re using RefCounted instead of Node because these states don’t need to be in the scene tree. They’re just containers for behavior3, which makes RefCounted perfect for our needs4.
Now let’s add the functions that every state will need:
1class_name BasePlayerState
2extends RefCounted
3
4## Called when we first enter this state.
5func enter(player: Player) -> void:
6⇥pass
7
8## Called when we exit a state.
9func exit(player: Player) -> void:
10⇥pass
11
12## Called before update is called, allows for state changes.
13func pre_update(player: Player) -> void:
14⇥pass
15
16## Called for every physics frame that we're in this state.
17func update(player: Player, delta: float) -> void:
18⇥passEach function takes a player parameter that will be an instance of our Player class, so the state can access and modify the player’s properties. We’ll make a rule for ourselves that won’t store any data on the states themselves; all data will live on the player.
Instead states will just house these functions that define how the player behaves while in that state. We have 4 functions that every state can implement by simply overriding the base version (which does nothing). These functions are…
enter: Called when we first enter this state. This is where we can set up any initial conditions, like starting an animation.exit: Called when we exit this state. This is where we can clean up anything we changed inenteror during the state’s lifetime (theupdatefunction).pre_update: Called before the mainupdatefunction. We use this to check conditions and potentially change to a different state.update: Called every physics frame while we’re in this state. This is where we implement the actual behavior for the state, like moving the player or applying gravity.
Why separate
pre_updateandupdate?The
pre_updatefunction runs beforeupdateand is where we check if we should switch to a different state. By calling this on the current state first, we allow an opportunity to changes before any state-specific logic runs inupdate.This can be useful for making state transitions predictable, as we know that all state changes primarily happen at the start of the frame, when
pre_updateis called. Thenupdatecan focus solely on the behavior of the current state without worrying about mid-frame transitions.
Creating our states containerJump to 35:00
Now we need a way to access all our states. We’ll create a player_states.gd script that acts as a container and holds all the instances of our state classes.
extends Node
var IDLE := IdlePlayerState.new()
var WALK := WalkPlayerState.new()
var RUN := RunPlayerState.new()
var FALL := FallPlayerState.new()
var JUMP := JumpPlayerState.new()In order to be able to access these states from any other script (including our states themselves), we’ll make this script a “singleton”5 by adding it to the Globals6 list in our project settings.
This gives us a clean way to reference our states like PlayerStates.IDLE or PlayerStates.WALK. We’re using uppercase names here because these act like constants in our code7.
Don't give
PlayerStatesaclass_nameWe intentionally did not give
PlayerStatesaclass_namebecause we don’t want to create multiple instances of it. Instead, we only want to use the single instance created by Godot when we add it to the Globals list. When we do, the name we enter into the Globals list (in this case,PlayerStates) becomes the global variable we use to access it.That means we can access our states like
PlayerStates.IDLE, almost like we’re accessing properties on the class itself. This is the same pattern the engine uses for built-in singletons likeInput,Engine, andTime.
Refactoring the PlayerscriptJump to 40:18
Refactoring gets a bad rap in programming circles. People hear the word “refactor” and immediately think of hours of tedious code changes with no visible benefit. But here’s the thing: refactoring isn’t a sign you did something wrong, it’s a sign that you’re developing code quickly and iterating as you realize better ways to structure your systems.
Sure, re-doing work in a lot of other fields is a sign of failure, but that’s just not the case in game and software development. Refactoring is a natural part of the process, and it’s how we evolve our code to be cleaner, more efficient, and easier to maintain.
Adding a state machine to our player character is a perfect example of this. We started with a simple movement system that worked fine for basic walking and jumping. But as we added more features, we realized our code was getting more complicated and potentially harder to extend, maintain, and eventually debug.
We didn’t do anything “wrong”. In fact, if we started by implementing a state machine, we might have over-engineered the solution for a simple problem. Instead, we built something that worked, learned from it, and now we’re refactoring to make it better.
How we’ll refactor
We’ll be moving a lot of code out of our Player script into the various state classes and introducing some new variables and functions in Player to support the state machine pattern. However, to get this done in a manageable way, we’re going to diverge from our usual step-by-step coding walkthrough and do things a bit different from the video.
Instead, we’re going to present the final code for each state and the necessary changes to the Player script, along with explanations of how everything fits together. This will make it easier to copy and paste the code into your own project with a clear explanation of what each state is trying to achieve.
Our new Player script
Ok, let’s start by showing the full Player script after our refactor. One thing to note is that this may look a bit different from the video. That’s because we’re using this refactor as opportunity to clean up the code a bit, removing values we no longer need, fixing typos, and adding more comments for clarity.
1class_name Player
2extends CharacterBody3D
3
4## Reference to the animation tree node for the player.
5@onready var anim_tree: AnimationTree = $AnimationTree
6
7## Tracks the last lean value for adding leaning to our running animation.
8var last_lean := 0.0
9
10## Determines how fast the player moves
11@export var base_speed := 5.0
12
13## Jump velocity applied when the player jumps.
14const JUMP_VELOCITY = 4.5
15
16## Reference to the camera node for adjusting movement direction.
17@onready var camera: Node3D = $CameraRig/Camera3D
18
19## Speed that the player is considered "running".
20const RUN_SPEED = 3.5
21
22## The current state that our player is in.
23var state: BasePlayerState = PlayerStates.IDLE
24
25## The player's movement input for the current physics frame.
26var move_input: Vector2 = Vector2.ZERO
27
28## The player's movement input, adjusted for the camera direction.
29## This will usually be a normalized vector or zero.
30var move_direction: Vector3 = Vector3.ZERO
31
32
33## Called when the node is added to the scene. We use this to enter the initial state.
34func _ready() -> void:
35⇥state.enter(self)
36
37
38## Changes the current player state and runs the correct functions.
39func change_state_to(next_state: BasePlayerState) -> void:
40⇥state.exit(self)
41⇥state = next_state
42⇥state.enter(self)
43
44
45## Called every physics frame. Delegates update logic to the current state.
46func _physics_process(delta: float) -> void:
47⇥# Read movement input and store it on the player
48⇥move_input = Input.get_vector("move_left", "move_right", "move_forward", "move_backward")
49
50⇥# Calculate adjusted movement direction based on camera
51⇥move_direction = (camera.global_basis * Vector3(move_input.x, 0, move_input.y))
52⇥move_direction = Vector3(move_direction.x, 0, move_direction.z).normalized()
53
54⇥# Delegate to current state
55⇥state.pre_update(self)
56⇥state.update(self, delta)
57
58
59## Rotates the player to face the given direction and smooths the rotation.
60func turn_to(direction: Vector3) -> void:
61⇥if direction.length() > 0:
62⇥⇥var yaw := atan2(-direction.x, -direction.z)
63⇥⇥yaw = lerp_angle(rotation.y, yaw, .25)
64⇥⇥rotation.y = yaw
65
66
67## Returns the player's current speed.
68func get_current_speed() -> float:
69⇥return velocity.length()
70
71
72## Applies velocity based on directional movement input.
73func update_velocity_using_direction(direction: Vector3, speed: float = base_speed) -> void:
74⇥if direction:
75⇥⇥velocity.x = direction.x * speed
76⇥⇥velocity.z = direction.z * speed
77⇥else:
78⇥⇥velocity.x = move_toward(velocity.x, 0, speed)
79⇥⇥velocity.z = move_toward(velocity.z, 0, speed)Here’s what functionally changed compared to our previous version:
- We added
class_nameto make thePlayerscript globally accessible as type. This is why we can usePlayeras a type hint in our state classes (e.g.,func enter(player: Player)). - We removed the
anim_playervariable since we useanim_treeinstead. - Almost everything in
_physics_processhas been moved to the actual states. Instead we just have calls to the current state’spre_updateandupdatefunctions. - We no longer need
BLEND_SPEEDbecause blending is handled by the AnimationTree directly. If we needed to adjust blending speeds for a certain state, we can do that in the state’senterfunction. - Because
run_speednever changes, we made it aconstant instead of avariable and changed the case to reflect that. - Added a missing
ato$CameraRig/Camera3D. Make sure that you fix the name of the CameraRig node in your scene too!
One BIG change…
There’s probably no better proof of how important iteration and refactoring is than this…
We realized after filming, it would’ve been better to calculate the movement input once per frame and store it on the player. So we’re going to make this change now and we’ll be addressing it at the beginning of the next episode.
In our state machine, you’ll notice that some states (like walk and run) read input, update velocity, move the player, and turn the player every frame. This might seem repetitive to do similar things in multiple states, but there are also subtle variations between these states and there are many states (like idle, jump, and fall) that don’t need to do all those things at all.
However, most of our states do ask the same questions, namely “what’s the player’s movement input?”. Not only does doing this multiple times per frame waste CPU cycles, but it can also lead to inconsistencies if the input changes between calls. That means it would actually be better to always do this once per frame and store the result on the player for all states to read.
We can easily move that logic into the Player class just before calling the state’s pre_update and update functions, storing the result on the player for the states to read. This can actually be a good optimization if you have many states that need the same data, since you only compute it once per frame instead of multiple times.
So first, we added two new properties to the Player class to store the movement input and adjusted movement direction.
22## The current state that our player is in.
23var state: BasePlayerState = PlayerStates.IDLE
24
25## The player's movement input for the current physics frame. #
26var move_input: Vector2 = Vector2.ZERO
27
28## The player's movement input, adjusted for the camera direction. #
29## This will usually be a normalized vector or zero. #
30var move_direction: Vector3 = Vector3.ZEROThen, in _physics_process, we read the input and calculate the direction before calling the state’s functions:
45func _physics_process(delta: float) -> void:
46⇥# Read movement input and store it on the player #
47⇥move_input = Input.get_vector("move_left", "move_right", "move_forward", "move_backward")
48
49⇥# Calculate adjusted movement direction based on camera #
50⇥move_direction = (camera.global_basis * Vector3(move_input.x, 0, move_input.y))
51⇥move_direction = Vector3(move_direction.x, 0, move_direction.z).normalized()
52
53⇥# Delegate to current state #
54⇥state.pre_update(self)
55⇥state.update(self, delta)This also means we were able to remove the get_move_input() function entirely from the Player class. We can just read player.move_input and player.move_direction directly in our states.
move_directiondoesn't include speedBecause
move_directionis a normalized vector (or zero), it doesn’t include any speed information (how far the analog stick is being pushed). That means you’ll need to use the length ofmove_inputif you want to know how fast the player is trying to move.var move := player.move_direction * player.move_input.length()
We’ve also made sure to update the states below to use these new properties instead of calling get_move_input(), so they will be different from the video. This is a better approach overall, so make sure to copy the code as shown here.
The Idle State
The first state on our list is the easiest one: idle. This state really just does nothing but watch for conditions to change to another state. This is is also the default state our player starts in.
1class_name IdlePlayerState
2extends BasePlayerState
3
4
5func enter(player: Player) -> void:
6⇥player.anim_tree.set("parameters/movement/transition_request", "idle")
7
8
9func pre_update(player: Player) -> void:
10⇥if not player.is_on_floor():
11⇥⇥player.change_state_to(PlayerStates.FALL)
12
13⇥elif player.move_input.length() > 0.0:
14⇥⇥player.change_state_to(PlayerStates.WALK)
15
16⇥elif Input.is_action_just_pressed("ui_accept"):
17⇥⇥player.change_state_to(PlayerStates.JUMP)
18
19
20func update(player: Player, _delta: float) -> void:
21⇥player.velocity = player.velocity.move_toward(Vector3.ZERO, player.base_speed)
22⇥player.move_and_slide()Something important to note that is quite different from the video is the presence of an update function here. We want this in order to decelerate the player’s velocity and smoothly bring them to a stop when they enter the idle state from a moving state (like walk or run). Otherwise, we stop instantly and abruptly, which looks jarring.
The transitions for the IdlePlayerState are as follows:
- If the player is not on the floor, transition to
FallPlayerState. - If the player has movement input8, transition to
WalkPlayerState. - If the player presses the jump button, transition to
JumpPlayerState.
The Walk State
Next up is the walking state. This state gets our player moving by taking the desired movement input and multiplying it by our base speed. This gets us the desired velocity we want the player to move at.
Currently, we always pass through this walk state before we can start running. This is only going to be a single frame for us, due to how we handle acceleration for our player character, but if you wanted to, you could add a small delay here to simulate the player accelerating from a walk to a run.
1class_name WalkPlayerState
2extends BasePlayerState
3
4
5func enter(player: Player) -> void:
6⇥player.anim_tree.set("parameters/movement/transition_request", "walk")
7
8
9func pre_update(player: Player) -> void:
10⇥var current_speed := player.get_current_speed()
11
12⇥if Input.is_action_just_pressed("ui_accept"):
13⇥⇥player.change_state_to(PlayerStates.JUMP)
14
15⇥elif not player.is_on_floor():
16⇥⇥player.change_state_to(PlayerStates.FALL)
17
18⇥elif player.move_input.length() == 0:
19⇥⇥player.change_state_to(PlayerStates.IDLE)
20
21⇥elif current_speed > player.RUN_SPEED:
22⇥⇥player.change_state_to(PlayerStates.RUN)
23
24
25func update(player: Player, _delta: float) -> void:
26⇥var move := player.move_direction * player.move_input.length()
27⇥player.update_velocity_using_direction(move)
28⇥player.move_and_slide()
29⇥player.turn_to(move)
30
31⇥var walk_speed := lerpf(0.5, 1.75, player.get_current_speed() / player.RUN_SPEED)
32⇥player.anim_tree.set("parameters/walk_speed/scale", walk_speed)We also have a difference from the video here, as we allow the player to initiate a jump from the walk state. We can also enter the falling state from walk if we walk off a ledge.
Another tweak is that we read the player’s move_input length to determine if we should transition back to idle, rather than speed. This prevents bouncing between idle and walk when decelerating. If our deceleration was less instantaneous, we would want to check speed instead to allow for a smoother transition.
Oh, and we prefixed delta with an underscore to tell Godot that we’re ok with not using that parameter (otherwise we get a warning).
While we’re in this state, we read the movement input, update the player’s velocity, move the player, and turn them to face the movement direction. We also adjust the walk animation speed based on how fast the player is moving.
The transitions for the WalkPlayerState are as follows:
- If the player presses the jump button, transition to
JumpPlayerState. - If the player is not on the floor, transition to
FallPlayerState. - If the player’s movement input is zero, transition to
IdlePlayerState. - If the player’s speed exceeds the run speed threshold, transition to
RunPlayerState.
The Run State
The run state is similar to the walk state but with a few key differences. The main one being that we move the player faster by using their full base speed instead of a reduced speed. We also have a transition directly back to idle from run if the player stops moving entirely.
1class_name RunPlayerState
2extends BasePlayerState
3
4
5func enter(player: Player) -> void:
6⇥player.anim_tree.set("parameters/movement/transition_request", "run")
7
8
9func pre_update(player: Player) -> void:
10⇥var current_speed := player.get_current_speed()
11
12⇥if Input.is_action_just_pressed("ui_accept"):
13⇥⇥player.change_state_to(PlayerStates.JUMP)
14
15⇥elif not player.is_on_floor():
16⇥⇥player.change_state_to(PlayerStates.FALL)
17
18⇥elif player.move_input.length() == 0:
19⇥⇥player.change_state_to(PlayerStates.IDLE)
20
21⇥elif current_speed < player.RUN_SPEED:
22⇥⇥player.change_state_to(PlayerStates.WALK)
23
24
25func update(player: Player, _delta: float) -> void:
26⇥var move := player.move_direction * player.move_input.length()
27⇥player.update_velocity_using_direction(move)
28⇥player.move_and_slide()
29⇥player.turn_to(move)
30
31⇥var lean := player.move_direction.dot(player.global_basis.x)
32⇥player.last_lean = lerpf(player.last_lean, lean, 0.3)
33⇥player.anim_tree.set("parameters/run_lean/add_amount", player.last_lean)We made similar changes here as we did in the walk state, allowing jumping from run and checking move_input length to transition back to idle, rather than checking speed.
While we’re in this state, we read the movement input, update the player’s velocity, move the player, and turn them to face the movement direction. We also include some additional behavior for leaning while running. This gives our character a more dynamic feel when moving quickly.
The transitions for the RunPlayerState are as follows:
- If the player presses the jump button, transition to
JumpPlayerState. - If the player is not on the floor, transition to
FallPlayerState. - If the player’s movement input is zero, transition to
IdlePlayerState. - If the player’s speed drops below the run speed threshold, transition to
WalkPlayerState.
The Jump State
So the jump state is admittedly pretty bare. We’re really just using it as a way to apply an upward velocity to the player and then immediately transition to the fall state. However, if jumping is going to be an important part of your game, you might want to expand this state to include more complex jump mechanics, like variable jump heights based on how long the jump button is held, supporting double jumps, triggering a jump animation, adding coyote time, and so on.
1class_name JumpPlayerState
2extends BasePlayerState
3
4
5func enter(player: Player) -> void:
6⇥player.velocity.y = player.JUMP_VELOCITY
7
8
9func pre_update(player: Player) -> void:
10⇥player.change_state_to(PlayerStates.FALL)Something to note is that we could even have the enter() function for our jump state handle the transition if we wanted to. As it stands right now, we’ll exist in the jump state for exactly one frame before moving to fall. But if we wanted to, we could just call player.change_state_to(PlayerStates.FALL) directly from enter(), skipping the jump state entirely in terms of frame updates.
The Fall State
The fall state is where we handle everything that happens while the player is airborne. This includes applying gravity, allowing for limited air control, and checking for when the player lands back on the ground.
1class_name FallPlayerState
2extends BasePlayerState
3
4
5func enter(player: Player) -> void:
6⇥player.anim_tree.set("parameters/movement/transition_request", "fall")
7
8
9func pre_update(player: Player) -> void:
10⇥if player.is_on_floor():
11⇥⇥player.change_state_to(PlayerStates.IDLE)
12
13
14func update(player: Player, delta: float) -> void:
15⇥var move := player.move_direction * player.move_input.length()
16
17⇥player.velocity += player.get_gravity() * delta
18⇥player.update_velocity_using_direction(move, player.base_speed * 0.25)
19⇥player.move_and_slide()
20⇥player.turn_to(move)While we’re in this state, we apply gravity to the player’s vertical velocity, allow for limited air control by updating the horizontal velocity based on input (but at a reduced speed), move the player, and turn them to face the movement direction.
The transitions for the FallPlayerState are as follows:
- If the player is on the floor, transition to
IdlePlayerState.
Why this is better
So what have we actually gained here? Our player script went from being a tangled mess of conditionals to just calling pre_update and update on whatever state we’re in. Each state is self-contained and only knows about its own behavior.
Want to add a new state, like dashing or climbing? Just create a new state class, add it to PlayerStates, and add the transitions to that state from whatever state you want to reach it from.
This pattern scales incredibly well. As your game grows and your character gains new abilities, you won’t be drowning in nested if-statements. Each behavior lives in its own little box, making your code easier to understand, test, and modify.
Plus, this opens the door to more advanced techniques. You could add state “history” with a debug overlay that shows which state you’re in and how you got there.
What’s next
We now have a solid foundation for our player character’s movement system. In the next episode, we’ll be building on this by adding combat mechanics as states within our state machine. This will allow us to seamlessly integrate attacking, blocking, and other combat actions into our existing movement framework.
Want to compare your work to ours? You can find a copy of the project on our GitHub repository. We track each episode as its own commit, so you can see exactly what changed between each episode. Check your work!