Peer messages

State is what the room is. A message is something one player says to another and then forgets: a wave, a ping on the map, a “nice shot”, a chat line. 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.

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.get(from)?.pings ?? 0;
  // 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.

The raw path

room.message(target, bytes) and room.onMessage(cb) are still there, and still take opaque bytes. 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.

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. A relay room holds no state, so there is nothing to lose in the gap.

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.

Not in this release

Replies and acknowledgements, ordering guarantees across sockets, a server handler per message name, message history or replay, and a per-message rate limit beyond the frame budget. Chat is built on this machinery rather than being part of it.