A custom manager, serializer, settings section or variable type plugs into the same inspector dropdowns, settings and tooling as the built-ins.

A custom persistence manager

Subclass PersistenceManager and implement the read, write and clear behavior: three methods for a synchronous-only manager. A read returns a payload string and a write receives one; the manager serializes and deserializes the target itself, so Target is never touched directly.

using PersistentAsset;

public class MyPersistenceManager : PersistenceManager
{
    protected override LoadResult ReadPayloadSync(PersistenceSource source, string slot)
    {
        if (MyBackend.TryRead(Key(slot), out string payload))
            return LoadResult.Success(payload);

        // Missing or unreadable: the normal first run. NOT Failure (that is for transient errors).
        return LoadResult.Invalid("No save found.");
    }

    protected override SaveResult WritePayloadSync(PersistenceSource source, string slot, string payload)
    {
        MyBackend.Write(Key(slot), payload);
        return SaveResult.Success();
    }

    protected override ClearResult ClearPayloadSync(PersistenceSource source, string slot)
    {
        MyBackend.Delete(Key(slot));
        return ClearResult.Success();
    }

    // slot comes from your game and may be player-controlled: escape it before use as a key.
    string Key(string slot) => (slot != null) ? $"{UniqueID}_{slot}" : UniqueID;
}

The result contract

Returning the right result is what keeps saves safe:

  • LoadResult.Success(payload): usable data was read.
  • LoadResult.Invalid(): there is no usable data (missing or unreadable) and a retry will not help. The target keeps its values and saving is allowed, so the next save overwrites it. This is the normal first run.
  • LoadResult.Failure(): the save location is temporarily unavailable (a locked file, a server down). The target is left not ready so a save cannot overwrite data a retry could still recover.
Invalid means "nothing there", Failure means "could not reach it right now". Getting it wrong either strands a first-run player (Failure on a clean miss) or risks overwriting a recoverable save (Invalid on a transient error).

Going asynchronous

For a manager that should not block the main thread, override the async trio instead (ReadPayloadAsync, WritePayloadAsync, ClearPayloadAsync), honor the CancellationToken, and declare what you support through ReadExecutionMode / WriteExecutionMode. For a manager that lives off the device specifically, RemotePersistenceManager already does the async plumbing and adds an offline cache.

Exposing and fixing settings

The base settings (AutoLoad, AutoSave, AutoLoadRetryDelay, RegularSaveDelay, LoadTimeout, SaveTimeout, ClearTimeout, the execution modes) are virtual properties with no backing field. There are two ways to set one:

  • Expose it in the inspector, when it is a user choice: override with a [field: SerializeField] auto-property.
  • Fix it in code, when the value is a fact of the manager: override with a plain getter and no serialization. It then shows no inspector field.
// Exposed in the inspector
[field: SerializeField]
public override bool AutoSave { get; protected set; } = true;

// Fixed in code: no field, no serialization, just a fact of this manager
public override ExecutionMode WriteExecutionMode => ExecutionMode.AsyncOnly;

The other hooks:

  • StorageLocation(slot): report a stable path or key (backs GetStorageLocation()). Two managers must report the same string only when they genuinely share that storage, so shape yours so no other storage can produce it. The editor reports managers resolving to the same location as a conflict (see Technical Q&A).
  • OnEnteringScope / OnLeavingScope: per-session setup and teardown.
  • Constraint: pin a setting the manager cannot function without, so a target's policy cannot override it. An in-memory manager can fix auto load and auto save on; a write-only manager can fix AutoLoad off.
  • [PersistenceManagerInfo(StorageKind = ...)] on the class: declare what the manager's storage holds. Durable (real player saves, the default without the attribute), Transient for a manager that keeps nothing recoverable (no import copy of it, never lockable), or Test for a fake manager that must never ship (see below). The attribute is inherited by subclasses.
  • SelfBoundsAsyncOperations: return true only when every async path is guaranteed to return on its own, so the core stops bounding it with the Load / Save / Clear timeouts. A self-bounding manager that hangs is never stopped by the core, so leave it false unless you enforce your own deadlines.
  • Log(text, level): write to the manager's inspector log (see Debugging; stripped from non-development builds).

