doc 4 of 4

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:

ResponseWhen
Load a backupBest. Keep the previous save; rotate on write
Start fresh, keep the fileRename to save_01.json.broken — never silently delete evidence
Refuse and say soFor newer-version saves; the player can update the game
CrashNever

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 filestest_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#

  • version is 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 .bak rotates on each save
  • You can explain: why sequential ifs rather than match on 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"] = v at 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.