Economy

Equipment

Worn/active gear in typed slots — a container role over the Container engine (opaque items + opaque slot ids, IEquipmentPolicy SPI). World-optional visible loadout broadcast; item effects via Equipped/Unequipped events (never references Stats).

Server
services.AddCrossplayEquipment();
Unity package
com.crossplay.equipment
Depends on
container

Seams you implement 1

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayEquipment(); and it wins.

IEquipmentPolicy game SPI

SPI the game implements to give equip slots their TYPED rules — the framework only moves opaque bytes into slots. "A helmet only fits slot 1", "a weapon only fits slot 0", "a card only fits a deck slot": all live here, per (slot, item). Slot ids are opaque, game-defined ints. The default (DefaultEquipmentPolicy) is permissive — anything fits any slot — so a game with no slot typing works out of the box and the wire stays genre-agnostic. Register your own BEFORE AddCrossplayEquipment (a TryAdd seam). Decode your item header from the opaque bytes to answer; CanEquip runs on the poll thread each equip attempt, so keep it allocation-free.

sealed class SlotTyping : IEquipmentPolicy
{
    public bool CanEquip(long ownerId, int slot, ReadOnlySpan<byte> item) => slot switch
    {
        0 => IsWeapon(item),   // slot 0 = weapon only
        1 => IsHelmet(item),   // slot 1 = helmet only
        _ => true,
    };
}
// services.AddSingleton<IEquipmentPolicy, SlotTyping>(); // BEFORE AddCrossplayEquipment()
  • bool CanEquip(long ownerId, int slot, ReadOnlySpan<byte> item)

    May this OWNER equip this item into this slot? The owner id lets a policy apply per-player rules (card thresholds, level gates, class restrictions) at equip time, not just per-(slot, item) typing.

Services you call 1

Crossplay implements these. Resolve them from DI and call them from your own systems.

IEquipmentService

Server-authoritative worn/active gear in TYPED slots — a role over the generic Container engine (the equip loadout is the container equip:{ownerId}). Equipping atomically moves an item out of the owner's bag and into an equip slot (swapping any item already there back to the bag), validated by the game's IEquipmentPolicy. Genre-agnostic: slot ids and item bytes are 100% the game's (FPS weapons/armor, MMO gear, a card loadout — the piece cannot tell the difference). Effects are the game's. This service raises Equipped/Unequipped; the game (or a Hosting bridge) subscribes and applies stat deltas / buffs / whatever an item MEANS. Equipment never references Stats — the events are the seam that keeps effects out of the base.

  • event Action<long> Changed

    Raised after any change to an owner's loadout, with the owner id — drives the owner's contents push and the visible broadcast (mirrors IInventoryService.Changed).

  • ushort Equip(long ownerId, int equipSlot, int fromBagSlot)

    Equips the item at the owner's bag slot fromBagSlot into equip slot equipSlot. Atomic and policy-gated. If the equip slot is occupied its item is swapped back into the bag. Returns 0 on success, else an EquipmentNotificationCodes value (nothing changes on failure).

  • event Action<long, int, byte[]> Equipped

    An item was just equipped: (ownerId, slot, item). The game applies its effect.

  • IReadOnlyList<EquipmentSlot> Get(long ownerId)

    The owner's current loadout (occupied equip slots; framework value shape, do not mutate).

  • ushort Unequip(long ownerId, int equipSlot)

    Unequips the item at equip slot equipSlot back into the owner's bag. Atomic. Returns 0 on success, else an EquipmentNotificationCodes value (nothing changes on failure).

  • event Action<long, int, byte[]> Unequipped

    An item was just unequipped: (ownerId, slot, item). The game removes its effect.

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

EquipmentOptions

Configurable equipment parameters (defaults here; never inlined in logic).

  • int BroadcastBufferBytes { get; set; }

    Initial encode buffer for the visible-loadout broadcast (grows if ever needed).

  • int MaxItemBytes { get; set; }

    Largest opaque item payload (bytes) accepted into an equip slot.

  • int MaxStackPerSlot { get; set; }

    Max stack per equip slot. 1 = one item per slot (the norm for gear); raise it for games whose loadout slots hold a stack (e.g. multiple copies of a card in a deck slot).

  • int SlotCount { get; set; }

    Number of typed equip slots per owner (weapon/armor/rings/… or deck slots).

Wire messages 6

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

4501 EquipmentRequest