Mark the manager's save-breaking fields with [Breaking(...)] so the inspector can warn about and lock them after release, the same protection the built-ins use.

Writing a test manager

A fake manager meant for testing only (the built-in Test manager is one) declares [PersistenceManagerInfo(StorageKind = StorageKind.Test)] on its class, and the editor handles the rest:

  • Assigning it always keeps a read-only import copy of the setup it replaces, whatever that setup was, so the previous settings (and saves) stay intact.
  • Its inspector shows a warning box and a one-click button that restores the previous setup, or removes the test manager when there was none.
  • It never ships: a non-development build fails while a test manager exists in the project, a development build logs a warning instead (so a QA build can carry one on purpose), and a player that still scopes one in reports it at runtime too.

A "delete local data" cleaner

A manager that writes local data can expose a cleaner, so it appears in Tools > Persistent Asset > Actions > Delete Local Data. Mark a parameterless static editor method with [ClearLocalData(typeof(...))]:

using PersistentAsset.Editor;

[ClearLocalData(typeof(MyPersistenceManager))]
static void ClearMyBackend()
{
    // delete your local files or keys here
}

A custom inspector

To customize the manager's inspector, inherit PersistenceManagerEditor with a [CustomEditor] attribute and override one of the Draw hooks (DrawParameters, DrawActions, DrawStatus). Call base to keep the standard sections (target header, parameters, the Load / Save / Clear actions, status and logs) and add to them:

using PersistentAsset.Editor;
using UnityEditor;

[CustomEditor(typeof(MyPersistenceManager))]
public class MyPersistenceManagerEditor : PersistenceManagerEditor
{
    protected override void DrawParameters()
    {
        base.DrawParameters();   // keep the standard fields
        // ... draw your own extra fields ...
    }
}

To build the inspector without inheriting PersistenceManagerEditor, PersistenceManagerEditorGUI exposes the individual GUI pieces the base inspector is made of, so a custom one still matches the built-in look.

Reusable building blocks

A local manager can reuse several public helpers from PersistentAsset.BuiltIns:

  • SaveFileUtility: read, write (atomically) and delete save files, with the package's header handling.
  • GzipUtility: compress and decompress with the Compression setting.
  • CheatProtectionUtility: the encryption and integrity engine behind the Security feature (Encrypt / Decrypt / AppendSecuredHash / CheckSecuredHash with the project key), and the home of RuntimeSecret.
  • SlotFormatUtility: turn a slot into a filesystem-safe segment and resolve the <slot> placeholder.
  • TaskUtility: hop between the main and background threads for async work.
  • WebGLFileSync: flush writes to the browser's IndexedDB on WebGL.
  • SaveNodeJson: parse JSON text into a SaveNode tree, to implement ParseToNode in a JSON serializer.
  • FieldSnapshot: copy a target's fields and put them back, so a failed deserialize leaves it unchanged.
These helpers live in the PersistentAsset.BuiltIns.Shared assembly, which is not auto-referenced: add a reference to it from your own assembly definition before using them.

A custom serializer

The serializer turns an object into a string and back. Subclass Serializer, mark it [Serializable] so it can be assigned in the inspector, and implement two methods:

[Serializable]
public class MySerializer : Serializer
{
    public override string SerializeToString(Object obj, string backReferenceFieldName)
    {
        return MyFormat.Encode(obj);
    }

    public override bool DeserializeFromString(string str, Object obj)
    {
        return MyFormat.TryDecodeInto(str, obj);
    }
}
A serializer must not fail on valid data. Returning null (or throwing) from serialize, or false (or throwing) from deserialize on data it produced, is treated as a fatal error: if the target cannot be encoded as it enters scope, persistence is disabled for it to avoid silently losing or corrupting saves.

