Skip to content
Make it work

Building a Save System in Godot 4: FileAccess, JSON, and Save Slots

Build a Godot 4 SaveManager with FileAccess, JSON, save slots, and autosave. Handle invalid data and reduce corruption risk with temporary-file writes.

On this page

This tutorial builds a Godot 4.7 save system in seven steps, from writing JSON to sharing save data through a SaveManager autoload. You will add slots, auto-save, and a temporary-file write that reduces the risk of replacing a good save with an incomplete one. The explanations cover failure cases and checks to run in your exported game.

Where the file actually goes

Godot gives you two path prefixes. res:// is your project folder, and in an exported game it is normally read-only and may be packaged with the export. user:// is a writable folder that Godot creates per project inside the operating system's application-data location: %APPDATA%\Godot\app_userdata\<project name> on Windows, ~/Library/Application Support/Godot/app_userdata/<project name> on macOS, and ~/.local/share/godot/app_userdata/<project name> on Linux. Every save file in this tutorial lives under user://.

Two editor conveniences save a lot of guessing: Project > Open User Data Folder opens that directory in your file manager, and print(OS.get_user_data_dir()) prints the absolute path at runtime. If your project name contains spaces or special characters and you want a cleaner folder, set Project Settings > Application > Config > Use Custom User Dir and a custom directory name.

Step 1: FileAccess, the whole API you need

In Godot 4 the FileAccess class opens, reads, and writes files. It replaced Godot 3's File class, and the important behavioural change is that FileAccess.open() returns null when it fails instead of returning an error code, so the null check is the error handling.

gdscript
# Writing to a file
var file = FileAccess.open("user://save.txt", FileAccess.WRITE)
if file:
    file.store_string("Hello, World!")
    file.close()
else:
    print("Error: ", FileAccess.get_open_error())

# Reading from a file
if FileAccess.file_exists("user://save.txt"):
    var file = FileAccess.open("user://save.txt", FileAccess.READ)
    var content = file.get_as_text()
    print(content)
    file.close()

FileAccess.WRITE creates the file or truncates an existing one to zero bytes before writing; FileAccess.READ fails if the file is missing, which is why the read path checks file_exists() first. get_open_error() returns an Error value such as ERR_FILE_NOT_FOUND or ERR_FILE_CANT_OPEN; pass it through error_string() if you want readable output in the log. Calling close() is optional in Godot 4 because the file closes when the last reference to it is freed, but closing explicitly flushes to disk at a moment you control, which matters for the temporary-file write in step 7.

Step 2: serialize the state as JSON

JSON is the right first format because you can open the file in a text editor, read it, and hand-edit it to reproduce a bug. Godot ships a JSON class with two directions: JSON.stringify(value) turns a Dictionary or Array into text, and JSON.parse_string(text) turns text back into a Variant. Build a plain Dictionary of the values you want to keep, and nothing else.

gdscript
func save_game():
    var save_data = {
        "version": 1,
        "player": {
            "position": {"x": player.position.x, "y": player.position.y},
            "health": player.health,
            "coins": player.coins,
            "inventory": player.inventory
        },
        "level": current_level,
        "playtime": playtime_seconds,
        "save_date": Time.get_datetime_string_from_system()
    }

    var file = FileAccess.open("user://savegame.json", FileAccess.WRITE)
    file.store_string(JSON.stringify(save_data, "\t"))  # Pretty print
    file.close()
    print("Game saved!")

Three details in that block are deliberate. The position is stored as a dictionary with x and y keys because JSON.stringify does not understand Vector2; it would write the string "(120.0, 40.0)", which you cannot turn back into a vector without parsing text. The "version" field costs nothing now and lets a later build of the game recognise old files and migrate them. And the second argument to stringify, the tab character, indents the output so the file is readable while you develop; drop it before release if file size matters.

gdscript
func load_game() -> bool:
    if not FileAccess.file_exists("user://savegame.json"):
        print("No save file found")
        return false

    var file = FileAccess.open("user://savegame.json", FileAccess.READ)
    var json = JSON.new()
    var error = json.parse(file.get_as_text())
    file.close()

    if error != OK:
        print("JSON parse error: ", json.get_error_message(), " at line ", json.get_error_line())
        return false

    var data = json.data

    # Restore player state. Every read uses get() with a default so an
    # older or hand-edited file cannot crash the loader.
    var player_data: Dictionary = data.get("player", {})
    var pos: Dictionary = player_data.get("position", {"x": 0.0, "y": 0.0})
    player.position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0))
    player.health = int(player_data.get("health", 100))
    player.coins = int(player_data.get("coins", 0))
    player.inventory = player_data.get("inventory", [])
    current_level = int(data.get("level", 1))

    print("Game loaded!")
    return true

