Lobbies
Create/join/list lobbies with slots and metadata.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayLobbies(); and it wins.
ILobbyStartPolicy game SPI
Whether the host may start the lobby RIGHT NOW — the SPI that keeps "when is a lobby ready to play" the game's. The piece deliberately has no opinion: readiness is state, not a trigger, and whether it gates the start (everyone ready, a minimum head count, a full table, a mode-specific rule) is a game rule. Consulted inside TryStartMatch after the host and state checks and BEFORE anything changes: a refused start flips no state, clears no ready flags, fires no start sink and broadcasts nothing. Default: AllowAllLobbyStartPolicy (the host's call alone — byte-identical to before this seam). How to implement. Register your policy in DI BEFORE AddCrossplayLobbies (a TryAdd seam). It runs inline under the lobby's lock once per start attempt — keep it fast and side-effect free. Return false with a reason (your own notification code, or StartRefused); the host gets it in the LobbyStartMatchResponse. A refusal with reason 0 is reported as StartRefused, so a client never sees a failed start without a code.
sealed class EveryoneReadyPolicy : ILobbyStartPolicy
{
public bool AllowStart(in LobbyStartCheck check, out ushort reasonCode)
{
foreach (LobbyMemberInfo m in check.Members)
if (!m.IsReady) { reasonCode = LobbyNotificationCodes.StartRefused; return false; }
reasonCode = 0;
return true;
}
}
services.AddSingleton<ILobbyStartPolicy, EveryoneReadyPolicy>(); // before AddCrossplayLobbies -
bool AllowJoinInProgress(in LobbyStartCheck check, long joiningAccountId, out ushort reasonCode)May joiningAccountId join this lobby while it is RUNNING? Asked only when LobbyOptions.AllowJoinInProgress is on, after the free-slot check; a refusal leaves everything as it was. Defaulted to allow, so a policy written before drop-in join existed keeps compiling and keeps meaning exactly what it did (rule 25).
-
bool AllowStart(in LobbyStartCheck check, out ushort reasonCode)May the host start this lobby now?
ILobbyStartSink game SPI
The start seam this piece OWNS (the IMatchFormedSink idiom): called inside TryStartMatch after the lobby's state flips to InProgress, BEFORE the MatchStarting broadcast, with the lobby id, its game-defined mode string, and every member — cross-node aware. The piece defaults it to NullLobbyStartSink (a TryAdd seam), so with nothing registered ahead of it both lobby twins behave byte-identically to before the seam existed. The composition layer (Hosting/game) registers a bridge FIRST to route lobby starts somewhere (e.g. the Director allocates an instance) — Lobbies itself never references what listens. MatchStarting is still broadcast either way.
sealed class DirectorLobbyBridge : ILobbyStartSink
{
public void LobbyStarted(long lobbyId, string mode, IReadOnlyList<LobbyStartMember> members)
{
var instance = _director.Allocate(mode, members.Count);
foreach (var m in members)
_push.InstanceAssignment(m, instance); // local via session, remote via account+node
}
}
services.AddSingleton<ILobbyStartSink, DirectorLobbyBridge>(); // before AddCrossplayLobbies -
void LobbyStarted(long lobbyId, string mode, IReadOnlyList<LobbyStartMember> members)The host just started lobbyId with these members.
-
void MemberJoinedInProgress(long lobbyId, string mode, LobbyStartMember member)A member joined lobbyId AFTER it started (LobbyOptions.AllowJoinInProgress). Fired inline in the join, before the joiner's MatchStarting, exactly as LobbyStarted fires before the start broadcast - so a router can hand them the running instance first. Defaulted to nothing, so a sink written before drop-in join existed keeps compiling (rule 25).
Services you call 2
Crossplay implements these. Resolve them from DI and call them from your own systems.
ILobbyJoinCodes
Which lobby a join code names, and which code a lobby has (JoinCodes).
-
string CodeOf(long lobbyId)The code a lobby is known by, or empty when it has none.
-
bool TryResolve(string typed, out long lobbyId)The lobby a typed code names, or false when it names none (mistyped, or the room has closed).
ILobbyService
-
IReadOnlyList<LobbyInfo> Browse()Snapshot of the browsable lobbies (open, public lobbies only), ordered by id.
-
event Action<long, long> MemberJoined(lobbyId, accountId): an account entered a lobby — its host on create, or a joiner (drop-in included). Lets a game react to membership instead of POLLING sessions or rosters every tick. Raised on the server poll thread after the membership changed. Defaulted to an event that never fires (rule 25).
-
event Action<long, long> MemberLeft(lobbyId, accountId): an account left a lobby — leave, disconnect, or the lobby closing on its last member. Raised after the membership changed. Defaulted to an event that never fires (rule 25).
-
event Action<long, byte> StateChanged(lobbyId, state): a lobby's match started (LobbyState.InProgress) or ended and it reopened (LobbyState.Open). Raised after the state changed. Defaulted to an event that never fires (rule 25).
-
bool TryCastVote(ISession session, byte optionIndex, out ushort reason)Casts (or changes) the caller's pick for the lobby's active vote.
-
bool TryCreate(ISession session, string name, string mode, int maxPlayers, bool isPrivate, string password, out long lobbyId, out ushort reason)Creates a lobby owned by session and adds it as the host. On success the new id is returned and the host is sent the initial roster. Returns false with reason set to a notification code.
-
bool TryEndMatch(long lobbyId, out ushort reason)Ends the match a started lobby is running: the lobby returns from InProgress to Open with the same members and host, every ready flag cleared, and the updated roster broadcast — so the group that just played can start another match together without leaving and re-creating the room.
-
bool TryGetMembership(ISession member, out LobbyMembership membership)The lobby member is in, and who hosts it. False (with membership at its default) when the session is in no lobby.
-
bool TryJoin(ISession session, long lobbyId, string password, out ushort reason)Adds session to lobbyId (checking password/capacity/state). On success every member is sent the updated roster. Returns false with reason.
-
bool TryLeave(ISession session, out ushort reason)Removes session from whatever lobby it is in, reassigning the host or closing the lobby when it empties, and broadcasting the updated roster. Returns false with reason when the session is in no lobby.
-
bool TrySetReady(ISession session, bool ready, out ushort reason)Sets (or clears) the caller's ready flag and broadcasts the roster it lives on.
-
bool TryStartMatch(ISession session, out ushort reason)Host-only: moves the caller's lobby to InProgress and broadcasts MatchStarting + the updated roster. Returns false with reason (not in a lobby / not host / already in progress / refused by the game's ILobbyStartPolicy).
-
bool TryStartVote(ISession session, string[] options, out ushort reason)Host opens a vote among opaque options — map names, mode keys, anything (a new vote replaces a stale one). Default: unsupported — the single-node service implements it; cross-node lobby votes are a future Redis-twin addition.
Configuration 2
Every tunable lives in an options object — there are no magic numbers to hunt for.
LobbyJoinCodeOptions
JoinCodes.
-
string Alphabet { get; set; }The characters a code is drawn from. The default leaves out 0/O and 1/I/L, because a code is read aloud and a letter that sounds like a digit is a room nobody can get into.
-
bool Enabled { get; set; }Assign codes and accept joins by code. Off by default.
-
string FoldFrom { get; set; }Look-alike characters folded when a code is typed (each to the same position of FoldTo), after upper-casing — so a friend who heard "zero" and typed O misses the same way.
-
string FoldTo { get; set; }What each FoldFrom character becomes.
-
string Ignore { get; set; }Characters ignored when a code is typed (separators people add when copying).
-
int Length { get; set; }Characters per code. Six from the default alphabet is about 887 million codes.
-
int MaxAssignAttempts { get; set; }Draws before giving up on a fresh code for a lobby (only a tiny code space ever needs more than one).
LobbyOptions
Configurable lobby rules (defaults live here, never inline in the service/handlers).
-
bool AllowJoinInProgress { get; set; }Drop-in join (CNC-01): a lobby that has already STARTED still accepts joins while it has a free slot, and stays in the browse list (its LobbyInfo.State says InProgress). The joiner gets MatchStarting like the members who were there at the start, and the start sink hears MemberJoinedInProgress so a router can send them to the running instance. The game may still refuse per join (AllowJoinInProgress). false (the default) is today's behaviour exactly: a started lobby refuses every join and is not listed - the right shape for a match that must not change hands mid-round.
-
int DefaultMaxPlayers { get; set; }Max-players used when a create request does not specify one (0 or less).
-
LobbyJoinCodeOptions JoinCodes { get; set; }Join codes: every lobby gets a short code its members can read out, and anyone can join by it — private lobbies included. Off by default: no code is assigned and the join-by-code message is not handled.
-
int MaxModeLength { get; set; }Maximum length of the game-defined mode tag.
-
int MaxNameLength { get; set; }Maximum lobby name length.
-
int MaxPasswordLength { get; set; }Maximum password length.
-
int MaxPlayersCap { get; set; }Largest max-players any lobby may be created with (requests are clamped down to this).
-
int MaxVoteOptionLength { get; set; }Longest allowed vote-option text.
-
int MaxVoteOptions { get; set; }Most options a single vote may offer.
-
int MinMaxPlayers { get; set; }Smallest max-players a lobby may be created with.
-
int MinNameLength { get; set; }Minimum lobby name length.
-
int MinPlayersToStart { get; set; }The fewest members a lobby needs before its host may start it; a start below it is refused with NotEnoughPlayers. Checked BEFORE the game's ILobbyStartPolicy, so a game expresses the common rule as config and keeps its policy for what config cannot say. 0 (the default) = no minimum, exactly as before.
-
List<LobbyQuickJoinMode> QuickJoin { get; set; }Quick join, one row per mode it is offered for: the client names a mode, the server joins it to an open room of that mode or opens one. Empty (the default) = quick join is not handled. Give quick-join rooms a mode tag of their own if custom rooms should never be filled by it.
-
bool RequireAllReady { get; set; }When true, every member other than the host must be READY before the host may start; otherwise the start is refused with NotEveryoneReady. The host starting IS the host's ready. Checked before ILobbyStartPolicy. False (the default) = readiness is informational, exactly as before.
Wire messages 23
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
LobbyCreateRequest Client -> server: create a new lobby owned by the sender.
LobbyCreateResponse Server -> client: result of a LobbyCreateRequest.
LobbyListRequest Client -> server: request the browsable list of open, public lobbies.
LobbyListResponse Server -> client: the browsable lobby list (open, public lobbies only).
LobbyJoinRequest Client -> server: join the given lobby (with an optional password).
LobbyJoinResponse Server -> client: result of a LobbyJoinRequest.
LobbyLeaveRequest Client -> server: leave the lobby the sender is currently in.
LobbyLeaveResponse Server -> client: result of a LobbyLeaveRequest.
LobbyStartMatchRequest Client -> server: the host asks to start the match for their lobby.
LobbyStartMatchResponse Server -> client: result of a LobbyStartMatchRequest (host only).
LobbyRoster Server -> client: the current roster + metadata of a lobby, pushed to every member whenever membership, host, or state changes.
MatchStarting Server -> client: the host started the match; broadcast to every lobby member.
LobbyVoteStartRequest Client → server: the host opens a vote among OPAQUE options — map names, mode keys, anything the game encodes as strings. The framework only tallies; a new vote replaces a stale one.
LobbyVoteStartResponse Server → client: vote-start result.
LobbyVote Server → client: a vote began (broadcast to the lobby with the options).
LobbyVoteCast Client → server: my pick.
LobbyVoteState Server → client: tallies after every cast; when every member voted, the round is complete and WinnerIndex holds the result (ties break to the lowest index).
LobbySetReadyRequest Client → server: "I am ready" / "I am not". A TOGGLE, which is the whole point of it existing next to the vote: a vote round completes the moment every member has cast anything and completing deletes the round, so a ballot cannot express ready/not-ready — casting *not ready* completes it with nobody ready.
LobbySetReadyResponse Server → client: the verdict for one LobbySetReadyRequest. The STATE arrives on the roster broadcast every member already gets, so this only answers "was that allowed".
LobbyJoinByCodeRequest Client → server: join the lobby a join code names — the code a member read out to a friend. Answered with the ordinary LobbyJoinResponse, so a client's join flow is the same either way. Private lobbies are joinable by code (that is the point of one); a password still applies.
LobbyJoinCode Server → client: the join code of the lobby this client just entered (sent on create and on join).
LobbyQuickJoinRequest Client → server: the menu's Play button — put me in an open, public, passwordless room of this mode with space, or open one with me in it. Searched and joined on the server, so two players pressing Play together land in the same room. Offered only for the modes LobbyOptions.QuickJoin lists.
LobbyQuickJoinResponse Server → client: where quick join put this client (the roster push follows as usual).
Unity seams you implement 2
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.
ILobbyJoinCodeClient unity SPI
Join codes (opt-in server side: Lobbies:JoinCodes:Enabled): the code of the lobby you entered, and joining by one. Its own interface, not new ILobbyClient members, so no implementation of that published interface has to change (rule 25). With the server option off no code arrives and a join by code times out.
-
event Action<LobbyJoinCode> JoinCodeReceivedRaised when the server sends the code of the lobby this client just created or joined.
-
string CurrentJoinCodeThe code of the lobby this client is in, or empty (cleared when it leaves).
-
UniTask<LobbyJoinResponse> JoinByCodeAsync(string code, string password, CancellationToken ct = default)Joins the lobby a code names (typed loosely — case, spaces, dashes and look-alikes are forgiven).
ILobbyQuickJoinClient unity SPI
Quick join (opt-in server side: a Lobbies:QuickJoin row per mode): the Play button — an open room of the mode, or a new one. Its own interface for the same reason as ILobbyJoinCodeClient.
-
UniTask<LobbyQuickJoinResponse> QuickJoinAsync(string mode, CancellationToken ct = default)Puts this client in an open room of mode, or opens one with it as host.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
ILobbyClient
Data-layer lobby service (no UI): async create/list/join/leave/start RPCs plus events for the server-pushed roster/state and match-starting broadcasts. Self-registers under the Crossplay client context. Depends only on Crossplay Core.
-
event Action<LobbyRoster> RosterChangedRaised whenever the server pushes the roster/state of the lobby this client is in.
-
event Action<MatchStarting> MatchStartingRaised when the host starts the match for the lobby this client is in.
-
UniTask<LobbyCreateResponse> CreateAsync(string name, string mode, int maxPlayers, bool isPrivate, string password, CancellationToken ct = default)Creates a lobby owned by this client (public unless isPrivate).
-
UniTask<LobbyListResponse> ListAsync(CancellationToken ct = default)Requests the browsable list of open, public lobbies.
-
UniTask<LobbyJoinResponse> JoinAsync(long lobbyId, string password, CancellationToken ct = default)Joins a lobby by id (with an optional password).
-
UniTask<LobbyLeaveResponse> LeaveAsync(CancellationToken ct = default)Leaves the current lobby.
-
UniTask<LobbyStartMatchResponse> StartMatchAsync(CancellationToken ct = default)Host-only: starts the match for the current lobby.
-
UniTask<LobbyVoteStartResponse> StartVoteAsync(string[] options, CancellationToken ct = default)Host opens a vote among opaque options (map names, mode keys, anything).
-
void CastVote(byte optionIndex)Casts (or changes) my pick. Fire-and-forget — the broadcast state is the feedback.
-
UniTask<LobbySetReadyResponse> SetReadyAsync(bool ready, CancellationToken ct = default)Sets (or clears) my ready flag. A TOGGLE, unlike a vote — which is why it exists next to one: a vote round completes the moment every member has cast anything, so a ballot cannot say "not ready".
-
event Action<LobbyVote> VoteStartedRaised when a vote begins.
-
event Action<LobbyVoteState> VoteStateChangedRaised after every cast (Complete carries the winner).
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.
ICrossplayLobbiesView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.