To support save versioning and IFieldResettable, also override ParseToNode (parse your format into a neutral SaveNode tree) and set SupportsNode to true.

A field pointing at a project asset needs handling: a reference means nothing outside the running session, so write AssetRegistry.IdOf(asset) instead of the reference, and read it back with AssetRegistry.TryResolve. See saving asset references.

A custom serializer drawer

To control how your serializer's own fields draw under the serializer dropdown, inherit SerializerDrawer, decorate it with [CustomPropertyDrawer(typeof(MySerializer))], and override DrawFields (and FieldsHeight when it needs more than one line):

using PersistentAsset.Editor;
using UnityEditor;
using UnityEngine;

[CustomPropertyDrawer(typeof(MySerializer))]
public class MySerializerDrawer : SerializerDrawer
{
    protected override void DrawFields(Rect position, SerializedProperty property)
    {
        // ... draw your serializer's own fields ...
    }
}

A custom remote manager

Saving off the device (a server, a third-party service, a cloud SDK) works with a plain custom manager, by overriding the async read, write and clear methods above. For an offline cache, a background push loop and conflict handling, RemotePersistenceManager provides all of it in exchange for three primitives. An always-online manager does not need it.

Each primitive returns an Answer describing how the remote responded:

using PersistentAsset.BuiltIns;   // RemotePersistenceManager lives here, not in Core

public class MyRemoteManager : RemotePersistenceManager
{
    protected override async Task<(Answer answer, string body, string message)>
        FetchRemote(string slot, CancellationToken cancellationToken)
    {
        var response = await MyApi.Get(Key(slot), cancellationToken);
        if (response.NotFound) return (Answer.NoSave, null, "No save.");
        if (response.Ok)       return (Answer.Success, response.Body, null);
        return (Answer.Unreachable, null, response.Error);
    }

    protected override async Task<(Answer answer, string message)>
        StoreRemote(string slot, string body, CancellationToken cancellationToken) { /* ... */ }

    protected override async Task<(Answer answer, string message)>
        DeleteRemote(string slot, CancellationToken cancellationToken) { /* ... */ }
}

The cache logic reasons about these answers, so map the remote's responses exactly:

  • Success: the operation landed (a fetch must return the body, byte for byte).
  • NoSave: reachable, but nothing saved at this address.
  • Corrupt: the data is unreadable, a retry will not fix it.
  • Error: the manager answered but the operation did not land (bad auth, server error), a retry may help.
  • Unreachable: the manager could not be contacted at all.

SetupError reports a permanent build-time fault (no URL configured), exposing CacheMode and OfflineColdStart makes the offline cache opt-in, and LocalCacheFolder names the cache. To wipe that cache from Delete Local Data, call the helper RemotePersistenceManager.ClearLocalCache(name, folder) from a [ClearLocalData] method.

For the inspector, inherit RemotePersistenceManagerEditor (it adds the cache parameters on top of the standard manager inspector; call DrawCacheParameters() to place that section).

Custom settings

To add your own section to Project Settings > Persistent Asset, subclass SettingsEntry and add your fields:

public class MyStudioSettings : SettingsEntry
{
    public string endpoint = "https://...";
    public bool verboseLogging;
}

Read it anywhere through Settings.Get<T>(), which always returns the same instance and is build-injected, so it works in a player too:

string endpoint = Settings.Get<MyStudioSettings>().endpoint;

The settings window saves itself. A setting changed from code is persisted with Settings.Save(), which is stripped from a build and so is always safe to call.

A custom settings drawer

By default a section's fields are drawn flat, one under another, with no foldout. For a custom layout, inherit SettingsEntryDrawer, decorate it with [CustomPropertyDrawer(typeof(MyStudioSettings))], and override DrawFields (and FieldsHeight when it needs more than the default height):

