Shared Containers (Group Kit)
Containers owned by a group rather than a character — a crew kit, a party stash, a team's shared crate — over the Container engine: the contents are pushed to every session the game's audience seam admits, and only those sessions may read or rearrange them. Items enter and leave through server-side game verbs (atomic exchanges with a member's bag); the wire carries request and move only, so a client is never trusted to say what it takes. Inert until an audience is composed.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplaySharedContainers(); and it wins.
ISharedContainerAudience game SPI
The game's answer to "who sees this container?" — the one decision that makes a container SHARED. Consulted on every wire request and move (may this session act on it?) and on every change (who gets the fresh contents pushed?). The framework never knows what a scope is: a crew, a party, a team, a match, a household, a guild — the game names its containers and answers for them here.
// A crew kit: "crew:{matchId}" is visible to everyone in that match.
public sealed class CrewKitAudience : ISharedContainerAudience
{
public bool CanSee(ISession s, ContainerId c)
=> c.TryGetOwner("crew", out long matchId) && _matches.IsIn(matchId, s);
public void CollectAudience(ContainerId c, List<ISession> into)
{
if (c.TryGetOwner("crew", out long matchId))
_matches.CollectSessions(matchId, into);
}
} -
bool CanSee(ISession session, ContainerId container)May session read and rearrange container?
-
void CollectAudience(ContainerId container, List<ISession> into)Adds every session that should receive container's contents after a change to into (which the piece clears and reuses — never keep a reference).
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.
ISharedContainerGrantPolicy provider
Whether a session may KEEP using a container the server opened for it — asked on every use (request, move, transfer) and before every pushed change, so a player who walked out of reach is closed rather than still trading. The default allows everything (the open was the server's own decision).
-
bool MayUse(ISession session, ContainerId id)True while session may still use the granted container id.
Services you call 2
Crossplay implements these. Resolve them from DI and call them from your own systems.
ISharedContainerOpens
Which sessions have which containers OPEN — the server side of "press F on the fridge". The server opens a container FOR a session (Open): that session is sent its contents, receives every change, and may use it (request, move, transfer) while it stays open, even when the game's audience would not otherwise admit it — the open itself is the server's grant, re-checked through ISharedContainerGrantPolicy. With SharedContainerOptions.TrackOpens, a client's own request also opens (without a grant).
-
void Close(ISession session, ContainerId id)Closes it for that session and tells the client (a Closed contents).
-
void CloseAll(ContainerId id)Closes it for EVERY session that has it open (the container is going away).
-
event Action<ISession, ContainerId> Closed(session, container): raised after a container closed for a session, for any reason.
-
bool IsOpen(ISession session, ContainerId id)True while session has id open.
-
bool Open(ISession session, ContainerId id)Opens id for session as a server GRANT and sends its contents. Returns false when the session already has its maximum open.
ISharedContainerService
A container owned by a SCOPE rather than a character — a crew kit, a party stash, a team's shared ammo crate, a household chest — whose contents are pushed to every session the game says can see it. The item engine underneath is Crossplay.Container (atomic moves, stacking policy, persistence); this role adds the two things a shared container needs on top: WHICH containers are shared, and WHO sees them (ISharedContainerAudience).
-
int CapacityOf(ContainerId id)The slot count clients are told for id. Defaulted (rule 25): 0 = unknown.
-
event Action<ContainerId> ChangedRaised after any change to a shared container — the handlers push its contents to the audience.
-
int CountOf(ContainerId id, byte[] item)Total count of an item across the container.
-
bool Drop(ContainerId id)Forgets a shared container entirely (contents, stored record, shape) - a match's fridge when the match ends; the next touch of the id is a fresh, empty container. Close it for its openers first (CloseAll). Defaulted (rule 25): drops nothing.
-
void Ensure(ContainerId id)Marks id as a shared container (creating it with the configured shape if it does not exist). Every other call does this implicitly.
-
void Ensure(ContainerId id, ContainerConfig config)Marks id shared with its OWN shape (a chest of 12 beside crew kits of 32) the first time it is created. Defaulted (rule 25) to Ensure with the configured shape.
-
IReadOnlyList<SharedContainerSlot> Get(ContainerId id)Occupied slots (a fresh list — a snapshot, not a view).
-
bool Give(ContainerId id, byte[] item, int count)Adds count of an opaque item, stacking then filling; all-or-nothing.
-
bool IsShared(ContainerId id)True once id has been shared through this service.
-
ushort Move(ContainerId id, int fromSlot, int toSlot)Moves / merges / swaps two slots. 0 on success, else a SharedContainerNotificationCodes value.
-
bool Take(ContainerId id, byte[] item, int count)Removes count of an item across stacks; all-or-nothing.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
SharedContainerOptions
Tunables for shared containers (defaults here, never inline — rule 4). Bound from Crossplay:SharedContainers by the composition layer. One configuration applies to every shared container the piece creates; a game that needs a differently-shaped shared container ensures it on the engine itself with its own ContainerConfig before this piece first touches it.
-
string BagKind { get; set; }The container kind of a player's bag (the Inventory convention bag:{ownerId}).
-
bool GridFootprints { get; set; }When true, items occupy multi-cell footprints on a grid of GridWidth columns.
-
int GridWidth { get; set; }Grid width in cells when GridFootprints is on (0 = linear slots).
-
int MaxContainerIdLength { get; set; }Longest container id a client may name on the wire. An id is a small engine key (crew:42); anything longer is refused before it reaches the audience seam, so a hostile client cannot make the server hash, log or echo kilobytes of its choosing.
-
int MaxItemBytes { get; set; }Maximum opaque item blob size, in bytes.
-
int MaxOpenPerSession { get; set; }How many containers one session may have open at once (a bound on what a client can subscribe to).
-
int MaxStack { get; set; }Maximum stack count per slot (the engine's default policy).
-
int SlotCount { get; set; }Slots per shared container.
-
bool TrackOpens { get; set; }Remember who has a container OPEN: a request opens it for the requester, who then gets every change pushed (beside the audience) until they close it (SharedContainerCloseRequest), disconnect, or stop being able to see it - at which point they are sent a Closed contents. Off by default: a request is a one-shot snapshot and pushes go to the audience only, as before. Server-side opens (ISharedContainerOpens) work either way.
-
bool TransferWithBag { get; set; }Handle SharedContainerTransferRequest: move items between the sender's OWN bag and a container they can see, all-or-nothing, through the engine (so IContainerPolicy.CanPlace still decides what a container takes). Off by default: the wire carries only request and move, as before.
Wire messages 7
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
SharedContainerRequest Client → server: send me the contents of the shared container ContainerId. The server answers only when this session is in the container's audience (the game's ISharedContainerAudience decides); otherwise the reply carries NotVisible and nothing else — a client cannot enumerate containers it was not given.
SharedContainerContents Server → client: a shared container's full contents. Sent in reply to a request, and PUSHED to every session in the container's audience after any change (a move, a game-side give/take), so a crew all see the same kit without polling.
SharedContainerMoveRequest Client → server: move / merge / swap two slots inside a shared container this session can see.
SharedContainerMoveResponse Server → client: the verdict on a SharedContainerMoveRequest. On success the fresh contents follow as a pushed SharedContainerContents, to every member of the audience.
SharedContainerTransferRequest Client → server: move Count items from a slot of one container to another, where one side is the sender's own bag and the other a shared container the sender can see (or both are the same shared container). Opt-in server side (SharedContainerOptions.TransferWithBag). All-or-nothing: on any refusal both containers are as they were.
SharedContainerTransferResponse Server → client: the verdict on a SharedContainerTransferRequest.
SharedContainerCloseRequest Client → server: stop sending me this container (its window closed). Opt-in: TrackOpens.
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.
ISharedContainerWindows unity SPI
Open containers ("press F on the fridge"): which containers the server has open for this client, closing one, and moving items between your bag and an open container. Opt-in server side (SharedContainers:TrackOpens / TransferWithBag, or a game that opens containers for players). Its own interface, not new ISharedContainersClient members, so no implementation of that published interface has to change (rule 25).
-
IReadOnlyCollection<string> OpenContainersThe containers currently open for this client (contents arrived, not yet closed).
-
event Action<string> ClosedRaised when a container closes for this client - it asked, or the server closed it (out of reach).
-
void Close(string containerId)Tells the server this container's window closed (it stops pushing its contents).
-
UniTask<SharedContainerTransferResponse> TransferAsync(string fromContainerId, int fromSlot, string toContainerId, int toSlot, int count, CancellationToken ct = default)Moves items between your bag and an open container (or within one). toSlot -1 = first fit; count 0 = the whole stack. Fresh contents follow as pushes, the bag's too.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
ISharedContainersClient
Client SDK for shared containers: request the contents of a container this session may see, move slots inside it, and observe the snapshot the server pushes to every member after any change. The server decides who sees what; a refused request completes with a non-zero ReasonCode and no slots. What a container IS — a crew kit, a stash, a crate — and how it looks are the game's.
-
bool TryGetContents(string containerId, out SharedContainerContents contents)The last contents received for containerId, if any.
-
event Action<SharedContainerContents> ContentsChangedRaised whenever contents arrive — a reply, or a push after a change by anyone.
-
UniTask<SharedContainerContents> RequestAsync(string containerId, CancellationToken ct = default)Requests the contents. Completes with ReasonCode != 0 when the server refuses.
-
UniTask<SharedContainerMoveResponse> MoveAsync(string containerId, int fromSlot, int toSlot, CancellationToken ct = default)Moves / merges / swaps two slots. On success the fresh contents follow as a push.