Client → server: send me my equipped loadout.

4502 EquipmentContents

Server → owner: the owner's full equipped loadout (occupied slots only). Pushed in reply to EquipmentRequest and after every server-side change (equip/unequip/server grant).

4503 EquipRequest

Client → server: equip the item at bag slot FromBagSlot into equip slot Slot. Atomic and policy-gated: the item leaves the bag and enters the equip slot, swapping any item already there back into the bag.

4504 UnequipRequest

Client → server: unequip the item at equip slot Slot back into the bag.

4505 EquipResponse

Server → client: result of an equip/unequip (the fresh contents arrive separately on success).

4506 EntityEquipment

Server → observers (and the wearer): a wearer's VISIBLE equipped loadout, keyed by the wearer's entity id — sent on change and replayed in full whenever the wearer enters an observer's view, so late arrivals render the gear correctly. Optional: emitted only when a spatial (World) or room delivery scope exists; a private / non-spatial game simply never sends it.

Unity seams you implement 3

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.

IEquipmentClient unity SPI

The Equipment piece, client side. Two halves, mirroring the two wire channels: The OWNER'S loadout (like an inventory): request your equipped slots (RequestAsync), equip a bag slot into an equip slot (EquipAsync), unequip (UnequipAsync), and receive a fresh EquipmentContents snapshot after every server-side change — raised on LoadoutChanged. Item bytes + slot ids are your game's vocabulary; map them to your own icons/UI. OTHERS' visible gear: each wearer's EntityEquipment broadcast (on change, replayed on interest reveal) is decoded and pushed to the game's IEquipmentView (bound by IEquipmentViewFactory) AND raised on EntityLoadoutChanged — so a game can bind gear to models through the SPI or react through the event. Remove this package and equipment simply stops — nothing else breaks.

  • EquipmentContents Loadout

    The latest OWNER loadout snapshot pushed by the server (null until first received).

  • event Action<EquipmentContents> LoadoutChanged

    A fresh OWNER loadout snapshot arrived (in reply to a request, or after any server change).

  • event Action<long, EquipmentSlotView[]> EntityLoadoutChanged

    Another wearer's VISIBLE loadout arrived (entity id + its occupied slots). Raised even when no IEquipmentView is bound, so an event-driven game can render gear without the factory.

  • UniTask<EquipmentContents> RequestAsync(CancellationToken ct = default)

    Requests the owner's current loadout (the reply raises LoadoutChanged).

  • UniTask<EquipResponse> EquipAsync(int slot, int fromBagSlot, CancellationToken ct = default)

    Equips the item at bag slot fromBagSlot into equip slot slot (atomic + policy-gated server-side; fresh contents arrive separately on success).

  • UniTask<EquipResponse> UnequipAsync(int slot, CancellationToken ct = default)

    Unequips the item at equip slot slot back into the bag.

IEquipmentView unity SPI

The presentation seam for ONE wearer's VISIBLE loadout — the game binds an entity id + its equipped slots to its own models / attachment points (or a text row, or a 2D paper-doll: the piece cannot tell). The piece receives each wearer's EntityEquipment broadcast (on change, and replayed in full when the wearer enters this client's interest) and pushes the full occupied set here; the game decides what "wearing this" looks like. Presentation-agnostic by construction: no mesh, no animator, no asset — just opaque slot ids and opaque item bytes. A view whose underlying object was destroyed should no-op (the piece tolerates it). This is the OTHERS'-gear half; the OWNER'S own loadout arrives instead through LoadoutChanged.

  • void ApplyLoadout(long entityId, EquipmentSlotView[] slots)

    Renders entityId's full occupied loadout (empty array = wears nothing).

IEquipmentViewFactory unity SPI

The game's factory that resolves a wearer's entity id to its IEquipmentView — the equipment analogue of IRigidBodyViewFactory / ICharacterFactory. The piece calls Resolve each time an EntityEquipment broadcast arrives for that wearer; a game that has no view for the id (not spawned locally, culled, …) returns null and the piece simply raises its EntityLoadoutChanged event without pushing to a view. Optional dependency: with no factory bound, the piece still decodes the broadcast and raises EntityLoadoutChanged — a headless / text / event-driven client is valid.

  • IEquipmentView Resolve(long entityId)

    Returns the view bound to entityId, or null if the game has none.

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.

ICrossplayEquipmentView

Narrow contract the presenter drives. UI-only: render calls in, unequip clicks out.