A save system looks like a small technical feature until real players depend on it. Then it becomes part database, part platform integration, and part promise. After 16 years in game development, including shipping Eagle Flight Arcade as a Gameplay Programmer at Ubisoft Montreal in 2016, publishing Touch Camera PRO, and freelancing across Unity projects, I treat persistence as production infrastructure. The goal is not merely to serialize an object. The goal is to preserve player intent through updates, crashes, platform interruptions, and design changes.
- Save durable player intent and stable identifiers, not an uncontrolled snapshot of the Unity scene.
- Use versioned data transfer objects that remain separate from MonoBehaviours and runtime systems.
- Write transactionally through temporary files, validation, replacement, and recoverable backups.
- Build explicit migrations and test them against real fixtures from every supported schema.
- Treat platform lifecycle handling, cloud conflicts, user feedback, and failure recovery as core features.
Why Is a Save System More Than Serialization?
Serialization answers one narrow question: how can an in-memory value become bytes or text? A production save system must answer a much larger set of questions. What happens if the application closes halfway through a write? What if a field disappears in the next update? What if the file is syntactically valid but refers to content that no longer exists? What if a returning player loads data created several releases ago?
Players rarely praise persistence when it works. They notice it immediately when it fails. Lost progress can invalidate hours of play, while a corrupted settings file can prevent the game from reaching its main menu. That makes the save pipeline part of the user experience, even though most of its code runs invisibly. I therefore design it with the same seriousness I would apply to input, scene loading, or platform submission requirements.
The first useful mental model is to treat a save operation as a transaction. It either produces a complete, validated new state, or the previous valid state remains available. Overwriting the only copy directly violates that rule. If the process loses power or storage access during the write, the player may be left with neither the old state nor a complete new one.
The second model is to treat every shipped save format as a public contract. Once players possess version 1 data, that schema exists in the world whether the team still likes it or not. Renaming a field, changing an item identifier, or reorganizing quests may become a data migration problem. This is why I push save design earlier than many teams expect. You do not need the final implementation during prototyping, but you do need to identify what progress means before content and code silently create incompatible assumptions.
What Game Data Should Actually Be Saved?
I start by separating durable player intent from reconstructable runtime state. Durable intent includes facts such as unlocked content, completed objectives, selected options, inventory quantities, or authored customization choices. Reconstructable state includes cached references, navigation paths, temporary animation values, pooled object status, and other information that systems can regenerate after loading. Saving less data usually creates a more resilient system.
Runtime objects are especially dangerous persistence targets. A MonoBehaviour may contain references to scenes, components, services, coroutines, and cached data that only make sense during one execution. Instead of storing the component, store a compact record that describes the durable outcome. A door might need a stable door identifier and an open state. It does not need its Transform, Animator, collider, or current animation time unless the game design specifically requires exact mid-animation restoration.
I also divide data by responsibility. Profile data can contain global unlocks or accessibility preferences. Campaign data can represent progress within one playthrough. Settings can have a separate lifecycle because they may need to load before gameplay. Temporary checkpoint data may be replaceable and more frequent than manual slots. These categories do not always require separate files, but naming them exposes conflicting requirements before they become code.
Authored identifiers are the bridge between saved records and current content. I prefer explicit, stable IDs over array positions, scene hierarchy paths, or display names. A designer should be able to reorder an item list or rename a visible label without invalidating old progress. IDs also need governance. Accidentally reusing a deleted ID can be worse than failing to find it because an old record may silently resolve to the wrong content. The save should express what happened, while current game systems decide how to reconstruct the playable state from that durable information.
How Should a Unity Save Schema Be Structured?
I keep persistence models separate from gameplay models. A save data transfer object should be boring: serializable fields, stable identifiers, simple collections, and minimal behavior. Gameplay systems can remain rich and interconnected, but the boundary between runtime state and persisted state should be explicit. This separation prevents an innocent refactor of a MonoBehaviour from automatically becoming a breaking file format change.
At the top level, I use an envelope containing a schema version, payload, integrity information, and useful diagnostic metadata. The schema version belongs to the data format, not the public game version. A game can ship several patches without changing its schema, or change the schema multiple times during one release cycle. Build information can still help diagnose reports, but it should not control migrations by itself.
Defaults must also be intentional. Missing data is normal when loading an older format. If a new accessibility option appears, an old save cannot contain it. The loader needs a documented default rather than whatever value a field happens to receive after deserialization. I prefer constructing a valid default model first and then applying loaded values or migration results. That approach makes absent optional information predictable.
Unity's JsonUtility is useful for straightforward serializable classes, but its limitations should shape the schema. Dictionaries, polymorphic graphs, and interface-heavy models require another representation or serializer. That is not necessarily a problem. Save formats benefit from simple structures. I avoid serializing UnityEngine.Object references and represent relationships with IDs instead. If a project needs human-readable files, JSON is convenient for debugging. If size or speed later becomes material, the storage encoding can change behind the same persistence interface. The domain schema matters more than whether the bytes are JSON, a binary format, or a platform-specific container.
How Can Save Writes Survive Crashes and Corruption?
My baseline file workflow is write, flush, validate, replace, and retain recovery data. The game first serializes a complete envelope into a temporary file located on the same storage volume as the final file. It flushes that file, then replaces the previous save. When the platform supports replacement with a backup, the old valid file becomes a recovery candidate rather than disappearing immediately.
A checksum helps detect truncation or accidental modification, but it is not an anti-cheat system. Anyone who controls the client can potentially reproduce a checksum. If competitive inventory, purchases, or entitlements require authority, those values belong on a trusted service. Local validation protects reliability, not business rules.
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
[Serializable]
public sealed class SaveEnvelope
{
public int schemaVersion = 3;
public string buildId;
public string payloadJson;
public string checksum;
}
public static class SaveFileWriter
{
public static void Save<T>(string slotName, T data)
{
string payload = JsonUtility.ToJson(data);
var envelope = new SaveEnvelope
{
buildId = Application.version,
payloadJson = payload,
checksum = ComputeSha256(payload)
};
string directory = Path.Combine(
Application.persistentDataPath, "saves");
string finalPath = Path.Combine(directory, slotName + ".json");
string tempPath = finalPath + ".tmp";
string backupPath = finalPath + ".bak";
Directory.CreateDirectory(directory);
byte[] bytes = Encoding.UTF8.GetBytes(
JsonUtility.ToJson(envelope));
using (var stream = new FileStream(
tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
stream.Write(bytes, 0, bytes.Length);
stream.Flush(true);
}
if (File.Exists(finalPath))
{
if (File.Exists(backupPath)) File.Delete(backupPath);
File.Replace(tempPath, finalPath, backupPath);
}
else
{
File.Move(tempPath, finalPath);
}
}
private static string ComputeSha256(string value)
{
using (var sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value));
return BitConverter.ToString(hash).Replace("-", string.Empty);
}
}
}
This is an illustrative desktop-style implementation, not universal platform code. File replacement, synchronous flushing, storage permissions, and available cryptography APIs can differ by Unity target and scripting backend. I put these operations behind an interface so console storage, WebGL persistence, mobile files, and editor tests can use appropriate adapters.
Loading should follow the reverse discipline. Read the primary file without mutating it, parse the envelope, validate the checksum, migrate the payload, and validate domain invariants. If that fails, try the backup and report the recovery. Never interpret a deserialized object as valid merely because parsing succeeded. A negative currency value, duplicate unique item, or reference to removed content may be structurally valid JSON while still being unusable game state.
How Should Save Data Migrations Work Across Updates?
In 2025 and 2026, games commonly remain installed through many updates, and players may return after long breaks. A save system therefore needs to handle skipped versions. I use sequential migrations: version 1 becomes version 2, version 2 becomes version 3, and so on until the current schema is reached. Each step is small, testable, and responsible for one historical change.
This is safer than a single function full of conditions for every previous release. A direct old-to-current conversion tends to accumulate branches that are difficult to reason about. Sequential migration preserves the meaning of each transition. For example, one step can replace a renamed item ID, while another initializes a newly introduced progression field. Tests can identify exactly which historical transition failed.
Migrations should operate on a copy and produce a validated result before replacing any file. Keep the original backup until the new save has loaded successfully. I also avoid destructive cleanup during the first migration pass. If content was removed, an archived or explicitly unknown record may be safer than silently deleting something that could become meaningful after a correction. The right policy depends on the game, but accidental data loss should never be the default.
Every migration needs fixtures representing actual old schemas. I check those files into version control with a short explanation of what they cover. That creates a permanent compatibility suite even after the original code has changed. It also forces the team to distinguish between schema history and current runtime classes. When support for a very old format must eventually end, make that a deliberate product decision with clear player communication and recovery options. Do not let compatibility disappear accidentally because a developer removed a class that appeared unused. Shipped data is an external dependency, even when the only external system is a file sitting on a player's device.
How Do Platform Lifecycles Change Save System Design?
A file path is only one part of platform persistence. Desktop, mobile, console, and WebGL targets have different lifecycle events, storage behavior, user identity models, and interruption risks. Unity's Application.persistentDataPath provides a useful location abstraction, but it does not make every platform behave identically. The storage adapter still needs testing on actual targets.
On mobile, the application can lose focus, pause, or be terminated without a long shutdown window. I do not rely exclusively on OnApplicationQuit. Instead, gameplay emits meaningful save requests during safe transitions, and pause handling attempts a final lightweight commit. Rapid events should be debounced so entering a menu does not trigger several redundant writes. A snapshot of Unity-owned state should be captured on the main thread before any background serialization or I/O, because most Unity APIs are not thread-safe.
Console projects may require platform user selection, reserved storage, quota handling, or platform-specific APIs. WebGL persistence may involve browser-managed storage and synchronization behavior rather than normal desktop files. Cloud saves add another layer, but cloud storage is replication, not a substitute for local transactional safety. Uploading corrupted state simply creates a remote copy of the problem.
Conflict resolution needs product rules. A revision number, device identifier, playtime summary, progression summary, and modification time can help, but the newest timestamp should not automatically win because clocks can be wrong. Some games can merge independent categories such as settings and unlocked cosmetics. Others must ask the player to choose between recognizable snapshots. Save size and frequency also affect cloud cost, synchronization time, and platform limits. I define these constraints before designing a massive world snapshot. Platform persistence is architecture plus user experience, so error messages, retry behavior, offline access, and account switching need the same attention as the serializer.
How Can a Team Test Save Failures Before Players Find Them?
I build a failure matrix rather than testing only a successful round trip. A useful suite covers a missing file, empty file, truncated JSON, invalid checksum, unsupported future version, every supported historical version, missing optional fields, removed content IDs, duplicate records, permission errors, and an interrupted temporary write. It should also verify recovery from the backup and confirm that repeated load-save cycles do not gradually change values.
The easiest way to test this is dependency injection. Gameplay code should talk to a save service, while that service talks to a storage interface. Editor tests can use memory storage that simulates partial writes, unavailable space, or exceptions at specific transaction stages. Integration tests can exercise real files in a temporary directory. Platform builds then verify the final adapter and lifecycle behavior.
Golden fixtures are particularly valuable. I keep representative files for each schema version and assert that they migrate into valid current models. I also test locale-sensitive values. Numbers and dates should use invariant, explicit representations rather than depending on the device's formatting rules. Randomized data can expose boundary mistakes, but carefully designed fixtures remain necessary for known historical cases.
Manual destructive testing still matters. Save during a meaningful transition, terminate the development build, relaunch, and inspect which state survives. Repeat the experiment at different moments around the transaction. On supported hardware, test low storage, user switching, suspended applications, and disconnected cloud access. Logging should identify the slot, schema version, transaction stage, and recovery path without exposing personal information or the full contents of a player's save. Finally, make failure visible to QA. If the game silently resets to defaults after a load error, a tester may report unrelated progression bugs instead of the real persistence failure. A clear diagnostic mode shortens that investigation dramatically.
What Production Workflow Keeps Persistence Maintainable?
A maintainable save system needs ownership and a written contract. I document the top-level data categories, current schema version, stable ID rules, save triggers, backup policy, migration policy, and platform adapters. Designers need to know which authored identifiers cannot be casually changed. Programmers need to know how new fields receive defaults. QA needs fixtures and a visible way to create, export, import, and damage test saves.
I also schedule persistence work alongside features rather than at the end. When a quest, inventory, customization system, or world interaction becomes durable, its save representation is part of its definition of done. That does not mean saving every internal field. It means proving that the player's meaningful outcome survives a restart and an upgrade from the previous schema.
Scope should match the game. A reliable autosave with one recoverable slot is better than an elaborate multi-slot interface built on unsafe writes. Add manual slots, thumbnails, cloud synchronization, cross-device merging, and import tools only when the product requires them. Each feature multiplies states that need testing. Even a thumbnail introduces questions about atomicity and whether the image matches the committed data.
My perspective here comes from both shipped game work and long-lived Unity products. Eagle Flight Arcade shipped in 2016, while assets such as Touch Camera PRO exist in an ecosystem where Unity versions and customer projects continue changing. The common lesson is that compatibility is ongoing work, not a launch checkbox. For teams that need broader architecture and delivery support, my full-cycle Unity game development service covers production planning, implementation, platform preparation, and systems such as persistence. Whatever the project size, the standard remains the same: preserve the last known good state, make schema changes explicit, and rehearse failure while the team can still fix it.
References & Further Reading
Written by Anthony KOZAK
Senior game developer with 16+ years of professional experience. Former Ubisoft engineer, contributed to Eagle Flight VR (Ubisoft Montreal) and Rabbids Coding (Ubisoft Lille, Games for Change Award 2020). Unity Asset Store publisher with 16+ actively maintained commercial plugins used by developers worldwide. Available for freelance game development, Unity consulting, VR/MR development, and technical training.
Need help with gamedev?
I'm a senior developer with 16+ years experience, including AAA projects at Ubisoft. Let's discuss how I can help with your project.
Start a Conversation