You’re building a minimal JSON save/load flow that works on iOS and Android and avoids PlayerPrefs for progression. This is a concise mobile game save data tutorial for PlayMobile.online by George Jones.
What you end up with: a GameData object holding progression (currency, high score, unlocks) and a SaveSystem script with Save() and Load() methods you can call from UI or gameplay events. Follow official docs: JsonUtility and Application.persistentDataPath, and use System.IO File for reading and writing text.
Code you can copy-paste:
using System;
using System.IO;
using UnityEngine;
[Serializable]
public class GameData { public int currency; public int highScore; public bool[] unlocks; }
public class SaveSystem : MonoBehaviour {
string path => Path.Combine(Application.persistentDataPath, "save.json");
public void Save(GameData data) {
var json = JsonUtility.ToJson(data);
File.WriteAllText(path, json);
}
public GameData Load() {
if (!File.Exists(path)) return new GameData();
var json = File.ReadAllText(path);
return JsonUtility.FromJson(json);
}
}
Note mobile limits: writes can be slow on old phones, JSON allocates memory, and frequent disk writes hurt battery and cause frame hitches. Treat files as user-editable and validate everything you load. Avoid saving every frame, using PlayerPrefs as a full save approach, or writing into Assets/ instead of persistentDataPath.
Drop-in JSON save/load script using JsonUtility and Application.persistentDataPath
This drop-in script pattern keeps things small and predictable. It focuses on a minimal schema, a stable path, and basic guardrails so your progress persists safely across installs and updates.
Create a minimal GameData class
Make a [System.Serializable] class that lists only plain public fields. Use simple types and arrays. Avoid properties; JsonUtility serializes fields by name.
File I/O and setup
Include using System.IO; at the top of your script so the compiler recognizes File APIs. In Awake(), set:
- saveFile = Application.persistentDataPath + “/gamedata.json”;
This fixes the save file path early, after the runtime has initialized persistentDataPath.
Implement Save() and Load() methods
Save flow: populate your GameData instance, call JsonUtility.ToJson(gameData), then File.WriteAllText(saveFile, json). Pretty-printing is optional and increases bytes.
Load flow: check File.Exists(saveFile). If it exists, read contents with File.ReadAllText and call JsonUtility.FromJson(contents). If missing, create defaults and write one file so future loads succeed.
Quick in-Editor test
- Run Play, trigger your Save() method, stop Play.
- Call Debug.Log(Application.persistentDataPath) to find the file.
- Open gamedata.json, inspect fields, then restart Play and confirm Load populates UI/state.
Troubleshooting tips: log the path, catch and log exceptions around File calls, and ensure fields are public or marked [SerializeField]. Remember: writing into the project folder on device will not work—use persistentDataPath.
What you should save in a mobile game save file vs what belongs in PlayerPrefs
Separate transient preferences from persistent progression to match player expectations and platform limits.
Use PlayerPrefs for tiny, instant settings you need often and that are easy to read or write. Good examples are audio toggles, control sensitivity, and graphics quality. These are single values and fit SetInt/GetInt patterns. Call PlayerPrefs.Save() at predictable times, like when the player changes a setting.
Use a structured save game file for real progression: currency, unlocked items, checkpoints, levelIndex, and bestScore. That format handles lists, schema versioning, and migrations. Storing progression in PlayerPrefs quickly becomes messy when you need arrays, validation, or backward compatibility.
- PlayerPrefs: musicEnabled, invertControls, gfxPreset
- Save file: coins, levelIndex, unlockedSkins, bestScore, checkpoints
| Type | Example | Why |
|---|---|---|
| Quick setting | musicEnabled | Single-value, frequent access |
| Progression | unlockedSkins | Lists, needs versioning |
| Runtime-only | currentAnimation | Ignore in persistence |
Rule of thumb: if it’s a simple flag or preference, use PlayerPrefs. If it’s structured or needs migration, use a file-based approach. Save settings on change; persist progression at checkpoints or app backgrounding. This keeps the player experience reliable and easier to maintain during development.
Unity save system JSON data model that won’t paint you into a corner
Build a small, intentional data format that is easy to extend and validate.
Versioning and migrations
Add a schemaVersion int to your GameData and check it on load. This lets you migrate old files without breaking players.
At a high level a migration switch looks like:
- case 1: do nothing; break;
- case 2: add defaults for new fields; break;
- default: create a safe fallback and bump version;
Keep fields flat and intentional
Use primitives, strings, and simple lists. Avoid references to MonoBehaviours, Transforms, or scene objects.
For unlocks, store identifiers rather than full objects. For example: List<string> unlockedItemIds.
Lists, limits, and runtime separation
The JsonUtility way handles List<int>, List<string>, and lists of [System.Serializable] types. It does not serialize dictionaries without wrappers.
Keep runtime caches, UI state, and timers out of persisted files. Trying to serialize the whole scene bloats files and breaks across versions.
| Concern | Recommended field | Why |
|---|---|---|
| Unlocks | List<string> | Stable IDs, forward-compatible |
| Progress | primitives | Compact and easy to migrate |
| Runtime | exclude | Avoid scene-specific objects |
Serialization and deserialization in Unity without the confusion
Think of serialization as exporting your runtime state to a compact text form, and deserialization as importing that text back into objects you can work with at runtime.
Clear definitions you’ll use in code
Serialization converts a GameData instance into a JSON string you can write to disk. In practice you call JsonUtility.ToJson().
Deserialization reads that string and rebuilds the same class instance. You do that with JsonUtility.FromJson<T>().
Why matching fields matter
If the JSON has highScore but your class uses HighScore, the field won’t populate. That mismatch causes silent bugs you’ll chase at runtime.
Unity serializes fields — public or marked with [SerializeField] — not C# properties by default. Developers from standard C# backgrounds often expect properties to work and run into this exact trap.
- note: add [System.Serializable] on the class.
- Use fields (not properties) and avoid interface-typed fields.
- Wrap dictionaries or use lists; Unity’s serializer won’t handle raw dictionaries.
| Term | Runtime call | Why it matters |
|---|---|---|
| Serialization | JsonUtility.ToJson() | Produces text for disk |
| Deserialization | JsonUtility.FromJson<T>() | Rebuilds objects safely |
| Validation | Post-load checks | Treat file input as untrusted |
Treat serialization as input handling, not security. Always validate loaded values and clamp impossible numbers before they affect gameplay.
Reading and writing the save file safely on iOS and Android
A resilient approach to reading and writing the save file keeps your app stable across installs and OS quirks.
Use Application.persistentDataPath as the target directory. It is per-app, writable at runtime, and survives restarts and updates. The app bundle is read-only on devices, so writing there will fail. Keep your files inside the persistent path to avoid platform permission headaches.
Common I/O failure points
Real-world failures happen. Plan for first-run cases where the file does not exist. Handle users who clear app storage or edit files with external tools.
Other issues include interrupted writes if the app is killed, OS cleanup when storage is low, and unexpected permission changes if you try to write outside the app directory.
Basic guardrails and pattern
Wrap reads and writes in try/catch and log the full path with any exception message. Use File.Exists before reading. If the file is missing, create a default in memory and optionally write a new file once.
- Check File.Exists(path) before File.ReadAllText.
- On failure, fall back to defaults and keep the session running.
- For safety, write to a temp file then replace the original to reduce corruption risk.
| Failure mode | Symptom | Mitigation |
|---|---|---|
| First run / missing file | Read call returns nothing or File.Exists false | Create defaults in memory and write a new save file |
| Interrupted write | Partial or corrupted content | Write to temp file then rename; keep a backup copy |
| User cleared storage | All files removed, app restarts with defaults | Detect missing files and notify player if needed |
| Low storage / IO exception | Write fails with exception | Catch exception, log path and message, use in-memory fallback |
Validating and sanitizing save data so edited files don’t break your game
Treat local files as untrusted input and validate every value before it touches gameplay. Local files can be edited by users, so you must handle malformed or malicious content the same way you handle bad network responses.
Practical rules you can apply
Implement a Sanitize(GameData d) method that enforces limits and fixes or rejects values.
- Clamp numeric fields: currency >= 0, lives between 0 and yourMaxLives.
- Validate indices: levelIndex must be within your build count.
- Check lists and strings: remove nulls or unknown item IDs instead of crashing.
Corruption and fallback methods
Partial writes or edited json can produce parse failures or default objects. On parse failure, try loading a last-known-good backup.
If the backup fails, create a new default file and rename the broken file with a .bad extension for debugging. This prevents soft-locks and preserves player experience.
| Issue | Detection | Action |
|---|---|---|
| Parse error | FromJson throws or returns defaults | Load backup → if fail, create default and rename broken file |
| Out-of-range value | Field exceeds allowed max/min | Clamp to allowed range and log |
| Unknown IDs | Item IDs not in registry | Remove unknown entries and continue |
Mobile performance considerations for save/load operations
On phones, disk writes and large string allocations cost time and battery. Plan your persistence so you do heavy work at safe moments. Keep the main thread free to maintain a smooth UI on small screens.
When to write to disk
Write at clear checkpoints: end-of-level, player-confirmed menu saves, and app backgrounding. Avoid per-frame or per-score-tick writes; those cause frame hitches and drain battery.
Memory spikes from JSON strings
Serialization creates temporary strings and can trigger garbage collection on low-memory devices. That GC causes short stutters during play.
Mitigate this by reusing a single data instance, minimizing temporary allocations, and keeping your serialized object flat and compact.
Keep saves small to reduce load time
Store only what you need to restore a session: IDs, counters, and indices. Drop long logs, per-frame histories, and large arrays that bloat files.
UI responsiveness and threading
Never block the main thread while writing or reading files. A frozen loading panel feels broken on phones with small displays.
If needed, schedule save load operations during natural quiet moments in play. Asynchronous I/O helps, but baseline patterns that avoid heavy work during gameplay are sufficient for most projects.
| Cause | Symptom | Mitigation |
|---|---|---|
| Frequent writes | Frame hitches, battery drain | Save at checkpoints and on backgrounding |
| Large serialized strings | GC pauses and memory spikes | Reuse objects, shrink payloads |
| Main-thread I/O | Frozen UI or loading screen | Run I/O off-frame or async; show spinner |
Common beginner mistakes with Unity JSON save systems and how you avoid them
Many junior implementations look fine in the Editor but fail hard on devices. Below are the faults I see most often and the precise fix for each one.
Saving in Update() or every score tick
Problem: writing on every frame or score tick creates constant disk I/O, battery drain, and visible stutters on phones.
Fix: trigger saves on events — end of level, checkpoint, menu confirm, or app backgrounding. Batch changes in memory and call your methods only when needed.
Using PlayerPrefs as a full save system
Problem: PlayerPrefs is fine for toggles and tiny settings, but it is not built for structured progression.
Fix: store progression in a single json file and keep simple preferences in PlayerPrefs.
Forgetting [System.Serializable] or using non-serializable properties
Problem: missing the attribute or relying on C# properties leads to empty fields on load.
Fix: use public fields or [SerializeField] on private fields and mark the class serializable. Test round-trip: ToJson → FromJson → verify values.
Hardcoded paths or writing into Assets/Resources
Problem: absolute paths or writing into project folders work in Editor but break on devices.
Fix: always target Application.persistentDataPath at runtime so the file is writable and survives updates.
Assuming loaded content is valid
Problem: FromJson can return defaults or partial objects when files are edited or corrupted.
Fix: validate and clamp every field, use backups, and fall back to sane defaults on parse failure.
- note: add a developer “Reset Save” button to test first-run logic without reinstalling the app.
| Common Mistake | Symptom | Quick Fix |
|---|---|---|
| Update() writes | Frame hitches, battery drain | Event-driven saves |
| PlayerPrefs for progression | Hard-to-maintain keys, no lists | Use a json file for progression |
| Missing serialization | Fields load empty | Add [System.Serializable] and use fields |
Implementing settings save with PlayerPrefs alongside your JSON save game
Settings belong in a tiny, instant store so your app responds to player actions with no lag.
Music toggle example (SetInt / GetInt)
Use SetInt and GetInt for a music flag because this API does not store booleans directly. Store 1 for on and 0 for off. On start, check HasKey(“music”) and initialize if missing.
Example flow: on awake, read GetInt(“music”, 1); set UI toggle accordingly; enable or disable the AudioSource.
When to call PlayerPrefs.Save()
Hook your UI Toggle’s OnValueChanged to a ToggleMusic(bool on) method that calls SetInt(“music”, on ? 1 : 0). Call PlayerPrefs.Save() after the user changes a setting or when leaving the settings menu.
Do not call Save every frame. Frequent writes hurt battery and can cause hiccups on phones. Keep preferences here and let your primary save file keep progression so concerns stay separated.
Designing Save and Load triggers that fit mobile play patterns
Short play sessions and frequent app switching change how you persist progress. Design triggers that match what the player does and when the OS might kill your app.
Recommended trigger points
Write progress after finishing a level. Save again at checkpoints. Persist when the user hits Back to Menu. Also save on app backgrounding or pause events.
Why backgrounding matters
Phones often suspend or kill paused apps. Saving on pause reduces lost progress and frustration. Treat background writes as essential, not optional.
Load flow and overwrite rules
On boot, show Continue if a save exists; show New Game otherwise. Require confirmation before starting a New Game that overwrites an existing slot.
Support multiple slots or a simple profile file if you expect several players on one device.
Industry practice and support
Treat client-side files as editable: validate every field and sanitize values on load. Predictable triggers plus a visible Reset option cut support tickets about lost progress.
| Trigger | When | Why |
|---|---|---|
| End of level | After completion | Definitive progress checkpoint |
| Checkpoint | During play | Minimize lost play time |
| Back to Menu | User action | User intent to pause or exit |
| App background | On pause/OS suspend | Protect against process kill |
Hardening the save system for production without overengineering
Make your project resilient with a few lightweight guardrails. Files can be interrupted or corrupted; simple ReadAllText/WriteAllText calls work but do not guarantee atomic safety. Add a tiny layer that protects players and reduces support load.
Atomic write pattern
Write the serialized text to a temp file first (gamedata.tmp). Flush and close the stream, then replace the main file (gamedata.json) with an atomic rename. This avoids half-written files if the app crashes mid-write.
Lightweight backup strategy
Before replacing the main file, copy the current file to gamedata.bak as a last-known-good snapshot. On load, if parsing fails, try the .bak file before falling back to defaults. Two files—temp and bak—cover most corruption cases without a big system.
Logging and telemetry hooks
Log the file path, write size, and any load failures during development. Gate verbose logs behind a DEBUG flag or compile-time define so they do not clutter release builds.
Record simple telemetry counts for load failures and backup recoveries. These metrics show whether real devices hit corruption and whether your recovery methods are effective.
- Temp write: gamedata.tmp → rename to gamedata.json
- Backup: gamedata.json → gamedata.bak before replace
- Telemetry: increment counters for parse errors and bak restores
| Concern | Action | Benefit |
|---|---|---|
| Interrupted write | Write to .tmp, then rename | Prevents partial files and corruption |
| Corrupted main file | Load .bak fallback | Restores last-known-good without user loss |
| Noisy logs in release | Gate logs with flags | Keeps release builds clean and efficient |
Official Unity docs you should keep open while implementing
Open the engine docs and the .NET file references side-by-side before you write any disk I/O methods. This reduces guesswork and helps you spot serializer limits, path timing, and exception behavior quickly.
Quick checklist while you code
Check the JsonUtility reference for precise ToJson/FromJson behavior and what the serializer ignores. Confirm which fields will be serialized and common causes of empty loads.
Read the Application.persistentDataPath page so you understand where the file lives on iOS versus Android and when the path is valid during startup.
Review the Microsoft System.IO.File docs for Exists, ReadAllText, and WriteAllText to learn about exceptions and overloads.
| Doc | What to check | Why |
|---|---|---|
| JsonUtility | Field rules, limits | Prevents silent empty fields |
| persistentDataPath | Platform path timing | Finds files at runtime |
| System.IO.File | API exceptions | Handles I/O errors safely |
Note: keep the Console and the Editor log window open and print persistentDataPath once so you can locate the actual file during tests. Treat files as untrusted and validate every value you load.
Conclusion
Close the loop: lock in a compact progression file on persistent storage and keep tiny preferences in PlayerPrefs. That architecture lets you create save behavior that is predictable across installs and updates.
Don’t do these: write every frame, jam progression into PlayerPrefs, skip [System.Serializable], or hardcode paths. Those choices cause crashes, corruption, and hard-to-debug issues.
Performance notes: write at checkpoints and on backgrounding, keep the payload small, and run I/O off the main thread to avoid frame hitches and battery hits.
Next steps you can add now: schemaVersion migrations, atomic temp-write + backup, and a debug reset/repair option. Final check: delete the save file, boot the app, and confirm a clean new-save flow with no crashes.
