Save Systems and Serialization: Keeping a World Between Sessions
A save file is a promise made to a future version of your game: it must survive crashes, schema changes and years of patches, so its format deserves as much design as any mechanic it records.

Every game that lets the player leave and return makes an implicit vow: what you built will still be here. The vow is easy to make and surprisingly hard to keep. Between one session and the next the program is torn down entirely, every object in memory dissolved, and the world must be reconstructed from whatever was written to disk in the final moments. If that record is incomplete, or written in a shape the next version of the game cannot read, the player returns to ruins of a kind nobody designed.
Save systems tend to be built late, hurriedly, by someone who would rather be working on anything else, and they show it. They also tend to be among the most consequential code in a project, because a bug in combat costs a player a fight while a bug in saving costs them forty hours. The difference in stakes deserves a difference in care, and much of that care is not cleverness but patience: thinking ahead about crashes, upgrades and the slow accumulation of change.
This piece examines the subject from the ground up in a Unity context: what serialization actually does, the particular limitations of Unity's own tools, where files should live, how to keep old saves readable after the data model changes, how to write files so that a crash cannot corrupt them, and how a procedurally generated world can be saved without storing every stone.
What serialization is for
Serialization is the conversion of a living graph of objects into a flat sequence of bytes or characters, and deserialization is the reverse. The difficulty is that runtime objects are full of things that do not survive the trip: references to other objects, references to Unity assets, cached values, delegates, open file handles. A save system is therefore less a matter of writing everything down than of deciding what the true state of the world is, the minimal description from which everything else can be rebuilt.
A useful discipline is to separate save data from runtime objects entirely. Instead of serializing a MonoBehaviour, one defines plain C# classes marked [Serializable], data transfer objects that hold only what must persist: a villager's name, position, health and current task identifier, rather than the villager GameObject with its Animator, NavMeshAgent and dozen components. On load, the game reads these plain records and uses them to instantiate and configure the real objects.
References between objects need translation. A villager assigned to a farm cannot store a pointer to the farm, because pointers mean nothing after the program restarts. The usual approach gives each persistent entity a stable identifier, often a GUID string generated when the entity is first created, and stores relationships as identifiers. Asset references, a prefab or a ScriptableObject describing an item type, are handled the same way, through an identifier that a registry resolves back to the asset at load time.
Unity's tools and their limits
JsonUtility is the serializer Unity ships with, and it is fast, allocation conscious and pleasantly simple: JsonUtility.ToJson turns an object into a string and JsonUtility.FromJson turns it back. Its simplicity comes from following the same rules as the Unity serializer used for scenes and prefabs. It serializes public fields and private fields marked [SerializeField], ignores properties, cannot take a bare array or list at the top level without a wrapper class, and does not support Dictionary at all.
The dictionary limitation catches nearly everyone once. The standard workaround is to implement ISerializationCallbackReceiver, keeping two parallel lists of keys and values that are filled from the dictionary in OnBeforeSerialize and used to rebuild it in OnAfterDeserialize. It works, but it is boilerplate, and it hints at the broader truth that JsonUtility was designed for editor data, not for arbitrary object graphs.
Polymorphism is the other wall. If a list declared as a base type Task holds instances of GatherTask and BuildTask, the ordinary Unity serializer stores only the fields of the declared base type and loses the subclass identity. The [SerializeReference] attribute changes this: fields marked with it are serialized as managed references, preserving the concrete type and allowing shared references and null. Without that attribute, a polymorphic list will deserialize as a list of base class instances, quietly stripped of everything that mattered.
When these constraints chafe, many projects move to Newtonsoft Json.NET, available through Unity's package manager, which handles dictionaries, properties and typed polymorphism through configurable settings, at the cost of speed and allocations. Binary formats are faster and smaller but harder to inspect and debug. One option should be avoided: the old BinaryFormatter, which Microsoft now describes as insecure because deserializing untrusted data with it can execute arbitrary code.
Where saves belong
Application.persistentDataPath is the location Unity provides for user data that should survive between runs and across updates. On each platform it resolves to an appropriate directory: somewhere under the user's application data on Windows, inside the app's sandbox on mobile platforms, and so on. Writing beside the executable, or inside the Assets folder, is a common beginner's error that works in the editor and fails in a build, often on a player's machine where permissions differ.
PlayerPrefs is often mistaken for a save system, and it should not be one. It stores small key and value pairs, integers, floats and strings, in a platform specific place such as the registry on Windows or a preference list on macOS. It is ideal for volume levels, the last chosen resolution or whether the tutorial has been seen. It is a poor home for a settlement's buildings, because it was never meant for large or structured data and offers no versioning or atomicity.
It is good practice to keep several files rather than one. Settings live apart from progress; each save slot has its own file, perhaps with a small header file alongside, holding the slot's display name, play time and a thumbnail, so that the load menu can list slots without parsing every world. Cloud synchronisation services on various platforms also tend to work best with a few files of modest size rather than a single enormous one.
Versioning a format that will change
The data model will change. A new resource is added, a field is renamed, a building gains an upgrade level, a system is redesigned from scratch. Every such change threatens saves made by earlier builds, and players do not forgive losing a settlement to a patch. The first defence is trivial and indispensable: every save file begins with a format version number, written from the very first build, even when it seems there will never be a second.
On load, the game reads the version before anything else and, if it is older than the current one, passes the data through a chain of migrations: a function that converts version one to two, another that converts two to three, and so on up to the present. Each migration is small and is written once, at the moment the format changes, while the developer still remembers exactly what changed. Chaining them means a save from the oldest build can still reach the newest.
JSON is forgiving here in ways binary formats are not. Missing fields deserialize to default values, and unknown fields are simply ignored, so many additive changes need no explicit migration at all, only sensible defaults. Renames and restructurings are the dangerous cases, because the serializer will silently drop the old field and fill the new one with zero. Unity's FormerlySerializedAs attribute helps for assets, but save files deserve explicit migration code that can be tested against real archived saves.
Keeping a folder of saves from every released version, and running them through the loader as an automated test, turns migration from a hope into a guarantee. It is dull work. It is also the only reliable way to discover that a refactoring three versions ago broke the reading of a field that nobody has looked at since, before a player discovers it for you.
Writing without losing everything
The most dangerous moment in any save system is the write itself. If the game opens the existing save file, truncates it and begins writing, and the process is killed halfway, by a crash, a power cut or a player impatiently closing the window, the result is half a file. The old save is gone and the new one is unreadable. Autosaves make this worse, because they happen often and at moments the player did not choose.
The remedy is the atomic write. The game serializes the new state to a temporary file beside the real one, flushes it fully to disk, and only then replaces the old file with the new one, using File.Replace or a delete and File.Move sequence. Because the replacement is a rename rather than a rewrite, the save on disk is at every instant either the complete old version or the complete new one. Keeping the previous file as a backup gives one further step of safety.
Verification belongs at load time too. Writing a checksum, or simply the length of the payload, into the header lets the loader detect a truncated or damaged file and fall back to the backup rather than crashing or loading garbage. Serialization should also never happen on a half updated world: the game should gather a consistent snapshot of state at one moment, then write it, possibly on a background thread, while play continues.
Saving a world made of rules
A procedurally generated world poses a particular question: what, exactly, needs saving? The terrain, the rivers, the placement of every tree are all functions of the world seed and the generator's code. Storing them would be wasteful when they can be recomputed perfectly. The state that cannot be recomputed is everything the player and the simulation have changed: the trees felled, the walls raised, the ground dug, the villagers born and lost.
The natural format is therefore the seed plus a record of changes. Changes are usually grouped by chunk coordinate, so that loading a region means generating it from the seed and then applying its stored modifications. A settlement such as the one in Crown & Ashes, raised on a generated map, could be saved this way, as its seed and the record of what its people have built and spent, with the untouched wilderness rebuilt freshly from rules each time the game starts.
This approach brings one serious obligation: the generator must never change its output for an existing seed, or old saves will apply their modifications to the wrong terrain, placing a farm in a lake or a wall in mid air. Teams handle this by versioning the generator alongside the save format, keeping old generator versions available for old saves, or storing fully the chunks a player has modified. Whatever the choice, it must be made deliberately, before the first player trusts the game with their world.


