Messages
State is what the room is. A message is something said once and then forgotten. This page covers both kinds: peer messages, which one player sends to another, and the room bus, which one of your rooms uses to reach another.
Peer messages
A peer message is something one player says to another and then forgets: a wave, a ping on the
map, a “nice shot”. It is not stored, not replayed to someone who joins later, and not reconciled
with anything. If you find yourself sending a message to keep two clients agreeing about
something, that thing belongs in room.state.
Text chat is built in and is not one of these. Use room.chat rather than building it out of messages: it has its own
cap, its own rate limit, and an onChat hook to check lines before anyone sees them.
There are two ways to send one, and the difference is whether the schema knows the shape.
Declaring a shape
// irtio/schema.ts
import { defineSchema, entity, enumOf, f32, str } from '@irtio/schema';
export const schema = defineSchema(
{
players: entity({ name: str(24), x: f32, y: f32 }),
},
{
messages: {
emote: { kind: enumOf('wave', 'laugh', 'thanks'), x: f32, y: f32 },
ping: { x: f32, y: f32 },
},
},
); A message shape is a record of the same field types a collection uses. The shapes are part of the schema hash, so a client on an old build is refused at the door rather than sending a wave another client reads as a laugh.
Sending and receiving
On the client:
const room = await joinRoom(schema, { key: 'p_...' });
// to everyone else in the room
room.messages.emote.send('all', { kind: 'wave', x: 12, y: 4 });
// to one player
room.messages.emote.send(otherClientId, { kind: 'thanks', x: 0, y: 0 });
// to a role
room.messages.ping.send({ role: 'spectator' }, { x: 12, y: 4 });
room.messages.emote.on((from, emote) => {
// `from` is the sender's client id, or 'server' when the room sent it
showEmote(from, emote.kind, emote.x, emote.y);
}); send takes exactly the declared shape and on hands back exactly the same, so changing a field
is a compile error on both sides at once. 'all' means everyone except you.
On the server, the room can send too:
room.messages.emote.send({ role: 'spectator' }, { kind: 'wave', x: 0, y: 0 }); What the room sees, and how it says no
A room does not get a handler per message name. It observes every message through the onMessage it already had, and the sixth argument carries the decoded value:
onMessage(state, from, target, bytes, ctx, typed) {
if (typed?.name === 'emote' && muted.has(from)) return false; // dropped, nobody sees it
if (typed?.name === 'ping' && !state.players.has(from)) return false; // no player record, no ping
// return nothing (or true) and the message goes on to its target
} Returning false drops it. That is the room’s only veto and it needs no other: one place to
decide, whatever the shape.
The payload is decoded before onMessage runs, so a room never sees a malformed message. One
that does not decode is counted and dropped, and the room is not called at all.
Ordering
Messages use an ordered, reliable stream per socket. Typed and raw messages from the same sender arrive in send order. State deltas are flushed separately, so a message can arrive before or after the state change it describes. Sending a message after a write does not guarantee the receiver has applied that write.
Include the relevant entity id in the message and check it against room.state as state updates
arrive. Treat message-driven previews as temporary: remove them when state marks the action as
finished or removes the entity. Put durable results in state.
The raw path
On the client, room.message(target, bytes) and room.onMessage(cb) are still there, and still
take opaque bytes. target is 'all', a client id, or { role }, and anything else throws. Use
them when you have your own encoding — an existing binary protocol, a compressed audio frame,
something a schema shape cannot describe.
The two paths never cross. A raw onMessage callback is never handed a typed message, and a typed on callback is never handed raw bytes. You can use both on one socket without either seeing the
other’s traffic.
Sending data that does not belong in state
The room sends raw bytes with room.send(target, bytes). target is 'all', a client id, or { role }, and anything else throws. To reach several specific clients, call it once per id. The
client receives them with room.onMessage(cb).
// irtio/room.ts
tick(state, _dt, room) {
const heightmap = encodeTerrain(state); // your own encoding, a Uint8Array
room.send('all', heightmap);
} // main.ts
const off = room.onMessage((from, bytes) => {
if (from !== 'server') return; // `from` is 'server' or the sending client's id
applyTerrain(decodeTerrain(bytes));
});
// `onMessage` returns an unsubscribe function. Use this for anything that is derived, one-shot or too big to sit in the schema: a generated level, a replay blob, an audio frame, a payload from an encoder you already have.
Size. irtio sets no per-message byte cap, so what you pass is what is sent. Two limits still
apply. A socket whose unsent bytes pass 4 MiB is closed with E_SLOW_CONSUMER (the join snapshot
is allowed on top of that while it is being sent), so one large message can disconnect a slow
client, and a very large frame is refused by the WebSocket layer below irtio. Keep single messages
well under a megabyte and split anything larger yourself.
Ordering. Raw messages follow the same ordering rules as typed messages.
Rate. A message is a frame, so it counts against the 240 frames per second per socket budget described below.
On a relay room
A project that deploys nothing has always been able to relay raw bytes between players. It can now have shapes too: deploy a schema with no room code (see deploying) and pass it to the join.
const room = await joinRelay({ key: 'p_...', schema });
room.messages.emote.send('all', { kind: 'wave', x: 0, y: 0 }); There is no room code in the middle, so nothing can veto a message and nothing decodes it on the way through — the receiving client checks it against the schema, and drops what does not fit.
Redeploying the schema of a relay project disconnects everyone connected to it with E_SCHEMA_MISMATCH; they rejoin on the new one. Relay rooms with collections have state; a changed schema resets it. See relay rooms.
What it costs
A message is one frame. A typed one costs three bytes over a raw one with the same target: the envelope byte and a two-byte message index.
The payload costs what its shape allows and nothing more. Every str and every list in a shape
carries a maximum, and a value over it is refused at the call site rather than sent — so the size
of a message is a number you can read off its declaration. The raw path has no such bound: bytes
you pass to room.message are sent as given.
Messages are frames, so they count against the same per-connection frame budget as everything else (240 frames per second, per socket). There is no separate per-message rate limit: the frame budget is the limit.
What a hostile player can do
Nothing but send a well-formed message of a shape you declared, to a target they could reach anyway.
- They cannot forge who a message is from. The sender slot is written by the server; whatever a client puts in the target is replaced.
- They cannot address the server. Typed messages have no server destination — the room observes.
- They cannot make a receiver misbehave with a malformed payload. An unknown message index, a truncated payload, an oversize string, an over-long list and a well-formed value of the wrong shape are each counted and dropped, on the room and on every client, and the next message is delivered normally.
- They cannot flood a room more cheaply than with any other frame; a bad message costs them a frame from their own budget exactly as a good one does.
room.stats.messages.dropped on the client counts typed messages this client could not read.
A number climbing there means a peer is running a schema this client does not have — usually a
stale tab after a deploy.
Adding a message later
A message is addressed on the wire by its position in your messages object, the same way an RPC
is. Append new shapes to the bottom and nothing already deployed moves; reorder them and every peer
holding the old schema would read one shape as another, which the deploy reports as breaking. See declaration order.
Messages between rooms
Use the room bus to communicate between server rooms in your project.
publish reaches subscribers on the same server; send reaches a room on any server. See the room bus guide for delivery limits and retries.
Related
- Room types for defining the second room the bus talks to.
- Alarms for the other durable thing a sleeping room keeps.
- Servers and room placement for what changes when a project runs on more than one server.