Saving game progress is a requirement for almost every game, and a broken save system is one of the fastest ways to lose players. Godot 4’s built-in FileAccess and JSON classes give you everything you need to build a robust, cross-platform save system with no plugins and no third-party libraries.
Table of Contents
This tutorial walks you through building a complete SaveManager autoload in GDScript that serializes game state to JSON on disk and reads it back cleanly. You will also learn how to add multiple save slots, protect against save-file corruption, convert Godot-native types that JSON cannot handle, migrate old saves when your data structure changes, and know when JSON is the right tool compared to Godot’s other save methods. All patterns here work across Windows, macOS, Linux, Android, iOS, and Web exports in Godot 4.4 through 4.6.x.
Quick Answer
To save in Godot 4: put your game state in a Dictionary, call JSON.stringify(data) to convert it to a string, open a file with FileAccess.open(“user://save_game.json”, FileAccess.WRITE), then call file.store_string(json_text). To load: confirm the file exists with FileAccess.file_exists(path), open it with FileAccess.READ, read with file.get_as_text(), parse with JSON.parse_string(text), and always null-check the result before accessing any keys.
Setting Up a SaveManager Autoload
Create a new GDScript file called save_manager.gd and register it as an Autoload singleton under Project > Project Settings > Autoload. Name it SaveManager. This makes your save and load functions available from any node via SaveManager.save_game() and SaveManager.load_game() without passing references through the scene tree.
At the top of the script declare two constants: gdscript extends Node const SAVE_PATH = “user://save_game.json” const SAVE_VERSION = 1 The user:// prefix resolves to a writable, OS-specific directory on every platform and stays writable in exported builds — unlike res://, which becomes read-only after export. The SAVE_VERSION constant lets you detect and migrate old saves when your data structure changes in a future update. During development, find the exact on-disk path by going to Project > Open User Data Folder in the Godot editor; this is also where you delete a save file to test a fresh start.
Where Are Godot 4 Save Files Stored on Disk?
The user:// path resolves to a different location on each platform. On Windows it maps to %APPDATA%\Godot\app_userdata\[ProjectName]\. On macOS the path is ~/Library/Application Support/Godot/app_userdata/[ProjectName]/. On Linux it is ~/.local/share/godot/app_userdata/[ProjectName]/. On Android and iOS the directory lives inside the app’s sandboxed container and is not directly browseable from outside the app. On Web (HTML5) exports, Godot maps user:// to the browser’s IndexedDB, which persists across sessions but is scoped to that browser on that device.
Call OS.get_user_data_dir() at runtime to log the exact resolved path on any platform. Note that the path includes the project name as set in Project Settings > Application > Config > Name, so changing the project name mid-development will orphan existing save files.
Writing the Save Function
The save function collects your game state into a Dictionary and writes it as a JSON string. Pass a tab character as the second argument to JSON.stringify() during development so the saved file is human-readable: gdscript func save_game(player_health: int, level: int, gold: int) -> void: var data = { “version”: SAVE_VERSION, “player_health”: player_health, “level”: level, “gold”: gold } var json_text = JSON.stringify(data, “\t”) var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file: file.store_string(json_text) Godot 4 automatically closes the FileAccess object when it goes out of scope, so you do not need to call file.close() manually. The “if file:” guard is essential — FileAccess.open() returns null if the path is invalid or permissions are denied, and calling a method on null crashes the game. Call SaveManager.save_game() at natural checkpoints such as level transitions, item pickups, and the pause menu, or attach a Timer node set to fire every few minutes for auto-save.
To deter casual save-file editing, replace FileAccess.open() with the encrypted variant. Godot writes the contents as AES-encrypted binary that looks like gibberish in a text editor: gdscript var file = FileAccess.open_encrypted_with_pass(SAVE_PATH, FileAccess.WRITE, “your_secret_key”) if file: file.store_string(json_text) Use the same password string when loading. Keep in mind the key is compiled into your binary, so this resists casual hex-editing rather than defeating a determined reverse engineer. For competitive games, enforce integrity server-side.
Writing the Load Function
The load function must handle two failure cases gracefully: the file does not exist yet on first launch, and the file exists but contains invalid JSON due to corruption or a leftover file from an older build. gdscript func get_default_save() -> Dictionary: return {“version”: SAVE_VERSION, “player_health”: 100, “level”: 1, “gold”: 0} func load_game() -> Dictionary: if not FileAccess.file_exists(SAVE_PATH): return get_default_save() var file = FileAccess.open(SAVE_PATH, FileAccess.READ) if not file: return get_default_save() var text = file.get_as_text() var data = JSON.parse_string(text) if data == null: return get_default_save() if data.get(“version”, 0) < SAVE_VERSION: data = _migrate(data) return data
JSON.parse_string() is the cleanest approach — it returns the parsed value directly or null on failure. During development, if you want detailed error messages including line numbers, use JSON.new().parse() instead: gdscript var json = JSON.new() if json.parse(text) != OK: push_error(“Save parse error at line %d: %s” % [json.get_error_line(), json.get_error_message()]) return get_default_save() var data = json.data In your game scenes, call var save_data = SaveManager.load_game() in _ready() and apply the values. Use save_data.get(“key”, default_value) rather than direct bracket access for any key that might be absent in older save files.
Handling Godot Types JSON Cannot Store
JSON only supports strings, numbers, booleans, arrays, and objects. Godot-native types — Vector2, Vector3, Color, Rect2, Transform2D — have no JSON equivalent and must be converted manually before saving and rebuilt on load: gdscript # Vector2 position “pos”: {“x”: position.x, “y”: position.y} # rebuild: Vector2(data[“pos”][“x”], data[“pos”][“y”]) # Color “tint”: color.to_html() # rebuild: Color.html(data[“tint”]) # Vector3 “velocity”: {“x”: v.x, “y”: v.y, “z”: v.z} # rebuild: Vector3(data[“velocity”][“x”], data[“velocity”][“y”], data[“velocity”][“z”])
One important subtlety: JSON has no separate integer type — every number becomes a float when parsed. Writing “level”: 5 and reading it back gives 5.0. Use int(data[“level”]) or data[“level”] as int when you need a strict integer, or explicitly type your variables so Godot coerces the value automatically.
If you find yourself converting many complex Godot types, consider switching to binary serialization instead: FileAccess.store_var(data) saves any Variant — including Vector2, Color, and nested types — as compact binary with no conversion code, and file.get_var() reads it back. The file cannot be read outside Godot and is not human-readable, but it is faster to write, smaller on disk, and far less boilerplate.
Implementing Multiple Save Slots
Multiple save slots let players keep separate runs or share a game with other household members. The cleanest pattern is to parameterize the file path with a slot index: gdscript const MAX_SLOTS = 3 func get_slot_path(slot: int) -> String: return “user://save_slot_%d.json” % slot func save_game(data: Dictionary, slot: int = 0) -> void: data[“version”] = SAVE_VERSION var file = FileAccess.open(get_slot_path(slot), FileAccess.WRITE) if file: file.store_string(JSON.stringify(data, “\t”)) func load_game(slot: int = 0) -> Dictionary: var path = get_slot_path(slot) if not FileAccess.file_exists(path): return get_default_save() var file = FileAccess.open(path, FileAccess.READ) if not file: return get_default_save() var data = JSON.parse_string(file.get_as_text()) return data if data != null else get_default_save()
To populate a save-slot selection screen, enumerate which slots are occupied: gdscript func get_slot_info() -> Array: var slots = [] for i in range(MAX_SLOTS): slots.append({ “slot”: i, “exists”: FileAccess.file_exists(get_slot_path(i)) }) return slots If you want to show the player’s level or a timestamp on the slot card, store those values at the top level of the save dictionary so you can display them without deserializing the full save. To delete a slot, call DirAccess.open(“user://”).remove(“save_slot_%d.json” % slot).
Save Versioning and Migration
Whenever you add a new field in a game update, old save files won’t have that key. The SAVE_VERSION constant and a migration function prevent crashes when returning players load older files: gdscript func _migrate(data: Dictionary) -> Dictionary: var from_version = data.get(“version”, 0) if from_version < 1: # v0 to v1: add stamina field introduced in update 1 data["stamina"] = 100 if from_version < 2: # v1 to v2: rename "gold" to "coins" data["coins"] = data.get("gold", 0) data.erase("gold") data["version"] = SAVE_VERSION return data Chain if blocks for each version step so a save from v0 runs through every migration in order rather than jumping to the latest. Use save_data.get("key", default) throughout the rest of your code as a second safety net for any key that might be absent in an old or partially migrated file.
Crash-Safe Writing
If the game or OS crashes mid-write, the save file can end up partially written and unparsable. A simple guard is to write to a .tmp file first, verify it contains valid JSON, and then rename it over the real save file: gdscript func save_game_safe(data: Dictionary) -> void: var tmp_path = SAVE_PATH + “.tmp” var json_text = JSON.stringify(data) var file = FileAccess.open(tmp_path, FileAccess.WRITE) if not file: return file.store_string(json_text) file = null # close before verifying var check = FileAccess.open(tmp_path, FileAccess.READ) if check == null or JSON.parse_string(check.get_as_text()) == null: return # temp file is bad, leave existing save intact var dir = DirAccess.open(“user://”) if dir: dir.rename(“save_game.json.tmp”, “save_game.json”) This ensures a failed write never overwrites a good save with garbage. DirAccess.rename() overwrites the destination if it already exists. Note this protects against in-process crashes; it does not guarantee atomicity against sudden OS-level power loss, but it eliminates the most common corruption scenario in practice.
JSON vs Other Godot 4 Save Methods
JSON is not the only option, and choosing the right method saves significant refactoring later. Here is when to reach for each approach:
JSON with FileAccess is the best choice when the save data needs to be readable by external tools or a web backend, when you want human-readable files you can inspect in any text editor, or when your studio shares save data across platforms. The cost is manual conversion of every Godot-specific type.
FileAccess.store_var(data) and file.get_var() write any Godot Variant — including Vector2, Color, and nested Dictionaries — as compact binary with no conversion code at all. Fast and concise; both methods default to false for the object-serialization flag, which keeps arbitrary object instantiation disabled — the correct choice for save data that contains no Object instances. The downside is the file cannot be read outside Godot.
Custom Resources (extending Resource, annotating properties with @export, using ResourceSaver.save() and ResourceLoader.load()) give you full type safety and editor integration with zero manual serialization. The significant risk: a .tres file can embed scripts, so loading a player-modified resource is a code-execution vulnerability. Use CACHE_MODE_IGNORE and validate the file source if players can supply save files.
ConfigFile is an INI-style format designed for user settings such as volume and keybinds. It has a built-in save_encrypted_pass() method but is not suited to complex, nested game state. For most indie games with straightforward save data, JSON is the pragmatic choice. Reach for Custom Resources when your save data maps naturally to typed Godot objects and you want editor visibility into the data.
Tips and Common Mistakes
Do not save on every frame. Call the save function at natural events — level complete, item acquired, menu close — or use a Timer for auto-save every two to five minutes. Calling FileAccess.open() every frame will tank performance and wear out storage on consoles and mobile devices.
Always null-check FileAccess.open(). On some platforms or with an invalid path the function returns null. Calling a method on null immediately crashes the game with no useful error message in a release build.
Cast integers after loading. JSON has no integer type, so data[“level”] returns 5.0 after a round-trip. Use int(data[“level”]) or declare the receiving variable as int so Godot coerces the value automatically.
Never store res:// paths in save files. Resource paths become invalid in exported builds. Store item IDs, enum values, or string keys and look them up in your data tables at runtime.
Test a fresh start regularly. Delete the save file via Project > Open User Data Folder in the editor, or add a debug flag that calls DirAccess.open(“user://”).remove(“save_game.json”) at startup. Running with a stale save masks bugs in your default-state logic and in your migration code.
godot-4-json-save-system FAQs
Where are Godot 4 save files stored on disk?
The user:// path maps to a platform-specific writable directory. Windows: %APPDATA%\Godot\app_userdata\[ProjectName]\. macOS: ~/Library/Application Support/Godot/app_userdata/[ProjectName]/. Linux: ~/.local/share/godot/app_userdata/[ProjectName]/. Android and iOS: the app’s private sandboxed storage, not directly browseable from outside the app. Web exports: the browser’s IndexedDB, which persists across sessions but only within that browser on that device. Call OS.get_user_data_dir() at runtime to log the exact path. In the Godot editor, open the folder instantly via Project > Open User Data Folder.
Should I use JSON or a .tres Resource file for save data in Godot 4?
JSON is simpler to start with and is readable in any text editor — ideal for inspecting saves during development or exchanging data with a web backend. Custom Resources (.tres/.res) eliminate all manual type conversion because Godot serializes @export properties automatically, giving you full type safety. The trade-off: a .tres file can embed scripts, so loading a player-modified resource is a code-execution risk unless you use CACHE_MODE_IGNORE and validate the source. For most indie games, JSON is the safer and more portable default.
How do I implement multiple save slots in Godot 4?
Use an indexed file path: “user://save_slot_%d.json” % slot_index. Pass the slot integer to your save and load functions, and check FileAccess.file_exists() in a loop over your slot range to know which slots are occupied. To delete a slot call DirAccess.open(“user://”).remove(“save_slot_%d.json” % slot). Store summary data such as player level in the top level of each slot’s save file so you can populate a slot-selection screen without loading the full save.
Can I encrypt my Godot 4 JSON save file?
Yes. Replace FileAccess.open(path, mode) with FileAccess.open_encrypted_with_pass(path, mode, password_string). Godot encrypts the contents with AES; use the same password string on load. The password is compiled into your binary, so this prevents casual text-editor edits but is not a substitute for server-side validation in competitive or online games.
How do I handle a corrupted or missing save file without crashing?
JSON.parse_string() returns null on invalid JSON — always null-check the result and fall back to get_default_save(), a function that returns a Dictionary with the same keys as a brand-new game. For extra resilience, write to a .tmp file first, verify it parses correctly, then rename it over the real save file so a mid-write crash never corrupts the existing save.
Does the JSON save system work on Android, iOS, and web exports?
Yes. FileAccess and JSON are part of Godot’s core and work on every export target. On Android and iOS, user:// points to the app’s private sandboxed directory — files are persistent but not accessible from outside the app. On Web exports, Godot maps user:// to the browser’s IndexedDB, so saves persist across page reloads within the same browser on the same device. Call OS.get_user_data_dir() to confirm the resolved path at runtime on any platform.
Get More from godot-4-json-save-system
Log the coasters, stadiums, and venues you’ve experienced, rate godot-4-json-save-system, and see what your friends thought. Get the ThrillZing app.