Lock and migrate saves

A lock keeps the saves your players already have working. It marks a manager as shipped: its breaking fields turn read-only, and a change that would strand those saves offers to migrate them first, so you can't strand them by accident.

The breaking fields are the settings that decide where a save is stored and how it is written: the file name, compression, encryption, the server URL, the serializer and so on.

A release build locks every manager for you. To lock them yourself, go to Tools > Persistent Asset > Actions > Lock All Persistence Managers.

Warning: The package treats an unlocked manager as one that has not shipped yet, so changing a breaking field on it strands its old saves with no warning.

Migrate with an import copy

When you change something that would strand the saves a manager has already written, the package offers to keep the old setup as a read-only import copy:

Keep import copy dialog

The copy stays under the asset as a sub-asset named [Import N] .... The next time the new manager finds no save of its own, it reads that copy, writes the data back in the new format and place, then deletes the copy, so each player's save migrates once.

Import sources under the asset

Clear a lock whose manager is gone

Deleting a manager leaves its lock behind. To tidy those away once the managers are gone for good, go to Tools > Persistent Asset > Actions > Clear Orphaned Locks….

Version your data

Some changes need nothing from the package. A new field loads at its default value, and your serializer's own mechanisms cover others, such as renaming a field with [FormerlySerializedAs("oldName")] on Unity JSON.

Implement IUpgradable for a change none of that covers:

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;   // refuse a save from a newer build
}

Upgrade runs after the load, and only on a save older than the current version. An asset with no IUpgradable ships as version 0, so you can add the interface in a later release.

Note: Reading the old values needs a serializer that can produce them. For more information, refer to Choose a serializer.

For a change even IUpgradable can't express, keep the old asset, create a new one beside it, and copy the values across in DynamicInitialize().

Delete a player's data

A right-to-erasure request, such as a privacy or account-deletion request, has to remove everything that player saved, not only what is loaded now. Persistence.ClearAll() reaches the managers that are in scope at that moment, and only their current slot, so the other slots have to be read before anything wipes the registry that lists them:

// Every save asset and slot registry of that player is in scope by now.
List<string> slots = new(profileRegistry.SavedSlots);   // read it before clearing empties it

await Persistence.ClearAllAsync();                       // the current slot of everything in scope

foreach (string slot in slots)
    foreach (ClearResult result in await Slots.DeleteAsync(slot, profileData, worldData))
        if (result.IsFailure || result.IsCancelled)
            return false;                                 // that slot still holds data: say so

await remoteManager.PushPendingChangesAsync();          // only with an offline cache on
return true;

Await each operation, so the deletes have run before your code reports success. The slot that was current answers Ignored, because the clear above already emptied it.

Note: On a server of your own, erasure is the server's job.
Note: On Unity Cloud Save, deleting the saved data is not the same as deleting the player: AuthenticationService.Instance.DeleteAccountAsync() removes the identity.