Game Content
Genre-agnostic authored-content store: define content types + records (entities, items, levels, classes, quests, anything) over a metaschema — every domain type is DATA, not code. Saved in the DB, served to clients on demand (manifest -> lazy fetch -> live push), asset-load-path binding. A must for Core — auto-installed, cannot be unselected.
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.
IContentAssetStore provider
Persistence seam for authored content ASSETS — keyed + versioned binary blobs (images/icons/audio). The in-memory default dies with the process; the persistence package overrides it with a document-backed store (over IDocumentStore) so assets survive restarts and ride every provider (SQLite/Postgres/Redis/ Mongo/Dynamo) + backup. Keyed by id; Put mints an id (version 1) when Id is 0 and bumps the version on update. Register a durable implementation (the persistence package does this via TryAdd/last-wins) to persist assets.
-
event Action<long, uint> ChangedRaised after an asset is upserted or removed — (id, newVersion), with newVersion == 0 signalling a removal. The delivery service (ContentAssetService) subscribes to push a live ContentAssetChanged to connected clients so a client holding the asset can re-fetch. Uploads are rare (an authoring/admin action), so a plain event with no hot-path allocation budget is fine here.
-
IReadOnlyList<ContentAssetMeta> List()Metadata for every stored asset (no bytes).
-
long Put(ContentAsset asset)Upserts an asset. Mints an id + sets version 1 when Id is 0; otherwise bumps the version. Returns the asset's id.
-
bool Remove(long id)Removes an asset; false when it did not exist.
-
bool TryGet(long id, out ContentAsset asset)Loads one asset (bytes included); false when it does not exist.
IContentStore provider
Persistence seam for authored content. The in-memory default dies with the process; the persistence package overrides it with a document-backed store (over IDocumentStore) so content survives restarts and rides every provider (SQLite/Postgres/Redis/Mongo/Dynamo) + backup.
-
bool DeleteRecord(string typeKey, long id)Deletes a record; returns false when it did not exist.
-
IReadOnlyList<ContentRecord> LoadRecords()All persisted records (empty on a fresh install).
-
IReadOnlyList<ContentTypeDef> LoadTypes()All persisted type definitions (empty on a fresh install).
-
long NextId()Mints a new, unique, monotonic record id.
-
void SaveRecord(ContentRecord record)Persists a record (upsert by type+id).
-
void SaveType(ContentTypeDef type)Persists a type definition (upsert by TypeKey).
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IContentService
Server-side content API — the editor/admin authors; gameplay reads; the wire serves clients on demand. Every Upsert/Remove persists and PUSHES the change to connected clients that hold that record. Clients pull the schema + manifest on login and fetch records lazily.
-
event Action ChangedRaised after any content change (already pushed to clients).
-
void DefineType(ContentTypeDef type)Defines/updates a content type (a family).
-
IReadOnlyList<ContentManifestEntry> Manifest()The manifest — one (type, id, version) per record, no bodies.
-
IReadOnlyList<ContentRecord> OfType(string typeKey)Every record of one type (a family).
-
bool Remove(string typeKey, long id)Removes a record; persists and pushes the removal. False when it did not exist.
-
int Reorder(string typeKey, IReadOnlyList<long> orderedIds)Sets the display/iteration order of a family: each id in orderedIds gets its index (0,1,2,…) as its SortOrder. Pass the FULL ordered id list. Bumps the version of each moved record, persists, and pushes the reordered family live. Returns how many moved.
-
IReadOnlyList<ContentTypeDef> Schema { get; }Every defined content type.
-
bool TryGet(string typeKey, long id, out ContentRecord record)Looks up one record.
-
long Upsert(ContentRecord record)Adds or updates a record (mints an id when Id is 0), bumps its version, persists, and pushes it live. Returns the record's id.
Configuration 2
Every tunable lives in an options object — there are no magic numbers to hunt for.
ContentAssetOptions
Limits for the content-asset store (defaults here, never inline — rule 4). Bound abuse without capping real games; a generated server binds these from config so they stay tunable.
-
int MaxAssetBytes { get; set; }Largest single asset accepted, in bytes (1 MiB default).
-
int MaxAssets { get; set; }Maximum number of stored assets.
ContentOptions
Content limits (defaults here, never inline — rule 4). Bound abuse without capping real games.
-
int MaxRecordsPerType { get; set; }Maximum number of records in one type/family.
-
int MaxTypes { get; set; }Maximum number of content TYPES a project may define.
Wire messages 13
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
ContentSchemaRequest Client → server: send me the schema (type definitions).
ContentSchemaResponse Server → client: every content type definition.
ContentManifestRequest Client → server: send me the manifest. KnownVersion = 0 for the full manifest.
ContentManifestResponse Server → client: the manifest — ids + versions only, no payloads.
ContentRecordRequest Client → server: send me this one record (lazy fetch when first referenced).
ContentRecordResponse Server → client: the requested record, or not-found.
ContentCatalogRequest Client → server: send me a whole family (or everything when TypeKey is null).
ContentBatch Server → client: a page of records (the whole family, or a slice) in one framed message.
ContentChanged Server → client: a record was added or changed — pushed live to clients that hold it.
ContentRemoved Server → client: a record was removed — pushed live.
ContentAssetRequest Client → server: send me the bytes of a content ASSET (an image/blob referenced by an AssetPath field whose value is casset:<id>). Lazy — the client asks only for the assets the records it holds actually reference.
ContentAssetResponse Server → client: a content asset's bytes (or not-found). The client caches by Version and rebinds any view that resolved this asset id.
ContentAssetChanged Server → client: a content asset changed — pushed live so a client holding it can re-fetch (it carries no bytes; the client re-requests only if it cares about this id).
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.
IContentAssetBinder unity SPI
The game-implemented seam that turns a content record's opaque asset-path field into a real Unity asset. The framework NEVER loads an asset — it hands the record to the game, whose binder reads the path (see GetAssetPath) and resolves it through IAssetService (com.crossplay.assets). This is the ICharacterFactory pattern at record scale — the one place a data-carried asset path is loaded, kept entirely on the game's side so the packages stay presentation-agnostic. Register your implementation via the client DI (a [SingletonClass] under CrossplayContexts.Client typed as IContentAssetBinder).
-
UniTask<GameObject> BindPrefab(ContentRecord record, Transform parent = null)Instantiates the prefab a record points at (its AssetPath field), or null when it has none.
-
UniTask<Sprite> BindIcon(ContentRecord record)Loads the icon sprite a record points at, or null.
Unity services you inject 3
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IContentAssetClient
The wire-delivery client for content ASSETS — the raw, opaque bytes of an image/blob a designer uploaded in the Hub, referenced by a record's casset:<id> field. It is the binary sibling of IContentClient (records): the client fetches an asset LAZILY the first time a record it holds references it, caches it by version, and gets a live "changed" notice when a designer re-uploads (the framework re-fetches the bytes only if it still holds the id). Fully presentation-agnostic — it deals in byte[] + a MIME string; turning those into a Sprite/Texture is the optional IContentAssetImageService's (or a game binder's) job.
-
void RequestAsset(long id)Fire-and-forget request for an asset's bytes (arrives via AssetReceived).
-
bool TryGetBytes(long id, out byte[] bytes, out string contentType, out uint version)A cached asset's bytes, if present (version-keyed — the highest received wins).
-
UniTask<byte[]> FetchBytesAsync(long id, CancellationToken ct = default)Returns the asset's bytes, awaiting the wire fetch when they are not already cached (coalesces concurrent fetches of the same id onto one request). Returns null on a not-found, timeout, or cancel — never throws.
-
event Action<long> AssetReceivedAn asset's bytes arrived (or were refreshed) and are now cached.
-
event Action<long> AssetInvalidatedThe server said an asset changed/was removed — its cached bytes were dropped; re-fetch to refresh.
IContentAssetImageService
Turns a wire-delivered content asset's raw bytes (fetched by IContentAssetClient) into a Unity Sprite / Texture2D, cached by asset id and rebuilt when the designer re-uploads. This is the ONE optional place the framework decodes image bytes — it keeps ContentAssetClient presentation-agnostic (bytes only) while giving games a ready casset: → Sprite path. A game that wants a different decode (or none) simply doesn't inject this and reads FetchBytesAsync itself.
-
UniTask<Sprite> LoadSpriteAsync(long id, CancellationToken ct = default)Loads (fetch + decode, then cache) the asset id as a sprite, or null on miss/timeout/non-image.
-
UniTask<Texture2D> LoadTextureAsync(long id, CancellationToken ct = default)Loads (fetch + decode, then cache) the asset id as a texture, or null on miss/timeout/non-image.
-
event Action<long> ImageChangedThe decoded sprite/texture for an id was dropped because the asset changed — rebind by calling LoadSpriteAsync again (the client has already re-fetched the fresh bytes if it held it).
IContentClient
The Content piece, client side: authored game content received ON DEMAND. On login the game asks for the schema + manifest (tiny — ids and versions, no bodies); it then fetches records lazily the first time it references one, caches them by version, and receives live pushes when a designer edits — never the whole library at once. Records are opaque typed data; the game reads their fields (see the Get* extensions) and binds a record's asset-path to a real prefab/icon via IContentAssetBinder. The client never interprets a record — it hands it to the game.
-
void RequestSchema()Ask the server for the content type definitions (arrives via SchemaChanged).
-
void RequestManifest()Ask for the manifest — one (type, id, version) per record, no bodies (via ManifestReceived).
-
void RequestRecord(string typeKey, long id)Lazily fetch one record the first time it's referenced (arrives via RecordChanged).
-
void RequestCatalog(string typeKey = null)Fetch a whole family (or everything when typeKey is null).
-
bool TryGet(string typeKey, long id, out ContentRecord record)A cached record, if present.
-
IReadOnlyList<ContentRecord> OfType(string typeKey)Every cached record of a type.
-
IReadOnlyList<ContentTypeDef> SchemaThe known content type definitions (populate via RequestSchema).
-
IReadOnlyList<ContentManifestEntry> ManifestThe last manifest received (ids + versions).
-
event Action<ContentRecord> RecordChangedA record was added, fetched, or changed (cached before this fires).
-
event Action<string, long> RecordRemovedA record was removed on the server.
-
event Action SchemaChangedThe schema arrived/changed.
-
event Action ManifestReceivedA manifest arrived.