A simple state machine and save system in Godot 4
Most small Godot games hit the same two walls. The player script turns into a long chain of if checks ("am I dashing? am I on the floor? was I hit?"), and saving the game gets put off until the end, when it's hardest to add. This tutorial fixes both with plain GDScript: a three-state machine for a top-down character, and a JSON save that survives a crash mid-write. Every snippet below was run and checked in Godot 4.7.2.
Part 1: a state machine in one script
A state machine says: the character is in exactly one state at a time, each state decides what to do each frame, and each state decides when to switch. We'll use three states: Idle, Move and Dash. Attach this to a CharacterBody2D:
extends CharacterBody2D
enum State { IDLE, MOVE, DASH }
const SPEED := 220.0
const DASH_SPEED := 600.0
const DASH_TIME := 0.15
var state: State = State.IDLE
var facing := Vector2.RIGHT
var dash_left := 0.0
var coins := 0
func _physics_process(delta: float) -> void:
var dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
step(dir, Input.is_action_just_pressed("ui_accept"), delta)
move_and_slide()
# All the decisions live here, so you can test them without pressing keys.
func step(dir: Vector2, dash_pressed: bool, delta: float) -> void:
if dir != Vector2.ZERO:
facing = dir.normalized()
match state:
State.IDLE:
velocity = Vector2.ZERO
if dir != Vector2.ZERO:
change_state(State.MOVE)
State.MOVE:
velocity = dir * SPEED
if dash_pressed:
change_state(State.DASH)
elif dir == Vector2.ZERO:
change_state(State.IDLE)
State.DASH:
velocity = facing * DASH_SPEED
dash_left -= delta
if dash_left <= 0.0:
change_state(State.MOVE if dir != Vector2.ZERO else State.IDLE)
func change_state(next: State) -> void:
if next == state:
return
# Exit work for the old state would go here.
state = next
# Enter work for the new state:
if state == State.DASH:
dash_left = DASH_TIME
Why it's built this way
- One
match, one state. Each branch only handles its own state, so Dash can't accidentally run Move's code. - All switching goes through
change_state(). That gives you one place for "enter" and "exit" work: start a timer, play an animation, emit a signal. - Input is read once, then passed in.
step()takes the direction and button as arguments, so you can call it from a test with made-up input. The arrow keys and Space (ui_accept) work out of the box because they're Godot's built-in UI actions; swap in your own actions from the Input Map later.
Test it without pressing keys
Because the logic is in step(), a few lines prove it works. We ran checks like these with godot --headless --script:
p.step(Vector2.RIGHT, false, 0.016) # Idle -> Move
p.step(Vector2.RIGHT, true, 0.016) # Move -> Dash (dash_left = 0.15)
p.step(Vector2.ZERO, false, 0.1) # still dashing at 600 px/s
p.step(Vector2.ZERO, false, 0.1) # timer used up -> Idle
Part 2: save and load with JSON
Each object that should be saved gets two small functions: one that returns its data as a Dictionary, and one that takes it back. Add these to the same player script:
func save_data() -> Dictionary:
return {"x": position.x, "y": position.y, "coins": coins}
func load_data(data: Dictionary) -> void:
position = Vector2(data.get("x", position.x), data.get("y", position.y))
coins = int(data.get("coins", coins)) # JSON numbers come back as floats
Then a small autoload (Project > Project Settings > Globals > Autoload) does the file work:
extends Node
const SAVE_PATH := "user://save.json"
func save_game(data: Dictionary) -> Error:
var tmp := SAVE_PATH + ".tmp"
var file := FileAccess.open(tmp, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(JSON.stringify(data, "\t"))
file.close()
# Swap the finished file in, so a crash mid-save keeps the old save.
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)
return DirAccess.rename_absolute(tmp, SAVE_PATH)
func load_game() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
return parsed if parsed is Dictionary else {}
If you name the autoload SaveGame, saving is SaveGame.save_game($Player.save_data()) and loading is $Player.load_data(SaveGame.load_game()).
Three gotchas worth knowing
- JSON has no integers. Every number comes back as a float, so wrap whole numbers in
int()when you load, asload_data()does withcoins. - JSON has no Vector2. Store
xandyseparately (or as a two-item array) and rebuild the vector. - Write to a temp file first. If the game crashes halfway through writing, the old save is still intact.
user://is your game's own data folder; open it from the editor with Project > Open User Data Folder.
Save files written this way are plain text and not encrypted, so never put passwords or private data in them.
When to outgrow this
The enum + match pattern is perfect for three or four states. Past that, a common next step is node-based states: one child node per state, each with its own short script and enter / exit / physics_update functions, plus a small machine node that switches between them. For saving, the next step is a "persist" group: every node in it is saved automatically, with save slots and a version number so old saves can be upgraded when your data changes.
For educational purposes only. Code is provided as is, tested in Godot 4.7.2; other versions may differ. Kitson Workshop is not affiliated with or endorsed by the Godot project.
Want the node-based version, ready to drop in? The Godot 4 State Machine + Save/Load Kit has StateMachine and State nodes with a state_changed signal, state history and transition_back(); a SaveManager autoload with a persist group, JSON slots, safe writes and a migrate() hook; a playable demo; and 35 automated tests you can run yourself.
Finding it hard to finish a game? Your First Finished Godot Game ($19) · All Godot kits and guides