Clean-account testing misses the important defect
A new Unity build can look perfect when it starts with an empty profile. A fresh account has no retired fields, half-finished quests, cached catalogue references, old inventory stacks, interrupted purchases, or values that were valid under an earlier ruleset. It therefore proves only that the new format can create a new state. It says very little about whether the game can safely carry a player from yesterday’s world into today’s one.
Migration work should begin from a stronger question: can this exact historical state enter the current build, preserve the meaning that still matters, and either become valid current state or fail in a way that is useful to diagnose? That is a product question as much as a serializer question. A field may deserialize successfully while still violating a progression rule, pointing to missing content, or changing a purchase entitlement. Loading bytes is not enough.
This article describes a local-first rehearsal process. It is deliberately not a promise that one tool can understand every Unity serialization setup, backend, or platform integration. Unity data models vary, and a game can add its own encryption, cloud sync, binary format, or custom adapters. The durable part is the test discipline: preserve historical shapes, copy them before mutation, make the migration path explicit, and check game invariants after every boundary.
Build a historical state corpus
Treat old saves as a versioned corpus, not as loose files passed around in chat. Each fixture needs a stable identifier, the version that produced it, a short scenario label, a format declaration, and a privacy classification. The scenario label matters because a migration suite should include meaningful states, not just random JSON. Examples include a new player, a player at a chapter boundary, an inventory at its stack limit, an interrupted tutorial, an old event reward, a user with optional cosmetic content, and a profile that intentionally contains an invalid legacy combination.
Use immutable working copies. The test runner should never overwrite the source fixture while attempting a migration. Copy the fixture to a temporary working directory, record its checksum or content hash, run the adapter, and retain the resulting output separately. This turns a surprising result into evidence someone else can replay. It also prevents a test from quietly improving its own input until the original problem disappears.
Keep production player data out of a general development fixture directory. A historical corpus can be synthetic, consented, carefully redacted, or generated from deterministic scenarios. The key is that it retains the structural and semantic edge cases that matter. Privacy is not an afterthought: remove direct identifiers, device identifiers, contact information, payment tokens, raw receipts, and secrets before a fixture becomes broadly accessible.
Draw the migration graph before writing a converter
Version numbers are not a migration plan. A game can move from 1 to 2, 2 to 3, and 3 to 4, then encounter a player who has skipped directly from 1 to 4. It can ship a hotfix that changes only a nested inventory record, or retire a field that old content still expects. A graph makes these choices visible: each node is a save schema or semantic version; each edge is an explicit transformation or an intentional rejection; each edge has preconditions and postconditions.
For every supported start version, decide whether the game chains adjacent transforms, uses a direct transform, or requires a service-side upgrade before the client loads. Do not leave skipped versions to whatever happens when fields are absent. A chain can be safer when every step is small and tested. A direct transform can be safer when intermediate formats were never intended to be observable. Both approaches need clear ownership and repeatable fixtures.

The graph should include more than the serialized type. Record content catalogue dependencies, configuration versions, feature flags that affect interpretation, and the current application version. A save may be syntactically current yet invalid because a referenced item no longer exists or a rule changed meaning. Those are compatibility boundaries, not annoying edge cases.
Make save-load round trips observable
Once a fixture enters the migration path, perform a full round trip: load the historical input, migrate it, construct the in-game model, save it using the current writer, then load that current output again. The first load checks whether a historical record can enter the system. The second load checks whether the current writer created a stable, self-consistent result. Compare the state after the first and second loads according to semantic rules, not only raw byte equality.
Raw equality is often the wrong assertion. New defaults may be added, field order may change, timestamps may be refreshed, and collections may receive stable ordering. Define a normalizer for values that are intentionally non-semantic, then compare the rest. The normalizer itself deserves review; it must not become a blanket ignore list that hides real loss. A good comparison reports which fields were added, removed, transformed, or intentionally ignored.
The test result should name the first invalid state. If a migration chain has three transitions, do not report only that the final load failed. Capture the state after each checkpoint, validate it, and stop at the earliest boundary that violates an invariant. That localization converts a vague “old save broken” report into a specific migration edge, fixture, and state difference.
Express game-specific invariants
A general migration harness cannot know what progression means in a particular game. The game must provide its own invariants. Write them in language that connects to player outcomes: a level is within the current range; an inventory quantity is non-negative and bounded; each owned item has a known definition or an explicit legacy fallback; a completed tutorial stays completed; currency is finite; a quest reference belongs to a current catalogue; an entitlement is unique; a world seed and generated world state agree.
Separate invariants into layers. Structural invariants answer whether required fields and types exist. Referential invariants answer whether IDs point to known definitions. Domain invariants answer whether values obey the game’s rules. Safety invariants answer whether data is finite, bounded, and safe to display. When a fixture fails, the layer tells the team whether it needs a serializer change, catalogue adapter, ruleset decision, or recovery policy.
Use deterministic mutations to make the corpus work harder. Start from a known fixture and deliberately remove a field, duplicate an inventory ID, replace a catalogue reference, skip a version, reorder an action list, or alter a boundary quantity. Each mutation should have an expected result: migrate with a documented default, reject with a diagnostic category, quarantine for recovery, or require an owner-approved data repair. “It does not crash” is not a sufficient expected result.
Reduce a failing save without losing the bug
Large player states are difficult to understand. If a fixture contains hundreds of fields and a migration fails, a developer can spend hours reading unrelated data. Counterexample minimization reduces the fixture while preserving the violated invariant. Remove a field group, retry the same deterministic migration, and keep the removal only when the failure still occurs. Then partition collections, shrink values toward boundaries, and reduce the action history if replay behavior matters.

