Why indie saves break
Most save systems fail in predictable ways: you add a field, rename something, change how you represent progress, or move the game between platforms. Suddenly older saves won’t load, and you either wipe progression or write one-off fixes that rot.
The fix is boring but effective: treat save data like an API. It needs versioning, migration, validation, and separation between game logic and persistence.
Start with a clear contract: what you actually save
Before writing any file I/O, write down the minimum you need to restore the player’s world state. Typical categories:
- Meta progress: unlocked abilities, options, settings, currencies.
- Run state (if you have runs): seed, difficulty modifiers, current run statistics.
- World state: which quests are completed, which tiles/enemies are changed.
- Inventory/state snapshots: items, durability, cooldown timers (careful with timers).
If you find yourself saving “whatever the objects contain”, you’ll eventually capture transient runtime state (pointers, cached paths, pooled IDs) that you don’t want. Save what you can recompute and store the rest explicitly.
Use a versioned envelope, not a raw blob
Your top-level save file should be an envelope that tells you how to interpret the payload. For example:
{
"saveVersion": 5,
"schema": "com.yourgame.save",
"timestamp": 1710000000,
"data": { ... }
}
Key points:
- saveVersion drives migrations.
- schema gives you an identifier so you don’t accidentally try to load the wrong format.
- timestamp helps debugging and conflict resolution (especially with cloud sync later).
Whether you use JSON, MessagePack, or a binary format is less important than the envelope and the discipline around it.
Keep runtime models separate from save models
Instead of serialising live game objects, create plain “save DTOs” (data transfer objects) that mirror the contract you just defined.
Example DTO idea (pseudo-code):
class SaveDataV5 {
int saveVersion = 5;
PlayerMeta meta;
List<QuestState> quests;
RunState run;
}
Your game systems convert between runtime state and save DTOs. This keeps save logic stable even when you refactor gameplay classes.
Migration strategy: step-by-step, not leap-of-faith
When you change the schema, don’t just guess how to load old saves. Write explicit migrations:
- Option A: migrate old saves stepwise until they reach the current version.
- Option B: write a migration path per historical version.
For indie projects, stepwise migrations are usually easiest to maintain. Conceptually:
function LoadSave(bytes):
envelope = ParseEnvelope(bytes)
v = envelope.saveVersion
data = envelope.data
while v < CURRENT_VERSION:
data = Migrate(v, data)
v++
return data
Each migration should be small and testable. For example:
- V2 -> V3: rename a field, set a default for missing values.
- V3 -> V4: change an enum representation; map old values to new ones.
- V4 -> V5: split one struct into two; compute derived fields.
When you add a new field, default it in the migration. When you remove a field, ignore it in older versions.
Validation: fail safely, not silently
Even if you “only ever write valid saves”, corruption happens: power loss, interrupted writes, bad edits during debugging, platform storage issues.
Validation should happen after parsing and after migration:
- Check required sections exist.
- Clamp numeric ranges (e.g. health can’t be negative).
- Verify enum values are within allowed sets.
- Reject obviously broken data and fall back to a safe default.
For validation, it’s okay to be strict. Better a fresh start than undefined behaviour.
Save atomically: write temp, then replace
One of the simplest reliability wins: write to a temporary file, flush, then replace the original. This prevents half-written saves.
Typical flow:
- Write
save.tmp - Ensure the write is complete (flush/fsync where available)
- Rename/replace
save.dat->save.bak(optional) - Rename/replace
save.tmp->save.dat
If you can’t do fsync reliably across all targets, at least do the temp + replace pattern. Keep an optional backup so you can recover if the replace fails.
Test saves like gameplay code
Most teams test saving manually once, then assume it works. Don’t.
Make a small test matrix:
- Golden files: keep a few representative save payloads for older versions.
- Round-trip tests: save → load should preserve state.
- Migration tests: loading V1 through Vn should produce valid current state.
- Fuzz/corruption tests: partially truncate data and confirm you recover safely.
Golden files can live as small fixtures (even if your format is binary). The goal is to catch schema drift the moment it happens.
When you save: throttle and separate “critical” from “nice-to-have”
Save frequency is a design and engineering choice. If you save every frame, you’ll fight I/O stutter. If you save too rarely, you lose progress.
Split saves into two tiers:
- Critical checkpoints: after finishing a quest, completing a level, or spending currency.
- Autosave snapshots: on a timer or after safe events.
For autosave, write less often and avoid blocking the main thread if your engine supports background tasks. For critical saves, you can afford a slightly heavier operation because the player expects persistence at those moments.
Be careful with time, randomness, and derived state
Two common pitfalls:
- Timers: store remaining durations or absolute timestamps, but be consistent. Decide whether you pause timers when the game is closed.
- Procedural runs: store the seed and the current step index/state so you can reproduce the same sequence deterministically.
Derived state (like “current health max after upgrades”) should be recomputed from canonical saved values during load, not stored as an independent mutable number that can drift.
Practical example: migrating a renamed field
Say you originally saved "coins" as an integer, and later you changed it to "currency.coins". Migration steps might:
- Read old
coinsif present. - Create
currencystruct if missing. - Copy and default the rest.
The key is that migration logic is deterministic and doesn’t depend on the current runtime.
Tooling tips: make schema changes painless
A few low-effort habits that pay off:
- Centralise version constants (current version in one place).
- Document migrations in code comments: what changed and why.
- Keep migration functions pure (input old data, output new data).
- Avoid “optional everywhere” parsing; it hides bugs. Use optional fields only when you genuinely introduced a field later.
Where this fits into your engine
This approach is engine-agnostic. If you’re on Unity, Godot, Unreal, or a custom stack, the pattern stays the same: build DTOs, version an envelope, migrate stepwise, validate, then load into runtime systems.
If you want a starting point for how to structure data and serialisation in practice, these references are worth skimming:
- Unity JSON serialization docs
- Godot JSON docs
- Unreal Engine documentation hub
- itch.io docs (useful for save portability considerations when shipping across platforms)
Ship with confidence
If you implement just three things—versioned envelope, stepwise migrations, and atomic writes—you’ll prevent the majority of save-breaking incidents. Then add validation and tests, and you’ll be able to evolve your game without punishing players who trusted your persistence.