The loader uses JSON.new() plus parse() rather than the shorter JSON.parse_string() because the instance form reports why parsing failed: get_error_message() and get_error_line() point at the broken line, which matters the first time a player sends you a corrupted file. parse_string() just returns null.

Two traps hide in the restore section. First, JSON has one number type, so every number comes back as a float: a level saved as 3 loads as 3.0, so convert deliberately when assigning it to current_level as an int. The int() casts make that conversion explicit. Second, reading data.player.health directly throws Invalid get index 'health' (on base: 'Dictionary') the moment a key is missing, which happens whenever you add a field to the game after players already have save files. get(key, default) turns that crash into a sensible default.

Unity (PlayerPrefs)
PlayerPrefs.SetInt("health", 100);
PlayerPrefs.SetFloat("posX", transform.position.x);
PlayerPrefs.Save();
Godot (FileAccess)
var data = {"health": 100, "pos_x": position.x}
var file = FileAccess.open("user://save.json", FileAccess.WRITE)
file.store_string(JSON.stringify(data))

For settings, ConfigFile provides an INI-style store similar in purpose to PlayerPrefs. For game state, choose a file format and record the values you need to restore.

Step 3: a SaveManager autoload

The functions above live in one script and reach into player directly, which stops working as soon as the player is instanced in a different scene, or there are twenty things to save. The fix is a SaveManager registered as an autoload (Project Settings > Globals > Autoload), so it exists for the whole run and every script can call SaveManager.save_game(). It does not know about players or coins. It owns a Dictionary and two signals, and the nodes that own state respond to those signals.

Project layoutGodot scene guide · read-only
Scene

Select a node to inspect it.

InspectorExample settings
SaveManagerNode (autoload)
SaveManager

A node in this example’s scene. Follow the setup note for its job here.

In this example

owns game_data, emits game_saved / game_loaded

SaveManager is an autoload, not part of any level scene; Player and Level listen to its signals.

Explore the setup here. Build and run it in Godot.
gdscript
# save_manager.gd - Add as Autoload in Project Settings
extends Node

const SAVE_PATH = "user://savegame.json"
const SAVE_VERSION = 1

signal game_saved
signal game_loaded

var game_data = {
    "version": SAVE_VERSION,
    "player": {},
    "world": {},
    "settings": {}
}

func save_game() -> bool:
    game_saved.emit()  # Every subscriber writes its section into game_data now

    var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if not file:
        push_error("Could not open save file: %s" % error_string(FileAccess.get_open_error()))
        return false

    file.store_string(JSON.stringify(game_data, "\t"))
    file.close()
    return true

func load_game() -> bool:
    if not FileAccess.file_exists(SAVE_PATH):
        return false

    var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        push_error("Could not read save file: %s" % error_string(FileAccess.get_open_error()))
        return false
    var json = JSON.new()
    var error = json.parse(file.get_as_text())
    file.close()

    if error != OK:
        push_error("Failed to parse save file: %s" % json.get_error_message())
        return false

    if not is_valid_save(json.data):
        push_warning("Save has an unsupported version or invalid fields.")
        return false

    game_data = json.data
    game_loaded.emit()  # Every subscriber reads its section back
    return true

func is_valid_save(data: Variant) -> bool:
    if not data is Dictionary:
        return false
    if data.get("version", SAVE_VERSION) != SAVE_VERSION:
        return false  # Add migrations before accepting another version.
    for section in ["player", "world", "settings"]:
        if not data.get(section, {}) is Dictionary:
            return false
    var player: Dictionary = data.get("player", {})
    var pos: Variant = player.get("position", {})
    if not pos is Dictionary:
        return false
    for axis in ["x", "y"]:
        if not is_number(pos.get(axis, 0.0)):
            return false
    return is_number(player.get("health", 100)) and player.get("inventory", []) is Array

func is_number(value: Variant) -> bool:
    return (value is int or value is float) and is_finite(float(value))

func has_save() -> bool:
    return FileAccess.file_exists(SAVE_PATH)

func delete_save() -> void:
    if has_save():
        DirAccess.remove_absolute(ProjectSettings.globalize_path(SAVE_PATH))

The order inside save_game() is the whole idea: the signal fires first, synchronously, so by the time the file opens every connected node has already written its latest values into game_data. This example requires immediate callbacks that finish collecting data before returning. Deferred connections or callbacks that await work would let the write begin before their data arrives.

Parsing JSON only checks its syntax. A file containing [] is valid JSON, but it cannot supply the dictionaries this game expects. is_valid_save() checks the sections and player fields before replacing game_data or notifying listeners. Missing fields keep their defaults; fields with the wrong type reject the load. Extend these checks when you add data that another subscriber reads.

Step 4: let each node save itself

gdscript
# In your Player script
extends CharacterBody2D

var health: int = 100
var inventory: Array = []

