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
await room.ratings.report('ranked', [
{ playerId: winnerId, place: 1 },
{ playerId: loserId, place: 2 },
]); 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.
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:
| Waited | Rating spread accepted |
|---|---|
| 0 to 5 seconds | 100 |
| 5 to 10 seconds | 200 |
| 10 to 15 seconds | 300 |
| … | plus 100 every 5 seconds |
| 60 seconds and on | any |
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:
| Field | Meaning |
|---|---|
size | Exact team size. Shorthand for minSize: n, maxSize: n. Cannot appear with minSize or maxSize. |
minSize | Smallest team this shape settles at. Defaults to maxSize when omitted. |
maxSize | Largest team this shape settles at. Required unless size is set. |
count | How many identical required teams this entry repeats as. Whole number, 1 to 64, default 1. |
fill | true 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:
| Field | Meaning |
|---|---|
team | 0-based team index. Required teams first, then fill teams in the order they opened. |
seat | 0-based index within that team. |
teamSize | How many players that team settled with. |
size | Total 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.
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
Rooms are offered oldest first, so the player who has been waiting longest gets company before a room opened a second ago does. 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.
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.
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.