Characters
Create / select / roster of characters with an opaque appearance blob (ICharacterFactory SPI).
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayCharacters(); and it wins.
ICharacterPolicy game SPI
5.3: the game's precondition gate on character CREATE and SELECT — beyond the built-in checks (length, name policy, roster limit, name uniqueness, ownership). Unlike the CharacterCreated / CharacterSelected events (which fire AFTER the fact, too late to stop anything), this VETOES with a reason before the character is created or bound: "you must reach account level 5 to make a third character", "this hero is locked until you finish the tutorial", "appearance uses a banned crest". The default (AllowAllCharacterPolicy) permits everything the built-in checks passed. Register yours BEFORE AddCrossplayCharacters (a TryAdd seam).
-
bool AllowCreate(long accountId, string name, ReadOnlySpan<byte> appearance, out ushort reasonCode)May this account create this (already length- and name-valid) character? On refusal set reasonCode to a notification code (0 falls back to a generic invalid-create code).
-
bool AllowSelect(long accountId, long characterId, out ushort reasonCode)May this account select this (owned) character? On refusal set reasonCode to a notification code (0 falls back to a generic not-allowed code).
Providers you can swap 2
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
ICharacterNamePolicy provider
The game's name rule beyond the universal length gate (donor-gap E3): reserved names, charsets, profanity filters, impersonation guards — all the game's vocabulary. The default allows everything the length gate passed. Register yours BEFORE AddCrossplayCharacters (a TryAdd seam).
-
bool Allow(string name, out ushort reasonCode)True when the (already length-valid, trimmed) name may be used. On refusal set reasonCode to a notification code (0 falls back to NameInvalid).
ICharacterStore provider
Character persistence SPI — the seam a game implements to store its roster durably. The base ships the in-memory InMemoryCharacterStore for dev and tests; register your own implementation after AddCrossplayCharacters to back it with a real database.
-
CharacterRecord Create(long accountId, string name, byte[] appearance)Creates and persists a new character. The caller has already validated the name and roster limit.
-
bool Delete(long id)Deletes a character by id — the GDPR / account-erase cascade calls this per owned character. The default returns false so a game-supplied store keeps compiling even before it implements deletion; override it to actually purge the row (both built-ins do).
-
CharacterRecord GetById(long id)Looks up a single character by its server-assigned id.
-
CharacterRecord GetByName(string name)Finds a character by its (case-insensitive) display name — used for mail / social targeting.
-
bool IsNameTaken(string name)Tests whether a display name is already taken (case-insensitive) — the create-time uniqueness guard.
-
IReadOnlyList<CharacterRecord> ListByAccount(long accountId)All characters owned by an account, in any order.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
CharacterOptions
Configurable character rules, enforced by CharacterHandlers at create time. Defaults live here (never inlined in the handlers); set them via the configure callback on AddCrossplayCharacters.
-
int MaxCharactersPerAccount { get; set; }Maximum characters one account may own. A create past this is rejected with LimitReached. Default 5. Raise for games with large rosters; set to 1 for single-character-per-account games.
-
int MaxNameLength { get; set; }Maximum character name length (characters, inclusive). Default 20. Keep in step with whatever width the game's UI and any downstream name column allow; this is the wire-side guard.
-
int MinNameLength { get; set; }Minimum character name length (characters, inclusive). A create with a trimmed name shorter than this is rejected with NameInvalid. Default 2. Raise to forbid single-letter names.
Wire messages 6
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
CharacterListRequest Client -> server: request this account's character roster.
CharacterList Server -> client: the account's characters.
CharacterCreateRequest Client -> server: create a character with a name and an opaque appearance blob.
CharacterCreateResponse Server -> client: result of a character creation.
CharacterSelectRequest Client -> server: select a character to play.
CharacterSelectResponse Server -> client: result of a character selection.
Unity seams you implement 1
The client half's sockets. Bind your implementation in the client context and Crossplay's client calls it — this is where your models, animators, UI and controls plug in.
ICharacterFactory unity SPI
SPI the game implements to turn an opaque appearance blob into a visible avatar. This is the "any character / any game" seam: Crossplay never interprets the blob — it hands it to the game, which builds and dresses the GameObject (load a class prefab, instantiate a body + attach clothing meshes, whatever). The returned root's Animator (if any) is driven by Movement's IAnimatorParameterMapper.
-
GameObject CreateAvatar(byte[] appearance, Transform parent)Builds an avatar for appearance under parent. The blob is the exact bytes the game put on the wire (via its appearance codec); Crossplay forwards it verbatim. Return the root GameObject (positioned by the caller).
-
void DestroyAvatar(GameObject avatar)Destroys an avatar previously returned by CreateAvatar.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
ICharacterClient
Client-side roster operations as async RPCs: list, create, select. On a successful select the chosen id is stashed in the Core client state's property bag (see SelectedCharacterIdKey) so later pieces can read it.
-
long? SelectedCharacterIdThe locally-selected character id, or null if none selected this session.
-
UniTask<CharacterSummary[]> ListAsync(CancellationToken ct = default)Requests the account's roster; empty array on timeout.
-
UniTask<CharacterCreateResponse> CreateAsync(string name, byte[] appearance, CancellationToken ct = default)Creates a character with a name and opaque appearance blob.
-
UniTask<CharacterSelectResponse> SelectAsync(long characterId, CancellationToken ct = default)Selects a character to play; on success records it in client state.
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.
ICrossplayCharactersView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.