Save systems
A save file is a serialized snapshot of game state that survives restarts.
The hard parts aren't writing bytes — they're choosing what to save, writing it
so a crash mid-save can't corrupt it, and reading old saves after you ship a
patch. Get those three right and the rest is plumbing.
When to use
- Use to persist progress: player stats, inventory, world flags, settings,
positions — across sessions and game updates.
- Use to design save slots, quicksave/autosave, and crash-safe writes.
- Use when old save files break after a content/code change (versioning &
migration).
When not to use: for Roblox cloud persistence specifics, use
roblox-datastores. For the data model the save serializes (resources/SOs), use
godot-resources / unity-scriptableobjects. For Godot's FileAccess/
ResourceSaver and user:// paths, defer to the Godot engine skill while
applying the patterns here.
Core workflow
- Decide what state is authoritative. Save the data (hp, position, seed,
unlocked flags), not engine objects or scene nodes. You will reconstruct
objects from data on load — never serialize live node references.
- Define a versioned schema. Every save embeds a
version integer. This is
the single most important field for a game you intend to patch.
- Pick a format. JSON/text for readability and debuggability; a binary
format for size/speed or mild tamper-resistance. Start with JSON.
- Write atomically. Serialize to a temp file, flush, then rename over the
real file. A crash leaves either the old save or the new one — never a
half-written one.
- Load defensively. Read version → migrate up to current → validate →
instantiate. Keep a backup of the last good save and fall back on parse error.
- Autosave on safe boundaries (level change, checkpoint), throttled, and to a
separate slot so it can't clobber a manual save.
- Verify: save, fully quit, relaunch, load — and confirm by inspection that
state matches. Test loading a save from the previous version.
Patterns
1. Serialize state as plain data (not engine objects)
# Build a dictionary of pure data. Each savable object reports its own state.
func capture_state() -> Dictionary:
return {
"version": SAVE_VERSION, # ALWAYS stamp the schema version
"player": { "hp": player.hp, "pos": [player.position.x, player.position.y] },
"inventory": player.inventory.to_array(), # ids + counts, not Item nodes
"flags": world.flags, # e.g. {"met_guard": true}
"seed": world.seed, # regenerate procedural content
}
# On load, RECONSTRUCT objects from the data — do not expect live references back.
func apply_state(data: Dictionary) -> void:
player.hp = data["player"]["hp"]
player.position = Vector2(data["player"]["pos"][0], data["player"]["pos"][1])
player.inventory.from_array(data["inventory"])
world.flags = data["flags"]
2. Atomic, crash-safe write (temp + rename)
# RIGHT: write to a temp file, then atomically rename over the target.
func save_atomic(path: String, data: Dictionary) -> void:
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
f.store_string(JSON.stringify(data))
f.flush() # ensure bytes hit disk
f.close()
DirAccess.rename_absolute(tmp, path) # replaces the target; atomic on POSIX
# WRONG: opening `path` directly and writing in place — a crash mid-write leaves a
# truncated, unloadable save and destroys the player's progress.
Rename-over-target is atomic on POSIX (same volume); on Windows a replace-by-rename
isn't guaranteed atomic, so keep the previous file as path + ".bak" before the
rename — that backup is what actually guarantees you can recover from a bad write.
3. Versioned load with migration
SAVE_VERSION = 3
def load_save(raw_bytes):
data = parse(raw_bytes) # JSON/binary -> dict
v = data.get("version", 0)
if v > SAVE_VERSION:
raise NewerSaveError(v) # save is from a newer build; refuse
while v < SAVE_VERSION: # apply migrations in order, v -> v+1
data = MIGRATIONS[v](data)
v += 1
data["version"] = v
validate(data) # check required keys / ranges
return data
# Each migration is a pure function from one version's shape to the next.
def migrate_1_to_2(d):
d["flags"] = {k: True for k in d.pop("completed_quests", [])} # list -> set-map
return d
MIGRATIONS = {1: migrate_1_to_2, 2: migrate_2_to_3}
4. Save slots + throttled autosave
const SLOT_PATH := "user://save_%d.json" # manual slots 0..N
const AUTOSAVE_PATH := "user://autosave.json" # separate file: never clobbers a slot
var _autosave_cooldown := 0.0
func autosave_if_due(dt: float) -> void:
_autosave_cooldown -= dt
if _autosave_cooldown <= 0.0:
save_atomic(AUTOSAVE_PATH, capture_state())
_autosave_cooldown = 60.0 # throttle: at most once a minute
# Trigger an immediate autosave on checkpoints/level transitions, not mid-combat.
Pitfalls
- Serializing engine objects/node paths ties saves to scene structure;
renaming a node breaks every old save. Save data, rebuild objects on load.
- No version field. The day you ship a patch, every existing save is a
guessing game. Stamp
version from version 1.
- In-place writes corrupt saves on crash/power loss. Always temp-write then
rename; keep a
.bak.
- Trusting the file blindly. Saves get truncated, hand-edited, or
cloud-synced stale. Validate on load and fall back to backup on failure.
- Floats and locale. Text serializers can drop precision or use comma
decimal separators in some locales. Use a locale-invariant serializer.
- Autosave clobbering manual saves, or firing mid-action and saving an
inconsistent state. Use a dedicated autosave slot and save on safe boundaries.
- Storing secrets or trusting client saves in multiplayer. A local save is
player-controlled; never treat it as authoritative for online state. For cloud,
handle the device's data limits and conflicts (
roblox-datastores).
References
references/versioning-and-migration.md — schema evolution strategies, the
migration chain, backups/rollback, format trade-offs (JSON vs binary), and a
load-time validation checklist.
Related skills
roblox-datastores — cloud persistence, request limits, session locking.
godot-resources, unity-scriptableobjects — the data model you serialize.
procedural-gen — store the seed to regenerate worlds instead of saving them.
rpg, survival-crafting, visual-novel — genres that compose this skill.
1---2name: save-systems3description: Design save/load for game state — choosing what to serialize, file formats, save slots, atomic crash-safe writes, schema versioning and migration, and autosave. Engine-neutral. Use when the user mentions save system, save/load, game state persistence, save slots, autosave, save file corruption, or migrating old saves to a new version.4license: Apache-2.05---67# Save systems89A save file is a **serialized snapshot of game state** that survives restarts.10The hard parts aren't writing bytes — they're choosing *what* to save, writing it11so a crash mid-save can't corrupt it, and reading *old* saves after you ship a12patch. Get those three right and the rest is plumbing.1314## When to use1516- Use to persist progress: player stats, inventory, world flags, settings,17 positions — across sessions and game updates.18- Use to design save slots, quicksave/autosave, and crash-safe writes.19- Use when old save files break after a content/code change (versioning &20 migration).2122**When *not* to use:** for Roblox cloud persistence specifics, use23`roblox-datastores`. For the data model the save serializes (resources/SOs), use24`godot-resources` / `unity-scriptableobjects`. For Godot's `FileAccess`/25`ResourceSaver` and `user://` paths, defer to the Godot engine skill while26applying the patterns here.2728## Core workflow29301. **Decide what state is authoritative.** Save the *data* (hp, position, seed,31 unlocked flags), not engine objects or scene nodes. You will reconstruct32 objects from data on load — never serialize live node references.332. **Define a versioned schema.** Every save embeds a `version` integer. This is34 the single most important field for a game you intend to patch.353. **Pick a format.** JSON/text for readability and debuggability; a binary36 format for size/speed or mild tamper-resistance. Start with JSON.374. **Write atomically.** Serialize to a temp file, flush, then rename over the38 real file. A crash leaves either the old save or the new one — never a39 half-written one.405. **Load defensively.** Read version → migrate up to current → validate →41 instantiate. Keep a backup of the last good save and fall back on parse error.426. **Autosave on safe boundaries** (level change, checkpoint), throttled, and to a43 separate slot so it can't clobber a manual save.447. **Verify**: save, fully quit, relaunch, load — and confirm by inspection that45 state matches. Test loading a save from the previous version.4647## Patterns4849### 1. Serialize state as plain data (not engine objects)5051```gdscript52# Build a dictionary of pure data. Each savable object reports its own state.53func capture_state() -> Dictionary:54 return {55 "version": SAVE_VERSION, # ALWAYS stamp the schema version56 "player": { "hp": player.hp, "pos": [player.position.x, player.position.y] },57 "inventory": player.inventory.to_array(), # ids + counts, not Item nodes58 "flags": world.flags, # e.g. {"met_guard": true}59 "seed": world.seed, # regenerate procedural content60 }6162# On load, RECONSTRUCT objects from the data — do not expect live references back.63func apply_state(data: Dictionary) -> void:64 player.hp = data["player"]["hp"]65 player.position = Vector2(data["player"]["pos"][0], data["player"]["pos"][1])66 player.inventory.from_array(data["inventory"])67 world.flags = data["flags"]68```6970### 2. Atomic, crash-safe write (temp + rename)7172```gdscript73# RIGHT: write to a temp file, then atomically rename over the target.74func save_atomic(path: String, data: Dictionary) -> void:75 var tmp := path + ".tmp"76 var f := FileAccess.open(tmp, FileAccess.WRITE)77 f.store_string(JSON.stringify(data))78 f.flush() # ensure bytes hit disk79 f.close()80 DirAccess.rename_absolute(tmp, path) # replaces the target; atomic on POSIX81# WRONG: opening `path` directly and writing in place — a crash mid-write leaves a82# truncated, unloadable save and destroys the player's progress.83```8485Rename-over-target is atomic on POSIX (same volume); on Windows a replace-by-rename86isn't guaranteed atomic, so keep the previous file as `path + ".bak"` before the87rename — that backup is what actually guarantees you can recover from a bad write.8889### 3. Versioned load with migration9091```python92SAVE_VERSION = 39394def load_save(raw_bytes):95 data = parse(raw_bytes) # JSON/binary -> dict96 v = data.get("version", 0)97 if v > SAVE_VERSION:98 raise NewerSaveError(v) # save is from a newer build; refuse99 while v < SAVE_VERSION: # apply migrations in order, v -> v+1100 data = MIGRATIONS[v](data)101 v += 1102 data["version"] = v103 validate(data) # check required keys / ranges104 return data105106# Each migration is a pure function from one version's shape to the next.107def migrate_1_to_2(d):108 d["flags"] = {k: True for k in d.pop("completed_quests", [])} # list -> set-map109 return d110MIGRATIONS = {1: migrate_1_to_2, 2: migrate_2_to_3}111```112113### 4. Save slots + throttled autosave114115```gdscript116const SLOT_PATH := "user://save_%d.json" # manual slots 0..N117const AUTOSAVE_PATH := "user://autosave.json" # separate file: never clobbers a slot118var _autosave_cooldown := 0.0119120func autosave_if_due(dt: float) -> void:121 _autosave_cooldown -= dt122 if _autosave_cooldown <= 0.0:123 save_atomic(AUTOSAVE_PATH, capture_state())124 _autosave_cooldown = 60.0 # throttle: at most once a minute125# Trigger an immediate autosave on checkpoints/level transitions, not mid-combat.126```127128## Pitfalls129130- **Serializing engine objects/node paths** ties saves to scene structure;131 renaming a node breaks every old save. Save data, rebuild objects on load.132- **No version field.** The day you ship a patch, every existing save is a133 guessing game. Stamp `version` from version 1.134- **In-place writes** corrupt saves on crash/power loss. Always temp-write then135 rename; keep a `.bak`.136- **Trusting the file blindly.** Saves get truncated, hand-edited, or137 cloud-synced stale. Validate on load and fall back to backup on failure.138- **Floats and locale.** Text serializers can drop precision or use comma139 decimal separators in some locales. Use a locale-invariant serializer.140- **Autosave clobbering manual saves**, or firing mid-action and saving an141 inconsistent state. Use a dedicated autosave slot and save on safe boundaries.142- **Storing secrets or trusting client saves in multiplayer.** A local save is143 player-controlled; never treat it as authoritative for online state. For cloud,144 handle the device's data limits and conflicts (`roblox-datastores`).145146## References147148- `references/versioning-and-migration.md` — schema evolution strategies, the149 migration chain, backups/rollback, format trade-offs (JSON vs binary), and a150 load-time validation checklist.151152## Related skills153154- `roblox-datastores` — cloud persistence, request limits, session locking.155- `godot-resources`, `unity-scriptableobjects` — the data model you serialize.156- `procedural-gen` — store the seed to regenerate worlds instead of saving them.157- `rpg`, `survival-crafting`, `visual-novel` — genres that compose this skill.