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.mdcovers 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.mdis the component game, where saveables carry aSaveDataComponentholding a polymorphic Resource; adding a saveable type = a new Resource subclass, no central switch.narrative-game-patterns.mdis the story game, whose save wraps an opaque story snapshot ({version, slot_id, snapshot_json, ...}) with a.bakfallback and never introspects it.menus-settings.mdhandles settings (audio, video, keybinds), which persist touser://settings.cfgviaConfigFile. 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 pathfunc 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)#
| Format | Types preserved | Human-readable | Notes |
|---|---|---|---|
ConfigFile | Variants (typed) | yes (INI) | Settings only (see menus-settings.md). Not save games. |
JSON | no (numbers/strings/arrays/dicts) | yes, diffable | Portable, tool-friendly. Vectors/floats need care (below). |
var_to_str/str_to_var | full Variant incl. Vector2/Color | yes | Text and type-faithful. Godot-only reader. |
FileAccess.store_var/get_var | full Variant | no (binary) | Fast, compact, opaque. |
Resource + ResourceSaver (.tres) | typed, @exported | .tres yes / .res no | Editor-inspectable. Security caveat below. |
- JSON is the portable default but loses types.
JSON.parse_stringreturns every number as afloatand has noVector2/Color, so a naiveVector2(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_stris the underrated middle. It writesVector2(10, 20)as literal text andstr_to_varreads 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_varis fastest and smallest, opaque on disk. Passstore_var(data, false)/get_var(false), where thefalsekeeps arbitrary object instantiation out of the reader (same risk as below). Resource+ResourceSaveris inspectable but unsafe for untrusted saves. A@exportedSaveData extends Resourcesaved viaResourceSaver.save(data, "user://save.tres")is typed and inspector-editable, which is great for authored data. But loading a.tres/.resinstantiates whatever types and scripts the file declares, so a tampered save can run code on load.FileAccess.get_varandJSON.to_nativegate this behind anallow_objectsflag;ResourceLoader.loadhas none. Standing community guidance: neverload()a save a player could swap. Use JSON orstore_var(..., false)there, and reserve Resource saves for shipped content and local headers. (open_encrypted_with_passonly 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 dataStepwise (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):
WorkerThreadPoolruns the write off the main thread.add_taskreturns an id; pollis_task_completed, thenwait_for_task_completionto reclaim it (required, even when done).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.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()- 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
Timernode 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(), thenDirAccess.rename_absolute(tmp, final). The rename is atomic on every desktop filesystem, so the slot is only ever the last complete write. Keep a.bakof 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 mainruntime 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 stateruntime eval --code 'SaveManager.load_from_slot(0); emit(get_node("Player").position)' # expect the SAVED pos, not 999scene stopThe 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, orvar_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
versionfield, 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.