func _ready():
    SaveManager.game_saved.connect(_on_save)
    SaveManager.game_loaded.connect(_on_load)

func _on_save():
    SaveManager.game_data.player = {
        "position": {"x": position.x, "y": position.y},
        "health": health,
        "inventory": inventory
    }

func _on_load():
    var data: Dictionary = SaveManager.game_data.get("player", {})
    var pos: Dictionary = data.get("position", {"x": position.x, "y": position.y})
    position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0))
    health = int(data.get("health", 100))
    inventory = data.get("inventory", [])

# Call from pause menu
func _on_save_button_pressed():
    SaveManager.save_game()

Each node writes and reads only its own section of the dictionary. A door writes game_data.world.doors["cellar"] = true, an inventory screen writes its list, and none of them knows the others exist. When you add a new system to the game, you add a new subscriber; the SaveManager does not change. This is the same decoupling the signals tutorial uses for damage and UI, applied to persistence.

Step 5: multiple save slots

gdscript
const SAVE_DIR = "user://saves/"
const MAX_SLOTS = 3

func get_save_path(slot: int) -> String:
    return SAVE_DIR + "slot_" + str(slot) + ".json"

func save_to_slot(slot: int) -> bool:
    if slot < 0 or slot >= MAX_SLOTS:
        return false
    game_saved.emit()  # Collect current values for slot saves too.
    # Ensure directory exists
    DirAccess.make_dir_recursive_absolute(
        ProjectSettings.globalize_path(SAVE_DIR)
    )

    var file = FileAccess.open(get_save_path(slot), FileAccess.WRITE)
    if not file:
        return false
    file.store_string(JSON.stringify(game_data, "\t"))
    file.close()
    return true

func get_all_saves() -> Array:
    var saves = []
    for i in range(MAX_SLOTS):
        var path = get_save_path(i)
        if FileAccess.file_exists(path):
            var file = FileAccess.open(path, FileAccess.READ)
            if file == null:
                continue
            var data = JSON.parse_string(file.get_as_text())
            file.close()
            if is_valid_save(data):
                saves.append({"slot": i, "data": data})
    return saves

Add these functions to the same SaveManager script. Slots are numbered 0 to 2. Like save_game(), save_to_slot() collects fresh values before writing. The directory call creates user://saves/ if needed. get_all_saves() skips unreadable or invalid files and returns each slot number with its data. This example does not record dates or playtime; add those fields if your menu needs them.

Step 6: auto-save on a timer and on quit

gdscript
var auto_save_timer: Timer

func _ready():
    auto_save_timer = Timer.new()
    auto_save_timer.wait_time = 60.0  # Save every 60 seconds
    auto_save_timer.timeout.connect(_on_auto_save)
    add_child(auto_save_timer)
    auto_save_timer.start()

    # We will quit ourselves after saving, so stop the automatic quit.
    get_tree().set_auto_accept_quit(false)

func _on_auto_save():
    save_game()
    print("Auto-saved at ", Time.get_time_string_from_system())

func _notification(what):
    if what == NOTIFICATION_WM_CLOSE_REQUEST:
        save_game()
        get_tree().quit()
    elif what == NOTIFICATION_APPLICATION_PAUSED:
        # Android and iOS send this when the app goes to the background.
        save_game()

A Timer created in code and added as a child works exactly like one placed in the editor. The two notifications cover the ways a session ends: NOTIFICATION_WM_CLOSE_REQUEST arrives when the player closes the window, and NOTIFICATION_APPLICATION_PAUSED arrives on mobile when the app is backgrounded, which on Android may be the last code that ever runs before the OS reclaims the process. The set_auto_accept_quit(false) call is essential on desktop: by default Godot quits as soon as the close request arrives, before your handler has finished, so you must disable the automatic quit and call get_tree().quit() yourself after saving.

Call save_game() at checkpoints, level completion, and other important state changes as well as on a timer. A “Saving” indicator tells players that a write is in progress, but the save system must still handle interruption.

Step 7: make the write atomic

FileAccess.WRITE truncates an existing file before writing. A crash or interrupted write can leave it incomplete, causing load_game() to fail. The example writes to a temporary file before renaming it over the save. This reduces that risk, but the API does not guarantee durability across every filesystem or power failure. Check write and rename errors, keep a recoverable backup, and test interruptions on your target platform.

gdscript
func write_atomically(path: String, text: String) -> bool:
    var tmp_path := path + ".tmp"
    var file := FileAccess.open(tmp_path, FileAccess.WRITE)
    if file == null:
        push_error("Cannot open %s: %s" % [tmp_path, error_string(FileAccess.get_open_error())])
        return false
    file.store_string(text)
    file.close()

    var err := DirAccess.rename_absolute(
        ProjectSettings.globalize_path(tmp_path),
        ProjectSettings.globalize_path(path)
    )
    if err != OK:
        push_error("Cannot replace %s: %s" % [path, error_string(err)])
        return false
    return true

