You need: matchmaking, multiplayer and auth composed.
Multiplayer gives you a session once players are in it. This is how they get there.
Which one to reach for
Four approaches is the model behind this page, the waiting queue is the one with the most machinery, Using Matchmaking is the API, and shipping a turn-based game is what happens after they are seated.
| Situation | Path |
|---|---|
| Two people in a room together | Room code |
| One person wants to play a specific person | Direct invite |
| A recurring opponent | Friends |
| Nobody in particular | Queue |
Room code — the zero-discovery path
POST /matchmaking/games/duel/rooms
→ { "code": "WXYZ-1234", "sessionId": "…" }A session is minted with the host already in it. The code points at it.
POST /matchmaking/rooms/WXYZ-1234/joinJoin tolerates lowercase, whitespace and a missing dash, because somebody is reading it aloud across a table. The alphabet already excludes I, O, 0 and 1 so nobody reads one back wrong.
Codes expire (15 minutes by default) and are limited-use (nine by default).
Direct invite
POST /matchmaking/games/duel/invites
{ "email": "ada@example.com" }Email is the reliable key — it is unique on the user table.
A screen name is best-effort. Auth exposes no unique one, so a name matching zero users or several is refused rather than guessed — matchmaking/user_not_found.
POST /matchmaking/invites/<id>/accept → mints the session and seats both
POST /matchmaking/invites/<id>/decline
GET /matchmaking/invites → pending, for the callerExactly two players. A game with a larger roster is refused here and told to use a code or the queue.
Friends
A symmetric graph formed by mutual accept:
POST /matchmaking/friends/<userId>/request
POST /matchmaking/friends/<userId>/accept
POST /matchmaking/friends/<userId>/decline
DELETE /matchmaking/friends/<userId>
GET /matchmaking/friendsfriends: false and these never mount.
Queue
POST /matchmaking/games/duel/queue
GET /matchmaking/games/duel/queue
DELETE /matchmaking/games/duel/queueA match forms on enqueue when an opponent is already waiting. The sweep exists to widen skill bands, not to pair.
queue: { initialBand: 100, widenPerSecond: 50, maxWaitSeconds: 120, sweepSeconds: 5 }The band grows every second waited, and after maxWaitSeconds any opponent in the region qualifies.
Read what a waiting queue costs before production. sweepSeconds is the one setting that decides the bill.
Tell the player they matched
Through presence, not by polling:
GET /matchmaking/presence (WebSocket){ type: "match_found"; sessionId: string; gameKey: string }
{ type: "invite"; inviteId: string; gameKey: string; from: string }
{ type: "friend_request"; from: string }Over the Hibernation API, so a socket waiting on nothing bills no duration. On connect it also delivers pending invites and which friends are online.
Nothing here trusts the client
Every route binds to the authenticated user id, never a client-supplied one.
A room a stranger can open in your name, or a friend request from an id the client chose, is the whole attack surface of a pairing layer.