Player Data
Per-player key/value store of opaque blobs.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayPlayerData(); and it wins.
IPlayerDataPolicy game SPI
SPI the game implements to gate CLIENT-initiated writes (the wire PlayerDataSet). The server-side Set is ALWAYS trusted and bypasses this check.
public sealed class MyPolicy : IPlayerDataPolicy
{
public bool CanClientSet(ISession session, long characterId, string key, byte[] value)
=> key.StartsWith("settings."); // clients may edit settings.* only; the rest is server-only
} -
bool CanClientSet(ISession session, long characterId, string key, byte[] value)Approve (or veto) a client-initiated write of key for this owner.
Providers you can swap 1
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IPlayerDataStore provider
Persistence seam for player data, keyed by the owner (character) id. The default InMemoryPlayerDataStore keeps blobs in RAM (dev/tests); register a document-backed override BEFORE AddCrossplayPlayerData to persist across restarts (it is a TryAdd seam). The service caches loaded rows, so TryLoad is hit once per owner and Save on each mutation.
-
void Save(long characterId, IReadOnlyDictionary<string, byte[]> entries)Persists one owner's complete key→value map (the service passes the full post-mutation set).
-
bool TryLoad(long characterId, out IReadOnlyDictionary<string, byte[]> entries)Loads one owner's full key→value map.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IPlayerDataService
Server-authoritative per-owner key→opaque-blob storage — progression, unlocks, settings, a JSON blob, whatever the game means by the bytes. The interface GAME CODE calls to read and write trusted data; resolve it from DI: [Inject] IPlayerDataService playerData.
-
bool Set(long characterId, string key, byte[] value)Writes (or deletes) one key for an owner — the trusted, game-side path (no policy check).
-
bool TryGet(long characterId, string key, out byte[] value)Reads one key's opaque value for an owner.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
PlayerDataOptions
Configurable limits for the PlayerData piece (defaults here; never inlined in logic). Passed to the configure callback of AddCrossplayPlayerData and enforced on every write (client- and server-initiated alike).
-
int MaxKeyLength { get; set; }Longest key string accepted; a write with a longer key is rejected. Default 64.
-
int MaxKeys { get; set; }Most distinct keys one owner may hold; adding a NEW key past this is rejected (overwriting an existing key still works). Default 256.
-
int MaxValueBytes { get; set; }Largest opaque value in bytes; a write with a bigger value is rejected. Default 4096.
Wire messages 4
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
PlayerDataGet Client → server: read the requester's values. The owner is resolved from the session; the reply is a PlayerDataContents.
PlayerDataContents Server → client: entry set for the requester — sent as the reply to a PlayerDataGet, and pushed unsolicited (carrying just the changed key) whenever the owner's data changes while they are online.
PlayerDataSet Client → server: set one key (empty Value = delete). Gated by the game's IPlayerDataPolicy and the size limits; the reply is a PlayerDataSetResponse.
PlayerDataSetResponse Server → client: the result of a client-initiated PlayerDataSet.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IPlayerDataClient
The PlayerData piece, client side: read your per-character values, write what the game's server policy allows, and observe pushes when the server changes something (a quest granting an unlock, a purchase updating currency). Values are your game's vocabulary — the framework never interprets them. Remove this package and player data simply stops — nothing else breaks.
-
bool TryGet(string key, out byte[] value)The latest known value per key (filled by gets and pushes).
-
event Action<string, byte[]> ChangedA value arrived or changed (empty value = deleted).
-
UniTask<PlayerDataContents> GetAsync(string[] keys = null, CancellationToken ct = default)Requests values (empty = everything); the reply also updates the cache.
-
UniTask<PlayerDataSetResponse> SetAsync(string key, byte[] value, CancellationToken ct = default)Sets one key (empty value = delete), subject to the server policy.
UI views you can replace 1
Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.
ICrossplayPlayerDataView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.