Matchmaking recipes

Start with Quick match for a basic queue. These recipes add policies for different game modes.

Skill queues

A queue that declares skill matches players by rating instead of by arrival order:

curl -X PATCH https://irt.io/v1/projects/<id> 
  -H 'content-type: application/json' 
  -d '{"name":"my game","queues":{"ranked":{"size":2,"skill":true},"casual":{"size":2}}}'

Nothing else changes. matchRoom(schema, { queue: 'ranked' }) is the same call, the answer is the same shape, and the room your players land in has no idea which kind of queue sent them.

Without skill, players match in arrival order.

skill also combines with teams. Rating bands gate who enters a match, but nothing balances the teams within it.

Where the rating comes from

Your room code reports results, and nothing else can:

// irtio/room.ts, when a match ends
void ctx.room.ratings.report('ranked', [
  { playerId: winnerId, place: 1 },
  { playerId: loserId, place: 2 },
]);

Room handlers are synchronous. defineRoom refuses an async handler when it bundles the room, with handlers are synchronous (no await inside a tick). report returns a promise, so start it and do not await it: void says the result is deliberately dropped, and the continuation runs as its own event between ticks. That is the same shape room.leaderboard.submit is written in.

Places are 1-based and ties are equal places, so a three-way game where two players draw for second is [{ place: 1 }, { place: 2 }, { place: 2 }]. One report is one rating period: the placements become pairwise results and everyone is rated against the field as it stood before the report, so the order you list them in does not matter.

The maths is Glicko-2. A player who has never played starts at 1500 with a deviation of 350, which says the rating is not known yet and is what lets their first few games move them a long way. If your game computes its own number, room.ratings.set(queue, playerId, { rating }) stores it instead.

Two things follow from where the report comes from. A rating can only move because your server code said a match happened, so a player cannot post one from the console. And it is riggable: if your room decides the loser won, irtio believes it, because deciding who won is your game’s job.

A player who joined with a project key rather than an identity cannot carry a rating, because their id lasts as long as their socket. They can be in a reported match, and they are then ignored: they get no rating and nobody is rated against them. Ship identities before you ship a ranked queue.

Bots move nobody’s rating

A bot is not a player. An NPC your room spawns (room.spawnNPC) and a bot from irtio simulate both join with a per-session id, so they are ignored in a report exactly like a key-join player. That has a consequence worth planning for: a ranked match one human plays against a bot reports nothing. There is only one ratable player in the field, a rating is a comparison, and the report resolves []. Queue ratings move in human-against-human matches only. A match of three humans and one bot rates the three humans against each other and leaves the bot out.

If your game fills an empty ranked queue with a bot after a timeout, it is still fine to call report with the bot in it; nothing moves and nothing is refused. Tell the player the match was unranked rather than showing them a delta of zero.

Showing the rating on a victory screen

report resolves with every rated player’s new rating and how far this match moved it. Building on the room above, with rating: i32, delta: i32 added to the seats entity:

void ctx.room.ratings
  .report('ranked', [
    { playerId: winner, place: 1 },
    { playerId: loser, place: 2 },
  ])
  .then((changes) => {
    // [{ playerId, rating: 1662.3, deviation: 290.3, delta: 162.3 }, …]
    for (const [, seat] of state.seats) {
      const c = changes.find((change) => change.playerId === seat.playerId);
      if (!c) continue;
      seat.rating = Math.round(c.rating);
      seat.delta = Math.round(c.delta);
    }
  });

The continuation runs as its own event, so writing to state there reaches the clients like any other change.

To show a rating at any other time (a lobby before the match, or a room that wakes after the report), read it back with room.ratings.get:

void ctx.room.ratings.get('ranked', ctx.playerId).then((r) => {
  // undefined if they have never played this queue
  // otherwise { rating, deviation, games, lastDelta }
});

lastDelta is how far the most recent report moved the rating, and null if no report has since the rating was created or last set. get follows report’s membership rule: the room can read a player it has held, and a stranger is E_RATING_NOT_IN_ROOM. Numbers are not rounded; round them for display. Ratings are still not public. There is no HTTP read a browser can call, and your room decides which client sees which number.

Naming both players

A report names every player in the match, and an RPC handler knows only its caller: ctx.playerId is the client that called it. So a ranked room writes down each player’s id as they arrive, and reads both back at the end. ctx.playerId is on the join context too, which is where to record it.

