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 persisted the moment it happens. As soon as you set a value - or even mutate a stored list, set or dictionary - it goes to disk on a background thread, with atomic, corruption-safe 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)
.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: data is written to a temporary file first and only then swapped in, so an interrupted save can never corrupt your existing file. A process killed in the window between the change and the write keeps the previous state intact and loses only the last change.
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).
Per-call overrides are available too: Get<T>(key, default, overrideMissingKeyBehavior) and Set<T>(key, value, overrideTypeMismatchBehaviour).
MIT. See LICENSE.