esc
navigate openSearch by Pagefind

Save systems

Authoritative vs derived state, the persist-group collector, the five-format tradeoff, atomic autosave.

The unifying reference for persistence. Four other docs cover a slice of saving from their genre’s angle. This one owns the shared architecture and the format decisions. Verify exact APIs against the live engine (engine class-info --class FileAccess) before writing: the serializer flags below came from that check, but signatures evolve.

Where to jump instead of reading here:

  • gdscript-architecture.md covers the big game with bulk mutable state: the two-tier save (small checksummed Resource header + JSON-diff-per-checkpoint), for when bulk state dwarfs the delta.
  • topdown-2d.md is the component game, where saveables carry a SaveDataComponent holding a polymorphic Resource; adding a saveable type = a new Resource subclass, no central switch.
  • narrative-game-patterns.md is the story game, whose save wraps an opaque story snapshot ({version, slot_id, snapshot_json, ...}) with a .bak fallback and never introspects it.
  • menus-settings.md handles settings (audio, video, keybinds), which persist to user://settings.cfg via ConfigFile. Keep them out of the save game; the two have different lifetimes.

What to save: authoritative state, not derived state#

Save the inputs to your simulation, not its outputs. Player position, inventory, quest flags, RNG seed, current scene key are authoritative. Health bars, pathfinding results, particle state are derived and recomputed on load. Saving derived state bloats the file and rots the moment the derivation changes.

  • The “everything in one dict” trap. Dumping get_tree() into one nested Dictionary feels fast and rots fast: private fields leak into the format, a refactor breaks old saves, and you can’t reason about what a slot holds. Save a deliberate per-type projection of each entity.
  • Granularity. Persist at the coarsest unit that still reloads correctly. A puzzle game saves “level N, moves so far”; an open-world game saves per-region deltas. Finer is wasted bytes and migration surface; coarser loses progress players expect kept.

The collector pattern (the flagship)#

Each saveable node joins a "persist" group and exposes save_state() -> Dictionary / load_state(d). A SaveManager autoload sweeps the group, keys each record by a stable identity, and never knows an entity’s internals, because each owns its own shape.

# on each saveable node (player, chest, door, enemy)
func _ready() -> void:
add_to_group("persist")
func save_state() -> Dictionary:
return {"pos": [position.x, position.y], "hp": health} # Vector2 flattened for JSON
func load_state(d: Dictionary) -> void:
var p: Array = d.get("pos", [0.0, 0.0])
position = Vector2(p[0], p[1])
health = d.get("hp", max_health) # tolerate a missing key
# SaveManager autoload: keys each record by scene_file_path + node path
func collect() -> Dictionary:
var nodes := {}
for node in get_tree().get_nodes_in_group("persist"):
if not node.has_method("save_state"):
continue
var path := str(node.get_path())
nodes["%s::%s" % [node.scene_file_path, path]] = {
"scene": node.scene_file_path, # to re-instance if it was spawned
"path": path,
"parent": str(node.get_parent().get_path()),
"state": node.save_state(),
}
return {"version": VERSION, "nodes": nodes}

Be honest about the respawned-node identity problem. Keying by scene_file_path + node path is stable only for nodes authored into the scene. The moment something is queue_free()d (an enemy dies) or instantiate()d at runtime (a dropped item, a spawned pickup), that identity breaks: a freed node has no state to patch, a runtime-spawned one has no authored path to match. A “patch each node’s state” loop silently loses both. Store spawn records, not just state patches: the collector keeps each record’s scene + parent, so restore re-instantiates what isn’t already in the tree. Record deaths too (a “dead” set, or free-on-load anything the save omits), or they resurrect.

func restore(data: Dictionary) -> void:
var tree := get_tree()
for key in data.get("nodes", {}):
var rec: Dictionary = data["nodes"][key]
var node := tree.root.get_node_or_null(NodePath(rec["path"]))
if node == null: # was spawned at runtime, so re-create it
var parent := tree.root.get_node_or_null(NodePath(rec["parent"]))
if parent == null or rec.get("scene", "") == "":
continue
node = (load(rec["scene"]) as PackedScene).instantiate()
parent.add_child(node)
node.load_state(rec["state"])

Build: script create + project add-autoload --name SaveManager --path res://systems/save_manager.gd; each saveable gets add_to_group("persist") + the two methods. save_to_slot/load_from_slot wrap collect/restore over the format and slot helpers below.

Format tradeoffs (pick per need, honestly)#

