Save/Load
Slot-based JSON saves with crash-safe atomic writes and version migration.
Slot-based JSON saves with crash-safe atomic writes and version migration · One-line off-switch:
NullSaveService
What it does
The save service writes your game state to disk and restores it later — without corruption, even if the game crashes mid-save. You call Save<T>(slot, data) or Load<T>(slot) with a named slot such as "slot0" or "autosave" (a slot is simply a file name without extension). The service handles the rest: JSON serialization, atomic file writes (write to a temporary file, then swap it in), and schema migration when your data format changes. If a save file is corrupted, or its version can no longer be migrated, you get an explicit result code instead of an exception — you stay in control of the recovery experience.
Quick example
using CommonGameSystem.Core;
using UnityEngine;
using Logger = CommonGameSystem.Core.Logger; // Unity has its own Logger type
[DefaultExecutionOrder(100)]
public class SaveLoadDemo : MonoBehaviour
{
private ISaveService _save;
void Awake()
{
_save = ServiceLocator.Resolve<ISaveService>();
}
public void SaveGame(string slot, PlayerData data)
{
var result = _save.Save(slot, data);
if (result.Status != SaveStatus.Ok)
Logger.Error(Logger.Categories.Save, $"Save failed: {result.Message}");
}
public void LoadGame(string slot)
{
var result = _save.Load<PlayerData>(slot);
if (result.Status == SaveStatus.Ok)
ApplyPlayerData(result.Value);
else
Logger.Warning(Logger.Categories.Save, $"Load failed: {result.Message}");
}
private void ApplyPlayerData(PlayerData data)
{
// Apply the loaded values to your game state here.
}
}
[System.Serializable]
public class PlayerData
{
public int level;
public float health;
}
Save files land in {Application.persistentDataPath}/saves/{slot}.json by default.
Full API surface
Writing
-
SaveResult Save<T>(string slot, T data) where T : class— Serializedataand atomically write it to a named slot. ReturnsSaveStatus.Okon success, orIOFailureon disk errors (disk full, permissions) — in that case the existing file is left completely untouched. ThrowsArgumentNullException/ArgumentExceptionfor invalid slot names — those are bugs in the calling code, not disk conditions. -
SaveResult Delete(string slot)— Delete a slot file. ReturnsOkon success,NotFoundif the slot (or the saves folder itself) does not exist — safe to call twice, no exception — orIOFailureif the file system refuses the delete. The companion.bakbackup file is intentionally left untouched; its lifetime is managed by the nextSaveto that slot. -
void RegisterMigration(int fromVersion, ISaveMigration migration)— Register one schema migration step, from versionvtov + 1, as a pure JSON-text transform.fromVersionmust be 1 or higher, and the migration must not be null. Register migrations once at startup, before the firstLoad. If two migrations share the samefromVersion, the last one registered wins.
Reading
-
SaveLoadResult<T> Load<T>(string slot) where T : class— Read and deserialize a slot. ReturnsOkwith the value on success. ReturnsNotFoundif the slot is absent,Corruptif the file cannot be parsed (or a migration step threw), orVersionUnsupportedif the file cannot be brought up to the current version. Never throws for disk or data conditions — only for invalid slot names. -
bool Exists(string slot)— Returnstrueif a slot file exists on disk. It does not check whether the file is readable or well-formed — useLoadfor that. -
IReadOnlyList<string> ListSlots()— List all slot names (file names without extension), sorted alphabetically. Returns an empty list if no saves exist yet; it never creates the saves folder as a side effect.
Properties
int CurrentSchemaVersion { get; }— The schema version stamped on every new save. Files already at this version load without migration. Defaults to 1; raise it throughSaveServiceOptionswhen your data format changes.
Result types
Both result types are lightweight structs with public read-only fields:
SaveResult—Status(aSaveStatus) andMessage(a diagnostic string,nullonOk; meant for logs, not for players).SaveLoadResult<T>—Status,Value(non-null only whenStatus == Ok), andMessage.
SaveStatus values:
| Status | Meaning |
|---|---|
Ok | The operation completed successfully. |
NotFound | The slot file does not exist. Returned by Load and Delete. |
Corrupt | The file exists but could not be parsed or deserialized — malformed JSON, missing fields, a type mismatch, or a migration step that threw. The file and its .bak are left byte-for-byte unchanged, so you can still recover them by hand. |
VersionUnsupported | The stored version is newer than CurrentSchemaVersion (saves never downgrade), or the migration chain has a gap so the file cannot be brought forward. The file is preserved unchanged. |
IOFailure | A file-system error occurred during a write or delete. The target file and its .bak are left unchanged, and the underlying exception is logged rather than thrown. |
Schema migration
When you change your save-data format, bump CurrentSchemaVersion and register one ISaveMigration per version step. Each step transforms the raw payload JSON text from version v to v + 1 — no file access, no side effects, just string in, string out. On Load, the service applies steps in order until the data reaches the current version, then deserializes.
using CommonGameSystem.Core;
// v1 stored "oldField"; v2 renamed it to "newField".
public sealed class V1ToV2Migration : ISaveMigration
{
public string Migrate(string payloadJson)
=> payloadJson.Replace("\"oldField\"", "\"newField\"");
}
// Once at startup, before the first Load:
_save.RegisterMigration(1, new V1ToV2Migration());
If a migration step throws, Load returns Corrupt and the file on disk stays untouched. Migrating down is not supported: a file written by a newer version of your game returns VersionUnsupported.
Custom locations, extensions, and serializers
The default service is registered by Bootstrap with standard options. To change the folder name, file extension, backup behavior, or schema version, construct your own instance and replace the registration:
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)]
public class CustomSaveInstaller : MonoBehaviour
{
void Awake()
{
var options = new SaveServiceOptions(
saveDirectoryName: "mygame_saves", // folder inside persistentDataPath (default "saves")
fileExtension: ".sav", // default ".json"
backupExtension: ".sav.bak", // default ".bak"; must differ from fileExtension
currentSchemaVersion: 2, // default 1; must be >= 1
createBackup: true); // default true — keep a backup of the prior save
ServiceLocator.Replace<ISaveService>(
new SaveService(new JsonUtilitySaveSerializer(), options));
}
}
The constructor validates every argument and throws ArgumentException for bad values (a directory name containing path separators, an extension without a leading dot, matching file and backup extensions, or a version below 1).
To use Newtonsoft JSON, System.Text.Json, or encryption, implement ISaveSerializer and pass it to the SaveService constructor in place of JsonUtilitySaveSerializer.
Turning it off
ServiceLocator.Replace<ISaveService>(new NullSaveService());
This one line disables persistence without touching any caller code. Save returns Ok (doing nothing), Load returns NotFound, Exists returns false, and ListSlots() returns an empty list. Zero disk access, zero allocations. Useful in development or for headless server builds. Note that the Configuration service routes settings through the save service by default — that routing is decided once at startup, so settings written earlier in the session are unaffected by a later swap to NullSaveService.
Behavior & edge cases
-
Main thread only. All methods check that they run on Unity's main thread (the check is stripped from Release builds). Calls from other threads throw
InvalidOperationException. -
Invalid slot names throw immediately. A slot must be a plain file-name stem: not
null, not empty or whitespace, no path separators, no.., no:, and no characters your file system forbids in file names. Violations throwArgumentNullExceptionorArgumentExceptionbefore any disk access — they are not wrapped in a result code. Real disk conditions (file missing, corrupted, version gap) come back as result codes instead. -
Atomic writes. The service writes to a
.tmpfile first, then swaps it in withFile.Replace. If the swap is interrupted (for example, by power loss), the original slot stays intact. The nextSavesimply overwrites the leftover.tmpfile. You also get a.bakbackup of the previous save automatically; disable that viaSaveServiceOptions.CreateBackupif you prefer lower disk churn (not recommended while you are also raising the schema version — a failed migration write would have no.bakto recover from). -
Failures never destroy data. Every non-
Okoutcome —Corrupt,VersionUnsupported,IOFailure— leaves the slot file and its.bakbyte-for-byte unchanged. The service never "cleans up" a file it could not read. -
IL2CPP / link.xml. The service's own types are already protected from IL2CPP code stripping. Your own save-data classes (like
PlayerDataabove) must be preserved in your project'slink.xml, or stripped builds will fail to deserialize them. -
Serialization limits. The default serializer uses Unity's
JsonUtility: public fields and[SerializeField]fields only, no auto-properties, noUnityEngine.Objectreferences, no reference cycles. If a field silently fails to save, an auto-property is the usual culprit — switch it to a public field.
Related pages
- Bootstrap — how the service starts automatically
- Configuration — settings persistence built on top of this service
- Logger — save diagnostics under
Logger.Categories.Save - Service Locator — replacing the service with your own implementation
- Manual: Troubleshooting — recovering from
CorruptandVersionUnsupportedresults