playerdata

Player Data

Per-player key/value store of opaque blobs.

Category Meta Seams 2 Services 1 Options 1 Wire ids 6 Unity types 2
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.

Example
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 CanClientRead(ISession reader, long ownerId, string key)

    Approve (or veto) another player reading one PUBLIC key of an owner through PlayerDataPeek. Only ever asked about keys under PublicKeyPrefixes — a key outside them is refused before this is called, so it cannot widen what is public, only narrow who sees it (friends only, not blocked, same guild, …). Defaulted to allow, so adding it breaks no existing policy (rule 25).

  • 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 MaxPeekOwners { get; set; }

    Most distinct owners one PlayerDataPeek may read; the rest of the request is ignored. Bounds the work and the reply size of a single request (the wire policy bounds the request rate). Default 50.

  • int MaxValueBytes { get; set; }

    Largest opaque value in bytes; a write with a bigger value is rejected. Default 4096.

  • string[] PublicKeyPrefixes { get; set; }

    Key prefixes OTHER players may read through PlayerDataPeek (for example "public."). Empty by default: every key is owner-only and the peek message is not even handled. Choose these with care — the framework itself stores server-only keys here (crossplay.*: ability books, upgrade tiers, one-time interactable markers), so a prefix like "" or "c" would publish them. Blank prefixes are ignored for exactly that reason. CanClientRead can narrow who may read.

Wire messages 6

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.

2105 PlayerDataPeek

Client → server: read other owners' PUBLIC values — a profile title, a banner, a showcased item. Only keys under the server's PlayerData:PublicKeyPrefixes can ever be returned, and the game's policy may narrow who sees them; everything else stays owner-only. The reply is a PlayerDataPeekResult. A server that made nothing public does not handle this message at all.

2106 PlayerDataPeekResult

Server → client: the reply to a PlayerDataPeek. An owner with nothing visible is left out, so "no such owner" and "nothing public" look the same — the reply never confirms that an owner exists.

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.

  • UniTask<PlayerDataPeekResult> PeekAsync(long[] ownerIds, string[] keys = null, CancellationToken ct = default)

    Reads OTHER owners' public values (empty keys = every public key). Only keys the server lists in PlayerData:PublicKeyPrefixes ever come back, filtered by the game's read policy; a server that made nothing public never answers, so this resolves empty after the RPC timeout. Not cached. Defaulted so adding it breaks no existing implementation of this interface (rule 25).

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.