Kitson.workshop

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

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

  1. JSON has no integers. Every number comes back as a float, so wrap whole numbers in int() when you load, as load_data() does with coins.
  2. JSON has no Vector2. Store x and y separately (or as a two-item array) and rebuild the vector.
  3. 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.

Get the kit ($11)

Finding it hard to finish a game? Your First Finished Godot Game ($19) · All Godot kits and guides