godot

How to Save and Load a Game in Godot 4

Cover Image for How to Save and Load a Game in Godot 4
14 min read
#godot

Sooner or later every game needs to remember something: the player's level, their inventory, which doors are open, the volume slider. Search for godot save game and you mostly get videos, or documentation written for Godot 2 and 3, where File and Directory classes no longer exist in Godot 4. Written, up-to-date guides that compare the actual options are surprisingly rare.

This guide fixes that. You will learn where Godot stores save files, how the Godot 4 FileAccess API works, and four different ways to save and load data:

  1. JSON: human-readable text, great for debugging.
  2. Custom Resources: the most "Godot-native" option, almost no code.
  3. store_var() / get_var(): compact binary, supports Godot types directly.
  4. ConfigFile: the right tool for settings.

Then we put them side by side so you can choose, and finish with the parts most tutorials skip: saving many objects, versioning, safe writes and security.

1. Where Godot Saves Files: res:// vs user://

Godot has two virtual file systems, and mixing them up is the most common beginner mistake.

  • res:// is your project folder. It is read-only in an exported game. Never save here.
  • user:// is a per-user, writable folder that Godot creates for your game. Always save here.

Where user:// lives on disk depends on the platform:

PlatformDefault user:// location
Windows%APPDATA%\Godot\app_userdata\[Project Name]
macOS~/Library/Application Support/Godot/app_userdata/[Project Name]
Linux~/.local/share/godot/app_userdata/[Project Name]
WebBrowser storage (IndexedDB)
Android and iOSThe app's private data folder

To open it quickly while developing, use Project > Open User Data Folder in the editor. If you enable Use Custom User Dir in Project Settings (under Application > Config), Godot drops the Godot/app_userdata part and uses your own folder name, which looks more professional in a shipped game.

2. The FileAccess Basics (Godot FileAccess)

Godot 4 replaced Godot 3's File class with FileAccess. You no longer create an instance and call open(). You call the static FileAccess.open() and it returns an open file, or null on failure.

gdscript
const SAVE_PATH := "user://savegame.txt" func write_text(text: String) -> void: var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file == null: push_error("Could not open save file: %s" % FileAccess.get_open_error()) return file.store_string(text) func read_text() -> String: if not FileAccess.file_exists(SAVE_PATH): return "" var file := FileAccess.open(SAVE_PATH, FileAccess.READ) return file.get_as_text()

Things to know:

  • Modes: READ, WRITE (creates or truncates the file), READ_WRITE (file must exist), and WRITE_READ (creates or truncates, then allows reading).
  • Closing: the file closes automatically when the file variable goes out of scope. Call file.close() yourself if you need to reopen the same path in the same function.
  • Errors: when open() returns null, FileAccess.get_open_error() tells you why.
  • Existence: FileAccess.file_exists(path) checks before you read.

Every approach below is built on these few calls, so it is worth getting comfortable with them.

3. Deciding What to Save

Before writing a single line of save code, separate state from content. You save what the player changed, not what ships with the game.

  • Save: current level, player position, health, inventory item IDs, unlocked abilities, quest progress, settings.
  • Do not save: textures, scenes, enemy stats that come from your data files, anything that can be rebuilt from res://.

For items, save an ID like "iron_sword" and look up the full item data at load time. If you save the whole item, a balance patch that changes the sword's damage will never reach existing save files.

4. Method 1: Saving and Loading with JSON

JSON is the most popular choice for good reasons: you can open the file in any text editor, see exactly what was saved, and fix it by hand while debugging.

Save

gdscript
# save_manager.gd, added as an Autoload named "SaveManager" extends Node const SAVE_PATH := "user://savegame.json" const SAVE_VERSION := 1 func save_game(player: Player) -> void: var data := { "version": SAVE_VERSION, "level": player.level, "health": player.health, "gold": player.gold, "position": {"x": player.global_position.x, "y": player.global_position.y}, "inventory": player.inventory, # Array of item ID strings "saved_at": Time.get_datetime_string_from_system(), } var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file == null: push_error("Save failed: %s" % FileAccess.get_open_error()) return file.store_string(JSON.stringify(data, "\t"))

