Save systems are one of the most deceptively simple parts of your game, that’s until you try to port to console. It’s one of the first things we flag in every healthcheck, and it’s one of the most common sources of bugs and extended timelines during a Unity console port.
The root cause almost always comes down to one assumption: that how we load and save data on a PC would work the same on a console. That when you call load, the data is there by the time the next line executes.
On console, you can not assume that.
How Save Systems Traditionally Work in Unity on PC
On PC, saving and loading is a file system operation. It’s blocking. When you call File.ReadAllBytes or File.ReadAllLines or using something such as StreamReader, This execution halts until the OS returns the data. Simple, predictable, and exactly what most Unity tutorials teach:
public class SaveManager : MonoBehaviour
{
private const string SaveFileName = "/save.json";
public SaveData Load()
{
string path = Application.persistentDataPath + SaveFileName;
if (!File.Exists(path))
return new SaveData();
string json = File.ReadAllText(path);
return JsonUtility.FromJson<SaveData>(json);
}
public void Save(SaveData data)
{
string path = Application.persistentDataPath + SaveFileName;
string json = JsonUtility.ToJson(data);
File.WriteAllText(path, json);
}
}
This is sample and eloquent if mean you can call this anywhere — Awake, Start, a button click and the result is immediate. This leads to patterns like:
public class GameManager : MonoBehaviour
{
public SaveManager SaveManager;
private SaveData _saveData;
void Awake()
{
_saveData = SaveManager.Load();
}
void Start()
{
PlayerController.SetHealth(_saveData.playerHealth);
InventorySystem.Restore(_saveData.inventory);
QuestTracker.Restore(_saveData.completedQuests);
}
}
This works no problem and for a PC only game this makes the most sense. Don’t overcomplicate something that players aren’t going to see. Problem comes in when you begin a console port.
Why Consoles Are Fundamentally Different
Although we can’t talk about specifics due to platform NDAs, we can say that every major console platform — PlayStation, Xbox, and Nintendo — works very differently. They require you to load with asynchronous save data access at the platform SDK level. You cannot read save data synchronously. The platform manages where data is stored, how it’s cached, when it flushes to cloud storage, and how conflicts are resolved.
When you call a read on console, you are submitting a request. The platform processes that request and notifies you when it’s complete normally via a callback, a polling mechanism, or a coroutine you yield on. The call itself returns immediately, before the data exists.
Here is what the broken PC code looks like when transplanted to a console environment:
void Awake()
{
// On console this just submits a read request and returns void, then will callback when the data is returned.
SaveManager.Load();
}
void Start()
{
// _saveData is null, as we haven't had the data back from the platform yet.
// silently uses default values, overwriting the player's actual save or crashes the game.
PlayerController.SetHealth(_saveData.playerHealth);
}
The most dangerous version of this bug doesn’t crash. It silently resets the player’s progress because _saveData is a freshly constructed default object, not their actual save — and then the game autosaves that default state over their real data.
How we should think about Unity save and loading on Consoles
The fix isn’t to find a synchronous console API — there isn’t one. The fix is to redesign how your game reacts to save data being available.
There are three patterns worth knowing. They aren’t mutually exclusive and most shipped games use a combination.
Pattern 1: Callback-Based Loading
The most direct approach. Instead of returning data from Load, you pass in an action that fires when loading is complete:
public class SaveManager : MonoBehaviour
{
public void Load(Action<SaveData> onComplete)
{
// There are special preprocessors that can be used per platform with unity
#if CONSOLE
StartCoroutine(LoadAsync(onComplete));
#else
// PC: load synchronously, invoke immediately
var data = LoadFromDisk();
onComplete?.Invoke(data);
#endif
}
private IEnumerator LoadAsync(Action<SaveData> onComplete)
{
// Platform-specific async load
var request = PlatformSaveSystem.BeginRead();
while (!request.IsDone)
yield return null;
var data = request.Result != null
? JsonUtility.FromJson<SaveData>(request.Result)
: new SaveData();
onComplete?.Invoke(data);
}
private SaveData LoadFromDisk()
{
string path = Application.persistentDataPath + "/save.json";
if (!File.Exists(path)) return new SaveData();
return JsonUtility.FromJson<SaveData>(File.ReadAllText(path));
}
}
Your game manager then never touches save data directly until the callback fires:
public class GameManager : MonoBehaviour
{
public SaveManager SaveManager;
private SaveData _saveData;
private bool _saveDataReady = false;
void Awake()
{
SaveManager.Load(OnSaveDataLoaded);
// Execution continues immediately — _saveData is NOT ready yet
}
private void OnSaveDataLoaded(SaveData data)
{
_saveData = data;
_saveDataReady = true;
// Initialise everything that depends on save data here
PlayerController.SetHealth(_saveData.playerHealth);
InventorySystem.Restore(_saveData.inventory);
QuestTracker.Restore(_saveData.completedQuests);
}
}
The important shift: nothing that touches save data runs at a fixed Unity lifecycle point. It runs when the data exists.
Pattern 2: Async/Await
If your Unity version supports C# async/await (When using Unity 2022+), this is the cleanest approach. It reads almost like synchronous code while remaining non-blocking:
public class SaveManager : MonoBehaviour
{
public async Task<SaveData> LoadAsync()
{
#if CONSOLE
return await LoadFromPlatformAsync();
#else
return await Task.Run(LoadFromDisk);
#endif
}
private async Task<SaveData> LoadFromPlatformAsync()
{
var tcs = new TaskCompletionSource<SaveData>();
PlatformSaveSystem.BeginRead(result =>
{
var data = result != null
? JsonUtility.FromJson<SaveData>(result)
: new SaveData();
tcs.SetResult(data);
});
return await tcs.Task;
}
private SaveData LoadFromDisk()
{
string path = Application.persistentDataPath + "/save.json";
if (!File.Exists(path)) return new SaveData();
return JsonUtility.FromJson<SaveData>(File.ReadAllText(path));
}
}
public class GameManager : MonoBehaviour
{
public SaveManager SaveManager;
private SaveData _saveData;
async void Awake()
{
_saveData = await SaveManager.LoadAsync();
// Everything below this line is guaranteed to run AFTER load completes,
// on both PC and console, with no callback nesting
PlayerController.SetHealth(_saveData.playerHealth);
InventorySystem.Restore(_saveData.inventory);
QuestTracker.Restore(_saveData.completedQuests);
}
}
The Save Side: Don’t Forget Write Confirmation
Async matters for loading, but the save side has its own console-specific requirements.
On console, you must not assume a write has committed until the platform confirms it. On PC, File.WriteAllText is synchronous — it returns when the data is on disk. On console, submitting a write request and receiving a completion callback are separate events. If the player powers off the console between those two events, the data may not be saved.
public class SaveManager : MonoBehaviour
{
private bool _saveInProgress = false;
public void Save(SaveData data, Action onComplete = null, Action onFailed = null)
{
if (_saveInProgress)
{
Debug.LogWarning("Save already in progress. Skipping duplicate save request.");
return;
}
_saveInProgress = true;
#if CONSOLE
StartCoroutine(SaveAsync(data, onComplete, onFailed));
#else
SaveToDisk(data);
_saveInProgress = false;
onComplete?.Invoke();
#endif
}
private IEnumerator SaveAsync(SaveData data, Action onComplete, Action onFailed)
{
string json = JsonUtility.ToJson(data);
var request = PlatformSaveSystem.BeginWrite(json);
while (!request.IsDone)
yield return null;
_saveInProgress = false;
if (request.Succeeded)
onComplete?.Invoke();
else
onFailed?.Invoke();
}
}
Practical implications:
- Show a saving indicator and do not dismiss it until the write callback fires
- Never let the player quit to menu while a save is in progress
- Platform certification requirements (TRC/XR/Lotcheck) will specifically test for power-loss scenarios during save
What to Audit Before You Start Porting
When we run a healthcheck on a Unity project, here’s what we look for in the save system:
Synchronous file access anywhere outside of PC-specific #if blocks. Any File.Read*, File.Write*, PlayerPrefs.GetX, or BinaryFormatter calls in shared code paths are a porting risk.
Save data access in Awake or Start without a readiness check. If any class reads from a savedata object during its initialisation lifecycle methods, that object needs to be guaranteed loaded before those methods run. Which means the loading system needs to gate scene initialisation.
Missing save-in-progress guard. On console, submitting two simultaneous writes to the same save slot is an error condition that will fail certification.
No handling for corrupted or missing save data. Consoles can return a valid-but-empty result if the player has never saved, and a separate error state if the data is corrupt. Both need explicit handling.
PlayerPrefs usage for anything that needs to persist across sessions. PlayerPrefs is not a valid save system on console — the data may not persist, and on some platforms it doesn’t work at all in shipped builds.
Excessive save and load calls during gameplay. Consoles try to protect their users’ storage by rate-limiting how frequently developers can save or load within a short period of time.
The Core Mindset Shift
The traditional Unity PC save systems are written with the assumption that data is available when you ask for it. Console save systems require you to write code that reacts to data becoming available.
The practical change is small — a callback here, an async/await there, a state machine in larger projects — but the architectural shift is significant. Every system that touches save data needs to stop assuming and start listening.
If you’re planning a port and your save system is synchronous, it’s worth refactoring before you start the console work. The alternative is fixing a cascade of timing bugs across every system that touches player data, which is considerably more painful mid-port.
If you’d like a review of your save system architecture before starting a console port or need assistance porting your unity game, our free HealthCheck covers this as part of the technical assessment.