Inventory
The character's bag — a container role over the Container engine (opaque item blobs, policy SPI, persistence).
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayInventory(); and it wins.
IInventoryPolicy game SPI
SPI the game implements to give items its rules — the framework only moves opaque bytes. The default is permissive: byte-equal items stack up to the configured max, anything can sit in any slot. Register your own (before AddCrossplayServer) for per-item stack limits, equipment-only slots, soulbound checks, and the rest of your game's vocabulary. Decode your own item header from the opaque bytes to answer each question; every call runs on the poll thread.
sealed class GameInventoryPolicy : IInventoryPolicy
{
public bool CanStack(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b) => a.SequenceEqual(b);
public int MaxStackFor(ReadOnlySpan<byte> item) => IsMaterial(item) ? 999 : 1;
public bool CanPlace(int slot, ReadOnlySpan<byte> item) => true;
}
// services.AddSingleton<IInventoryPolicy, GameInventoryPolicy>(); // BEFORE AddCrossplayInventory() -
bool CanPlace(int slot, ReadOnlySpan<byte> item)May this item occupy this slot?
-
bool CanPlace(int slot, ReadOnlySpan<byte> item, IContainerView view)Occupancy-aware placement — the same question with a read-only view of the bag's current contents, so rules that depend on OTHER slots (grid row-fit, adjacency) become expressible. The default delegates to the classic CanPlace, so existing policies keep working unchanged.
-
bool CanStack(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b)Can these two item payloads share a stack?
-
int FootprintFor(int anchorSlot, ReadOnlySpan<byte> item, Span<int> cells)The cells this item CONSUMES anchored at anchorSlot — the 2D-grid hook, consulted only when the bag runs with GridFootprints enabled. Decode your WxH shape from your own item header, write the covered slot indices (anchor included) into cells, return the count. The default is the classic single cell, so slot/stack bags are untouched.
-
int MaxStackFor(ReadOnlySpan<byte> item)Largest stack for this item.
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.
IInventoryStore provider
Persistence seam for inventories, keyed by character id. In-memory by default; the persistence package overrides with a document-backed store so inventories survive restarts and are shared across cluster nodes.
-
void Save(long characterId, IReadOnlyList<StoredSlot> slots)Saves (replaces) a character's stacks.
-
void SaveBatch(IReadOnlyList<ValueTuple<long, IReadOnlyList<StoredSlot>>> saves)Saves several characters' stacks in ONE unit — used by the Trade piece so a completed exchange persists both inventories together (a crash mid-write can't leave items duplicated or lost). The default applies them sequentially (fine for the in-memory store); a durable store overrides with a real atomic multi-document write.
-
bool TryLoad(long characterId, out IReadOnlyList<StoredSlot> slots)Loads a character's stacks; false when none were ever saved.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IInventoryService
Server-authoritative inventories, keyed by character id. The GAME grants and consumes items through this API (quest rewards, loot, crafting costs); players rearrange via the wire ops. Every operation is all-or-nothing on the single poll thread. Items are opaque bytes — the game's vocabulary, stacked only when the policy says two payloads match. This is the same seam Trade / Crafting / Loot / Market / Mail call to move items; it is a thin role over the generic Container engine (the character bag is the container bag:{characterId}).
-
event Action<long> ChangedRaised after any change to a character's inventory (handlers push fresh contents).
-
int CountOf(long characterId, byte[] item)Total count of byte-equal items held.
-
IReadOnlyList<InventorySlot> Get(long characterId)The occupied slots (wire-shaped; do not mutate).
-
bool Give(long characterId, byte[] item, int count)Grants items: fills matching stacks first, then empty slots. All-or-nothing — false when it can't fit.
-
ushort Move(long characterId, int fromSlot, int toSlot)Player move: to-empty = move, stackable match = merge (remainder stays), else swap. Returns 0 on success or an InventoryNotificationCodes reason.
-
bool Take(long characterId, byte[] item, int count)Consumes items across matching stacks. All-or-nothing — false when not enough are held.
-
bool TryExchange(long characterA, IReadOnlyList<int> slotsA, long characterB, IReadOnlyList<int> slotsB)Atomically exchanges the stacks at slotsA/slotsB between two characters (the Trade piece's escrow commit): both removals and both insertions succeed together or nothing changes at all.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
InventoryOptions
Configurable inventory parameters (defaults here; never inlined in logic).
-
bool GridFootprints { get; set; }OPT-IN 2D-grid bags (the Tarkov-style inventory switch). When true, every bag runs with multi-cell footprints: slot index = your grid coordinate (slot = y*width + x — the width is your convention), items consume the WxH cells your FootprintFor reports, covered cells are blocked, and moves relocate whole shapes. When false (the default) bags are the classic slot/stack inventory, byte for byte — including for games that never implement the footprint hooks.
-
int MaxItemBytes { get; set; }Largest opaque item payload (bytes) accepted.
-
int MaxStack { get; set; }Default max stack size (the policy can vary it per item).
-
int SlotCount { get; set; }Slots per character.
Wire messages 4
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
InventoryRequest Client → server: send me my inventory.
InventoryContents Server → client: the character's full inventory (occupied slots only). Pushed in reply to InventoryRequest and after every server-side change (give/take/move/trade).
MoveItemRequest Client → server: move the stack at FromSlot onto ToSlot — plain move to an empty slot, merge onto a stackable match, swap otherwise.
MoveItemResponse Server → client: result of a move (the new contents arrive separately on success).
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IInventoryClient
The Inventory piece, client side: request your contents, move/merge/swap slots, and receive a fresh snapshot after every change (server grants, moves, trades). Item bytes are your game's vocabulary — map them onto your own icons/tooltips/UI. Remove this package and inventories simply stop — nothing else breaks.
-
InventoryContents ContentsThe latest contents snapshot pushed by the server (null until first received).
-
event Action<InventoryContents> ContentsChangedA fresh snapshot arrived.
-
UniTask<InventoryContents> RequestAsync(CancellationToken ct = default)Requests the current contents (the reply raises ContentsChanged).
-
UniTask<MoveItemResponse> MoveAsync(int fromSlot, int toSlot, CancellationToken ct = default)Moves the stack at fromSlot onto toSlot.
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.
ICrossplayInventoryView
Narrow contract the presenter drives. UI-only: render calls in, slot clicks out.