Core Systems quest

Save & Load Guide. Separate durable save data from live nodes, scene paths, and temporary runtime state.

10-20 min · Godot 4.7
Start here

Which save approach should I use?

The right save system depends on how much state you need to restore. Start with the smallest approach that can survive a real export and a failed load.

Unity comparison

Use FileAccess plus JSON for what Unity projects often solve with PlayerPrefs, JsonUtility, or custom file IO. Godot does not force a SaveGame class; you own the format.

Settings only

Use
ConfigFile or small JSON
Best for
Volume, fullscreen, language, accessibility options, input preferences.
Watch out
Do not mix settings with game progress unless the game is very small.

Simple progress

Use
One JSON file in user://
Best for
Level reached, coins, player position, unlocked items, flags.
Watch out
Do not save raw node references. Save IDs, names, paths, or data values.

Medium game state

Use
SaveManager autoload plus per-node save data
Best for
Many saveable objects, checkpoints, opened chests, killed enemies, quest state.
Watch out
Do not let the manager know every detail of every node. Let nodes provide their data.

Multiple profiles or slots

Use
A saves directory with slot metadata
Best for
Save slots, autosaves, continue menu previews, player profiles.
Watch out
Do not overwrite the only save without a backup or validation path.
Default recommendation

For most solo 2D and small 3D projects, use one JSON save file first. Add slots, autosaves, and encryption only after the basic load path is reliable.

File basics

Use FileAccess for all save file reads and writes

FileAccess.open() returns a file object or null. Check it, write to user://, and close the file when you are done.

Write a small file safely

write_save_text.gd
const SAVE_PATH := "user://save.txt"
 
func write_save_text(text: String) -> bool:
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_error("Could not open save file. Error: %s" % FileAccess.get_open_error())
        return false
 
    file.store_string(text)
    file.close()
    return true

Read only after checking existence

read_save_text.gd
const SAVE_PATH := "user://save.txt"
 
func read_save_text() -> String:
    if not FileAccess.file_exists(SAVE_PATH):
        return ""
 
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        push_error("Could not read save file. Error: %s" % FileAccess.get_open_error())
        return ""
 
    var text := file.get_as_text()
    file.close()
    return text

Text and JSON methods

  • store_string() writes full text or JSON.
  • get_as_text() reads a full text file.
  • store_line() is useful for logs or line-based formats.

Binary and Variant methods

  • store_var() can write Variants quickly.
  • get_var() reads matching Variant data back.
  • Use JSON first when you want debuggable save files.
Godot 4 change

Godot 3 used File.new(). Godot 4 uses static FileAccess.open(). Old snippets from tutorials need this update.

Readable saves

Use JSON for debuggable save data

JSON is a good default because you can open the file, inspect the shape, and recover from bad data. Convert engine types into plain values before writing.

Save a versioned JSON dictionary

save_game.gd
const SAVE_PATH := "user://savegame.json"
const SAVE_VERSION := 1
 
func vector2_to_dict(value: Vector2) -> Dictionary:
    return {
        "x": value.x,
        "y": value.y,
    }
 
func save_game(player: Node2D) -> bool:
    var data := {
        "save_version": SAVE_VERSION,
        "saved_at": Time.get_datetime_string_from_system(),
        "player": {
            "position": vector2_to_dict(player.position),
            "health": player.health,
            "coins": player.coins,
            "inventory": player.inventory,
        },
        "world": {
            "current_level": "forest_01",
            "opened_chests": ["chest_01", "chest_07"],
        },
    }
 
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_error("Save failed. Error: %s" % FileAccess.get_open_error())
        return false
 
    file.store_string(JSON.stringify(data, "\t"))
    file.close()
    return true

Load with validation and defaults

load_game.gd
const SAVE_PATH := "user://savegame.json"
 
func dict_to_vector2(data: Dictionary, fallback := Vector2.ZERO) -> Vector2:
    if not data.has("x") or not data.has("y"):
        return fallback
    return Vector2(float(data.x), float(data.y))
 
func load_game(player: Node2D) -> bool:
    if not FileAccess.file_exists(SAVE_PATH):
        return false
 
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        push_error("Load failed. Error: %s" % FileAccess.get_open_error())
        return false
 
    var json_text := file.get_as_text()
    file.close()
 
    var json := JSON.new()
    var parse_error := json.parse(json_text)
    if parse_error != OK:
        push_error("Save JSON parse error: %s" % json.get_error_message())
        return false
 
    var data: Dictionary = json.data
    var player_data: Dictionary = data.get("player", {})
 
    player.position = dict_to_vector2(player_data.get("position", {}))
    player.health = int(player_data.get("health", 100))
    player.coins = int(player_data.get("coins", 0))
    player.inventory = player_data.get("inventory", [])
    return true

Example output

savegame.json
{
    "save_version": 1,
    "saved_at": "2026-01-23T10:30:00",
    "player": {
        "position": { "x": 150.5, "y": 200.0 },
        "health": 85,
        "coins": 42,
        "inventory": ["sword", "potion", "key"]
    },
    "world": {
        "current_level": "forest_01",
        "opened_chests": ["chest_01", "chest_07"]
    }
}
Serialization rule

Save plain data, not live objects. Convert Vector2, Vector3, Color, resources, and node references into dictionaries, IDs, file paths, or names you can validate.

Reusable architecture

Create a SaveManager autoload

A SaveManager is useful once multiple scenes need to request saves, loads, or slot metadata. Keep it focused on file orchestration and data validation.

1

Create save_manager.gd

Put file paths, versioning, and signals in one place.

2

