Quick answer: Use user:// for all save paths, JSON for portable save data, ConfigFile for settings, and always write to a temporary file before renaming to prevent corruption. Add a version field to every save file so you can migrate old saves when your format changes.
A solid save system is one of those things players never think about until it breaks. Losing hours of progress to a corrupted save file or a path that only works in the editor will generate the angriest bug reports you have ever read. This guide covers everything you need to build a save system in Godot 4 that works reliably across every platform.
Choosing the Right Save Format
Godot gives you three main options for persisting data: JSON, ConfigFile, and binary serialization. Each has a clear use case.
JSON is the best default for game save data. It is human-readable, easy to debug, and portable if you ever need to read save files outside Godot. Use JSON.stringify() and JSON.parse_string() for serialization.
ConfigFile is ideal for player settings like audio volume, graphics quality, and keybindings. It supports sections, handles Godot types natively, and provides default values on read.
Binary with var_to_bytes() produces smaller files and is faster to read and write. Use it when you have large save files with thousands of objects, like in a sandbox or open-world game. The tradeoff is that binary saves are not human-readable and harder to debug.
# JSON — best for most game saves
var data := {"level": 5, "gold": 1200, "inventory": ["sword", "shield"]}
var json := JSON.stringify(data, " ")
# ConfigFile — best for settings
var config := ConfigFile.new()
config.set_value("audio", "master_volume", 0.8)
# Binary — best for large or performance-critical saves
var bytes := var_to_bytes(data)
Always Use user:// for Save Data
Every save file must use the user:// path. The res:// path points to your project directory during development, but in exported builds it is packed into the PCK file and is read-only. This is the single most common cause of save data not persisting.
The user:// path maps to a platform-specific writable directory:
Windows: %APPDATA%\Godot\app_userdata\YourProject\
macOS: ~/Library/Application Support/Godot/app_userdata/YourProject/
Linux: ~/.local/share/godot/app_userdata/YourProject/
Set a custom user directory name in Project Settings → Application → Config → Custom User Dir Name so your save files are easy to find on the player’s machine.
Structuring Save Data with a Save Manager
Create a dedicated autoload script that centralizes all save and load logic. This keeps save operations consistent and prevents scattered file access calls throughout your codebase.
# save_manager.gd — add as an Autoload named SaveManager
extends Node
const SAVE_DIR := "user://saves/"
const SETTINGS_PATH := "user://settings.cfg"
var game_data := {}
func _ready() -> void:
var dir := DirAccess.open("user://")
if not dir.dir_exists("saves"):
dir.make_dir("saves")
func save_game(slot: int) -> bool:
var save := {
"version": 1,
"timestamp": Time.get_unix_time_from_system(),
"data": game_data
}
return _write_json(SAVE_DIR + "slot_%d.json" % slot, save)
func load_game(slot: int) -> bool:
var path := SAVE_DIR + "slot_%d.json" % slot
var save := _read_json(path)
if save.is_empty():
return false
save = _migrate_save(save)
game_data = save["data"]
return true
func _write_json(path: String, data: Dictionary) -> bool:
var json := JSON.stringify(data, " ")
var tmp_path := path + ".tmp"
var file := FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
push_error("Save failed: %s" % FileAccess.get_open_error())
return false
file.store_string(json)
file = null # Close the file
# Atomic rename — if the game crashes during write, original save is safe
DirAccess.rename_absolute(tmp_path, path)
return true
func _read_json(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return {}
var result = JSON.parse_string(file.get_as_text())
if result == null:
return {}
return result
Save File Versioning and Migration
Always include a version number in your save files. When you add new fields, rename keys, or restructure data between updates, the version number lets you detect old saves and migrate them forward instead of breaking them.
func _migrate_save(save: Dictionary) -> Dictionary:
var version: int = save.get("version", 0)
if version < 1:
# v0 -> v1: renamed "coins" to "gold"
if save["data"].has("coins"):
save["data"]["gold"] = save["data"]["coins"]
save["data"].erase("coins")
if version < 2:
# v1 -> v2: added difficulty setting with default
if not save["data"].has("difficulty"):
save["data"]["difficulty"] = "normal"
save["version"] = 2 # Current version
return save
Each migration step handles exactly one version bump. The migrations run in order, so a save from version 0 will pass through every migration to reach the current version. This pattern scales cleanly no matter how many updates you ship.
Atomic Writes to Prevent Corruption
If the game crashes or the player kills the process while a save is being written, you can end up with a partially written file that cannot be loaded. The fix is atomic writes: write to a temporary file first, then rename it to the real path. Renaming a file on the same filesystem is an atomic operation on all major operating systems.
The _write_json function in the save manager above already implements this pattern. For additional safety, keep a backup of the previous save:
func _write_json_with_backup(path: String, data: Dictionary) -> bool:
var json := JSON.stringify(data, " ")
var tmp_path := path + ".tmp"
var backup_path := path + ".bak"
var file := FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
return false
file.store_string(json)
file = null
# Back up current save before replacing it
if FileAccess.file_exists(path):
DirAccess.rename_absolute(path, backup_path)
DirAccess.rename_absolute(tmp_path, path)
return true
If a load fails on the primary file, you can fall back to the .bak file and recover the previous save instead of losing everything.
Encrypting Save Data
Godot provides built-in encryption through FileAccess.open_encrypted_with_pass(). This encrypts the entire file with a password you provide.
const SAVE_KEY := "your-encryption-key-here"
func save_encrypted(path: String, data: Dictionary) -> bool:
var json := JSON.stringify(data)
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, SAVE_KEY)
if file == null:
push_error("Encrypted save failed: %s" % FileAccess.get_open_error())
return false
file.store_string(json)
return true
func load_encrypted(path: String) -> Dictionary:
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, SAVE_KEY)
if file == null:
return {}
var result = JSON.parse_string(file.get_as_text())
return result if result != null else {}
Only encrypt save data when you have a specific reason: competitive leaderboards, multiplayer where save editing affects others, or in-app purchases tied to progress. For single-player games without these concerns, plain JSON is easier to debug and lets players mod their saves if they want to.
Multiple Save Slots
Organize save slots as individual files in a dedicated directory. Store metadata (timestamp, playtime, level name) alongside each slot so you can display it in the load screen without parsing the full save.
func get_slot_info(slot: int) -> Dictionary:
var path := SAVE_DIR + "slot_%d.json" % slot
if not FileAccess.file_exists(path):
return {"empty": true}
var save := _read_json(path)
return {
"empty": false,
"timestamp": save.get("timestamp", 0),
"playtime": save["data"].get("playtime_seconds", 0),
"level": save["data"].get("current_level", "Unknown")
}
func list_all_slots(max_slots: int = 3) -> Array:
var slots := []
for i in range(max_slots):
slots.append(get_slot_info(i))
return slots
func delete_slot(slot: int) -> void:
var path := SAVE_DIR + "slot_%d.json" % slot
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
var backup := path + ".bak"
if FileAccess.file_exists(backup):
DirAccess.remove_absolute(backup)
Autosave
Implement autosave with a Timer node in your save manager. Save on a regular interval and also on key game events like completing a level, entering a new area, or pausing.
var autosave_timer: Timer
const AUTOSAVE_INTERVAL := 300.0 # 5 minutes
const AUTOSAVE_SLOT := 0
func _ready() -> void:
autosave_timer = Timer.new()
autosave_timer.wait_time = AUTOSAVE_INTERVAL
autosave_timer.autostart = true
autosave_timer.timeout.connect(_on_autosave)
add_child(autosave_timer)
func _on_autosave() -> void:
save_game(AUTOSAVE_SLOT)
print("[SaveManager] Autosave complete")
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
save_game(AUTOSAVE_SLOT)
get_tree().quit()
Handle NOTIFICATION_WM_CLOSE_REQUEST to save when the player closes the window. On mobile, also handle NOTIFICATION_APPLICATION_PAUSED since mobile operating systems can kill your app at any time after it goes to the background.
Handling Godot Types in JSON
JSON only supports strings, numbers, booleans, arrays, and objects. If your save data includes Vector2, Color, or other Godot types, you need to convert them manually:
func serialize_vector2(v: Vector2) -> Dictionary:
return {"x": v.x, "y": v.y}
func deserialize_vector2(d: Dictionary) -> Vector2:
return Vector2(d["x"], d["y"])
func serialize_color(c: Color) -> String:
return c.to_html()
func deserialize_color(s: String) -> Color:
return Color.html(s)
Alternatively, use var_to_str() and str_to_var() for Godot-native serialization that preserves all types. The tradeoff is that the resulting format is Godot-specific and not standard JSON.
Testing Your Save System
Test every one of these scenarios before shipping:
1. Export build test: Run your save and load cycle in an exported build, not just the editor. Path issues only surface in exports.
2. Fresh install test: Delete the user data directory and launch the game. Verify it handles a missing save file gracefully.
3. Corrupted file test: Manually corrupt a save file (truncate it, write garbage data) and verify the game loads the backup or shows a clear error instead of crashing.
4. Version migration test: Save with an older version of your game, then load with the current version. Verify all migration steps run correctly.
5. Disk full test: Fill the disk and attempt a save. Verify the atomic write pattern protects the existing save file.
6. Platform test: If you ship on multiple platforms, test save and load on each one. Case sensitivity, path separators, and storage behavior all vary.
Related Issues
If your save data works in the editor but disappears in exported builds, see Fix: Godot Save/Load Game Data Not Persisting. For tracking save-related bugs reported by players, read Bug Reporting Tools for Godot Developers.
Ship your save system early and test it in exported builds on every platform you support. The bugs players hate most are the ones that eat their progress, and those bugs only show up when your code leaves the editor.