Spatial

Characters

Create / select / roster of characters with an opaque appearance blob (ICharacterFactory SPI).

Server
services.AddCrossplayCharacters();
Unity package
com.crossplay.characters
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.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.

200 CharacterListRequest

Client -> server: request this account's character roster.

201 CharacterList

Server -> client: the account's characters.

202 CharacterCreateRequest

Client -> server: create a character with a name and an opaque appearance blob.

203 CharacterCreateResponse

Server -> client: result of a character creation.

204 CharacterSelectRequest

Client -> server: select a character to play.

205 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? SelectedCharacterId

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