The "\t" argument pretty-prints the file with tabs, which makes it much easier to read. Drop it in release builds if you want a smaller file.

Load

gdscript
func load_game(player: Player) -> bool: if not FileAccess.file_exists(SAVE_PATH): return false var file := FileAccess.open(SAVE_PATH, FileAccess.READ) var data = JSON.parse_string(file.get_as_text()) if typeof(data) != TYPE_DICTIONARY: push_error("Save file is corrupted") return false player.level = int(data.get("level", 1)) player.health = int(data.get("health", 100)) player.gold = int(data.get("gold", 0)) var pos: Dictionary = data.get("position", {}) player.global_position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0)) player.inventory.assign(data.get("inventory", [])) return true

Two JSON Gotchas You Will Hit

Every number comes back as a float. JSON has one number type, so "level": 3 loads as 3.0. That is why the load code wraps values in int(). Skip it and a typed var level: int assignment, a match or an array index will misbehave.

Godot types do not survive the round trip. JSON.stringify() turns a Vector2 into the string "(12, 34)", and it does not parse back into a Vector2. Either store components yourself, as with position above, or on Godot 4.4 and newer use JSON.from_native() when saving and JSON.to_native() when loading, which encode engine types in a form that converts back.

Using .get() with a default for every key is not paranoia. It is what keeps old save files loading after you add new fields. My Godot Dictionary guide covers this pattern in depth.

5. Method 2: Custom Resources

Resources are Godot's native data containers. Define a class with @export properties, and ResourceSaver and ResourceLoader handle the rest. Vector2, Color, typed arrays, even nested resources are all saved correctly with zero conversion code.

Define the Save Data

gdscript
# save_game.gd class_name SaveGame extends Resource @export var version: int = 1 @export var level: int = 1 @export var health: int = 100 @export var gold: int = 0 @export var position: Vector2 = Vector2.ZERO @export var inventory: Array[String] = [] @export var unlocked_doors: Dictionary = {}

Save and Load

gdscript
const SAVE_PATH := "user://savegame.tres" func save_game(player: Player) -> void: var save := SaveGame.new() save.level = player.level save.health = player.health save.gold = player.gold save.position = player.global_position save.inventory = player.inventory.duplicate() var err := ResourceSaver.save(save, SAVE_PATH) if err != OK: push_error("Save failed with error %s" % err) func load_game(player: Player) -> bool: if not ResourceLoader.exists(SAVE_PATH): return false var save := ResourceLoader.load(SAVE_PATH, "", ResourceLoader.CACHE_MODE_IGNORE) as SaveGame if save == null: return false player.level = save.level player.health = save.health player.gold = save.gold player.global_position = save.position player.inventory = save.inventory.duplicate() return true

Note CACHE_MODE_IGNORE. Godot caches loaded resources by path, so without it, loading the same save twice in one session can hand you the stale copy from the first load instead of what is on disk.

Use the .tres extension for a readable text file while developing, and .res for a compact binary file.

The Security Catch

A .tres or .res file can contain embedded scripts, and loading it will run them. For save files that stay on the player's own machine, that is usually acceptable. Never load Resource files from untrusted sources, such as saves shared online, downloaded mods or cloud files from other players. For anything shareable, use JSON or store_var() instead.

6. Method 3: store_var and get_var (Binary)

store_var() writes any Godot Variant in Godot's binary format, and get_var() reads it back with its type intact. It is compact, fast, and understands Vector2, Color, Dictionary and friends natively.