// irtio/schema.ts (excerpt)
seats: entity({ playerId: str(64) }),
// irtio/room.ts
export default defineRoom(schema, {
  onJoin(state, ctx) {
    // The client id is this connection; the player id is who they are. Keep the pairing,
    // because the report at the end has to name a side that is not the caller.
    state.seats.add(ctx.clientId, { playerId: ctx.playerId });
  },
  rpc: {
    finish(state, _params, ctx) {
      const winner = state.seats.get(ctx.clientId)?.playerId;
      let loser: string | undefined;
      for (const [clientId, seat] of state.seats) {
        if (clientId !== ctx.clientId) loser = seat.playerId;
      }
      if (winner === undefined || loser === undefined) return;
      void ctx.room.ratings.report('ranked', [
        { playerId: winner, place: 1 },
        { playerId: loser, place: 2 },
      ]);
    },
  },
});

Keep the row when the player leaves. report accepts a player the room has held since it was created, so a loser who closed the tab still counts; a player the room never held is refused with E_RATING_NOT_IN_ROOM.

Waiting, and widening

A queue that only ever matched players within a hundred points would be an empty queue on a small game. So the band widens with the wait:

WaitedRating spread accepted
0 to 5 seconds100
5 to 10 seconds200
10 to 15 seconds300
…plus 100 every 5 seconds
60 seconds and onany

Two players match when their ratings differ by no more than the smaller of their two bands, so a newcomer is never dragged into a lopsided match by somebody else’s patience.

Past a minute a ticket will take anybody, and that overrides the other side’s band. A player who has waited that long is matched with the next arrival whatever their rating, even though the newcomer’s own band is still 100. That is what stops a lone player on a quiet game waiting forever, and it is the one case where you may be handed an opponent far from your own level. One player nobody can match never blocks the queue: everybody else keeps pairing up around them.

It still never degrades silently. If nobody comes at all, the deadline answers “no match” exactly as a FIFO queue does.

Teams

A queue can group players into teams instead of one shared pool. Declare teams instead of size:

"queues": { "5v5": { "teams": [ { "size": 5, "count": 2 } ] } }

A queue entry is size or teams, never both. Declaring both is refused.

Each team entry describes one team shape:

FieldMeaning
sizeExact team size. Shorthand for minSize: n, maxSize: n. Cannot appear with minSize or maxSize.
minSizeSmallest team this shape settles at. Defaults to maxSize when omitted.
maxSizeLargest team this shape settles at. Required unless size is set.
countHow many identical required teams this entry repeats as. Whole number, 1 to 64, default 1.
filltrue means this shape repeats to fill whatever space is left. At most one fill entry per queue, and fill cannot combine with count.

minSize and maxSize are whole numbers, 1 <= minSize <= maxSize <= 64. Total seats across all required teams (after count expansion) is at most 64, at most 8 team entries before that expansion, and teams: [] is refused. The queue must be able to hold at least two teams, so a queue whose smallest viable match is one player is refused.

Some shapes:

"queues": {
  "5v5": { "teams": [ { "size": 5, "count": 2 } ] },
  "asymmetric": { "teams": [ { "size": 1 }, { "size": 30 } ] },
  "royale": { "teams": [ { "minSize": 1, "maxSize": 3, "fill": true } ] }
}

5v5 is two required teams of five. asymmetric is a solo team against a team of 30. royale is battle royale: solos, duos and trios, whichever groups show up, because the one team shape is both the required floor and the fill.

To require at least ten groups before a match starts, write the shape twice: once with count: 10 for the floor, once with fill: true for anyone past it.

"queues": {
  "arena": {
    "teams": [
      { "minSize": 1, "maxSize": 4, "count": 10 },
      { "minSize": 1, "maxSize": 4, "fill": true }
    ]
  }
}

Readiness

A match settles as soon as every required team is full, and every opened fill team is full. Both phases need at least two non-empty teams: one full fill team alone is a lobby, not a match. A group that cannot complete a team does not hold the others up: its half-filled fill team is set aside, the full teams settle, and the group keeps waiting for the next match.

If any team declares a range (minSize below maxSize), the queue can settle short once the longest-waiting player has waited 15 seconds: every team at or above its minSize, and at least two non-empty teams. It never settles below a team’s minSize, and the long poll still answers timeout honestly at the deadline.

The answer

On a team queue the match answer carries four fields:

FieldMeaning
team0-based team index. Required teams first, then fill teams in the order they opened.
seat0-based index within that team.
teamSizeHow many players that team settled with.
sizeTotal players actually settled. Can be below the theoretical maximum on a short settle.

These fields are additive. A queue declared with size instead of teams answers exactly as it always did, with no team or teamSize field.

Skill and teams

skill: true combines with teams. Rating bands gate who can enter a match together, exactly as on a skill queue without teams. There is no balancing between teams: the two strongest players in a settled match can end up on the same team.

Backfill: joining a game already in progress

By default a match is a fresh room. A room type that says otherwise can be filled up instead:

