Godot 4 Save System Tutorial
Persist Game Data with JSON & Resources
Every game needs persistence. Whether it's a quick settings file, a full RPG save slot, or an auto-save that fires every few minutes — your players expect their progress to survive a restart. Yet save systems are one of the most commonly “I'll do it later” features in indie development.
In this Godot 4 save system tutorial, you'll build a production-ready GDScript save/load system from scratch. We'll cover two approaches — FileAccess with JSON and custom Resource-based saves — plus techniques for saving entire node trees, auto-save timers, and basic encryption. Full code examples, copy-paste ready.
If you've read our Inventory System tutorial or the State Machine guide, this is the natural next step — making all that game state survive between sessions.
JSON vs Resources — Which Should You Use?
Godot 4 gives you two solid options for persisting game data. Understanding when to use each one will save you headaches down the road.
- JSON via FileAccess — human-readable, easy to debug, works great for settings, high scores, and any data you might want to inspect or edit outside the engine. Cross-platform and mod-friendly.
- Custom Resources (.tres/.res) — type-safe, integrates with the Inspector, supports nested objects natively. Ideal for complex game state with many typed fields. Harder for players to tamper with.
Rule of thumb: use JSON for simple key-value data, Resources for complex game state with nested objects. Many projects use both — JSON for settings, Resources for save slots.
FileAccess JSON — Save Game Data to Disk
The FileAccess class in Godot 4 replaces the old File class from Godot 3. Here's a complete save manager that writes a dictionary to JSON:
extends Node
# Path inside user:// — works on all platforms
const SAVE_PATH := "user://savegame.json"
func save_game(data: Dictionary) -> void:
var json_string := JSON.stringify(data, " ")
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("Cannot open save file: ", FileAccess.get_open_error())
return
file.store_string(json_string)
print("Game saved to ", SAVE_PATH)The user:// path is critical — it maps to a writable, platform-specific directory (AppData on Windows, .local/share on Linux, Library on macOS). Never save to res:// as it's read-only in exported builds.
The second argument to JSON.stringify() is the indent string. Pass " " during development so you can open the file in a text editor and debug it. Remove it in production for smaller files.
FileAccess JSON — Load Game Data
Loading is the reverse. Open the file, parse the JSON, and validate the result:
func load_game() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
print("No save file found.")
return {}
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("Cannot read save file.")
return {}
var json_string := file.get_as_text()
var json := JSON.new()
var error := json.parse(json_string)
if error != OK:
push_error("JSON parse error: ", json.get_error_message())
return {}
var result = json.data
if result is Dictionary:
print("Game loaded successfully.")
return result
push_error("Save file root is not a Dictionary.")
return {}Always validate. A corrupted save file shouldn't crash your game. The JSON.new() instance approach lets you access detailed error info via get_error_message() and get_error_line(). Here's how you'd call both functions from your player script:
func _on_save_pressed() -> void:
var data := {
"player_hp": health,
"position_x": global_position.x,
"position_y": global_position.y,
"inventory": inventory.to_dict(),
"level": current_level,
}
SaveManager.save_game(data)
func _on_load_pressed() -> void:
var data := SaveManager.load_game()
if data.is_empty():
return
health = data.get("player_hp", 100)
global_position = Vector2(data.get("position_x", 0), data.get("position_y", 0))
current_level = data.get("level", 1)Notice the data.get(key, default) pattern — it provides fallback values so older save files without new fields won't crash. This is basic save migration and it's free with dictionaries.
Want to build the inventory system that data.inventory references above? Our Inventory tutorial covers resource-based items, UI grids, and drag-and-drop.
Want more Godot tricks?
Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.
Custom Resource Saves — Type-Safe Persistence
For complex game state, Godot's Resource system is more powerful than JSON. You define a class with typed @export variables and let Godot handle serialization:
class_name SaveData
extends Resource
@export var player_hp: int = 100
@export var player_position: Vector2 = Vector2.ZERO
@export var current_level: String = "res://levels/level_01.tscn"
@export var inventory_items: Array[String] = []
@export var play_time_seconds: float = 0.0
@export var quest_flags: Dictionary = {}Now saving and loading becomes two lines:
extends Node
const SAVE_PATH := "user://savegame.tres"
func save_game(data: SaveData) -> void:
var error := ResourceSaver.save(data, SAVE_PATH)
if error != OK:
push_error("Failed to save: ", error)
func load_game() -> SaveData:
if ResourceLoader.exists(SAVE_PATH):
return ResourceLoader.load(SAVE_PATH) as SaveData
return SaveData.new()The SaveData resource gives you autocompletion, type checking, and the ability to nest other Resources inside it. You could have a PlayerData resource, an InventoryData resource, and a SettingsData resource all nested inside your root save — each with their own typed fields.
Tip: Use .tres (text-based) during development for easy debugging and .res (binary) in production for smaller, faster files.
Saving Node Trees — Persist Dynamic Worlds
RPGs, survival games, and sandbox worlds often have dynamically spawned objects — enemies, dropped items, placed buildings. You need to serialize an entire node tree. The pattern: add every “saveable” node to a group, then iterate:
func save_world() -> void:
var save_nodes := get_tree().get_nodes_in_group("saveable")
var world_data: Array[Dictionary] = []
for node in save_nodes:
if not node.has_method("get_save_data"):
continue
var data := node.get_save_data()
# Store the scene path so we can re-instance it
data["scene_path"] = node.scene_file_path
data["node_name"] = node.name
world_data.append(data)
var file := FileAccess.open("user://world.json", FileAccess.WRITE)
file.store_string(JSON.stringify(world_data, " "))Each saveable node implements a get_save_data() method that returns its state as a Dictionary. Here's what that looks like on a spawned enemy:
extends CharacterBody2D
func get_save_data() -> Dictionary:
return {
"pos_x": global_position.x,
"pos_y": global_position.y,
"health": current_health,
"state": current_state_name,
}
func load_save_data(data: Dictionary) -> void:
global_position = Vector2(data.pos_x, data.pos_y)
current_health = data.get("health", max_health)Loading the world means clearing existing nodes, re-instancing from the stored scene_path, and calling load_save_data() on each. This pairs beautifully with the State Machine pattern — save the current state name and restore it on load.
func load_world(parent: Node) -> void:
# Remove existing saveable nodes
for node in get_tree().get_nodes_in_group("saveable"):
node.queue_free()
var file := FileAccess.open("user://world.json", FileAccess.READ)
var json := JSON.new()
json.parse(file.get_as_text())
for entry in json.data:
var scene := load(entry.scene_path) as PackedScene
var instance := scene.instantiate()
parent.add_child(instance)
if instance.has_method("load_save_data"):
instance.load_save_data(entry)Building an RPG or survival game? Our micro-courses cover signals, state machines, and more patterns that pair perfectly with save systems.
Auto-Save Patterns — Never Lose Progress
Players hate losing progress. An auto-save system is table stakes for modern games. Here's a clean autoload pattern using a Timer:
extends Node
# Add this script as an Autoload in Project Settings
@export var interval_seconds: float = 120.0
var _timer: Timer
var _enabled: bool = true
signal auto_save_started
signal auto_save_completed
func _ready() -> void:
_timer = Timer.new()
_timer.wait_time = interval_seconds
_timer.autostart = true
_timer.timeout.connect(_on_auto_save)
add_child(_timer)
func _on_auto_save() -> void:
if not _enabled:
return
auto_save_started.emit()
SaveManager.save_game(_gather_save_data())
auto_save_completed.emit()
func pause_auto_save() -> void:
_enabled = false
func resume_auto_save() -> void:
_enabled = trueThe auto_save_started and auto_save_completed signals let your UI show a “Saving...” indicator. Connect them via a signal bus to keep things decoupled.
Key design decisions:
- Pause during cutscenes — call
pause_auto_save()during story sequences or boss intros so the player doesn't reload into a half-finished cutscene. - Save on key events — trigger a manual save after boss kills, level transitions, or checkpoint zones in addition to the timer.
- Rotate save files — keep a
savegame_backup.jsonof the previous save in case the latest one gets corrupted during a crash.
Encryption Basics — Protect Save Files
Don't want players editing their save files? Godot 4's FileAccess has built-in encryption via open_encrypted and open_encrypted_with_pass. Here's the simplest approach using a password:
const SAVE_PATH := "user://savegame.dat"
const SAVE_PASS := "my_secret_key_change_this"
func save_encrypted(data: Dictionary) -> void:
var json := JSON.stringify(data)
var file := FileAccess.open_encrypted_with_pass(
SAVE_PATH, FileAccess.WRITE, SAVE_PASS
)
if file:
file.store_string(json)
func load_encrypted() -> Dictionary:
var file := FileAccess.open_encrypted_with_pass(
SAVE_PATH, FileAccess.READ, SAVE_PASS
)
if file == null:
return {}
var json := JSON.new()
json.parse(file.get_as_text())
return json.data if json.data is Dictionary else {}Important caveat: the password is embedded in your exported binary. A determined player can extract it. This prevents casual file editing, not serious reverse engineering. For competitive/online games, validate saves server-side.
You can also use open_encrypted() with a raw 256-bit key (a PackedByteArray) for more control. For most indie games, the password approach is more than enough — it stops 99% of casual cheating.
Best Practices & Common Pitfalls
After building save systems for dozens of projects, here are the patterns that keep things reliable:
- Version your saves — include a
"save_version": 1field in your data. When you add new fields, bump the version and write a migration function. This prevents breaking existing players' saves on update. - Always use user:// — the
res://filesystem is read-only in exported builds. Useuser://for all runtime file operations. - Handle missing keys gracefully — use
dict.get(key, default)instead of direct access. A save from version 1 won't have version 2 fields. - Test with corrupted files — manually break your save file and verify your game handles it gracefully. Delete the file entirely. Truncate it. Replace it with garbage bytes. Your game should never crash from bad save data.
- Use an Autoload — make your SaveManager a singleton autoload. Any node can call
SaveManager.save_game()without worrying about node paths or tree structure. - Animate save feedback — show a small save icon using a tween that fades in and out. Players need visual confirmation that their progress was saved.
Level up your entire Godot workflow. The Godot Pro Pack bundles signals, state machines, shaders, and 3 more courses at 50%+ off.
That's your complete Godot 4 save system — from basic JSON files to type-safe Resources, node tree persistence, auto-save timers, and encrypted saves. Every approach shown here is production-ready and copy-paste friendly.
For most games, start with the JSON approach. It's simple, debuggable, and works everywhere. Once your save data grows complex with nested objects and typed arrays, migrate to the Resource approach. The node tree pattern handles dynamic worlds, and auto-save ensures your players never lose progress.
Pick the approach that fits your project, add error handling and versioning from day one, and you'll have a save system that scales from a game jam to a full release.
Get the Free GDScript Cheat Sheet + Weekly Godot Tips
Join indie devs getting our GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines, and more — plus weekly Godot tips and architecture tricks straight to your inbox.
Ready to go deeper? Our 15-minute micro-courses cover signals, state machines, and more — practical patterns for indie devs who ship.
Mastering GDScript Signals
Signal buses, typed signals, async patterns — the definitive signals course for Godot 4.
$4.99 · 15 min →State Machines in GDScript
Clean game logic with reusable state machines. Perfect for AI, UI, and game flow.
$4.99 · 15 min →Godot Pro Pack Bundle
All courses at a discount. Signals, shaders, state machines, multiplayer, and more.
$12.99 · Save 50%+ →Keep reading
Enjoyed this? Read Godot 4 Inventory System Tutorial, Godot 4 State Machine Tutorial, and How to Use the Signal Bus Pattern in Godot 4 for more architecture patterns. Browse all courses →