Four approaches

Multiplayer gives you a session once players are in it. This is how they get there — and getting players into a session walks the shortest of the four end to end.

Each of the four is a route in Using Matchmaking, and every field they take is in the reference.

Room code

The host opens a room: a session is minted with them in it, and a short code is stored in KV pointing at it.

WXYZ-1234

Four letters, four digits, drawn from alphabets with I, O, 0 and 1 removed so nobody reads one back wrong.

Short-lived (15 minutes by default) and limited-use (nine by default). Join tolerates lowercase, whitespace, and a missing dash — because somebody is reading it aloud across a table.

The zero-discovery path: play with whoever is beside you. The fourth way in — the waiting queue — is the opposite: no code, no invite, and skill deciding who you meet.

Direct invite

Invite by email or screen name. The invite is pending until accepted, and accepting mints the session and seats both players.

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 or many users is refused rather than guessed.

A direct invite seats exactly two players, so 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 — request, accept, decline, remove.

One row per pair, indexed both ways so a lookup from either side is one read.

Turn it off with friends: false and its routes never mount.

Open queue

One Durable Object per game pairs waiting players, bucketed by Cloudflare’s own edge geolocation and by skill read from the rating capability.

A player first matches only opponents within initialBand of their skill. The band widens by widenPerSecond for every second they wait, and after maxWaitSeconds it is unbounded — any opponent in their region qualifies.

See the waiting queue for what that costs.

Games are config

matchmaking({
  games: [
    {
      key: "duel",
      players: 2,
      skillPool: "duel",          // omit for region only
      snapshot: {
        kind: "connect-n",
        rules: { rows: 3, cols: 3, connect: 3 },
      },
      roomCodes: { ttlSeconds: 900, maxUses: 9 },
      queue: { initialBand: 100, widenPerSecond: 50, maxWaitSeconds: 120, sweepSeconds: 5 },
    },
  ],
  friends: true,
})

Reviewed and deployed like the rest of your app. There is no game admin screen and no game table to seed.

Presence, over hibernating sockets

One Durable Object holds every online player’s WebSocket, over the Hibernation API — so a socket waiting on nothing bills no duration.

On connect it delivers that player’s pending invites and which of their friends are online. Thereafter it pushes three events:

{ type: "match_found";    sessionId: string; gameKey: string }
{ type: "invite";         inviteId: string;  gameKey: string; from: string }
{ type: "friend_request"; from: string }

The identity comes from the authenticated handler as a server-set header and is stashed with serializeAttachment, so it survives eviction.

The Durable Object never trusts a client-supplied user id.

It is a single shared object with a soft ceiling around 1,000 requests per second. Ample for notifications, and the number to plan against — a scaling consideration, not a hidden limit.

There is no public surface

Every route binds to the authenticated user id and never to 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.

Matchmaking reads core’s auth seam, never the auth package’s internals. Without auth composed, every route denies.

ESC