Technology · Persistence

A save file that survived
42 format changes
under a live game.

One migration step per version, old shapes kept verbatim, every reconstruction declared rather than hidden. Plus the trap that looks derived, is causal, and has caught me six separate times.

By Marcin Firmuga·2026-08-29·8 min read·Technology

A save file is a promise. Somebody put forty hours into a campaign, and every time you add a feature you are asking their file to survive a change they did not agree to.

This is how I keep that promise on a simulation whose data model has changed 42 times while people were playing it, and the four rules that make it boring instead of frightening.

The rule that came from a bug

On an earlier project I had a save system that looked immaculate. Every file carried a version number. There was a Sanitize method that ran on load and made everything valid.

0 upgrade steps existed. Sanitize stamped the current version onto every file it touched without ever reading the value that was already there. The version field was decorative. It worked perfectly and did nothing.

That is the worst class of failure, because there is no symptom. Nothing crashes. Files load. The number in the file is always correct, because you just wrote it. You find out when a genuinely old file finally needs upgrading and there is no machinery to do it.

Four rules

RuleWhy
One step per version, run in a loop A loop cannot skip a step. A hand-written chain of calls can, and mine did: it stopped one short of the newest version and left later fields uninitialised.
Old shapes kept verbatim SaveDataV1 still exists in the codebase. The upgrade path reads a real historical structure instead of your memory of one.
Each step stamps its own version Not the newest one. A step claiming to produce the current version is a trap for whatever reads that field next.
Every bump ships a migration test The test is the only thing that proves the step runs. Without it you have written a step and hoped.
// The loop counts for itself, so nothing can be skipped. while (data.version < SaveData.CurrentVersion) { data = Steps[data.version](data); // each step stamps its own target version }

Reconstruction is declared, never disguised

Sooner or later a migration has to invent a value the old format never stored. Version 1 did not record when hardware was purchased, and version 2 needs that date.

You have three options and only one of them is honest. You can guess flatteringly, which silently hands old players an advantage. You can guess neutrally, which is still a guess nobody can audit. Or you can pick the least flattering assumption that is still defensible, and write down what you did.

// v1 never stored a purchase date. Assume the oldest plausible one, and say so. data.purchasedOn = campaignStart; data.lastMigrationNotes = "v1 to v2: purchase dates were not recorded. Assumed campaign start, " + "which is the least flattering reading and undervalues the fleet.";

That note costs one string field and it means a player who asks why their hardware looks older than they remember gets an answer instead of a shrug.

The trap: it looks derived and it is causal

This is the one that has caught me six separate times on the same project, and it is the reason I now distrust my own judgement about what needs saving.

If tomorrow reads it, it is state

Yesterday's server load looks computed. It is a number you could recalculate. But the market reads it to decide how customers feel today, so it is causal: drop it from the save and the day after a reload plays out differently.

Same shape, five more times. Version adoption shares, because tomorrow's drift reads today's split. An open regulatory inspection whose verdict has not been rolled yet, because dropping it lets a player reload their way out of every penalty in the game. A rival's original planned launch date and its current intended one, which look almost identical and are not: restoring one as the other made every lab that had decided to wait for better hardware forget that decision and launch on schedule anyway.

The test is not "could I recompute this". It is does anything tomorrow read it. If yes, it goes in the file, however derived it looks.

The test that catches all of it

One test does more work here than the rest of the suite combined. Play a scripted campaign into year four. Save. Load it back. Play through year five.

The interrupted run must end identically to a run that was never interrupted. If loading changes the future, the file is not a save. It is a photograph of a game that no longer exists.

var uninterrupted = Campaign(seed).Advance(FiveYears); var interrupted = Campaign(seed).Advance(FourYears); var reloaded = SaveStore.Load(SaveStore.Capture(interrupted)); reloaded.Advance(OneYear); Assert.That(reloaded.CashUsd, Is.EqualTo(uninterrupted.CashUsd), "Saving and loading changed the future, so the save is not a save.");

Mine failed, and the way I debugged it is the part worth stealing.

Instrument the first disagreement, not the final gap

For three days I measured how far apart the two runs ended up. The numbers looked encouraging: 73.8, then 75.9, then 78.3. I thought I was converging on one root cause. I was not. They were separate bugs wearing the same coat, and one of them I had introduced while fixing another.

Once I started logging the first tick where the two runs stopped agreeing, bugs that had been taking evenings started taking minutes. The first disagreement points at the cause. The final number only shows the damage.

The value that could not survive its own save

One failure in that test had nothing to do with missing fields. A value was 1.0999999999999999 in memory and 1.1 after a round trip through the file.

Nobody would ever see that difference. Year five did. A quantity that cannot survive being written down and read back is not well-defined state, it is a rumour about state, and the fix is to decide the precision you actually mean and round to it before saving rather than hoping the serializer agrees with you.

What this costs

Roughly twenty minutes per version bump: one method, one test, one line in the notes. That is the entire price.

What it buys is the ability to change anything. I have restructured the market model, added a safety system, split a save into new sub-objects and renumbered nothing, all while people had campaigns open. Not once have I had to choose between shipping a feature and keeping somebody's forty hours.

All of this is from Scaling Laws, an AI company tycoon built in Unity, with the save system, the migration steps and the replay test open in the repository.

The game page and the source · The sibling guide: finished, tested and unreachable

Questions people ask about this

How do you change a save format without breaking saves?

One step per version, run in a loop. Keep the old shapes in the codebase. Have each step stamp its own version. Ship a migration test with every bump.

What is the most common save migration bug?

A version field that is written and never read. It works perfectly and does nothing, and you find out only when an old file finally needs upgrading.

Which fields actually need saving?

Anything tomorrow reads, however derived it looks. If yesterday's load feeds today's reaction, it is causal state and belongs in the file.

How do you test that loading does not change the game?

Save mid-campaign, reload, continue, and assert the run ends identically to one that was never interrupted. When it fails, log the first tick where they disagree.

MF

Marcin Firmuga

Solo developer · HCK_Labs · building in public

I write about what I actually shipped, with real numbers and real code, including the parts that did nothing. More: my story.