Matchmaking
Queue-based matchmaking with team/role quotas and MMR.
Seams you implement 3
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayMatchmaking(); and it wins.
IMatchFormedSink game SPI
The post-formation seam this piece OWNS (the IMatchResultSink idiom): called after a match forms, BEFORE any MatchFound is sent, with every member — cross-node aware. The piece defaults it to NullMatchFormedSink (a TryAdd seam), so with nothing registered ahead of it both matchmaker twins behave byte-identically to before the seam existed. The composition layer (Hosting/game) registers a bridge FIRST to route formed matches somewhere (e.g. the Director allocates an instance) — Matchmaking itself never references what listens. MatchFound is still sent either way; backfill into an already-running match is NOT a formation and does not call this sink.
sealed class DirectorMatchBridge : IMatchFormedSink
{
public void MatchFormed(long matchId, string queueId, IReadOnlyList<MatchedMember> members)
{
var instance = _director.Allocate(queueId, members.Count);
foreach (var m in members)
_push.InstanceAssignment(m, instance); // local via session, remote via account+node
}
}
services.AddSingleton<IMatchFormedSink, DirectorMatchBridge>(); // before AddCrossplayMatchmaking -
void MatchFormed(long matchId, string queueId, IReadOnlyList<MatchedMember> members)A match just formed from queueId with these members.
IMatchmakerPolicy game SPI
SPI the game implements to gate match QUALITY: given a full set of candidates, is this a good enough match to form now? The default accepts any full set. A skill-based game returns false while the rating spread is too wide, then relaxes as WaitedSeconds grows.
services.AddSingleton<IMatchmakerPolicy>(_ => new RatingSpreadPolicy(baseSpread: 150, spreadPerSecond: 25));
-
bool ShouldForm(string queueId, MatchCandidates candidates)Accept (form the match) or keep waiting for a better set.
IRatingModel game SPI
SPI the game implements to rate players — the MMR the matchmaker balances by. The framework only reads the number; how it's computed, stored and updated after a match is 100% the game's (usually over PlayerData / a rating store). Default: everyone is DefaultRating, so an unconfigured queue behaves like plain FIFO.
sealed class StoredRatingModel : IRatingModel
{
public double RatingOf(ISession s) => _ratings.Get(s.AccountId ?? 0); // your MMR source
}
services.AddSingleton<IRatingModel, StoredRatingModel>(); -
double RatingOf(ISession session)The player's rating for balancing purposes (higher = stronger; the unit is the game's).
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IMatchmaker
Genre-neutral queue-based matchmaker: sessions join a named queue and, once a queue reaches the configured match size, the first waiting sessions are popped into a match. A session may be in at most one queue at a time. Depends only on Core primitives (sessions + identity).
-
void ClearOpenSlots(long matchId)Drops a match's backfill registration (it ended / closed).
-
int DissolveQueue(string queueId, ushort reason)Server-authoritative dissolve of an entire named queue (e.g. the node is draining for shutdown): every waiting member is pushed a MatchmakingQueueLeft carrying reason and the queue is emptied. Returns the number of members notified (0 when the queue was empty/unknown). reason is a Matchmaking notification code (e.g. QueueDissolved).
-
bool Evict(ISession session, ushort reason)Server-authoritative removal of a queued session (and its whole party ticket), pushing each affected member a MatchmakingQueueLeft carrying reason — so their searching UI can end on a signal a client-side timer cannot observe (a server-side timeout, an admin/host kick). Returns false when the session is not queued. reason is a Matchmaking notification code (e.g. QueueTimedOut / QueueEvicted). Distinct from TryLeave, which is the player's own withdrawal and pushes nothing.
-
void RegisterOpenSlots(long matchId, string queueId, int openSlots)Registers openSlots free slots in an already-running match, so the next compatible queuers BACKFILL it (receiving a MatchFound carrying the existing matchId) before any new match is formed. Cleared when filled or on ClearOpenSlots.
-
bool TryJoin(ISession session, string queueId, out ushort reason)Attempts to add session to queueId. On success the joiner is sent a status update and, if the queue is now full, a match is formed (each matched player is sent a MatchFound). Returns false with reason set to a notification code.
-
bool TryJoin(ISession session, string queueId, byte role, out ushort reason)Role-aware join: role is game vocabulary (0 = any) counted against the queue's configured RoleQuotas.
-
bool TryJoinParty(IReadOnlyList<ISession> members, string queueId, out ushort reason)Queues a PARTY as one ticket: the members are matched together into the same match and the same team, never split across matches. False (with a per-member reason) when any member is already queued, the party is empty, or it exceeds a single team's size.
-
bool TryJoinParty(IReadOnlyList<ISession> members, IReadOnlyList<byte> roles, string queueId, out ushort reason)Role-aware party join: roles parallels the members (null = all any-role).
-
bool TryLeave(ISession session, out ushort reason)Attempts to remove session from whatever queue it is in. Returns false with reason set to a notification code when the session is not queued.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
MatchmakingOptions
Configurable matchmaking rules (defaults live here, never inline in the service).
-
double DefaultRating { get; set; }The rating the flat model reports for every player (also the "unrated" baseline).
-
int MatchSize { get; set; }Number of players required to form a match.
-
int MaxQueueIdLength { get; set; }Maximum queue-id length.
-
List<RoleQuota> RoleQuotas { get; set; }Role quotas PER MATCH (empty = roles ignored). With quotas set, a match forms only when every quota is met; players whose role has no quota (or role 0) fill the remaining flexible slots. Roles are game vocabulary — the matchmaker only counts them.
-
int TeamCount { get; set; }Teams to balance a formed match into (1 = free-for-all, 2 = red/blue…). MatchSize must divide evenly.
Wire messages 7
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
MatchmakingJoinRequest Client -> server: request to join the given matchmaking queue.
MatchmakingJoinResponse Server -> client: result of a MatchmakingJoinRequest.
MatchmakingLeaveRequest Client -> server: request to leave the current matchmaking queue.
MatchmakingLeaveResponse Server -> client: result of a MatchmakingLeaveRequest.
MatchFound Server -> client: sent to each matched player when a match is formed.
MatchmakingStatus Server -> client: current standing in a queue (best-effort progress update).
MatchmakingQueueLeft Server -> client: the recipient's matchmaking search ended without a match — the server removed their queue ticket. A queue-lifecycle TERMINAL for the cases a client-side timer cannot observe (a server-side timeout, a queue dissolved on node drain/shutdown, an admin/host eviction), so the searching screen can stop waiting on an authoritative signal instead of an indistinguishable RPC timeout. Carries only the queue id and an opaque reason (a Matchmaking notification code) — no match, roster, or outcome data, so it stays genre-agnostic.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IMatchmakingClient
Data-layer matchmaking service (no UI): async join/leave RPCs plus events for match-found and queue-status. Self-registers under the Crossplay client context.
-
event Action<MatchFound> MatchFoundRaised when the server forms a match this client is part of.
-
event Action<MatchmakingStatus> StatusChangedRaised with periodic queue status while waiting.
-
event Action<MatchmakingQueueLeft> SearchEndedRaised when the server ends this client's search without a match (its queue ticket was removed) — a server-authoritative terminal (timeout / queue dissolved / eviction) the searching screen can bound its wait on, distinct from an RPC timeout. Carries the queue id + a reason notification code.
-
MatchFound CurrentMatchThe most recently formed match this client is part of, or null before any match forms and after the client re-enters a queue. Lets a presenter created AFTER the transient MatchFound push still read the match id + roster — the cached mirror of the fire-once event (the same role IRoundClient.Current / IVotingClient.ActiveOf play for their pieces).
-
UniTask<MatchmakingJoinResponse> JoinAsync(string queueId, CancellationToken ct = default)Joins a named queue.
-
UniTask<MatchmakingJoinResponse> JoinAsync(string queueId, byte role, CancellationToken ct = default)Role-aware join: the role byte is game vocabulary (0 = any), counted against the queue's configured role quotas.
-
UniTask<MatchmakingLeaveResponse> LeaveAsync(CancellationToken ct = default)Leaves the current queue.
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.
ICrossplayMatchmakingView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.