// irtio/room.ts
export default defineRoom(schema, { backfill: true, maxClients: 8 });

Now a player who queues alone can be answered with a seat in a running room of that queue rather than a room of their own. The ticket says so:

// main.ts
const room = await matchRoom(schema, { queue: 'arena' });
// the ticket carries backfill: true and seat: -1

The seat is -1 on a backfill ticket, because arrival order among players already in a room is not something the matchmaker knows. If your game uses seat as an index, handle it.

Backfill applies to plain queues only. A team queue is never backfilled: irtio knows a room’s occupancy only as one total player count and cannot know which team has space, so it always mints a fresh room for a team queue.

Start now, fill later: createIfNone

A game that runs with bots, or one that is fun alone, does not want a player staring at “searching…” until the queue fills. Pass createIfNone and a short deadline:

// main.ts
const room = await matchRoom(schema, { queue: 'arena', timeoutMs: 3_000, createIfNone: true });
// irtio/room.ts
export default defineRoom(schema, { backfill: true, maxClients: 8 });

If the queue fills in time, this is an ordinary match. If it does not, the matchmaker mints a room at the deadline instead of answering E_NO_MATCH, and puts every player waiting in that queue with createIfNone in it, up to the queue’s size. Two players who press Play on an empty project at the same moment land in the same room. The ticket carries created: true, and size is still the queue’s size rather than the head count.

The room is registered for backfill the moment it is minted. For its first few seconds, before the room has reported its occupancy, the matchmaker sends later createIfNone players into it from memory. After that it is an ordinary backfill candidate, offered to anyone in the queue while the room declares backfill: true and has space.

The tempting alternative, catching E_NO_MATCH and calling joinRoom with no code, does not work: that room is one the matchmaker never hears of, so every player who falls back ends up alone in a room of their own.

createIfNone applies to plain queues only. A team queue refuses it with E_BAD_REQUEST, because a team room is never backfilled and one minted short would never fill. A party goes in whole: one still missing members at the deadline gets E_NO_MATCH.

Closing the door during a round

An arena is always open. A versus game is not, and the toggle is how it says so:

// irtio/room.ts
export default defineRoom(schema, {
  backfill: true,
  rpc: {
    start(state, _params, ctx) {
      ctx.room.backfill.set(false);   // no strangers mid-round
      // ... start the round
    },
  },
  alarms: {
    roundOver(state, room) {
      room.backfill.set(true);        // open again between rounds
    },
  },
});

The declaration is the ceiling. A room that never declared backfill: true is never offered, and calling set(true) on one logs a warning and changes nothing.

The race, and the retry

The matchmaker works from an occupancy reading a few seconds old, so a seat it offers can be gone by the time your player arrives. The room is the authority, and maxClients still refuses the join with E_ROOM_FULL. That is normal, and matchRoom handles it: on a backfill ticket it queues again, once, and joins whatever the second answer names. Once, never a loop, because a client that retried forever would turn a full server into a flood.

If you drive the queue yourself with findMatch, that retry is yours to write.

A party is offered a running room only when the whole party fits in it, and every member lands in the same one. A party that does not fit is not split up. It waits, and is matched into a room of its own like any other party.

Parties

One player mints a code and reads it out. The others take a seat with it. Then everybody queues with the code and their own seat token.

// main.ts
// on the screen of the player who makes the party
import { createParty, matchRoom } from '@irtio/client';

const { party, member } = await createParty(schema.project, { size: 2 });
// show `party`: ten characters from the room-code alphabet
const room = await matchRoom(schema, { queue: '2v2', party, member });
// main.ts
// on every other player's screen, with the code they were told
import { joinParty, matchRoom } from '@irtio/client';

const { member } = await joinParty(schema.project, party);
const room = await matchRoom(schema, { queue: '2v2', party, member });

The queue does not fill until the whole party is waiting, and the members come out on consecutive seats. A member who closes their tab drops out of the party. The others keep waiting, and they are eligible again the moment that member queues back in.

A party can span a bigger queue: two friends and two strangers fill a four-queue, with the pair placed together. A party bigger than the queue is refused with E_PARTY_TOO_BIG on the first call.

On a team queue, a party always lands whole in one team and is never split. A party larger than the queue’s largest team maxSize is refused the same way, with E_PARTY_TOO_BIG.

Why a code and not a list of friends

You cannot send the matchmaker a list of player ids. A device credential is a secret that never leaves the browser that minted it, so one player cannot hold another’s. A ticket that claimed to speak for several players would either be forgeable by anyone or would require players to hand each other credentials.

So a party is a code you read out, plus a seat token you keep. The code says which party; the token says you are in it. createParty hands the first token to whoever mints the code, and joinParty hands one to everybody else. Every call that uses the party needs yours, matchmaking included: a ticket with the code and no token is refused with E_PARTY_UNKNOWN, the same answer a code nobody minted gets.

