Meta

Player Data

Per-player key/value store of opaque blobs.

Server
services.AddCrossplayPlayerData();
Unity package
com.crossplay.playerdata
Depends on
core

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.

2101 PlayerDataGet

Client → server: read the requester's values. The owner is resolved from the session; the reply is a PlayerDataContents.

2102 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.

2103 PlayerDataSet

Client → server: set one key (empty Value = delete). Gated by the game's IPlayerDataPolicy and the size limits; the reply is a PlayerDataSetResponse.

2104 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[]> Changed

    A 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.