How to Build a Save System in Godot 4
Every game needs to save and load data. Whether it's player progress, settings, or a full open-world state, you need a reliable system to persist data to disk. Godot 4 makes this straightforward — but there are a few decisions to make early that will save you from rewriting everything later.
This guide covers the core concepts and patterns. By the end, you'll understand how Godot handles file I/O, when to use each approach, and how to architect a save system that scales.
The Two Approaches
There are two fundamental ways to save data in Godot:
- ConfigFile — Key-value storage. Good for settings and simple data.
- FileAccess + JSON/binary — Full control. Good for complex game state.
Most games need both.
Approach 1: ConfigFile for Settings
ConfigFile is Godot's built-in INI-style storage. It handles sections, keys, and typed values automatically.
# Save settings
func save_settings() -> void:
var config := ConfigFile.new()
config.set_value("audio", "master_volume", 0.8)
config.set_value("audio", "music_volume", 0.6)
config.set_value("video", "fullscreen", true)
config.set_value("video", "vsync", true)
config.save("user://settings.cfg")
# Load settings
func load_settings() -> void:
var config := ConfigFile.new()
if config.load("user://settings.cfg") != OK:
return # No settings file yet, use defaults
master_volume = config.get_value("audio", "master_volume", 0.8)
music_volume = config.get_value("audio", "music_volume", 0.6)
fullscreen = config.get_value("video", "fullscreen", false)
The third argument in get_value is the default — used when the key doesn't exist. This makes your code forward-compatible: you can add new settings without breaking old save files.
Important: Always use
user://for save paths, notres://. Theuser://directory is writable at runtime and maps to the user's app data folder.res://is your project directory and is read-only in exported builds.
Approach 2: JSON for Game State
For more complex data — inventories, quest progress, character stats — you need structured serialization. JSON is the simplest option.
func save_game() -> void:
var data := {
"player": {
"position": {"x": player.position.x, "y": player.position.y, "z": player.position.z},
"health": player.health,
"level": player.level,
},
"inventory": player.inventory.serialize(),
"quests": quest_manager.serialize(),
"timestamp": Time.get_datetime_string_from_system(),
}
var file := FileAccess.open("user://savegame.json", FileAccess.WRITE)
file.store_string(JSON.stringify(data, "\t"))
func load_game() -> void:
if not FileAccess.file_exists("user://savegame.json"):
return
var file := FileAccess.open("user://savegame.json", FileAccess.READ)
var json := JSON.new()
if json.parse(file.get_as_text()) != OK:
push_error("Failed to parse save file")
return
var data: Dictionary = json.data
player.position = Vector3(
data.player.position.x,
data.player.position.y,
data.player.position.z
)
player.health = data.player.health
player.level = data.player.level
player.inventory.deserialize(data.inventory)
quest_manager.deserialize(data.quests)
The Serialize/Deserialize Pattern
The key to a clean save system is giving each object responsibility for its own serialization. Don't let one giant save_game() function reach into every object's internals.
# InventoryComponent.gd
func serialize() -> Array:
var items := []
for item in inventory_items:
items.append({
"id": item.id,
"quantity": item.quantity,
"durability": item.durability,
})
return items
func deserialize(data: Array) -> void:
inventory_items.clear()
for item_data in data:
var item := Item.new()
item.id = item_data.id
item.quantity = item_data.quantity
item.durability = item_data.get("durability", 100) # Default for old saves
inventory_items.append(item)
Notice the data.get("durability", 100) — this handles saves from before you added durability. Always provide defaults for new fields so old save files don't crash your game.
Handling Versioning
As your game evolves, your save format changes. Add a version number to every save file:
const SAVE_VERSION := 2
func save_game() -> void:
var data := {
"version": SAVE_VERSION,
"player": player.serialize(),
# ...
}
func load_game() -> void:
# ... parse file ...
var version: int = data.get("version", 1)
if version < 2:
# Migrate old save format
data = migrate_v1_to_v2(data)
This lets you ship updates without breaking players' saves. Version 1 saves get migrated up, version 2 saves load directly.
Multiple Save Slots
Most games need more than one save file. Use a simple naming convention:
func get_save_path(slot: int) -> String:
return "user://save_slot_%d.json" % slot
func get_all_saves() -> Array[Dictionary]:
var saves: Array[Dictionary] = []
for i in range(1, 4): # 3 slots
var path := get_save_path(i)
if FileAccess.file_exists(path):
var file := FileAccess.open(path, FileAccess.READ)
var json := JSON.new()
if json.parse(file.get_as_text()) == OK:
saves.append({"slot": i, "data": json.data})
return saves
Common Mistakes
Saving Vector3 directly to JSON — JSON doesn't understand Godot types. Convert to plain numbers:
# Wrong — won't serialize
data["position"] = player.position
# Right — breaks into primitives
data["position"] = {
"x": player.position.x,
"y": player.position.y,
"z": player.position.z,
}
Not handling missing files — Always check FileAccess.file_exists() before loading. First-time players won't have a save file.
Saving every frame — Save on specific events (entering a new area, completing a quest, manual save). Autosave on a timer (every 5 minutes) is fine, but not every frame.
No error handling — Corrupted save files happen. Wrap your parse logic in error checks and consider keeping a backup of the previous save.
Going Deeper
This covers the foundations, but a production save system has more layers: encrypted saves for anti-cheat, cloud saves for cross-device play, save file thumbnails, and complex world state serialization.
If you want to build a complete, production-ready save and load system step by step, we have a full 14-lesson course that takes you from basics to a polished system with multiple slots, migration, and error recovery.