FormatTypes preservedHuman-readableNotes
ConfigFileVariants (typed)yes (INI)Settings only (see menus-settings.md). Not save games.
JSONno (numbers/strings/arrays/dicts)yes, diffablePortable, tool-friendly. Vectors/floats need care (below).
var_to_str/str_to_varfull Variant incl. Vector2/ColoryesText and type-faithful. Godot-only reader.
FileAccess.store_var/get_varfull Variantno (binary)Fast, compact, opaque.
Resource + ResourceSaver (.tres)typed, @exported.tres yes / .res noEditor-inspectable. Security caveat below.
  • JSON is the portable default but loses types. JSON.parse_string returns every number as a float and has no Vector2/Color, so a naive Vector2(10, 20) errors on read. Two fixes: (a) serialize helpers that flatten to arrays out and rebuild in ([v.x, v.y] / Vector2(a[0], a[1])), the pattern the collector uses; (b) JSON.from_native(v, false) / JSON.to_native(j, false) tag engine types into a JSON-safe structure and back.
  • var_to_str is the underrated middle. It writes Vector2(10, 20) as literal text and str_to_var reads it back with the Vector intact. That is full Variant fidelity, still diffable and hand-editable, with no serialize helpers. Cost: only Godot reads it.
  • Binary store_var/get_var is fastest and smallest, opaque on disk. Pass store_var(data, false) / get_var(false), where the false keeps arbitrary object instantiation out of the reader (same risk as below).
  • Resource + ResourceSaver is inspectable but unsafe for untrusted saves. A @exported SaveData extends Resource saved via ResourceSaver.save(data, "user://save.tres") is typed and inspector-editable, which is great for authored data. But loading a .tres/.res instantiates whatever types and scripts the file declares, so a tampered save can run code on load. FileAccess.get_var and JSON.to_native gate this behind an allow_objects flag; ResourceLoader.load has none. Standing community guidance: never load() a save a player could swap. Use JSON or store_var(..., false) there, and reserve Resource saves for shipped content and local headers. (open_encrypted_with_pass only obfuscates: the key ships in your binary.)

Encrypting a save vs validating one#

They solve different problems, and encryption alone is often sold as both.

Encryption stops casual editing. FileAccess.open_encrypted takes a binary key, open_encrypted_with_pass a passphrase, and ConfigFile mirrors both (save_encrypted/load_encrypted plus the _pass variants, all confirmed on the live 4.7 build). The same honesty as pck encryption applies: the key ships in the binary, so this deters a player with a text editor, not one with a debugger. Worth it when a visible plaintext save invites fiddling; decorative against anything determined.

Validation detects tampering, including by someone who found the key. Store an HMAC beside the payload and compare on load: Crypto.new().hmac_digest(HashingContext.HASH_SHA256, key, payload) (live-verified signature: hmac_digest(hash_type, key, msg) -> PackedByteArray). A mismatch means the file changed outside the game, so treat it as corrupt and fall into the backup-fallback path rather than crashing. The same caveat about shipped keys applies to the HMAC key; for a single-player game that is fine, because the goal is catching accidents and casual edits, not defeating an adversary. Anything that must survive a hostile player belongs on a server, which is a multiplayer-patterns.md concern.

Neither replaces the rule above: a save a player could swap never goes through load().

Versioning and migration#

Put a version int in every save from day one. One int, and the next format change has something to branch on. On load, run a migration ladder that upgrades stepwise, and read every optional field through dict.get(key, default) so a missing key is a default, not a crash.

func migrate(data: Dictionary) -> Dictionary:
var v: int = data.get("version", 1)
if v < 2:
data["gold"] = data.get("coins", 0) # v1->v2 renamed coins->gold
data.erase("coins")
v = 2
if v < 3:
data["difficulty"] = "normal" # v2->v3 added a field
v = 3
data["version"] = v
return data

Stepwise (each if v < N upgrades one version) migrates a v1 save 1→2→3 in one pass, with no v1→v3 jump. gdscript-architecture.md’s Resource header does the same with match save.version.

Relocating the save file is a migration step like any other. A portable or storefront build often wants its data beside the executable instead of under user://, so the whole install copies to a stick and keeps its progress. Run it on the same ladder: detect the old location, create the new directory, copy the file across if the new one is absent, then record the resolved path in the one place the rest of the code reads. OS.get_executable_path().get_base_dir().path_join("data") is the destination on desktop; every step needs its return value checked, because a failed make_dir or copy must leave the old path in force rather than pointing the game at a file that does not exist. Keep the old file until a launch from the new one has saved successfully. The editor runs from the engine binary’s directory, so gate this on a build check or an exported setting rather than letting it fire during development.

Slots and metadata#

Lay out user://saves/slot_N/ with the payload plus a sidecar meta the load menu reads without deserializing the whole game: timestamp, playtime, a thumbnail.