Reduction requires reproducibility. Lock the fixture hash, migration version, adapter version, deterministic seed, relevant configuration snapshot, and expected invariant. If the environment changes during each retry, a reducer can produce a smaller state for a different problem. Keep a replay manifest with those inputs and the observed result. The output is not merely a smaller file; it is a regression case someone else can execute.
Generated regression fixtures should enter the same review path as hand-authored ones. Give each fixture a stable name, a short explanation of the failure, the invariant it preserves, and an expected classification. A regression fixture is valuable because it makes a previously surprising boundary routine. It should not become a hidden source of production player data or a catch-all for unexplained failures.
Put the rehearsal in CI without making it noisy
Run a fast representative subset on every relevant change: current migration edges, high-risk historical states, and a small set of deterministic mutations. Run the full corpus on a scheduled job, release candidate, or change that touches serialization, progression, catalogue interpretation, cloud sync, or save-format code. Shard by fixture family or migration edge rather than sending every test through one long sequence.
CI output should be concise but inspectable. Report the fixture ID, start and target versions, migration edge, invariant layer, first invalid checkpoint, and a path to the redacted artifact or diff. Avoid printing full profile contents in logs. When a failure is new, retain the minimal information needed to reproduce it locally; when it is expected, record that classification so a release does not silently normalize a known risk.
The release decision should state what the evidence means. Passing migration tests support a bounded claim: the tested corpus and declared paths behaved as expected in that environment. They do not prove every player save, device, or external service will behave identically. Teams still need rollback plans, customer-support handling, and monitoring that respect privacy.
Practical checklist
- Keep a versioned historical corpus with scenario labels and privacy classifications.
- Copy inputs before migration and preserve hashes for source and output artifacts.
- Draw supported migration edges, including skipped-version policy and explicit rejections.
- Validate structural, referential, domain, and safety invariants after every checkpoint.
- Round-trip migrated output through the current writer and reader.
- Use deterministic mutations to test missing fields, duplicate records, retired content, and boundary values.
- Reduce reproducible failures into reviewed regression fixtures with replay manifests.
- Run risk-focused subsets continuously and the full corpus before release-sensitive changes.
- Keep logs and artifacts redacted; do not turn CI into a player-data archive.
Frequently asked questions
Do we need real player saves to test migration?
No. Real historical shapes are useful, but the corpus can be synthetic or carefully redacted. What matters is preserving the difficult state combinations: retired content, long progression, interrupted transitions, optional data, and values at meaningful boundaries. If production data is used to discover an edge case, derive a safe fixture that retains only the property under test.
Should every old version stay supported forever?
Not necessarily. Support is a product and operational decision. The important thing is to make the boundary explicit: which versions migrate, which require an earlier update, which are rejected, and how the player is informed. An undocumented accidental path is the least safe option because it is neither reliably supported nor clearly refused.
Can a serializer test replace gameplay tests?
No. Serializer tests establish that data can be read and written according to declared shapes. Gameplay and domain tests establish that the resulting state makes sense in the current ruleset. A robust migration rehearsal needs both, plus a review of content and configuration references that influence the meaning of saved values.
What is Persistium’s role here?
Persistium is an in-development local-first reliability product family. The guidance in this note is practical engineering advice, not a claim that a public Persistium product is available or that it has diagnosed real customer data. Its Continuity direction explores the kind of explicit migration, replay, comparison, and reduction workflow described above.







