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:
| 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.
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' }); order | offers | use it for |
|---|---|---|
'oldest' (default) | the room opened longest ago | a match that starts when its lobby fills |
'fullest' | the room with the most players that still fits you, or your whole party | a 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.