I put off building a save system for months on my first project. It felt like plumbing, boring code you write after the fun stuff. That was a mistake. The longer you wait, the more systems you have to retrofit, and the file I/O is honestly the easy part in Godot 4. It's the early decisions that bite you later.
Here's what I wish someone had told me up front.
The Two Approaches to Saving Data in Godot 4
There are two fundamental ways to save data in Godot:
- ConfigFile for key-value storage. Settings, options, simple flags.
- FileAccess plus JSON or binary for full control over complex game state.
Most games need both, so don't agonize over picking one.
ConfigFile for Settings
ConfigFile is Godot's built-in INI-style storage. Sections, keys, typed values, and you write zero parsing code.
# Save settingsfunc 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 settingsfunc 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, returned when the key doesn't exist. Don't skip it. It's the thing that lets you add a new setting in update 1.2 without breaking the config file a player saved back in 1.0.
Important: Save to
user://, neverres://. Theuser://directory is writable at runtime and maps to the player's app data folder.res://is your project directory, and it's read-only in exported builds. Saving there works in the editor, then silently fails for every player.
Saving Game State with JSON
Settings are the warm-up. The real work is game state: position, health, inventory, quest progress. For that you want structured serialization, and JSON is the simplest place to start.
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 and Deserialize Pattern
Here's what actually keeps a save system maintainable: each object serializes itself. The moment you write one giant save_game() that reaches into the player, the inventory, and four different managers, you've built a function that breaks every time anything changes.
# InventoryComponent.gdfunc serialize() -> Array: var items := [] for item in inventory_items: items.append({ "id": item.id, "quantity": item.quantity, "durability": item.durability, }) return itemsfunc 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)Look at data.get("durability", 100). That one line handles saves created before durability existed. Every field you add later needs a default like this, or players who update will crash on load. I learned that one the hard way.
Save File Versioning
Your save format will change. Not might. Will. Stamp a version number into every file from day one:
const SAVE_VERSION := 2func 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)Now you can ship updates without nuking anyone's progress. Version 1 saves get migrated up, version 2 loads directly. Twenty minutes of work that spares you the worst kind of negative review.
Multiple Save Slots
Nothing clever here, just a naming convention and a loop:
func get_save_path(slot: int) -> String: return "user://save_slot_%d.json" % slotfunc 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 savesCommon Save System Mistakes
The first one everybody hits: shoving a Vector3 straight into JSON. JSON has no idea what a Godot type is. Break it into plain numbers:
# Wrong - won't serializedata["position"] = player.position# Right - breaks into primitivesdata["position"] = { "x": player.position.x, "y": player.position.y, "z": player.position.z,}A few more from real projects. Check FileAccess.file_exists() before loading, because first-time players have no save file. Save on events (new area, quest complete, manual save), not every frame. An autosave timer every 5 minutes is fine.
And assume save files will get corrupted eventually. A crash mid-write, a full disk, a cloud sync fight. Wrap your parse logic in error checks and keep a backup of the previous save. Players forgive a lost five minutes. They don't forgive a lost 40 hours.
Going Deeper
This covers the foundation, and honestly it's enough for most small games. A production system adds layers on top: encrypted saves, cloud sync, save file thumbnails, and full world-state serialization where every opened chest stays opened.
Most of what you'll serialize is inventory data. If you haven't built that part yet, my Godot 4 inventory system tutorial uses the same serialize pattern, so the two snap together.
And if you want to build the whole thing properly, step by step, the Save and Load quest goes from these basics to a polished system with multiple slots, migration, and error recovery across 14 lessons.
Whatever you do, don't bolt saving on the week before release. Past me would like a word.
FAQ
Where does Godot save user files?
In your OS app data folder, under user://. On Windows that's %APPDATA%\Godot\app_userdata\YourProjectName, with equivalents under ~/.local/share on Linux and ~/Library/Application Support on macOS. Quickest way to find it: open the Project menu in the editor and hit "Open User Data Folder."
Should I use JSON or Resources for save files?
JSON, for most games. Saving custom Resources with ResourceSaver is convenient, but a Resource file can carry embedded scripts, so loading a save that a player (or a cheater) edited is a real security hole. JSON is plain text, easy to inspect, and can't execute anything.
How often should my game save?
On meaningful events: area transitions, quest completions, manual saves. An autosave timer is fine too. Writing to disk every frame is wasted work and a good way to cause stutter, so don't.