func slot_dir(slot: int) -> String:
return "user://saves/slot_%d" % slot
func write_meta(slot: int, playtime: float) -> void:
DirAccess.make_dir_recursive_absolute(slot_dir(slot))
var meta := {
"saved_at": Time.get_datetime_string_from_system(false, true), # local, space-separated
"unix": Time.get_unix_time_from_system(), # sort key
"playtime_s": playtime,
}
var f := FileAccess.open(slot_dir(slot) + "/meta.json", FileAccess.WRITE)
f.store_string(JSON.stringify(meta, "\t"))
f.close()
func write_thumb(slot: int) -> void:
var img := get_viewport().get_texture().get_image() # last rendered frame
img.resize(320, 180)
img.save_png(slot_dir(slot) + "/thumb.png")
func list_slots() -> Array: # for the load screen
if not DirAccess.dir_exists_absolute("user://saves"):
return []
return Array(DirAccess.get_directories_at("user://saves")).filter(
func(n): return n.begins_with("slot_"))

Build the menu from the tiny meta files alone (sort by unix for “most recent”); the full slot loads only on click.

Async and threaded saves#

Most saves are a small Dictionary that finishes sub-frame, so write them synchronously. Don’t thread until a save visibly hitches (large worlds, thumbnail encode, big blobs):

  • WorkerThreadPool runs the write off the main thread. add_task returns an id; poll is_task_completed, then wait_for_task_completion to reclaim it (required, even when done).
    func save_async(path: String, text: String) -> void:
    _task_id = WorkerThreadPool.add_task(_write_worker.bind(path, text), false, "save")
    func _write_worker(path: String, text: String) -> void:
    var f := FileAccess.open(path, FileAccess.WRITE); f.store_string(text); f.close()
    func _process(_dt) -> void:
    if _task_id != -1 and WorkerThreadPool.is_task_completed(_task_id):
    WorkerThreadPool.wait_for_task_completion(_task_id); _task_id = -1; saved.emit()
    Build the snapshot on the main thread, hand the worker only plain data (the serialized string / a duplicated dict). Never touch the scene tree from a worker.
  • Simpler alternative: call_deferred + chunking. Serialize a slice per idle callback so no single frame does all the work, with no thread and no data-race surface. Enough for big-but-not-huge.

Autosave and crash-safe writes#

  • Trigger on checkpoints, plus a low-frequency timer (a Timer node firing every ~120 s). Never autosave mid-combat: a save that captures a half-resolved fight reloads broken, so gate it (if _in_combat: return) and let the next checkpoint catch it.
  • Write atomically: temp then rename. A crash during a write leaves a truncated, wiped slot. Write to save.json.tmp, close(), then DirAccess.rename_absolute(tmp, final). The rename is atomic on every desktop filesystem, so the slot is only ever the last complete write. Keep a .bak of the prior slot too (as the other two docs’ headers do).
func write_atomic(path: String, text: String) -> void:
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE); f.store_string(text); f.close()
DirAccess.rename_absolute(tmp, path)

Verify by driving (prove the round-trip in a playing game)#

A save that returns OK is not a save that round-trips. Prove it against the live game:

scene play --mode main
runtime eval --code 'SaveManager.save_to_slot(0); emit(FileAccess.file_exists("user://saves/slot_0/save.json"))'
runtime set --node-path Player --property position --value "Vector2(999,999)" # corrupt live state
runtime eval --code 'SaveManager.load_from_slot(0); emit(get_node("Player").position)' # expect the SAVED pos, not 999
scene stop

The load must restore the saved position, not the corrupted one, which proves the write, format, and load_state agree. Extend it: kill an enemy, save, reload, confirm it stays dead; bump version, reload an old slot, confirm migrate ran. runtime eval runs in the game’s own process, so it reads the real user:// and autoloads, not the editor’s.

Common mistakes#

  • Floats/Vectors through raw JSON. Use serialize helpers, JSON.from_native, or var_to_str.
  • load()ing an untrusted Resource save runs embedded scripts; use JSON / store_var(..., false).
  • Saving node references. Save data (path, id, scene string) and rebuild the node on load.
  • Patching state without spawn records: freed/spawned nodes desync; store scene + parent.
  • No version field, so the first format change orphans every existing save.
  • Non-atomic writes. A crash mid-write corrupts the slot. Temp-then-rename, keep a .bak.
  • Autosaving mid-combat, or threading a save that was already sub-frame. Risk, no win.
  • Trusting remembered API signatures. Confirm against the running engine with engine class-info/search.
Built with the help of godot-mcp. MIT licensed.