Part 03 — When it breaks
You will build: version handling and migration, so that a save written by today's build still loads in next year's build.
This part is insurance. It feels pointless right up until the moment it isn't, and by then it's too late to add — the old saves already exist, without the field you'd need.
The problem, stated plainly#
You ship. Someone plays for forty hours. You release an update where hp becomes
health.current and items gained a quality field.
Their save file knows nothing about any of that. It loads, data.get("hp", 0) returns 0
because that key is gone, and a stranger's forty hours end with a character at zero health in a
level that no longer exists. They will not file a helpful bug report.
The version field#
One integer, written from the very first save:
const SAVE_VERSION: int = 3
Bump it whenever the shape changes — a renamed key, a restructured section, a field that
changes meaning. Not when you add a new optional field with a sensible default; get() already
handles that.
Then, on load, before touching anything:
var version: int = int(data.get("version", 0))
if version > SAVE_VERSION:
# a save from a NEWER build — this happens with betas and cloud sync
push_warning("save is from a newer version (%d > %d) — refusing" % [version, SAVE_VERSION])
return false
if version < SAVE_VERSION:
data = _migrate(data, version)
The "newer than us" case is easy to forget and genuinely happens — a player opts into a beta, opts back out, and their save is from the future. Refusing cleanly beats loading it wrong.
Migration is a chain, not a switch#
The instinct is match version: with one branch per old version, each converting straight to
current. That means version 1's branch has to be rewritten every time you bump, and you end up
with v1 → v7 conversions nobody has ever tested.
Instead, write one small step per version bump, and run them in sequence:
func _migrate(data: Dictionary, from_version: int) -> Dictionary:
var v: int = from_version
if v == 0: # saves written before versioning existed
data["flags"] = {}
v = 1
if v == 1: # hp moved into a health section, still inside "player"
var player: Dictionary = data.get("player", {})
player["health"] = { "current": int(player.get("hp", 100)), "max": 100 }
player.erase("hp")
data["player"] = player
v = 2
if v == 2: # items gained a quality field
for slot in data.get("inventory", {}).get("slots", []):
if slot != null:
slot["quality"] = "normal"
v = 3
data["version"] = v
return data
Sequential ifs, not elif, not match — a version-1 save falls through every step in
order and arrives as version 3. Each step only ever needs to know about the one change it
represents, so a step you wrote two years ago never needs revisiting.
Note that the v == 1 step reaches inside player rather than writing a top-level health
key. Migration steps have to match the schema's actual nesting — writing the right data at the
wrong depth is the quietest possible bug, because the file still loads and nothing errors. It's
worth re-reading your schema from part 01 while writing each step.
The v == 0 case handles saves written before you had a version field. That's why part 01
insisted on writing version from day one: without it, get("version", 0) is your only
handle, and you have to guess the shape of those files.
Fail in a way the player can survive#
When a save can't be loaded, three responses are acceptable and one isn't:
| Response | When |
|---|---|
| Load a backup | Best. Keep the previous save; rotate on write |
| Start fresh, keep the file | Rename to save_01.json.broken — never silently delete evidence |
| Refuse and say so | For newer-version saves; the player can update the game |
| Crash | Never |
Rotating a backup is four lines and buys you the first row:
func save_game() -> Error:
if FileAccess.file_exists(SAVE_PATH):
DirAccess.copy_absolute(SAVE_PATH, SAVE_PATH + ".bak")
# ... write as before
Now a corrupt write costs one session instead of the whole file. If you do only one thing from this part, do this one.
Test migration for real#
Migration code that has never run is a guess. Keep a folder of real old save files —
test_saves/v1_midgame.json, v2_endgame.json — committed to the repository, and load each
one after any change to the save format.
The habit that makes this cheap: every time you bump SAVE_VERSION, drop a copy of a current
save into that folder first. It takes ten seconds and it is the only way to know your v == 1
branch still works two years later.
Checkpoint — definition of done#
-
versionis written and checked on every save and load - A save from a newer version is refused with a clear message
- Migration steps are sequential
ifs that chain, and you tested a v0 file all the way up - A corrupt save is preserved (renamed), never deleted, and never crashes the game
- A
.bakrotates on each save - You can explain: why sequential
ifs rather thanmatchon the version number?
Stretch (no instructions)#
Add a checksum: store a hash of the data alongside it and verify on load, so you can tell corruption apart from hand-editing. Then decide what your game should do about each — they are not the same event, and the answer depends on whether you consider save editing cheating or a feature.
If you get stuck#
- Migration runs twice → you forgot
data["version"] = vat the end, so the next load thinks it's still old. - A step crashes on some old saves → it assumes a key that those files don't have. Every
migration step must be as defensive as the loader:
get()with defaults, all the way down. - Not sure whether a change needs a version bump → ask whether an old file would load wrong rather than incomplete. Wrong needs a bump; incomplete is what defaults are for.
Back to the overview. The natural next shelf is the event bus — loading emits a lot of "everything changed" notifications, and that shelf is about where those should travel.