Guides
Multiple save slots
A slot is a named save: "Slot 1", "Hardcore", a player profile id. To make a persistent object
support slots, add the empty marker interface IMultiSlot:
public class PlayerData : PersistentScriptableObject, IMultiSlot
{
public int gold;
}
Now the object saves and loads under whichever slot is selected. Until a slot is set, saves
and loads are ignored (there is nowhere to put them). Switch slot at runtime through the
manager, or for every manager at once through Slots.GlobalSlot:
// Per object
playerData.PersistenceManager.Slot = "Slot 1";
// Or globally, for every active manager
Slots.GlobalSlot = "Slot 1";
Setting a slot is a scope transition: the old slot is saved (if auto-save is on), then the new
slot is loaded (if auto-load is on), exactly as if the object had been unloaded and reloaded. GlobalSlot
also applies to managers that load later: any manager entering scope after it was set
picks up the current global slot on its own.
Managing slot storage
The static Slots class operates on a slot's saved data without switching
slots, so you can build a save-select screen: copy, rename and delete slots, snapshot
the live data onto one, or bring one's data into the ongoing game.
// Duplicate Slot 1 onto Slot 2 for these targets
Slots.Copy("Slot 1", "Slot 2", playerData, worldData);
// Rename, then delete
Slots.Rename("Slot 2", "Backup", playerData);
Slots.Delete("Backup", playerData);
// Snapshot the LIVE data onto another slot; the current slot's save is untouched
Slots.SaveTo("snapshot 0", playerData);
// Bring a slot's saved data into the ongoing game: a real load of that slot,
// the next save persists it onto the current slot, the source is never modified
Slots.LoadFrom("snapshot 0", playerData);
Slots also raises OnSlotUpdated, OnSlotCopied and
OnSlotDeleted so your UI can refresh as storage changes.
Listing slots without loading them (slot registry)
A save-select menu needs to show what saves exist, and a small summary of each (level
reached, play time, a thumbnail), without loading every profile in full. That is what a
slot registry does. Inherit PersistentSlotRegistry<TTarget, TInfo>:
it is an always-global persistent object that watches your saves and records one entry per
slot, with a summary you define. The tracked target type (ProfileData here) must
itself be a multi-slot object, that is, implement IMultiSlot as above.
// The small per-slot summary the menu shows, no full load needed
[Serializable]
public struct ProfileSummary
{
public int clearedLevels;
public int unlockedWorlds;
}
public class ProfileRegistry : PersistentSlotRegistry<ProfileData, ProfileSummary>
{
// Called when a profile is saved: pull out the summary to remember for its slot
protected override ProfileSummary CaptureInfo(ProfileData target)
=> new() { clearedLevels = target.cleared.Count, unlockedWorlds = target.worlds };
}
The registry updates itself as slots are saved, deleted, copied or renamed, so your menu just reads it:
foreach (var entry in profileRegistry.Entries)
AddSlotButton(entry.Slot, entry.Info.clearedLevels, entry.Screenshot);
// Jump straight to the newest save for a "Continue" button
string latest = profileRegistry.LatestSlot;
Each SlotEntry carries the Slot name, your Info summary,
the save time, and an optional Screenshot (override CaptureScreenshot
or CaptureScreenshotAsync to snap a thumbnail per save). The Demo's
ProfileRegistry is a working example.
Rotating snapshots
Some games keep the last few automatic snapshots alongside the manual save and let the
player load any of them (a common pattern in adventure games and RPGs). The game
plays on one slot as usual; on your trigger of choice, a single Slots.SaveTo
snapshots the live data into a rotating ring of snapshot slots. The current slot's save is
never touched, so the manual save stays exactly where the player left it:
const int MaxSnapshots = 3;
// Call it from your trigger of choice: a timer, a checkpoint, an area transition.
void TakeSnapshot() => Slots.SaveTo(NextSnapshotSlot(), profileData);
// Each profile keeps its own ring: its snapshot slots carry its slot name as a prefix.
string SnapshotPrefix => profileData.PersistenceManager.Slot + " snapshot ";
// Overwrite the oldest snapshot slot, or fill a free one. No rotation index to
// persist: the registry's save times already order the ring.
string NextSnapshotSlot()
{
List<SlotEntry<ProfileSummary>> snapshots = new();
foreach (SlotEntry<ProfileSummary> entry in profileRegistry.Entries)
if (entry.Slot.StartsWith(SnapshotPrefix))
snapshots.Add(entry);
if (snapshots.Count < MaxSnapshots)
return SnapshotPrefix + snapshots.Count;
return snapshots[snapshots.Count - 1].Slot; // Entries is newest-first, so last is oldest
}
Each snapshot records its summary, save time and thumbnail in the slot registry above, so
the ring shows up in the load menu for free. When the player picks one, a single
Slots.LoadFrom brings it into the game: a real load of the snapshot slot's data
into the object. The snapshot slot itself is never modified, so the ring stays an intact
history, and later saves keep landing on the current slot (the next one persists the
brought-over state there):
// From the load menu, in game or after a game over: no slot switching involved.
Slots.LoadFrom(chosenSnapshotSlot, profileData);
LoadFrom only changes the object's live, in-memory state; no save is copied or
overwritten. It does need a current slot (for later saves to land on), so on a title screen
pick the profile first (Slots.GlobalSlot = ...) and then call
LoadFrom; in game, you are already on the slot. Since no slot switching is
involved, no automatic save runs on the way.
The same two calls also cover multiple manual saves. When each manual save is a
slot the player plays on (a profile), the ring above already follows it through its
prefix. When manual saves must instead stay untouched while playing (a "save as" list),
treat them exactly like snapshots: the game plays on one working slot, a save command is a
Slots.SaveTo("manual ..."), and the load menu is a LoadFrom of
any entry, manual or snapshot alike; only the working slot ever receives the automatic
saves.
Reacting to load and save
You can hook persistence from outside the object, through its manager's events, or from inside it, through the object's own callbacks.
From outside the object: manager events
Subscribe to the manager's events to observe operations (drive a "saving..." spinner, log analytics, refresh UI after a load):
manager.OnAfterLoad += (source, result) =>
{
if (result.IsSuccess)
RefreshHud();
};
manager.OnBeforeSave += source => ShowSavingSpinner();
There are OnBefore/OnAfter pairs for load, save and clear, plus
BlockLoad/BlockSave/BlockClear hooks that veto an
operation. The static Persistence class mirrors all of these across every manager
(OnAfterAnySave and friends).
The source each load and save event carries is a PersistenceSource
telling you why it ran: Manual for an explicit Save or
Load call, Automatic for one the system triggered
(the auto-load on scope-in, an auto-save safe point, a slot change),
and SlotCopy / SlotSnapshot, seen only by the storage methods of
a custom manager (a Slots copy or rename moving a payload verbatim, and a
Slots.SaveTo writing the live data to another slot). Use it to treat them
apart, for instance only flashing a "Saved!" toast on a Manual save.
From inside the object: serialization callbacks
The object itself can react by implementing ISerializationCallbacks (a
serializer-neutral equivalent of Unity's ISerializationCallbackReceiver):
public class PlayerData : PersistentScriptableObject, ISerializationCallbacks
{
public void OnBeforeSerialize() { /* flatten caches into fields */ }
public void OnAfterDeserialize() { /* rebuild caches, validate, clamp */ }
}
Lifecycle: entering and leaving scope
For one-time setup and teardown (and reacting to slot changes), implement
IScopeCallbacks. Its enable / disable flags are
true only on the very first scope-in and final scope-out, so it is
the right place to arm and release one-time runtime state, like an event subscription:
public class GameState : PersistentScriptableObject, IScopeCallbacks
{
public void OnBeforeScopeIn(string slot, bool enable)
{
if (enable == false) // false on the slot-change scope-ins, true only the first time
return;
Application.lowMemory += Trim; // arm once
}
public void OnAfterScopeOut(string slot, bool disable)
{
if (disable == false)
return;
Application.lowMemory -= Trim; // release once
}
void Trim() { /* ... */ }
}
It runs on scope transitions, not on every operation: OnBeforeScopeIn
fires as the object enters scope (right before that load), OnAfterScopeOut as it
leaves (right after its final save), bracketing every slot the object lives through.
slot is the slot being entered or left, or null when there is none (a
non-multi-slot object, or a multi-slot object with no slot selected).
Saving on quit (the shutdown drain)
When play mode exits or the application quits with asynchronous saves still in flight (a file manager set to async, a remote manager pushing to a server), Persistent Asset runs a shutdown drain: it waits for those saves to finish before letting the quit go through. A save is never cut off just because the game is closing, so a last-moment save is not lost to the exit.
The wait is bounded on two levels:
- Each manager's own Save Timeout caps how long its save may run.
- A hard ceiling on the whole drain, in Project Settings > Persistent Asset > General: Max Drain Time In Editor and Max Drain Time In Build (5 seconds each by default; set 0 for no ceiling).
Usually you do nothing, it just works. When you want to react, to show a "saving..." screen so
the player does not think the game hung, or offer a "quit anyway" button, use the
Persistence hooks:
Persistence.OnShutdownDrainStarted += () => savingScreen.Show();
Persistence.OnShutdownDrainCompleted += () => savingScreen.Hide();
// Or poll it, without subscribing to a static event
if (Persistence.IsDraining)
...
// Cut the drain short and let the quit through (unsaved data may be lost)
Persistence.ForceQuitDrain();
A manager whose saves run synchronously (Player Prefs, Prototype, Local File in its default sync mode) has nothing to drain, its saves already completed inline; the drain only matters for managers running asynchronously. See Technical Q&A for more on what happens to in-flight saves.
Resetting & Restoring
Two different operations, each available for the whole object or for individual fields: resetting to the authored defaults, and snapshot/restore, capturing the current values to put them back later.
Reset to defaults
ResetToDefaults() on the persistence manager resets every persisted field to its
pre-play authored values, in memory only (the save is untouched, and a later save persists the
defaults). To also delete the save, use Clear().
Snapshot and restore
To capture the whole object's current values and put them back later, an options menu the
player can cancel, a rollback point, capture them to a string with Snapshot() and
restore with Restore():
string checkpoint = manager.Snapshot();
// ... player tweaks settings live ...
if (cancelled)
manager.Restore(checkpoint); // back to the checkpoint, in memory; the save is untouched
Resetting or restoring individual fields
To reset or restore some fields rather than the whole object (a "reset this one
setting to default" button), implement IFieldResettable. It declares two events
that the persistence manager answers, it subscribes to them automatically, and
nothing else ever should. Raise the one you need to get a serializer-neutral
SaveNode view, then assign just the fields you want from it:
DefaultValuesRequested: the authored defaults, to reset a field.CurrentValuesRequested: a snapshot of the current saved values, to restore a field to where it was.
public class OptionsData : PersistentScriptableObject, IFieldResettable
{
[SerializeField] float volume = 0.8f;
[SerializeField] bool subtitles = true;
// The persistence manager subscribes to these automatically; your code must never subscribe,
// so the saved data stays reachable only from inside this object. Declare the event you do not
// raise as an empty add/remove pair so it compiles with no backing field, as below.
public event Func<SaveNode> DefaultValuesRequested;
public event Func<SaveNode> CurrentValuesRequested { add { } remove { } }
// Reset just the volume to its authored default, leaving everything else untouched.
public void ResetVolume()
{
SaveNode defaults = DefaultValuesRequested();
if (defaults != null)
volume = defaults[nameof(volume)].AsFloat(volume);
}
}
An invocation returns null before the first load, with no manager, or with a
serializer that does not support nodes, treat that as a no-op.
Global & always-loaded objects
Some objects need to be reachable from anywhere and must never miss a load or save: a settings object, the player profile, a registry of save slots.
PersistentSingleton (the easy path)
For one project-wide persistent object, inherit PersistentSingleton and add your
fields:
public class GameSettings : PersistentSingleton
{
public float masterVolume = 0.8f;
public bool subtitles = true;
}
You do not even create the asset: it is made for you automatically, in the folder set under
Project Settings > Persistent Asset > Singleton.
Open it, assign a Persistence Manager, and it saves like any other persistent object. Once it
has a manager it stays loaded for the whole session and is reachable from anywhere by type
through the GlobalPersistedData class, with no serialized reference:
GameSettings settings = GlobalPersistedData.Find<GameSettings>();
if (settings != null)
ApplyVolume(settings.masterVolume);
Find returns null.
IAlwaysGlobal (the raw interface)
PersistentSingleton is simply a persistent object that implements
IAlwaysGlobal, with its asset auto-created. Implement IAlwaysGlobal
directly to get the same behavior on an object that is not a singleton (a slot registry does
exactly this): the object stays loaded and active for the whole session even when no scene
references it, and registers itself in GlobalPersistedData so you can fetch it by
type.
IAlwaysGlobal.
Registering a global by hand
A PersistentSingleton and anything marked IAlwaysGlobal register
themselves. To make an ordinary persistent object globally findable without that
interface, register it yourself with GlobalPersistedData:
GlobalPersistedData.Add(myData); // findable through Find, and kept alive, until you remove it
GlobalPersistedData.Remove(myData); // stop keeping it global
Add keeps the object in scope until you Remove it, so removing it when
you are done is your responsibility (the same reason you would not hold a static reference
yourself). AddPermanent does the same but can never be removed; the package uses it
for the objects it pins itself. Only a persistent object can be registered.
Other optional hooks
Only save when changed
Implement IDirtyTracked so a save is skipped while nothing changed. Set
IsDirty = true when you modify a persisted field; it is reset to
false by the persistence manager after a successful save.
public bool IsDirty { get; set; }
public void AddGold(int amount)
{
gold += amount;
IsDirty = true;
}
Fresh-start initialization
Implement IDynamicInitialize to set values programmatically the first time an
object loads with no save found. It never overwrites a loaded save, and never runs in edit
mode, ideal for randomized or computed starting state.
public void DynamicInitialize()
=> startingSeed = Random.Range(0, int.MaxValue);
Forcing behavior by data type
Implement IPersistencePolicy when the kind of data dictates how it must
persist regardless of the manager, for example a settings object that must always auto-load
and auto-save. Every non-null field of the returned PersistencePolicy overrides
the manager's own setting, and the inspector shows those settings locked.
public PersistencePolicy Policy => new() { AutoSave = true };
Requiring a specific serializer
When an object's data only works with a particular serializer, put
[RequiredSerializer(typeof(...))] on the class. The editor locks the manager's
serializer to that type, and if a shipped manager was using a different one, it offers an
import copy so existing saves migrate automatically. The common case is
an IUpgradable object, which needs a node-supporting
serializer:
[RequiredSerializer(typeof(UnityJsonSerializer))]
public class PlayerData : PersistentScriptableObject, IUpgradable { /* ... */ }
Unlike the [PersistentScriptableObject] attribute, this one
can be redeclared on a subclass to change the requirement.
Persisting without inheriting the base class
Inheriting PersistentScriptableObject is the simplest path, but sometimes a
ScriptableObject already inherits another class you cannot change (a third-party base, your own
framework). For those, the same behavior is available through an attribute instead.
Decorate the class with [PersistentScriptableObject(...)], give it a serialized
PersistenceManager field, and name that field in the attribute:
[PersistentScriptableObject(nameof(persistenceManager))]
public class MyData : SomeOtherScriptableObject
{
[SerializeField] PersistenceManager persistenceManager;
public int gold;
}
That is exactly what the base class does internally, so the attribute route is fully
equivalent: the same inspector dropdown, the same automatic load and save, the same optional
interfaces. Reach the manager through your own field (here persistenceManager)
rather than the base class's PersistenceManager property.
Securing saves
The Local File and remote managers can protect saves from tampering and snooping. On the Local File manager you have two independent levers:
- Compression (
Fast/Optimal): shrink the save with gzip. - Encryption:
AppendHash: the data stays readable, and a modified save is not rejected: it still loads but is detected and permanently marked as tampered, for your code to react to (disable achievements or leaderboards, show a badge...).Encrypt: the data is fully encrypted, and a modified save cannot be loaded at all.
With AppendHash, react to tampered saves through the static file-manager API. The mark is sticky:
it is written into every save of the tampered data (protected by the hash itself, so it cannot be edited out),
surviving sessions and slot copies until the save is cleared or a pristine file is loaded again:
// Query it anytime (also up to date inside an OnAfterLoad handler)
bool cheated = FilePersistenceManager.IsTampered(myData);
// Or react to the load that brought tampered data in
FilePersistenceManager.OnTamperedLoaded += target => DisableAchievements();
// And when your own checks catch a cheat, brand the save yourself: a flag in your own
// data could simply be edited back, this mark cannot
FilePersistenceManager.MarkTampered(myData);
The encryption key is derived from a project secret that is generated automatically,
unique to your project, stored in Project Settings > Persistent Asset >
Security (you do not have to invent or manage a
key yourself). You can additionally bind protected data with Lock Save To (a
[Flags] choice):
TargetMachine: the save cannot be read on another machine.FileLocation: the file cannot be read if moved, renamed or swapped (a copy or rename you make through theSlotsAPI still works, it re-protects the data for its new path; a player moving files on disk does not).RuntimeSecret: the save cannot be read without a secret your code supplies at runtime, which never ships in the build.
Encryption level decides what a failed
check does: under Encrypt the save is unreadable (blocked), under AppendHash it still
loads, marked as tampered. Use Encrypt when a lock must physically block.
// Provide the runtime secret once during startup (e.g. from your server)
FilePersistenceManager.RuntimeSecret = sessionSecretFromServer;
Locking & migrating saves
A few settings on a persistence manager decide where and how a save is stored: the file name, compression, encryption, the lock, the server URL, the serializer. Change one after players already have saves, and the new setup points at a different location that holds nothing, the old saves are stranded. These are the manager's breaking fields, and the package gives you two tools around them: locking to prevent accidental changes, and import sources to migrate on purpose.
Locking a manager
Locking a manager is your signal that this setup is shipped to players. Lock it from its inspector and its breaking fields go read-only behind a padlock; from then on, a change that would strand those players' saves first prompts you to migrate (see below). To lock every manager in the project at once, use Tools > Persistent Asset > Actions > Lock All Persistence Managers.
You can also have the project lock every manager for you when a build is made, configured in Project Settings > Persistent Asset > General:
- Auto Lock On Build:
Release Build(the default, locks on a non-development build),Any Build, orNone. - Prompt Before Auto Lock: off by default, so saves are protected without a prompt; turn it on to confirm first (a command-line build never prompts).
Import sources
When you change a locked persistence manager in a way that would strand its saves, unlocking it to edit a breaking field, or switching its type, the inspector offers to keep a read-only import copy of the old setup:
Keep it, and the old configuration is preserved as a read-only sub-asset named
[Import N] .... The next time the new manager runs a load and finds no save of
its own, it reads that import copy, brings the data into the object, and immediately re-saves
it in the new format and location. From then on the new save exists, so the import is a
one-time, automatic migration. If several import copies exist, they are tried newest first,
and if none holds a save the object falls back to its
fresh-start initialization.
Delete an import copy once your players have all migrated (it only matters while someone still has a save in the old format). You can also roll back to a kept setup from the inspector, making it the active manager again, useful if a change turns out wrong.
Versioning your data
Versioning is about what your data contains: fields you add, rename or remove between releases.
For simple changes you often need nothing from Persistent Asset at all, the serializer's own
rules cover them. Adding a field just loads it at its default, and renaming one is handled
by the serializer's rename mechanism: for the default Unity JSON serializer, that is Unity's
[FormerlySerializedAs("oldName")]:
[FormerlySerializedAs("hp")]
public int health; // old saves that stored "hp" still load
Reach for IUpgradable when the change is beyond what those can express: splitting
one field into two, changing units, deriving a new value from old ones, and so on. Increment
SaveVersion whenever the saved fields change in a way that needs handling, and do
the work in Upgrade:
public class PlayerData : PersistentScriptableObject, IUpgradable
{
public int coins;
public int SaveVersion => 1;
public void Upgrade(int fromVersion, SaveNode oldData)
{
// v0 stored "gold" and "silver" separately; combine them
if (fromVersion < 1)
coins = oldData["gold"].AsInt() + oldData["silver"].AsInt();
}
public bool Downgrade(int fromVersion) => false;
}
Upgrade runs after loading, only when the loaded save is older than the current
version. The oldData parameter is a serializer-neutral SaveNode tree
of the original payload, so you can recover values the deserialization dropped. It exposes typed
readers: AsInt(), AsString(), AsVector3(),
AsEnum<T>(), and indexing for objects (node["x"]) and arrays
(node[0]).
Downgrade handles the reverse: a save written by a newer build than the one
running. Return false to refuse it (saving is blocked, so the newer save is left
intact on disk for the newer build), or true to accept it in the older format.
IUpgradable ships as version 0, so you can add the interface in a
later release and handle the upgrade from version 0, every existing save
is treated as version 0.
SupportsNode is true (the
built-in JSON serializer is).
The escape hatch: migrate into a fresh object
For a rare change that neither import sources nor IUpgradable can express cleanly,
there is a blunt but reliable option: keep the old persistent object as it is, create a
new one for the new shape, and have the new object copy across on first run via
IDynamicInitialize (which runs once when a load finds no
save of its own):
public class PlayerDataV2 : PersistentScriptableObject, IDynamicInitialize
{
public int coins;
public void DynamicInitialize()
{
// No V2 save yet: fetch the old object on demand and copy its data across. Fetched,
// not kept in a serialized field: that field would be persisted into the V2 save itself.
PlayerDataV1 old = Resources.Load<PlayerDataV1>("PlayerDataV1");
if (old != null && old.PersistenceManager.IsReady)
{
coins = old.gold;
old.PersistenceManager.Clear(); // wipe the old save now it has been migrated
}
}
}
The IsReady check guards against a failed load: it stays
false when an old save exists but could not be read, so the migration (and its
Clear) never runs on data that never loaded. A player with no old save passes
the check on defaults, and copying those across is simply their fresh start. From then on
your game reads only the new object; retire the old one once players have moved over.
Deleting a player's data (privacy / account deletion)
When a player exercises a "right to erasure" (GDPR), or you ship a "delete account" or "delete save" button, you have to remove everything that player saved, not just whatever is loaded at that moment. This takes care, because the bulk operations only reach what is currently in scope.
Persistence.ClearAll() only clears the managers that are
active right now, and only their current slot. A persistent
object that is not loaded, or a save slot that is not the current one, is silently left behind.
A bare ClearAll() will almost always miss data, so it is not a compliant wipe on
its own.
To erase a player completely, do all three steps in order:
- Bring every persistent object into scope. Load or reference your save objects and any slot-tracked profile objects so their managers are active; anything still unloaded is invisible to the next steps. (Always-global objects, such as singletons and slot registries, are already in scope, so they need nothing here.) How you do this is game-specific (direct references, Resources, Addressables, your own registry of them).
- Snapshot the slot list, then clear everything active. Read the slot names from your slot registries first, because the next call wipes the registry that lists them. Then
await Persistence.ClearAllAsync()deletes each active manager's current-slot save and resets the object to defaults, on the device and, for a remote manager, on the backend. A backend delete can only land while the server is reachable. With no cache, the awaited result reports whether it did; with a cache on, the clear lands in the cache as a pending clear request, pushed to the server in the background, so confirm it withawait PushPendingChanges()(see the saving indicator). - Delete every remaining slot.
ClearAllonly emptied the current slot, so for each multi-slot object delete each of the snapshotted slots withSlots.DeleteAsync(slot, targets)(a slot that happens to be the current one is simply ignored, it is already gone).
// Returns false when something could not be deleted: keep the erasure request open and retry.
async Task<bool> EraseEverything()
{
// 1. The player's save objects and slot-tracked profiles must be loaded / in scope
// (game-specific). Always-global objects, singletons and slot registries, already are.
// 2. Snapshot the slot names BEFORE clearing wipes the registry that lists them...
List<string> slots = profileRegistry.Entries.Select(e => e.Slot).ToList();
// ...then clear the current slot of every active manager (device + remote + cache).
ClearResult[] cleared = await Persistence.ClearAllAsync();
bool erased = cleared.All(r => r.IsFailure == false && r.IsCancelled == false);
// 3. Delete every remaining slot of each multi-slot object (the current slot's delete
// reports Ignored, it is already gone; only a Failure or Cancelled means not erased).
foreach (string slot in slots)
{
ClearResult[] deleted = await Slots.DeleteAsync(slot, playerData, worldData);
erased &= deleted.All(r => r.IsFailure == false && r.IsCancelled == false);
}
// For a remote manager with a cache on, also flush: await remoteManager.PushPendingChanges().
return erased;
}
ClearAllAsync, DeleteAsync) and await
them, so the deletes have actually run before you confirm anything to the player (and for a
cached remote manager, flush with PushPendingChanges() as above).
Debugging
Every persistence manager exposes what it is doing, its live state and a running log of each operation, so you can see exactly what happened. This information is recorded in the editor and development builds only, and stripped from release builds, so it costs nothing in what you ship.
In the inspector
While you play, the manager's Status section is your live window into it: whether the object is ready, the current slot, the last operation and its result, and a running log of every load, save and clear, each with its outcome and any messages (marked info, warning or error). It is the quickest way to see why a load came back Invalid, or whether a save was ignored.
In game
To read the same logs from a running build or on a device, add the In Game Logs Viewer: an on-screen overlay that lists every active manager and shows the selected one's log over your game. Add it via Add Component > Persistent Asset > In Game Logs Viewer on a GameObject in your scene. The panel is collapsible and resizable, and like the inspector logs it exists only in the editor and development builds (it removes itself in a release build).
[Persistent Asset] prefix, and exceptions are logged as native Unity
entries (with a full stack trace), so a real problem surfaces even with no inspector or overlay
open. The fine-grained per-operation log stays in the inspector and the in-game viewer.
From your own manager
A custom manager writes to these logs with Log(), choosing a severity from
LogLevel (None, Info, Warning,
Error):
Log("Reached the server but it returned 500.", LogLevel.Warning);
Calls to Log are compiled out of non-development builds, so leaving them in costs
nothing in release.