Codes last ten minutes. After that no new member can join that party, but members already queued keep their place.

Public games: join one, or open one

The queue above is strict. It waits for a whole party and answers E_NO_MATCH if one never turns up, which is the right shape for a ranked 1v1 and the wrong one for a Join a game button. That button wants the other answer: put me in the next open game, and make one if there are none.

// main.ts
import { joinPublic } from '@irtio/client';

const room = await joinPublic(schema);        // lands in a lobby, either way

joinPublic resolves on the first round trip. Either it found a public room with space and you are in it, or it minted a code and you are the first one there. There is no waiting on a spinner, because the waiting happens in the room, where a lobby panel can say “1 of 4 players” and show who arrives.

That honesty is the whole reason this is a different call rather than a flag on matchRoom. Dropping a player alone into a silent arena and calling it a match would be a lie; dropping them into a visible lobby is what every game with a quick-play button actually does.

Which room you get

By default, rooms are offered oldest first, so the player who has been waiting longest gets company before a room opened a second ago does. Pass order: 'fullest' to get the room with the most players that still has a seat for you instead (see Persistent public hangouts). A room is offered only while all of this is true:

  • it is registered public, in the same queue, in the same project
  • it has space, measured against the occupancy your box last reported
  • its box is still reporting it
  • its game has not started

A room whose game starts leaves the registry and never comes back. There is no backfill into a game in progress in this release.

Because the occupancy reading is a few seconds old, a public join can still lose the race for the last seat. joinPublic handles that itself: it asks once more, naming the room it could not join, so the second answer is a different one. Once, never a loop.

Oldest or fullest

const room = await joinPublic(schema, { queue: 'harbor', order: 'fullest' });
orderoffersuse it for
'oldest' (default)the room opened longest agoa match that starts when its lobby fills
'fullest'the room with the most players that still fits you, or your whole partya hangout, where people should end up together

A tie goes to the older room. To set the order for every caller of a queue, declare it on the queue in irtio.json. An order passed to the call still wins:

{ "queues": { "harbor": { "size": 12, "order": "fullest" } } }

'fullest' counts the seats it has just handed out as well as the occupancy your box last reported. So when two players join at the same moment and the fullest room has one seat left, only one of them is sent there. The other gets the next fullest room.

How a room becomes public

Two ways, and both move the same registry row.

A room minted by joinPublic is registered the moment the code is answered, which is what lets the second player through the door find it before anybody has reported the room existing.

A private lobby is published by its own code:

ctx.room.lobby.setPublic(true);

The room is the authority. A client cannot publish a room, and the panel’s toggle renders the room’s flag rather than the click. See the lobby panel for the whole convention, including the onSetPublic veto and the ready-and-start policies.

Your own backend can flip it too, with PATCH /v1/projects/:id/rooms/:roomId and { "public": true } on the rooms API.

Persistent public hangouts

A social space, like a plaza or a harbor where people drop in and out, is a public lobby whose game never starts. A room leaves the public registry when its game starts, so if you never start it, it stays findable while it has a seat:

// irtio/schema.ts
export const schema = defineSchema({
  ...lobbyCollections,
  players: entity({ x: f32, y: f32 }),
});
// irtio/room.ts
export default defineRoom(schema, {
  mode: 'tick',
  maxClients: 12,
  // Never starts by itself, and this room never calls start(). `queue` files a room that was
  // opened from a shared link under the same queue `joinPublic` asks.
  lobby: { start: 'manual', queue: 'harbor' },
  onJoin(state, ctx) {
    ctx.room.lobby.setPublic(true);   // idempotent; keeps the room listed however it was opened
  },
  tick() {},
});
// main.ts
const room = await joinPublic(schema, { queue: 'harbor', order: 'fullest' });

Declare the queue in irtio.json with "order": "fullest" and the room’s maxClients as its size, and you can drop order from the call. The room’s own maxClients is the limit: a full harbor is not offered, and a player who finds every harbor full opens a new one, which the next player joins. Because start() is never called, the room stays in the registry for as long as it runs. To take a harbor off the list, call setPublic(false). A room that shuts down or hibernates stops being offered.

Parties

A party lands in a public game as a group. joinPublic(schema, { party, member }) finds a lobby with room for the whole party, or opens one, and every member is answered the same room. Pass your own seat token, as you do when you queue. A party larger than the queue is refused with E_PARTY_TOO_BIG rather than split across two lobbies, which is the one thing parties exist to prevent.

See Presence, chat and parties for the party itself: seats, the leader, and how a member who is not polling reads the room.