A small update goes live with a few new levels and one renamed field in the code. The next morning the first store reviews come in: progress is gone. Another player writes that her tablet overwrote the save from her phone, and suddenly she is back in world two instead of world five. The code behind it is often only a few lines long and never caused trouble in the editor.
Saving looks trivial in Unity at first. One call to PlayerPrefs.SetInt and you are done. In practice, save systems almost always fail at the same three points: the game is closed in the middle of writing, the data format changes with an update, or someone plays on more than one device. This article answers the question of how to save game data in Unity without falling into these traps. You will learn what PlayerPrefs is meant for, how to store save games cleanly as JSON files, how versioning works and what a cloud save has to handle when several devices are involved.
Three Layers, Three Tools
Before we get to code, a clear split helps. Most games store three kinds of data, and each one fits a different tool:
- Settings such as volume, graphics quality, language or "tutorial seen". These are a few small values, and this is exactly what PlayerPrefs is made for.
- The actual save game with level, inventory, quests and unlocks. This data is structured and grows with the game. It belongs in its own file, for example as JSON.
- Progress that has to survive a device change. As soon as players reinstall, switch phones or play on two devices, you need a copy on a server, which means a cloud save.
The three are not mutually exclusive. Most games end up using at least two of them, for example PlayerPrefs for settings and a JSON file for the save game that is also mirrored to the cloud.
PlayerPrefs: Good for Settings, Not for Save Games
What PlayerPrefs Can Do
According to the Unity documentation, PlayerPrefs stores exactly three data types: int, float and string. The values live in a local store on each platform, without encryption. Unity explicitly advises against storing sensitive data in PlayerPrefs.
For settings, that is exactly right. One key, one value, done. It becomes a problem when an entire save game ends up in PlayerPrefs, either as dozens of separate keys or as one huge string. There is no structure, the save is hard to back up or transfer, and on some platforms you will hit limits.
Where the Data Ends Up
Where PlayerPrefs stores its data depends on the platform. The Unity documentation lists these locations, with CompanyName and ProductName coming from your Player Settings:
- Windows: in the registry under
HKEY_CURRENT_USER\Software\CompanyName\ProductName - macOS: as a plist file in
~/Library/Preferences/, named after the bundle identifier - Linux: in
~/.config/unity3d/CompanyName/ProductName - Android: as a SharedPreferences file at
/data/data/pkg-name/shared_prefs/pkg-name.v2.playerprefs.xml - iOS: through the NSUserDefaults system API
- Web: in the browser's IndexedDB, limited to 1 MB
- Universal Windows Platform: as a
playerprefs.datfile in the LocalState folder of the app package
The 1 MB limit on the web alone shows why PlayerPrefs is a poor foundation for save games. A debugging tip: in the editor's Play mode, Unity uses separate locations. On Windows the values live under HKEY_CURRENT_USER\Software\Unity\UnityEditor\CompanyName\ProductName, and the key names carry a hash suffix. If you look in the wrong registry branch, you will find nothing.
When Data Is Actually Written
A detail many developers miss: according to the documentation, Unity writes PlayerPrefs to disk automatically when the application quits, in OnApplicationQuit, and on the Universal Windows Platform when the app is suspended. You can save yourself with PlayerPrefs.Save(). Unity points out, however, that this can cause short hitches and should not happen in the middle of gameplay.
From our experience: on smartphones the operating system often kills background apps without warning. Do not rely on the app quitting there. Save at quiet moments such as the end of a level or the pause menu instead, and also in OnApplicationPause(true). Unity calls this method when the application pauses, which on mobile devices usually means moving to the background.
The Save Game as a JSON File
The Right Folder: persistentDataPath
For the actual save game, a separate file is the better choice. Unity provides Application.persistentDataPath for this. According to the documentation, it is the folder for data that should be kept between runs. On iOS and Android, files there also survive app updates. Some examples from the documentation:
- Windows:
%userprofile%\AppData\LocalLow\CompanyName\ProductName - macOS:
~/Library/Application Support/with the company and product name - Android: the app folder returned by the system through
getExternalFilesDir - iOS: the Documents folder inside the app container
- Web: an area inside the browser's IndexedDB
Always build the full path with Path.Combine(Application.persistentDataPath, "save.json"). Unity warns that some APIs otherwise resolve relative paths against the project folder.
JsonUtility and Its Limits
Unity ships its own JSON serializer, JsonUtility. According to the documentation, it is significantly faster than popular .NET libraries, produces little garbage and can even run on a background thread. In return, the same rules apply as in the Inspector:
- Only fields are serialized, meaning public fields or fields marked
[SerializeField]. Properties do not end up in the JSON. - Custom classes and structs need the
[Serializable]attribute. - Dictionaries are not supported. If you keep your inventory in a dictionary, none of it will end up in the file.
- Arrays and primitive types cannot be passed directly, only as a field inside a wrapper class.
Two more properties are extremely useful for save games. Fields in the JSON that do not exist in your class are ignored. And if a field is missing from the JSON, it keeps whatever value your class gives it on construction. Versioning builds on exactly that later. If you need dictionaries or inheritance, the documentation recommends a general purpose .NET JSON library alongside it, and Unity offers its own package for Newtonsoft Json. For most save games, though, a list of small entries instead of a dictionary is perfectly sufficient.
Code Example: Saving Without Data Loss
The most common mistake is a plain File.WriteAllText straight onto the save file. If the game stops at exactly that moment, because the battery dies or the system kills the app, a half written file is left behind. On the next launch the save cannot be read.
A proven pattern against this: write to a temporary file first, then replace the old file and keep a backup. When loading, the game falls back to the backup if the main file is broken.
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
[Serializable]
public class InventoryEntry
{
public string itemId;
public int amount;
}
[Serializable]
public class SaveData
{
public int version;
public int level = 1;
public int coins;
public List<InventoryEntry> inventory = new List<InventoryEntry>();
}
public static class SaveSystem
{
public const int CurrentVersion = 2;
private static string SavePath => Path.Combine(Application.persistentDataPath, "save.json");
private static string TempPath => SavePath + ".tmp";
private static string BackupPath => SavePath + ".bak";
public static void Save(SaveData data)
{
data.version = CurrentVersion;
File.WriteAllText(TempPath, JsonUtility.ToJson(data));
if (File.Exists(SavePath))
{
// Replaces the old file and keeps it as a backup
File.Replace(TempPath, SavePath, BackupPath);
}
else
{
File.Move(TempPath, SavePath);
}
}
public static SaveData Load()
{
return TryRead(SavePath) ?? TryRead(BackupPath) ?? new SaveData { version = CurrentVersion };
}
private static SaveData TryRead(string path)
{
if (!File.Exists(path)) return null;
try
{
return SaveMigrations.FromJson(File.ReadAllText(path));
}
catch (Exception e)
{
Debug.LogWarning($"Save file {path} could not be read: {e.Message}");
return null;
}
}
}
If the game now stops while writing, only the temporary file is affected. The last good save stays intact. Note that the inventory is a List<InventoryEntry> rather than a dictionary, so JsonUtility actually stores it. This pattern is not something Unity prescribes, it is common practice. Test it on all target platforms, because file access follows its own rules on the web and on consoles in particular.
Versioning: Save Games That Survive Updates
Your game keeps evolving, and so does the save game. Fields are added, others are renamed or restructured. Without a plan, exactly what happened in the introduction occurs: because JsonUtility silently ignores unknown fields, a renamed field is simply empty after the update.
The solution is a version number in every file and a small migration on load. The Unity documentation itself describes a suitable approach: first read only the common fields, then decide which type the rest belongs to. Suppose the coin field was called gold in version 1 and is now called coins:
using System;
using UnityEngine;
public static class SaveMigrations
{
[Serializable]
private class SaveHeader
{
public int version;
}
[Serializable]
private class SaveDataV1
{
public int level;
public int gold;
}
public static SaveData FromJson(string json)
{
SaveHeader header = JsonUtility.FromJson<SaveHeader>(json);
if (header.version >= 2)
{
return JsonUtility.FromJson<SaveData>(json);
}
// Version 1 or files from before the version field existed
SaveDataV1 old = JsonUtility.FromJson<SaveDataV1>(json);
return new SaveData
{
version = 2,
level = old.level,
coins = old.gold
};
}
}
One detail matters here: if an old file has no version field, it stays at 0 in SaveHeader. If the class gave the field a different starting value, such as the current version, old files would never be migrated. That is why the version number in the header has no default value of its own.
Three rules from our experience:
- Add a version field from the first release. Introducing it later is painful.
- Never rename a field without migrating. Add a new field instead and move the old value over in the migration.
- Keep old save games. Store a test file from every released version and load all of them in an automated test. That way you notice before release when a migration breaks.
Being Realistic About Cheating and Encryption
Anything stored on the device can be read and changed. That applies to PlayerPrefs as much as to JSON files. Encryption makes this harder but not impossible, because the key ultimately lives inside the game. It mainly stops casual tinkerers who want to multiply their coins with a text editor.
For settings and simple values, a wrapper around PlayerPrefs is often enough. At ProjectMakers we have released the open source package PmPrefs for this. It encrypts values with AES, stores any serializable object and comes with an editor window for viewing and editing entries. One important point, as the README explains: the built-in default key is public, so set your own.
Anything that really matters does not belong on the device. Leaderboards, purchases or rewards that affect other players are validated and stored by a server.
Cloud Saves and Multiple Devices
When a Cloud Save Is Worth It
Local files are enough as long as a player uses exactly one device. As soon as they reinstall, switch phones or continue on the PC in the evening and on the phone while out, the save needs a home on a server. If your game ships on only one platform, platform services such as Steam Cloud or iCloud can be an option. For games on several platforms, you need storage tied to a player account rather than to a platform.
The Real Problem: Conflicts
Uploading itself is rarely the issue. It gets difficult when two devices hold different saves. A typical case: the tablet spent a week offline in a drawer. Meanwhile, the player finished three worlds on her phone. Then she turns on the tablet, it goes online and uploads its old save. If the rule is "last writer wins", the progress from the phone is gone.
The solution in principle: every save on the server carries a revision. A device may only save if it knows the current revision. If the save has changed in the meantime, the server rejects the write and the game has to decide. Possible strategies are the save with more progress, the newer save, or asking the player with both saves to choose from. You may know the underlying problem from backend development, where duplicate or delayed requests can overwrite data as well. More on that in our article on idempotent API design.
Build It Yourself or Use a Ready Solution
Building a cloud save yourself takes more than an upload endpoint. You need sign-in that also works anonymously, storage per player, revisions for conflicts, handling for dropped connections and a way to reconcile the local save with the server save. It is doable, but it takes time that many small teams would rather spend on the game itself.
A ready alternative is our game backend horizOn. Its SDKs for Unity, Godot and Unreal include a cloud save tied to the player account. Since October 2026 it works with revisions, which prevents an older save from overwriting a newer one when a player switches devices. How a conflict is resolved is still up to your game. If you prefer to run the server yourself, you can use the open source Simple Server, which stores cloud saves of up to 300 KB per player. It does not support revisions, though, as those are reserved for hosted horizOn.
Checklist: A Save System in Five Steps
- Split the data correctly. Settings in PlayerPrefs, the save game as a JSON file in
Application.persistentDataPath. - Write safely. Write to a temporary file first, then replace and keep a backup.
- Version from the first release. A version field in every file, migration on load and test files from every released version.
- Save at the right time. At quiet moments and in
OnApplicationPause(true), not in the middle of gameplay. - Take multiple devices seriously. A cloud save with revisions instead of "last writer wins", plus a clear rule for conflicts.
Conclusion
PlayerPrefs is not a bad tool, it is simply meant for something else. It is ideal for settings, but for save games it lacks structure, safety and, on some platforms, simply space. The save game belongs in its own JSON file that is written safely, carries a version number and survives updates. Once multiple devices are involved, the quality of a cloud save is not decided by the upload but by how it handles conflicts.
A practical next step: open your current save system and check three things. Is there a version field? Do you write through a temporary file? And what happens if the file is broken on load? If your game runs on mobile devices, also take a look at our tip on mobile optimization for Android and iOS, because saving at the wrong moment quickly costs noticeable frames there. And if you need support with your save system or the backend for your game, our game development services are there for you.