save-schema-migration
Skill2 starsChanged 6 months agoMITView on GitHub
Version save data and migrate old saves forward when the schema changes — `schema_version` on every save Resource, sequential migration registry (v1→v2→v3, never skip), fallback for unknown versions, regression tests that load fixture saves from each released version. Use when a save format change is about to ship, or when adding versioning to a project that does not yet have it.
In this repository33 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- bootstrap-godot-projectSkill
- codebase-surveySkill
- create-autoloadSkill
- create-componentSkill
- create-resourceSkill
- create-sceneSkill
- create-state-machineSkill
- export-configSkill
- feature-planSkill
- feature-specSkill
- game-brainstormingSkill
- gdd-writerSkill
- genre-pack-3d-actionSkill
- genre-pack-platformerSkill
- genre-pack-topdownSkill
- genre-pack-turnbasedSkill
- godot-patternsSkill
- gut-test-writerSkill
- networking-foundationSkill
- performance-auditSkill
- setup-collision-layersSkill
- setup-git-godotSkill
- setup-input-mapSkill
- setup-localizationSkill
- setup-save-systemSkill
- sfx-generatorSkill
- shader-writerSkill
- subagent-dev-modeSkill
- ui-patterns-godotSkill
- update-docsSkill
- using-godot-superpowersSkill
- writing-game-planSkill
The file
Files
Save schema migration
A shipped game inherits every save format it ever published. The first time you add a field to SaveData and re-export, every existing player loads a save with the old shape and gets either: silent data loss (missing field defaults to zero), a hard crash (null.method() on a missing sub-Resource), or a corrupt run.
This skill is the contract for evolving save data without breaking existing players. Pair it with setup-save-system, which already includes a version field and a _migrate() stub — this skill fills in the migration registry and the test that proves it works.
Rules
- Every save Resource has a
schema_version: intat the top level. Bump it whenever you add, remove, rename, or change the meaning of a field. - Migrations are sequential. A v1 save migrates to v2, then v2 to v3 — never v1 → v3 directly. This keeps each migration small and independently testable.
- Migrations are pure functions of the loaded payload. They do not read the current scene, the autoloads, or anything else — same input → same output.
- Migrations never delete data without an explicit decision. Renaming a field? Move the value. Removing? Either drop silently with a comment or stash under
_legacy.<field_name>if there is any chance you'll want it back. - Every released version has a fixture save in
test/fixtures/save/v<N>.tres. CI loads each fixture and asserts the migrated result matches the current schema'sassert_valid()invariants. - Unknown future version = refuse to load. A v5 save loaded by a v3 build means the player rolled back. Do not guess — show "save from a newer version, please update".
File shape
resources/save_data.gd (extended from setup-save-system)
class_name SaveData
extends Resource
## Top-level save container. One file per slot.
##
## When `SCHEMA_VERSION` bumps, register the matching migration in
## SaveMigrations._migrations and ship a fixture under test/fixtures/save/.
const SCHEMA_VERSION := 3 # bump on every breaking change
@export var schema_version: int = SCHEMA_VERSION
@export var timestamp: int = 0
@export var play_time_seconds: float = 0.0
@export var current_scene: String = ""
@export var entries: Dictionary[NodePath, Variant] = {}
# v3 added: difficulty preset (was inferred from world flags before)
@export var difficulty: StringName = &"normal"
func assert_valid() -> void:
assert(schema_version == SCHEMA_VERSION, "save not migrated to current schema")
assert(timestamp > 0, "missing timestamp")
assert(difficulty in [&"easy", &"normal", &"hard"], "invalid difficulty: %s" % difficulty)autoload/save_migrations.gd
extends Node
## Sequential migration registry. Each entry migrates schema_version=N
## to schema_version=N+1. Migrations run in order — never skip.
const _MIGRATIONS := {
1: "_v1_to_v2",
2: "_v2_to_v3",
}
func migrate(data: SaveData) -> SaveData:
while data.schema_version < SaveData.SCHEMA_VERSION:
var previous := data.schema_version
if not _MIGRATIONS.has(previous):
push_error("no migration registered from schema_version=%d" % previous)
return null
var fn := _MIGRATIONS[previous] as String
callv(fn, [data]) # mutates `data` in place
# Each migration must advance schema_version by exactly one — never skip,
# never stay flat. A flat version would loop forever.
assert(data.schema_version == previous + 1,
"migration %s left schema_version=%d (expected %d)" % [fn, data.schema_version, previous + 1])
return data
# v1 → v2: split monolithic `inventory` Dictionary into typed Resource list.
func _v1_to_v2(data: SaveData) -> void:
var player_path := NodePath("/root/Game/World/Player")
var entry: Variant = data.entries.get(player_path)
if entry is Dictionary:
var legacy_inv: Dictionary = entry.get("inventory", {})
var migrated: Array[Resource] = []
for id in legacy_inv:
var item := load("res://data/items/%s.tres" % id) as ItemData
if item != null:
var stack := ItemStack.new()
stack.item = item
stack.count = int(legacy_inv[id])
migrated.append(stack)
else:
push_warning("v1→v2: dropping unknown item '%s'" % id)
entry["inventory"] = migrated
data.entries[player_path] = entry
data.schema_version = 2
# v2 → v3: difficulty was inferred from world_flags["hardcore"]; lift to top-level.
func _v2_to_v3(data: SaveData) -> void:
var world_path := NodePath("/root/Game/World")
var entry: Variant = data.entries.get(world_path, {})
var hardcore: bool = entry.get("world_flags", {}).get("hardcore", false) if entry is Dictionary else false
data.difficulty = &"hard" if hardcore else &"normal"
data.schema_version = 3Wire into SaveManager.load_game
In the setup-save-system template, the _migrate stub becomes:
func load_game(slot: int) -> bool:
# ... existing loading code ...
var data := load(path) as SaveData
if data == null:
load_finished.emit(slot, false)
return false
if data.schema_version > SaveData.SCHEMA_VERSION:
# save is newer than this build — player rolled back; refuse cleanly
push_error("save schema_version=%d > supported %d (rolled-back build?)" % [data.schema_version, SaveData.SCHEMA_VERSION])
load_finished.emit(slot, false)
return false
if data.schema_version < SaveData.SCHEMA_VERSION:
data = SaveMigrations.migrate(data)
if data == null:
load_finished.emit(slot, false)
return false
data.assert_valid()
# ... rest of loading code ...(SaveMigrations is registered as autoload — use the create-autoload skill.)
Fixture-based regression tests (GUT)
Every released schema version gets a fixture file checked into the repo. The test loads each fixture, runs migration, and asserts the result. This is the only thing that proves migrations actually work — a future you cannot reproduce a v1 save by hand once the v1 code is gone.
test/fixtures/save/
├── v1.tres # checked in when v2 ships; never edited again
├── v2.tres # checked in when v3 ships; never edited again
└── v3.tres # currenttest/unit/test_save_migrations.gd:
extends GutTest
## Loads each historical save fixture, migrates, and validates.
## Add a new test_v<N>_loads() function every time SCHEMA_VERSION bumps.
func test_v1_loads() -> void:
var data := load("res://test/fixtures/save/v1.tres") as SaveData
assert_not_null(data)
assert_eq(data.schema_version, 1)
var migrated := SaveMigrations.migrate(data)
assert_not_null(migrated, "v1 migration returned null")
assert_eq(migrated.schema_version, SaveData.SCHEMA_VERSION)
migrated.assert_valid()
func test_v2_loads() -> void:
var data := load("res://test/fixtures/save/v2.tres") as SaveData
assert_eq(data.schema_version, 2)
var migrated := SaveMigrations.migrate(data)
assert_not_null(migrated)
assert_eq(migrated.schema_version, SaveData.SCHEMA_VERSION)
migrated.assert_valid()
func test_future_version_rejected() -> void:
# A future version cannot be migrated — load_game must refuse.
var data := SaveData.new()
data.schema_version = SaveData.SCHEMA_VERSION + 1
# SaveManager.load_game checks this case and emits load_finished(slot, false)
# without calling migrate(); the unit test for SaveManager covers that path.
passThe fixture files are produced by the build of the game that shipped that version — when you bump SCHEMA_VERSION, immediately copy the current shipping save into test/fixtures/save/v<previous>.tres before merging the bump.
Workflow when bumping the schema
- Decide the change: rename a field? Add one? Restructure?
- Capture a fixture from the previous shipped version (run the previous binary, save a representative game state, copy the file to
test/fixtures/save/v<prev>.tres). - Bump
SaveData.SCHEMA_VERSIONby exactly one. - Add fields to the Resource with sane
@exportdefaults (so a freshly-loaded migrated save is not full ofnull). - Write the migration (
_v<prev>_to_v<new>) that mutates a payload of the previous shape into the new shape. - Register the migration in
SaveMigrations._MIGRATIONS. - Update
assert_valid()with any new invariants the migration is supposed to establish. - Add
test_v<prev>_loads()to the migration test suite. - Run the full suite locally with
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://test/unit. - Update the changelog with one line: "Save format bumped to v
<new>; v<prev>saves migrate automatically".
Anti-patterns (do not do these)
- ❌ Skipping migrations:
if version == 1: data = v1_to_v3(data)— once v4 ships you have a combinatorial explosion. Always sequential. - ❌ Migrating in
SaveManager.load_gamedirectly: keepsSaveManagershort for one version, becomes a 400-linematchblock by v6. Use the registry from day one. - ❌ Mutating fixtures retroactively: a fixture is a snapshot of what the game wrote at that version. If a migration breaks because the fixture changed, the fixture is wrong, not the migration.
- ❌ Letting unknown fields silently pass through: a Resource ignores unknown fields on load by default, so renaming a field without a migration produces a save with both the old (zeroed) and the new (also zeroed) field. Always migrate.
- ❌ Storing
Nodeinstances: same caveat as insetup-save-system. Migrations cannot rebuild Node references.
When NOT to bump
- Adding a new optional field that defaults sensibly for old saves and is purely additive — many engines ship without a bump in this case. Bumping anyway is cheap and gives you a checkpoint; the cost is a few minutes of fixture work. Default to bumping.
- Reading the same data with a different code path. The save format did not change.
See also
setup-save-system— the underlying SaveManager + SaveData this skill extendsgut-test-writer— pattern for the fixture-based regression test- Saving games (official)
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

