Almost every project needs to persist small pieces of data: player progress, settings, the last opened screen, feature flags. The default tool for this in Unity is PlayerPrefs, and for anything beyond a couple of values it gets painful fast.
Unity's standard PlayerPrefs has several limitations:
- It supports only three types:
int,floatandstring. Nobool, noVector3, noDateTime, no enums, and no collections. - It is not type-safe. Nothing stops you from writing a value with
SetIntand reading it back withGetString- you just get a wrong value at runtime, with no error. - It stores data in platform-specific locations (Windows registry, macOS plist, ...) as text, which is slow, bloated, and awkward to inspect or ship with the project.
- It has no way to remove keys by pattern, no change notifications, and no control over when data is written - saving is all-or-nothing.
- There is no way to group related keys, so everything lives in one flat global namespace.
All of these issues are addressed by BinaryPrefs: a configurable, strongly typed, binary key-value storage with support for Unity types, enums, collections, custom serializers, change events, and scoped sub-storages.
And the feature I like most: every change is saved on its own. As soon as you set a value - or even mutate a stored list, set or dictionary - it is written to disk a moment later on a background thread, with atomic writes. You never have to remember to call Save(), and the calling thread never waits for the disk. When you need it, many changes can still be batched into a single write.
- Package installation
- Quick start
- Configuring storage
- Reading and writing
- Collections
- Batch changes
- Saving
- Change events
- Nested storage
- Behavior reference
- License
Using OpenUPM-CLI run the command
openupm add com.appegy.binary-prefs
Alternatively, you can install the package manually by following the instructions on the package page.
Add the package to your manifest.json.
"dependencies": {
"com.appegy.binary-prefs": "https://github.com/appegy/binaryprefs.git?path=/src",
...
},The simplest way to get a storage with all primitive types and auto-save enabled:
using System.IO;
using Appegy.Storage;
using UnityEngine;
var path = Path.Combine(Application.persistentDataPath, "player.bin");
// Pre-configured: primitive types + auto-save on change.
using var storage = BinaryStorage.Get(path);
storage.Set("player_score", 100);
storage.Set("player_speed", 5.5f);
storage.Set("player_name", "John Doe");
int score = storage.Get("player_score", 0);
float speed = storage.Get("player_speed", 1.0f);
string name = storage.Get("player_name", "Unknown");
BinaryStorageimplementsIDisposable. Dispose it (e.g. withusing) to flush and release the file. In the Editor the file path is locked while a storage instance is open, preventing accidental concurrent access to the same file.
For full control use the fluent builder via BinaryStorage.Construct:
using var storage = BinaryStorage.Construct(path)
.AddPrimitiveTypes() // built-in C# and Unity types
.SupportEnum<GameState>() // an enum
.SupportListsOf<int>() // list of a supported type
.SupportSetsOf<string>() // set of a supported type
.SupportDictionariesOf<string, int>() // dictionary of supported types
.SetMissingKeyBehaviour(MissingKeyBehavior.ReturnDefaultValueOnly)
.SetTypeMismatchBehaviour(TypeMismatchBehaviour.OverrideValueAndType)
.SetCorruptedFileBehaviour(CorruptedFileBehaviour.ResetToEmptyWithError)
.EnableAutoSaveOnChange()
.SaveOnBackgroundThread(true) // on by default, false writes the file before every change returns
.Build(KeyLoadFailedBehaviour.IgnoreWithWarning);AddPrimitiveTypes registers all C# primitives plus common Unity types: bool, char, all integer types, float, double, decimal, string, DateTime, TimeSpan, Quaternion, Vector2/3/4, Vector2Int and Vector3Int.
Notes:
SupportListsOf<T>/SupportSetsOf<T>/SupportDictionariesOf<TKey, TValue>require the element types to be registered first (e.g. viaAddPrimitiveTypesorAddTypeSerializer); otherwise aCantSupportCollectionOfExceptionis thrown.AddTypeSerializerthrows if a serializer for that type, or with the same type name, is already registered.
To persist your own type, register a serializer for it:
.AddTypeSerializer(myCustomSerializer) // a TypeSerializer<MyType>storage.Set("level", 7); // returns bool: true if the value was written
bool exists = storage.Has("level"); // key present?
System.Type type = storage.TypeOf("level"); // stored type, or null if absent
int level = storage.Get("level", 1); // typed read with default
storage.Remove("level"); // remove one key
storage.Remove(key => key.StartsWith("tmp_")); // remove by predicate, returns count
storage.RemoveAll(); // clear everythingUntyped access is available via GetRaw / SetRaw when the type is only known at runtime:
object raw = storage.GetRaw("level");
storage.SetRaw("level", 10);Collections returned by the storage are live: mutating them updates the storage (and triggers auto-save if enabled).
IList<int> scores = storage.GetListOf<int>("scores");
scores.Add(100);
scores.Add(250);
ISet<string> tags = storage.GetSetOf<string>("tags");
tags.Add("vip");
IDictionary<string, int> inventory = storage.GetDictionaryOf<string, int>("inventory");
inventory["coins"] = 50;Read-only views are also available: GetReadOnlyListOf<T>, GetReadOnlySetOf<T>, GetReadOnlyDictionaryOf<TKey, TValue>.
When changing many keys at once, wrap them in a scope so the storage saves only once at the end instead of on every change:
using (storage.MultipleChangeScope())
{
storage.Set("a", 1);
storage.Set("b", 2);
storage.Set("c", 3);
} // saved here once (when auto-save is enabled)By default (and with BinaryStorage.Get) auto-save is enabled, so every change goes to disk on its own - the moment you Set a value or mutate a stored collection. Forget-to-save bugs simply don't happen.
The change is serialized on your thread and handed to a shared background writer, so Set returns without waiting for the disk. A newer change replaces an older one that hasn't been written yet, because the file is always written whole and only the last state matters.
Writes are atomic: the data goes to a .tmp file first, is flushed to the disk, and only then replaces the storage file. The previous save stays next to it as a .bak file. A process killed in the window between the change and the write loses only the last change.
On load, if the storage file cannot be read, the storage takes the .bak file instead, and if a save was stopped while the files were being swapped, it takes the .tmp file. When nothing can be read, CorruptedFileBehaviour decides what happens.
Need to apply many changes as a single write? Wrap them in a change scope - auto-save then fires once, when the scope ends.
If you prefer full manual control, skip EnableAutoSaveOnChange and persist yourself. Save() blocks until the data has actually reached the disk - it writes the file on your thread rather than queueing behind the background writer:
storage.Save();Disposing a storage writes out whatever is still pending. To go back to writing the file before every change returns, turn the background writer off:
using var storage = BinaryStorage.Construct(path)
.AddPrimitiveTypes()
.SaveOnBackgroundThread(false)
.Build();storage.OnKeyAdded += key => Debug.Log($"Added: {key}");
storage.OnKeyChanged += key => Debug.Log($"Changed: {key}");
storage.OnKeyRemoved += key => Debug.Log($"Removed: {key}");CreateChild returns an IBinaryStorage view scoped under a prefix, useful for grouping keys (e.g. per player or per feature) while sharing one file:
IBinaryStorage player1 = storage.CreateChild("player1");
player1.Set("score", 100); // stored under the "player1" prefix in the same fileControls what Get<T> does when the key is absent.
InitializeWithDefaultValue- store the provided default and return it.ReturnDefaultValueOnly- return the default without storing it.
Controls what happens when a key already exists with a different type.
ThrowException- throw on mismatch.OverrideValueAndType- replace the stored value and its type.Ignore- keep the existing value, ignore the new one.
Passed to Build; controls what happens when a key fails to deserialize on load.
ThrowException- abort loading with an exception.Ignore- skip the bad key silently.IgnoreWithWarning- skip the bad key and log a warning (default).
Controls what Build does when neither the storage file nor its backup can be read. In every mode the unreadable files are removed, so the next start is clean. An error on opening the file (no access, the file is locked) is not a corrupted file: it always reaches the caller and the files stay.
ThrowException- throw aStorageFileCorruptedException.ResetToEmpty- start with an empty storage.ResetToEmptyWithError- start with an empty storage and log the exception (default).
Per-call overrides are available too: Get<T>(key, default, overrideMissingKeyBehavior) and Set<T>(key, value, overrideTypeMismatchBehaviour).
MIT. See LICENSE.