Almost every game needs to remember something between sessions: the level reached, the inventory, the settings. Unity has no built-in “save game” button, but you can build a solid save system with a few dozen lines of C#: put your data in a plain class, turn it into JSON, and write it to Application.persistentDataPath. This tutorial builds that system step by step, then makes it robust with save slots, safe writes, versioning and dictionaries, and makes it work in WebGL builds, where saves silently vanish if you miss one setting.
PlayerPrefs, JSON or Binary?
There are three common ways to persist data in Unity:
- PlayerPrefs stores single values (int, float, string) by key. On Windows it writes to the registry. It’s perfect for settings like volume and resolution, and a poor fit for game state: no structure, no slots, and hard to back up or inspect.
- JSON files store a whole object graph as readable text. Easy to debug (open the file and read it), easy to version, and supported out of the box by
JsonUtility. This is what most indie games should use. - Binary formats are smaller and faster for huge saves. Avoid the old
BinaryFormatterexamples in older tutorials: Microsoft marks it as insecure and obsolete. If you need binary, use a maintained serializer library.
This guide uses JSON files, with PlayerPrefs kept for settings only.
Step 1: Design the Save Data
The key idea: don’t save GameObjects, save data. Write a plain C# class that holds only what you need to rebuild the game state, and mark it [System.Serializable]:
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class SaveData
{
public int version = 1;
public string sceneName;
public Vector3 playerPosition;
public int health = 100;
public int coins;
public List<string> unlockedLevels = new();
public List<InventoryItem> inventory = new();
public float playTimeSeconds;
}
[Serializable]
public class InventoryItem
{
public string itemId; // e.g. "sword_iron", not a reference to an asset
public int count;
}
Notice the inventory stores an itemId string rather than a reference to a ScriptableObject or prefab. References to Unity objects can’t be stored meaningfully in a save file, so give each item a stable ID and look it up again when loading.
Step 2: Save and Load with JsonUtility
Unity’s built-in JsonUtility turns the object into a JSON string and back. Combined with System.IO.File, saving and loading takes a handful of lines:
using System.IO;
using UnityEngine;
public static class SaveSystem
{
static string SavePath => Path.Combine(Application.persistentDataPath, "save.json");
public static void Save(SaveData data)
{
string json = JsonUtility.ToJson(data, true); // true = pretty print
File.WriteAllText(SavePath, json);
}
public static SaveData Load()
{
if (!File.Exists(SavePath)) return new SaveData(); // first launch
string json = File.ReadAllText(SavePath);
return JsonUtility.FromJson<SaveData>(json);
}
}
Call SaveSystem.Save(data) and open the file: you’ll see readable JSON with your fields. Always use Application.persistentDataPath. It points to a writable, per-user folder on every platform. Application.dataPath points inside the game’s install folder, which is read-only or packed in a build.
Step 3: A Reusable SaveManager
In a real game, many objects own state: the player knows its position, the wallet knows the coins. Rather than having one script reach into everything, let each system copy its state into the SaveData and read it back. A small interface keeps this tidy:
public interface ISaveable
{
void SaveTo(SaveData data);
void LoadFrom(SaveData data);
}
using UnityEngine;
public class SaveManager : MonoBehaviour
{
public static SaveManager Instance { get; private set; }
public SaveData Current { get; private set; }
void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
Current = SaveSystem.Load();
}
public void SaveGame()
{
foreach (var s in FindObjectsByType<MonoBehaviour>(FindObjectsSortMode.None))
if (s is ISaveable saveable) saveable.SaveTo(Current);
SaveSystem.Save(Current);
}
public void ApplyLoadedData()
{
foreach (var s in FindObjectsByType<MonoBehaviour>(FindObjectsSortMode.None))
if (s is ISaveable saveable) saveable.LoadFrom(Current);
}
}
A player script then only has to handle its own fields:
public class PlayerState : MonoBehaviour, ISaveable
{
public int health = 100;
public void SaveTo(SaveData data)
{
data.playerPosition = transform.position;
data.health = health;
}
public void LoadFrom(SaveData data)
{
transform.position = data.playerPosition;
health = data.health;
}
}
Call ApplyLoadedData() after the saved scene has loaded, for example from a SceneManager.sceneLoaded handler. The FindObjectsByType scan is fine on save and load; don’t run it every frame. If the player has a CharacterController, disable it before setting the position and re-enable it after, or it can snap the player back.
JsonUtility Limits (and When to Use Newtonsoft)
JsonUtility is fast and built in, but it follows Unity’s serialization rules, which catch everyone out at least once:
- Fields only. C# properties (
{ get; set; }) are ignored. Private fields are saved only with[SerializeField]. - No dictionaries. A
Dictionaryfield is silently skipped. - No top-level arrays or primitives. The root must be a class or struct, so wrap a list in a class.
- Nested custom classes need
[Serializable], or they’re skipped. - For a MonoBehaviour or ScriptableObject, use
JsonUtility.FromJsonOverwriteinstead ofFromJson, since those can’t be created withnew.
The standard workaround for dictionaries is to save them as a list of pairs and rebuild them on load:
[Serializable] public class IntEntry { public string key; public int value; }
// Saving
data.questProgress = questDict.Select(kv => new IntEntry { key = kv.Key, value = kv.Value }).ToList();
// Loading
questDict = data.questProgress.ToDictionary(e => e.key, e => e.value);
(This needs using System.Linq;.) If your data is complex, with dictionaries, polymorphic lists or properties, switch to Newtonsoft Json.NET. Add the package by name in the Package Manager (+ > Install package by name, com.unity.nuget.newtonsoft-json) and use JsonConvert.SerializeObject(data, Formatting.Indented) and JsonConvert.DeserializeObject<SaveData>(json). It handles dictionaries and properties directly, at the cost of being slower than JsonUtility.
๐พ Rather not maintain your own save code?
Easy Save is the long-running Asset Store save system: it handles dictionaries, references, encryption, compression, save slots, backups and WebGL out of the box, with a two-line API. It’s a common time-saver once a project’s save data grows complex.
Slots, Safe Writes and Versioning
Save slots
Slots are just different file names. Change the path to take a slot number, such as $"save_{slot}.json", and list existing slots with File.Exists. To show “Slot 2: Forest, 3h 12m” in a menu, load each slot’s SaveData and read sceneName and playTimeSeconds.
Write safely so saves can’t be corrupted
If the game crashes or the power fails in the middle of WriteAllText, the player can be left with half a file and a lost save. Write to a temporary file first, keep a backup of the previous save, then swap:
public static void SaveSafely(SaveData data, string path)
{
string tmp = path + ".tmp";
File.WriteAllText(tmp, JsonUtility.ToJson(data, true));
if (File.Exists(path))
File.Copy(path, path + ".bak", true); // keep the last good save
File.Copy(tmp, path, true);
File.Delete(tmp);
}
When loading, wrap FromJson in a try/catch. If the main file fails to parse, fall back to the .bak file instead of wiping the player’s progress.
Version your save format
You’ll add fields after release. JsonUtility handles new fields gracefully: they get their default values when loading an older save. Renamed or restructured data needs migration code, which is what the version field is for:
const int CurrentVersion = 2;
static SaveData Migrate(SaveData data)
{
if (data.version < 2)
{
data.coins *= 10; // e.g. v2 changed the coin economy
data.version = 2;
}
return data;
}
Encryption and cheating
A readable JSON file is easy for players to edit. For a single-player game, that’s usually fine and even welcome. If you want to discourage casual edits, you can encrypt the string before writing it. Anything that runs on the player’s machine can eventually be reversed, though, so anything that really matters (leaderboards, purchases) must be validated on a server.
Saving in WebGL Builds
WebGL has no real file system. Unity emulates one: Application.persistentDataPath points to /idbfs/ followed by a hash of the page’s directory, and files there are stored in the browser’s IndexedDB. The catch is that writes go to memory first and must be synced to IndexedDB, or they’re lost when the tab closes.
- In Unity 6, the WebGL loader’s config object in your template’s
index.htmlacceptsautoSyncPersistentDataPath: true, which syncs automatically after writes. Check your template for this setting first. - Without auto-sync, you need a small
.jslibplugin that callsFS.syncfs(false, ...)after each save. - Saves are tied to the page’s URL. Moving the game to a different path or domain starts a fresh save, and players who clear their browser data lose their saves.
- Browsers limit storage per site. Keep saves small; a few hundred KB is plenty.
For more on WebGL builds, see how to optimize Unity WebGL builds and our WebGL build size estimator.
Where Save Files Are Stored
Application.persistentDataPath resolves to a different folder on each platform. The company and product names come from Project Settings > Player. Change them before release, because changing them afterwards moves the save folder and players “lose” their saves:
- Windows:
%userprofile%\AppData\LocalLow\<Company>\<Product> - macOS:
~/Library/Application Support/<Company>/<Product>(the exact folder can vary with the bundle identifier) - Android: the app’s external files folder, typically
/storage/emulated/0/Android/data/<package name>/files - iOS: the app’s
Documentsfolder - WebGL:
/idbfs/<hash>in IndexedDB
Quick tip: add Debug.Log(Application.persistentDataPath); once, or open the folder with EditorUtility.RevealInFinder(Application.persistentDataPath) from an editor menu item. Our editor scripting guide shows how to add a “Delete Save” menu item too, which is useful when testing first-launch behaviour.
Troubleshooting
ArgumentException: JSON must represent an object type.
You passed JSON whose root is an array or a single value, such as [1,2,3]. JsonUtility needs an object at the root. Wrap the list in a class: [Serializable] class Wrapper { public List<Item> items; }. The same error appears if the file is empty or contains something that isn’t JSON.
The saved file is just {} or is missing fields
The fields aren’t serializable. Check that they’re public fields or have [SerializeField], that they aren’t properties, that nested classes have [Serializable], and that the type isn’t a Dictionary. Also check you’re saving the object you think you are, not a fresh new SaveData().
UnauthorizedAccessException: Access to the path is denied
You’re writing somewhere the game isn’t allowed to write, usually Application.dataPath or Application.streamingAssetsPath in a build. Use Application.persistentDataPath. On Windows, antivirus “controlled folder access” can also block writes; a path under LocalLow is normally allowed.
Self referencing loop detected for property ‘normalized’
Newtonsoft is serializing a Vector3 and follows its normalized property, which returns another Vector3, forever. Save positions as your own serializable struct or a float[], add a custom converter, or set ReferenceLoopHandling = ReferenceLoopHandling.Ignore in your serializer settings. JsonUtility doesn’t have this problem because it only looks at fields.
Saves disappear after reloading the WebGL page
The data never got synced to IndexedDB. Enable autoSyncPersistentDataPath or sync manually after saving, as described in Saving in WebGL builds. Also check that the game is served from the same URL each time and that the browser isn’t in private mode, which discards storage on close.
Loaded values are immediately overwritten
Something runs after your load and resets the state, often a Start() method that sets default health or position. Load after initialization (call ApplyLoadedData() at the end of scene setup), or have your scripts read their starting values from the loaded SaveData.
FAQ
What’s the best way to save game data in Unity?
For most games, a serializable data class saved as JSON to Application.persistentDataPath. Use PlayerPrefs only for settings.
Can JsonUtility save a Dictionary?
No. Convert it to a list of key-value entries before saving and rebuild it after loading, or use Newtonsoft Json.NET, which supports dictionaries directly.
Where does Unity store save files on Windows?
In %userprofile%\AppData\LocalLow\ followed by your company name and product name from Player settings.
Should I use PlayerPrefs for saves?
Only for small settings. PlayerPrefs has no structure or slots and stores data in the registry on Windows, which makes it a poor fit for game progress.
Why don’t my WebGL saves persist?
WebGL writes must be synced to the browser’s IndexedDB. Enable autoSyncPersistentDataPath in your template’s loader config, or call FS.syncfs from a .jslib after saving.
Is BinaryFormatter safer than JSON?
No. BinaryFormatter is obsolete and insecure. It also isn’t meaningfully harder to tamper with. Use JSON, optionally encrypted, or a modern binary serializer.
Reader questions
Asked by readers of this article and answered by AI. Behavior can differ between Unity versions, so test in yours.
Does this JSON save method work on Android builds or do I need something different?
Yes, this JSON save method works on Android builds without any changes. Application.persistentDataPath points to a writable local folder on Android just as it does on Windows and other platforms. You do not need a different approach for standard file I/O on mobile devices.




Leave a Reply