Extending Advanced
Write your own manager, serializer, settings section or variable type, and it 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. For
a synchronous-only manager, that is three methods. You return a payload string from a read and hand
back a payload string on a write; the manager serializes and deserializes the target for you,
so you never touch Target 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. The rules:
LoadResult.Success(payload): you read usable data.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.
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, so
you have two ways to set one:
- Expose it in the inspector, when it is a choice you want to expose: override with a
[field: SerializeField]auto-property. - Fix it in code, when the value is a fact of your manager, not a user choice: 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;
Other hooks worth knowing:
StorageLocation(slot): report a stable path or key (backsGetStorageLocation()). Two managers must only report the same string when they genuinely share that storage, so shape yours so no other storage can produce it; the editor reports managers resolving to one same location as a conflict (see Technical Q&A).OnEnteringScope/OnLeavingScope: per-session setup and teardown.Constraint: pin a setting your manager cannot function without, so a target's policy cannot override it. For example, an in-memory manager that is pointless unless it loads and saves automatically can fix both on; a write-only manager might fixAutoLoadoff.[PersistenceManagerInfo(StorageKind = ...)]on the class: declare what your manager's storage holds, a fact of the type rather than a setting.Durable(real player saves; the default without the attribute),Transientfor a manager that keeps nothing recoverable (no import copy of it, never lockable), orTestfor a fake manager that must never ship (see below). The attribute is inherited by subclasses.SelfBoundsAsyncOperations: returntrueonly 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 itfalseunless you enforce your own deadlines.Log(text, level): write to the manager's inspector log (see Debugging; stripped from non-development builds).
Mark your manager's save-breaking fields with [Breaking(...)] so the inspector can
warn 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
If your manager writes local data, 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 ...
}
}
For finer control, or to build the inspector without inheriting
PersistenceManagerEditor at all, PersistenceManagerEditorGUI exposes
the individual GUI pieces the base inspector is made of, so you can compose your own and still
match the built-in look.
Reusable building blocks
You do not have to reimplement everything for a local manager, several public helpers in
PersistentAsset.BuiltIns are there to reuse:
SaveFileUtility: read, write (atomically) and delete save files, with the package's header handling.GzipUtility: compress and decompress with theCompressionsetting.CheatProtectionUtility: the encryption and integrity engine behind the Security feature (Encrypt / Decrypt / AppendSecuredHash / CheckSecuredHash with the project key), and the home ofRuntimeSecret.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.
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);
}
}
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 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) can be done with a plain
custom manager by overriding the async read, write and clear methods above.
But when you also want an offline cache, a background push loop and conflict
handling, RemotePersistenceManager gives you all of that for the cost of three
primitives. It is a built-in (not part of Core) precisely because it is only worth it for that
scenario; a simple 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) { /* ... */ }
}
Map your manager's responses honestly, the cache logic reasons about them:
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.
Use SetupError for a permanent build-time fault (no URL configured), expose
CacheMode and OfflineColdStart to make the offline cache opt-in,
and override LocalCacheFolder to name your 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, but if you change a setting from code, call
Settings.Save() to persist it. It is a no-op outside the editor (the call is
stripped from a build), so it is always safe to call.
A custom settings drawer
By default a section's fields are drawn flat, one under another, with no foldout. To lay them
out yourself, 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; add it to give a friendlier name, a description (shown as a
tooltip), an ordering and a category. It can decorate any of the types you extend here:
- 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 one of your own types 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. This is exactly how every built-in variable type is built, the simple values, Input Binding and Locale Identifier alike.
The sample shows the two habits every codec must copy: format and parse with
CultureInfo.InvariantCulture (the string lands in save files, which must read the same on every
machine locale, and a comma decimal separator would even collide with the component separator here), and
never throw on corrupt input, degrade to the default value instead.
Two constraints: enums need no codec (they are handled generically, so do not write one), and a codec must target a single value type, collections are not registrable (use a list or map variable of your type instead).
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, say). Add your own by subclassing
ValueConverter<TFrom, TTo>; it 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. For a two-way binding, also declare the reverse
(string to int). Your converters join 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 for you, so you just draw 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 your editor needs more than one line.