gdscript
const SAVE_PATH := "user://savegame.dat" func save_game(data: Dictionary) -> void: var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file == null: push_error("Save failed: %s" % FileAccess.get_open_error()) return file.store_var(data) func load_game() -> Dictionary: if not FileAccess.file_exists(SAVE_PATH): return {} var file := FileAccess.open(SAVE_PATH, FileAccess.READ) var data = file.get_var() return data if data is Dictionary else {}
gdscript
SaveManager.save_game({ "version": 1, "position": player.global_position, # stays a real Vector2 "tint": player.modulate, # stays a real Color "inventory": player.inventory, })

Leave the optional second argument at its default of false (full_objects on store_var(), allow_objects on get_var()). Turning it on lets the file contain whole Objects, and with them code, which brings back the same security risk as Resources.

Encrypting Save Files

Any FileAccess method can work on an encrypted file. Swap open() for open_encrypted_with_pass():

gdscript
var file := FileAccess.open_encrypted_with_pass(SAVE_PATH, FileAccess.WRITE, "your-secret-key")

Be realistic about what this gives you. The key is inside your game, so a determined player can extract it. Encryption stops casual editing of gold and health, not a motivated cheater. For single-player games that is usually all you want.

7. Method 4: ConfigFile for Settings

Settings (volume, resolution, key bindings, language) are different from game progress: they are per machine, not per save slot, and players sometimes want to edit them by hand. ConfigFile writes a simple INI-style file for exactly this.

gdscript
const SETTINGS_PATH := "user://settings.cfg" func save_settings() -> void: var config := ConfigFile.new() config.set_value("audio", "master_volume", master_volume) config.set_value("audio", "music_volume", music_volume) config.set_value("video", "fullscreen", fullscreen) config.save(SETTINGS_PATH) func load_settings() -> void: var config := ConfigFile.new() if config.load(SETTINGS_PATH) != OK: return master_volume = config.get_value("audio", "master_volume", 1.0) music_volume = config.get_value("audio", "music_volume", 0.8) fullscreen = config.get_value("video", "fullscreen", false)

get_value() takes a default, so new settings added in an update just fall back cleanly.

8. JSON vs Resources vs store_var vs ConfigFile

JSONCustom Resourcestore_varConfigFile
Human readableYesYes (.tres)NoYes
Godot types (Vector2, Color)Manual, or from_native in 4.4+AutomaticAutomaticAutomatic
Numbers keep int typeNo, ints become floatsYesYesYes
Code requiredMediumVery littleLittleLittle
Safe with untrusted filesYesNoYes (default settings)Yes
Easy to read outside GodotYesNoNoMostly
Best forMost save games, debugging, cloud savesSingle-player, fast iterationCompact binary savesSettings

My recommendation:

  • Starting out or unsure? JSON. You can see what went wrong by opening the file.
  • Solo project, lots of Godot types, saves never leave the player's machine? Resources. The least code by far.
  • Want compact binary with native types and no script risk? store_var().
  • Settings? ConfigFile, always, and in a separate file from the save game.

9. Saving Every Object in a Level

A real level has many things to remember: collected coins, opened chests, defeated enemies. Rather than hard-coding each one, add the relevant nodes to a group named persist and give each a save() method.

gdscript
# chest.gd extends Node2D var is_open := false func save() -> Dictionary: return { "scene": scene_file_path, "parent": get_parent().get_path(), "x": position.x, "y": position.y, "is_open": is_open, }
gdscript
# in SaveManager func save_level() -> void: var nodes: Array = [] for node in get_tree().get_nodes_in_group("persist"): if node.has_method("save"): nodes.append(node.save()) var file := FileAccess.open("user://level.json", FileAccess.WRITE) file.store_string(JSON.stringify({"version": 1, "nodes": nodes})) func load_level() -> void: if not FileAccess.file_exists("user://level.json"): return for node in get_tree().get_nodes_in_group("persist"): node.queue_free() var file := FileAccess.open("user://level.json", FileAccess.READ) var data = JSON.parse_string(file.get_as_text()) if typeof(data) != TYPE_DICTIONARY: return for entry in data.get("nodes", []): var node: Node2D = load(entry["scene"]).instantiate() node.position = Vector2(entry["x"], entry["y"]) node.is_open = entry.get("is_open", false) get_node(entry["parent"]).add_child(node)

The loader removes existing persistent nodes and recreates them from the file, so the level matches the save exactly. Only put objects in the group that the player can actually change.

10. Save Versioning and Migration

You will add fields after launch. Plan for it on day one by storing a version number (all the examples above do), then upgrade old data step by step when loading:

gdscript
const SAVE_VERSION := 3 func migrate(data: Dictionary) -> Dictionary: var version: int = int(data.get("version", 1)) if version < 2: data["gold"] = data.get("coins", 0) # renamed in v2 data.erase("coins") if version < 3: data["unlocked_doors"] = {} # added in v3 data["version"] = SAVE_VERSION return data

Call migrate() right after parsing, before reading any field. Each if handles one upgrade, and old saves move through them in order until they are current. Players never lose progress because you renamed a variable.

11. Safe Writes: Do Not Corrupt the Save

If the game crashes or the power fails halfway through writing, the player can lose the only copy of their save. The fix is cheap: write to a temporary file, then swap it in.

gdscript
func write_safely(path: String, text: String) -> Error: var temp_path := path + ".tmp" var file := FileAccess.open(temp_path, FileAccess.WRITE) if file == null: return FileAccess.get_open_error() file.store_string(text) file.close() if FileAccess.file_exists(path): DirAccess.rename_absolute(path, path + ".bak") return DirAccess.rename_absolute(temp_path, path)

As a bonus, you keep the previous save as .bak. If loading the main file fails, try the backup before telling the player their progress is gone.

12. Save Slots

Multiple slots are just multiple file names:

gdscript
func slot_path(slot: int) -> String: return "user://save_slot_%d.json" % slot func list_slots() -> Array[int]: var slots: Array[int] = [] for i in range(1, 4): if FileAccess.file_exists(slot_path(i)): slots.append(i) return slots

Store a small summary in each file (level name, play time, saved_at) so the load menu can show details without loading the whole game.

13. Common Mistakes

MistakeSymptomFix
Saving to res://Works in the editor, fails in exportsAlways use user://
Using Godot 3's File classParse error: identifier not declaredUse FileAccess.open()
Not converting JSON numbersInts become floats, typed code breaksWrap with int() on load
Saving Vector2 straight to JSONLoads back as a StringSave components, or from_native/to_native
Loading .tres from other playersArbitrary code can runUse JSON or store_var for shared saves
Loading a Resource save twiceSecond load returns the cached copyCACHE_MODE_IGNORE
No version fieldOld saves break after an updateSave version, migrate on load
Settings mixed into save slotsVolume resets when switching slotsSeparate settings.cfg

14. Frequently Asked Questions

Where are Godot save files stored?

In the user:// folder. On Windows that is %APPDATA%\Godot\app_userdata\[Project Name] by default. Use Project > Open User Data Folder to jump there from the editor.

What happened to the File class in Godot 4?

It was replaced by FileAccess, and Directory was replaced by DirAccess. Both use static constructors such as FileAccess.open() and DirAccess.open().

Does saving work in web exports?

Yes. user:// is backed by the browser's IndexedDB. Data can be lost if the player clears site data, so offer an export or cloud option for long games.

When should I save?

Autosave at natural checkpoints (level complete, entering a new area) plus a manual save in the pause menu. Avoid saving every frame or during combat, and never save in the middle of a scene change.

Can I save a whole scene with PackedScene?

You can, with PackedScene.pack() and ResourceSaver.save(), but it saves far more than you need and ties your save format to your scene structure. Saving plain data and rebuilding the scene, as in section 9, is more robust.

Conclusion

A reliable Godot save game system comes down to a few decisions. Save to user://, use FileAccess for the file itself, and choose a format that fits: JSON for readability and safety, Resources for the least code on a single-player project, store_var() for compact binary with native types, and ConfigFile for settings. Add a version number from the start, write through a temporary file, and never load Resource files you did not create.

Get those right and your players will never lose progress, even after a crash or your tenth patch.

Follow and Support me on Medium and Patreon. Clap and Comment on Medium Posts if you find this helpful for you. Thanks for reading it!!!

Related Blogs

View All

Other Blogs

View All