# In save_game(): replace the open/store/close lines with
#     return write_atomically(SAVE_PATH, JSON.stringify(game_data, "\t"))

You can keep the previous save as savegame.json.bak and let load_game() try it when the main file is missing or invalid. If you rename the old file before replacing it, there is a period when only the backup exists. Test that recovery path too.

Versioning and migration

The version field written in step 2 is what lets a game update change its save format without wiping everyone's progress. In load_game(), read int(data.get("version", 0)) and run one migration function per version step: a version 1 file gets migrate_1_to_2(), then migrate_2_to_3(), and so on, each one adding the new keys with defaults and renaming old ones. Because every reader already uses get() with defaults, most format changes need no migration at all; the version number is for the cases where a field changed meaning.

Check that it works

  • Save, then open Project > Open User Data Folder and read savegame.json. Every value you expect must be there and readable.
  • Hand-edit a number in the file, load, and confirm the change shows up in the game. If it does not, the node that owns that value is not connected to game_loaded.
  • Delete one key from the file and load again. The game must start with the default, not crash with Invalid get index.
  • Export the project and run the exported build. Saving must still work; if it only worked in the editor, a res:// path slipped in.
  • Close the window mid-game and reopen. Progress since the last manual save must be there, which proves the close-request handler ran before quit.
  • Kill the process while a large save is being written (a debugger break in write_atomically before the rename works). The old file must still load.

Best Practices

  • Validate loaded data: use get() with defaults everywhere and cast numbers with int() where you need integers.
  • Keep a version number in every file and migrate forward one step at a time.
  • Auto-save at moments that matter (level complete, checkpoint, purchase) and show a saving indicator.
  • Write atomically: temp file, close, rename.
  • For data you do not want casually edited, FileAccess.open_encrypted_with_pass() encrypts the file; it is a deterrent, not real security, because the key ships with the game.
  • Do not save gameplay state as Resources with ResourceSaver into user://: a tampered .tres can reference scripts, and the official Saving games guide advises against loading resources from untrusted files.

Next Steps

Start with JSON and the SaveManager, add the atomic write before you ship anything, and grow into slots when your game needs them. The interactive Save & Load guide lets you switch between the approaches side by side, the autoload guide explains when a global like SaveManager is the right tool, and Godot Resources explained covers the data you should keep in res:// instead of saving at all.

Frequently asked questions

Where does Godot save files?
Files written to user:// go to a per-project folder in the OS app-data location: %APPDATA%\Godot\app_userdata\<project name> on Windows, ~/Library/Application Support/Godot/app_userdata/<project name> on macOS, and ~/.local/share/godot/app_userdata/<project name> on Linux. In the editor, Project > Open User Data Folder opens it. OS.get_user_data_dir() returns the same path at runtime.
Should I use JSON or binary for save files?
Use JSON while you are building the game: you can open the file, read it, and hand-edit it to test a state. Switch to FileAccess.store_var / get_var (binary) only if the file gets large or you want it less casual to edit. Avoid saving Resources with ResourceSaver into user:// for game saves; a tampered .tres can reference scripts, and the official docs recommend against loading it from untrusted files.
Why do my integers come back as floats after loading JSON?
JSON has one number type, so JSON.parse_string returns every number as a float: a saved level of 3 loads as 3.0. Wrap values you use as integers with int(), and use the data.get(key, default) form so a missing key falls back instead of crashing.
How do I save Vector2 or Vector3 in JSON?
JSON.stringify turns a Vector2 into a string like "(120.0, 40.0)", which is awkward to read back. Save it as a dictionary, {"x": position.x, "y": position.y}, and rebuild it on load with Vector2(data.x, data.y). The same applies to Color and Vector3.
How can I reduce the risk of corrupting a save file?
Write to a temporary file beside the save, check errors, close it, then replace the save with DirAccess.rename_absolute. Keep a backup and test interrupted writes on your target platform. This reduces corruption risk but does not guarantee recovery from every power or filesystem failure.
Verification notes

Sources and revision context

Updated September 8, 2026. Corrected fresh slot saves, malformed-data validation, and slot bounds. Clarified the seven-step sequence and temporary-file limitations. Updated the title, description, and FAQ question to remove crash-survival guarantees. Selected checks passed in Godot 4.7.2, standard macOS build, headless, on September 8, 2026. Checked save/load round trips, fresh slot data, temporary-file replacement, malformed-data rejection, unchanged live state on rejection, and slot bounds. The fixture combines the manager, slots, and temporary-write helper. Autosave callbacks, exports, interrupted writes, and power-loss recovery were not tested. These checks used the article scripts with supplied scene fixtures; they are not a full tutorial playtest.