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 short version: save game state to user://, never res:// (which is read-only in exported builds). Use ConfigFile for settings, and FileAccess plus JSON for full game state. Give every object its own serialize() / deserialize() so no single function has to know about everything. Stamp a version number into every save from day one so you can migrate old files instead of breaking them. Everything below builds that, in order.
Before you start: you should already be comfortable with GDScript basics and have some game state worth saving (player position, health, an inventory). This is written for Godot 4.x, and the API used here is Godot 4 (FileAccess.open, JSON.new(), ConfigFile, typed arrays), so it will not run unchanged on Godot 3, where file I/O used the older File class.
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.
Build It Yourself
Reading a save system and writing one are different skills, and the gap only closes when you type it from a blank project. Try these in order:
- Save and load one setting with
ConfigFile(master volume), then quit and relaunch to prove it persisted. - Save the player position and health to
user://savegame.json, move the player, load, and watch it snap back. - Add a
serialize()to one object (an inventory or a stats component) and call it fromsave_game()instead of reaching into its fields directly. - Break a save on purpose. Add a new field to your data, load an old save that doesn't have it, and confirm your
.get("field", default)handles it instead of crashing. That is versioning in miniature.
If step 4 crashes, you just found the exact bug that eats real players' save files after an update. Better to meet it now.
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. Saving is also where a full game comes together, which is why it lands in week 3 of the Godot 4 Action Roguelike campaign and week 4 of the 2D Top-Down RPG campaign, right before every system gets wired into one playable game.
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.


