Presence, chat and parties
Three built-in social features that work without any room code: ask whether a player is online, send text in a room, and hold a group of friends together across several games.
They share one idea. Everything is keyed on the player id your game already has, irt: plus a
string, which is what ctx.playerId gives room code and what identity gives the browser. That id
is specific to your project. The same person in another game has a different one, so nothing here
reaches across projects.
Presence
Presence answers one question: for a player id you already know, are they online.
// main.ts
import { getPresence, watchPresence } from '@irtio/client';
// Ask once.
const who = await getPresence(projectId, [friendA, friendB]);
if (who[friendA]) console.log('friend A is playing right now');
// Or keep asking. The callback runs now, then on every change.
const stop = watchPresence(projectId, [friendA, friendB], (present) => {
render(present);
});
// later
stop(); A player who is not in a room is missing from the answer. There is no record that says offline, so asking about an id that never existed reads the same as asking about someone who has logged off.
The answer says whether, not where. It does not carry the room a player is in: a room id is how you get into a room, and this is an open route that anyone able to name a player id can call. If you want a follow or spectate button, keep the room in your own storage, where you decide who gets it.
watchPresence is a long poll, not a socket. It holds one request open until the answer changes or
the request times out, then asks again. It calls back on the first answer and on every change, and
not on a timeout with nothing new. Call the function it returns to stop; the request in flight is
cancelled and nothing arrives after that.
How current the answer is
Servers report who is in their rooms every few seconds. A join shows up within about that, and a
player who leaves clears within about that plus their reconnect window (reconnectGraceMs, 30
seconds by default): a player who drops keeps their seat, so they are still online while they might
still come back.
A server that stops reporting altogether does not leave its players online: a report older than 15 seconds is ignored, so a box that goes away takes its players offline within that.
Presence is a “who is around” signal. Do not build a game rule on the exact moment it changes.
Limits
| Limit | Value | When you reach it |
|---|---|---|
| Player ids in one query | 64 | The query is refused with E_PRESENCE_TOO_MANY. Split it and ask twice |
| Wait per request | 60 seconds | A longer wait is clamped down to this |
| Watches open at once, per address | 4 | A fifth is refused with E_PRESENCE_TOO_MANY_WATCHES. One watch covers 64 ids, so one is usually enough |
| Watches open at once, everywhere | 512 | Refused with E_PRESENCE_WATCHES_BUSY. Not your doing: read without a wait, and try a watch again shortly |
| Reads per minute, per address | 120, with a burst of 30 | E_RATE_LIMITED |
| Watches started per minute, per address | 20, with a burst of 5 | E_RATE_LIMITED |
What presence does not do
There is no friends list, no invites by name, and no blocking. Presence answers about ids you already hold, and where you got them is your game’s business.
There is also no privacy setting. Anyone who can name a player id of your project can learn whether that player is online, and that is the whole of what they learn — not the room, not a name, not an address. Player ids are per-project and hard to guess, and that is the whole of the protection. If your game needs a player to be able to hide, hold that flag in your own storage and do not show them.
Nothing is kept. Every answer is the state right now, and there is no history of who was online when, in your project or anywhere else.
The other meaning of the word
room.clients is also called presence, and it is a different thing: the roster of one room you are
already in. Use it to draw the player list inside a game. Use this page’s presence to ask about
players from outside any room. See Presence and lifecycle.
Chat
Chat is a built-in text message. It works in every room, including a room with no code of yours.
// main.ts
room.chat.on((from, text) => {
addLine(room.state.players[from]?.name ?? from, text);
});
sendButton.onclick = () => {
room.chat.send(input.value); // everyone in the room
// room.chat.send(input.value, id); // one client
}; Chat has its own stream. A chat line never reaches room.onMessage, and a message sent with room.message never reaches room.chat.on, so neither handler is given something it does not
expect.
Check a line before anyone sees it
A room with code gets onChat. It runs before the line goes anywhere.
// irtio/room.ts
export default defineRoom(schema, {
onChat(state, line) {
// line: { from, text, target } — target is 'all' or a client id
if (state.players[line.from]?.muted) return false; // refuse
if (line.text.includes(bannedWord)) return false;
},
}); Returning nothing accepts, so a handler that only wants to log does not have to remember to return.
Returning false refuses. Throwing refuses, because a handler that threw has said nothing and
the safe reading of silence is no.
A refused line is dropped. Nobody sees it and the sender is not told, so a muted player does not learn they are muted from the game’s own error messages.
Relay rooms have no code, so they have no onChat and every line goes through. If you need to
check lines, deploy room code.
No history
Chat is not stored. room.chat.on hears what is said while you are listening and nothing that was
said before, and a player who joins late sees nothing. If you want a scrollback, keep the last N
lines in your own state or in the browser:
// main.ts
const lines: { from: string; text: string }[] = [];
room.chat.on((from, text) => {
lines.push({ from, text });
if (lines.length > 100) lines.shift();
}); Limits
| Limit | Value | When you reach it |
|---|---|---|
| One line | 512 bytes of UTF-8 | Refused with E_CHAT_TOO_LONG. Refused whole, never trimmed |
| Lines per second, per client | 1, with a burst of 5 | Refused with E_CHAT_RATE. The line is not sent, the connection stays open, and you are told once |
Counting bytes and not characters matters for scripts outside ASCII: 512 bytes is about 500 Latin characters and about 170 emoji.
Parties
A party is a code a group of friends share so they queue and land together. It outlives one match, so the same party can play several games in a row.
// main.ts
import { createParty, joinParty, readParty, leaveParty, matchRoom } from '@irtio/client';
// One player makes it and reads the code out.
const { party, member } = await createParty(projectId, { size: 4, identity });
// The others take a seat with it.
const joined = await joinParty(projectId, party, { identity });
// Then everyone matches with the same code and their own seat token.
const room = await matchRoom(schema, { party, member: joined.member }); party is the code you read out. member is your seat, and it is a secret: it proves you are in
the party. Every call that uses the party needs it, including matchmaking. Without it a call is
refused exactly as a wrong code is. Do not show it.
If you call the HTTP API yourself rather than through the client, the seat token goes in the x-irt-party-member header on the party read, and in the body everywhere else. It never goes in a
URL, where it would end up in server logs and in the Referer of the next page the browser opens.
What a party does
| Thing | Behaviour |
|---|---|
| Size | Set when you create it, 2 to 64. A join past it is refused with E_PARTY_FULL |
| Leader | The player who created it. If they leave it passes to whoever has been in longest |
| Life | 10 minutes from creation, and joining does not extend it |
| Empty | The party ends the moment the last member leaves, and the code stops working |
Landing together
Pass the party code and your own seat token to either kind of matchmaking, and the whole group ends up in one room.
// main.ts
// Queue: the queue fills only when the whole party is waiting.
const room = await matchRoom(schema, { party, member, queue: 'ranked' });
// Or join-or-create: land in a public lobby that has room for all of you.
const room = await joinPublic(schema, { party, member, queue: 'casual' }); The first member through picks the room and the rest are answered the same one. A member who is not polling can read it instead:
// main.ts
const view = await readParty(projectId, party, member);
if (view.room) joinRoom(schema, { room: view.room }); A party larger than the queue is refused with E_PARTY_TOO_BIG, naming both numbers.
Someone who joins the party after it has already landed only gets in if the room still has a seat
for them. If it does not they are refused with E_ROOM_FULL, and they can match on their own or
wait for the next game.
Identity and seats
Pass identity when you create or join and your seat is bound to that player. Reloading the page
takes the seat you already had, rather than a second one.
Without an identity there is nothing to recognise you by, so a reload is a new member and your old seat waits out the party’s ten minutes. Use identity for parties. See Player identity.
What a party does not do
There is no friends list and no invites by name. A party is a code somebody reads out, which is why it is short and readable.
The code is not a credential and it does not have to be. Your seat token is the credential: it is what proves you are in the party when you read it, when you leave it, when you queue with it, and when you land with it. Somebody who overhears the code and nothing else is told the code is not valid, the same answer a code nobody ever minted gets. They cannot read who is in your party, take one of its seats, queue as part of it, or send it somewhere its members are not.
Next steps
- Player identity for the player ids all three of these key on.
- Quick match for queues, and for join-or-create.
- Messages for your own message shapes, which are a different thing from chat.