using PersistentAsset.Editor;
using UnityEditor;
using UnityEngine;

[CustomPropertyDrawer(typeof(MyStudioSettings))]
public class MyStudioSettingsDrawer : SettingsEntryDrawer
{
    protected override void DrawFields(Rect position, SerializedProperty property)
    {
        // ... lay out the section's fields however you like ...
    }
}

The InspectorDisplay attribute

[InspectorDisplay] is optional everywhere. Without it, a type appears under its plain C# name; with it, it gets a friendlier name, a description (shown as a tooltip), an ordering and a category. It decorates any of the types on this page:

  • a persistence manager: its entry in the manager dropdown;
  • a serializer: its entry in the serializer dropdown;
  • a settings entry: its section title in Project Settings;
  • a No-Code variable codec: its entry in the variable type dropdown;
  • a delete-local-data cleaner method: its label in the Delete Local Data window.
[InspectorDisplay("My Backend", "Saves to my custom service.", Order = 100, Category = "My Studio")]
public class MyPersistenceManager : PersistenceManager { /* ... */ }

Only the name is required; description, Order and Category are optional.

Custom variable types (No-Code)

To make a type of your own usable as a No-Code variable, write a VariableTypeCodec<T> that turns the value into the flat string a PersistentVariables asset saves, and back, then register it with [VariableType]:

using System.Globalization;
using PersistentAsset.NoCode;

[VariableType(typeof(Color32), "Color32")]
public class Color32Codec : VariableTypeCodec<Color32>
{
    protected override string Write(Color32 value)
        => string.Format(CultureInfo.InvariantCulture, "{0},{1},{2},{3}", value.r, value.g, value.b, value.a);

    protected override Color32 Read(string text)
    {
        if (string.IsNullOrEmpty(text))
            return default;   // an empty string means the default value

        // Corrupt input degrades to the default instead of throwing, like every built-in codec.
        string[] p = text.Split(',');
        if (p.Length != 4
            || byte.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte r) == false
            || byte.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte g) == false
            || byte.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte b) == false
            || byte.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte a) == false)
            return default;
        return new Color32(r, g, b, a);
    }
}

The type then appears in the variable type dropdown, with a default value field. Every built-in variable type is built this way, the simple values, Input Binding and Locale Identifier alike.

Two rules every codec follows, both shown above. Format and parse with CultureInfo.InvariantCulture, since the string lands in save files that must read the same under every machine locale (a comma decimal separator would also collide with the component separator here). And never throw on corrupt input: degrade to the default value.

Two constraints: enums are handled generically and need no codec, and a codec targets a single value type, so collections are not registrable (use a list or map variable of your type).

Custom value converters (No-Code)

A Variable Binder uses a ValueConverter to drive a member whose type differs from the variable's (an int variable onto a string label, for example). Add your own by subclassing ValueConverter<TFrom, TTo>, which is discovered automatically:

using PersistentAsset.NoCode;

// int -> string, so an int variable can drive a text label
public class IntToStringConverter : ValueConverter<int, string>
{
    protected override string Convert(int value) => value.ToString();
}

Each converter goes one way, so a two-way binding also needs the reverse (string to int). Custom converters sit alongside the built-in ones.

Custom inspector widgets (No-Code)

A widget controls how a variable type is edited in the inspector (its drawer). Subclass VariableWidget<T> and pair it with a codec by id (matching the [VariableType] id) through [VariableWidget]. The codec round trip is handled by the base class, so the widget draws the typed value:

using PersistentAsset.Editor.NoCode;
using UnityEditor;
using UnityEngine;

[VariableWidget("Color32")]   // the codec id from [VariableType] above
public class Color32Widget : VariableWidget<Color32>
{
    protected override Color32 Draw(Rect rect, GUIContent label, Color32 value)
        => EditorGUI.ColorField(rect, label, value);
}

Override GetHeight too when the editor needs more than one line.