Register it as an Autoload

Go to Project > Project Settings > Autoload and name it SaveManager.

3

Let nodes contribute their own data

Use signals or groups so the manager does not become a giant object-specific script.

Compact SaveManager pattern

save_manager.gd
extends Node
 
const SAVE_PATH := "user://savegame.json"
const SAVE_VERSION := 1
 
signal save_requested(data: Dictionary)
signal load_finished(data: Dictionary)
 
func save_game() -> bool:
    var data := {
        "save_version": SAVE_VERSION,
        "saved_at": Time.get_datetime_string_from_system(),
        "nodes": {},
    }
 
    save_requested.emit(data)
    return _write_json(data)
 
func load_game() -> bool:
    var data := _read_json()
    if data.is_empty():
        return false
 
    data = _migrate_if_needed(data)
    load_finished.emit(data)
    return true
 
func _write_json(data: Dictionary) -> bool:
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_error("Could not write save file.")
        return false
 
    file.store_string(JSON.stringify(data, "\t"))
    file.close()
    return true
 
func _read_json() -> Dictionary:
    if not FileAccess.file_exists(SAVE_PATH):
        return {}
 
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        return {}
 
    var json := JSON.new()
    var error := json.parse(file.get_as_text())
    file.close()
 
    if error != OK or typeof(json.data) != TYPE_DICTIONARY:
        return {}
 
    return json.data
 
func _migrate_if_needed(data: Dictionary) -> Dictionary:
    var version := int(data.get("save_version", 1))
    if version < SAVE_VERSION:
        data["save_version"] = SAVE_VERSION
    return data

Node registration example

saveable_player.gd
extends CharacterBody2D
 
@export var save_id := "player"
var health := 100
var inventory: Array[String] = []
 
func _ready() -> void:
    SaveManager.save_requested.connect(_on_save_requested)
    SaveManager.load_finished.connect(_on_load_finished)
 
func _on_save_requested(data: Dictionary) -> void:
    data.nodes[save_id] = {
        "position": { "x": position.x, "y": position.y },
        "health": health,
        "inventory": inventory,
    }
 
func _on_load_finished(data: Dictionary) -> void:
    var saved := data.get("nodes", {}).get(save_id, {})
    if saved.is_empty():
        return
 
    var saved_position: Dictionary = saved.get("position", {})
    position = Vector2(saved_position.get("x", position.x), saved_position.get("y", position.y))
    health = int(saved.get("health", health))
    inventory = saved.get("inventory", inventory)
Before release

Make saves harder to corrupt

Production save systems are less about fancy formats and more about predictable recovery: versioning, backups, validation, and safe writes.

Multiple slots with metadata

save_slots.gd
const SAVE_DIR := "user://saves/"
const MAX_SLOTS := 3
 
func get_save_path(slot: int) -> String:
    return SAVE_DIR + "slot_%s.json" % slot
 
func save_to_slot(slot: int, data: Dictionary) -> bool:
    if slot < 0 or slot >= MAX_SLOTS:
        return false
 
    DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(SAVE_DIR))
    data["slot"] = slot
    data["saved_at"] = Time.get_datetime_string_from_system()
 
    var file := FileAccess.open(get_save_path(slot), FileAccess.WRITE)
    if file == null:
        return false
 
    file.store_string(JSON.stringify(data, "\t"))
    file.close()
    return true
 
func get_save_slots() -> Array[Dictionary]:
    var slots: Array[Dictionary] = []
    for slot in range(MAX_SLOTS):
        var path := get_save_path(slot)
        if not FileAccess.file_exists(path):
            continue
 
        var file := FileAccess.open(path, FileAccess.READ)
        var parsed = JSON.parse_string(file.get_as_text())
        file.close()
 
        if typeof(parsed) == TYPE_DICTIONARY:
            slots.append(parsed)
 
    return slots

Version migration pattern

save_migration.gd
const SAVE_VERSION := 3
 
func migrate_save(data: Dictionary) -> Dictionary:
    var version := int(data.get("save_version", 1))
 
    if version < 2:
        data = migrate_v1_to_v2(data)
        version = 2
 
    if version < 3:
        data = migrate_v2_to_v3(data)
        version = 3
 
    data["save_version"] = SAVE_VERSION
    return data
 
func migrate_v1_to_v2(data: Dictionary) -> Dictionary:
    var player: Dictionary = data.get("player", {})
    if player.has("hp"):
        player["health"] = player["hp"]
        player.erase("hp")
    data["player"] = player
    return data
 
func migrate_v2_to_v3(data: Dictionary) -> Dictionary:
    if not data.has("world"):
        data["world"] = {}
    data.world["opened_chests"] = data.world.get("opened_chests", [])
    return data

Production checklist

  • Save to user://, never res://.
  • Check FileAccess.open() before writing.
  • Validate JSON parse errors before using data.
  • Use a save_version number and migrate old saves.
  • Keep a backup before overwriting important progress.
  • Use stable IDs for world objects instead of node instance IDs.
  • Treat encrypted local saves as tamper-resistant, not secure.
Security note

Local encryption can discourage casual editing, but it is not trustworthy for competitive or online games. Validate important state on a server when stakes matter.

Tutor Checkpoint

Lock the pattern in

Before jumping to the next page, turn the idea into one tiny scene or script. That is where the Godot habit sticks.

Unity habit

PlayerPrefs is not a full save architecture.

Unreal habit

SaveGame-style data still needs stable IDs and migration habits.

Godot habit

Use user://, plain dictionaries or Resources, and validate loaded data.

Try this

Add a save_version field before the save file grows.