A notifications hub

Most games end up wanting one room that is not a game. Somewhere for “your friend invited you”, “your match is ready”, “the tournament bracket moved”. Messages that arrive while a player is sitting in a menu, or in a different match, or has the tab open and is doing nothing at all.

This guide builds that room. It is the pattern the room bus exists for, and it uses three things together: a room type so the hub is not pretending to be a game, the Nano class so it costs almost nothing to leave running, and room.bus.send so your game rooms can reach it without going through a player’s browser.

The shape

One room, one well-known id, joined by every client alongside whatever else they are doing:

irtio/
  rooms/
    hub.ts       <- the notifications type
    match.ts     <- the game

Every player’s client connects to hub:global when they sign in and stays there. The id is a constant in your code, not something you have to discover: a client that names a room that does not exist creates it, so the first player of the day brings the hub up and everyone after joins the same one.

The hub

// irtio/rooms/hub.ts
import { defineSchema, entity, str, u8, u32 } from '@irtio/schema';
import { defineRoom } from '@irtio/server';

const schema = defineSchema(
  {
    // One row per pending notification, owned by nobody and visible to everyone in the room.
    // A real hub would scope these per player; see "Who sees what" below.
    notices: entity({ player: str(64), kind: str(24), body: str(200), at: u32, seen: u8 }),
  },
  { roles: ['player'] as const },
);

export default defineRoom(schema, {
  mode: 'event',
  // The hub holds text and connections, not a simulation. This declaration is what puts it in the
  // Nano class, and the class is what makes leaving it running affordable.
  memoryMb: 32,
  // Hundreds of players sitting in a menu is the normal state of this room.
  maxClients: 500,
  idleMs: 60_000,

  bus: {
    onMessage(state, message) {
      const notice = JSON.parse(message.payload) as {
        id: string;
        player: string;
        kind: string;
        body: string;
      };
      // Keyed on the sender's own id for the event. Delivery is at least once, so this handler
      // can run twice for one send, and a keyed write makes the repeat land on the same row
      // instead of showing the player the same invite twice. This is the whole idempotency
      // discipline the bus asks for, and it is one line.
      state.notices.add(notice.id, {
        player: notice.player,
        kind: notice.kind,
        body: notice.body,
        at: Math.floor(Date.now() / 1000),
        seen: 0,
      });
    },
  },

  rpc: {
    // Players mark their own notifications read. Nothing else writes.
    dismiss(state, { id }, ctx) {
      const row = state.notices.get(id);
      if (row && row.player === ctx.playerId) row.seen = 1;
    },
  },

  onJoin() {},
});

The game room reaching it

// irtio/rooms/match.ts, inside whatever decides a match is ready
async function announceReady(room, players: string[]) {
  for (const player of players) {
    try {
      await room.bus.send(
        'hub:global',
        JSON.stringify({
          id: `${room.id}:${player}`,
          player,
          kind: 'match-ready',
          body: `Your match is ready. Room ${room.id}.`,
        }),
      );
    } catch (err) {
      // The hub being unreachable is not a reason to fail the match. Log it and move on: the
      // player is about to be told by the match room anyway.
      room.log('could not notify the hub', err);
    }
  }
}

Two things are happening here that are easy to miss.

The hub does not have to be awake. If nobody has touched hub:global for a while it is hibernated, holding no worker and costing nothing. send wakes it, and the handler runs. Your game room does not check, and does not care.

No client is involved. The message goes from your match room to your hub, inside your project, without passing through anybody’s browser. That is the part the old workaround could not do honestly: a client asked to relay a message can edit it, drop it, or navigate away mid-relay.

Who sees what

The schema above puts every notice in one collection that everyone in the room can read, which is fine for a prototype and wrong for a real game. Two ways to fix it, in increasing order of effort:

  • Visibility rules. Scope the notices collection so a client only sees rows whose player matches its own identity. The hub keeps one collection and the platform does the filtering.
  • Player KV. Park the notification in room.kv under the recipient’s player id instead of in room state, and have the client read its own queue on join. Slower to read, but it survives the hub being empty for a week.

Most games want both: state for what is pending right now, KV for what should still be there tomorrow.

What it costs

This is the arithmetic, and it is the reason the Nano class exists.

An always-on room is priced by the memory it declares. At 32 MB declared, the hub is a Nano room, billed at $0.0006 an awake hour. Left running every hour of a 730-hour month, that is $0.44 a month, and it does not go up when your game gets popular: one hub is one room whether ten people or ten thousand are connected to it. The same hub declared as a Small room would be $1.80 a month, which is also not much, but the difference matters on the free tier.

On the free tier the constraint is the 100 room-hours a month, and cheap classes draw it down in proportion to their price. A Nano hour costs about a quarter of a room-hour, so 100 room-hours buys roughly 17 days of an always-on hub, against about 4 days for a Small one. That is a real improvement and it is still not a full month, so a free-tier project running a hub around the clock will reach the wall before the month ends. Two ways to live with that: let the hub hibernate when nobody is signed in (it wakes on the next send, which is the entire design), or put a card on the account, where 44 cents is inside the included bundle.

The Nano rate is provisional and may move before it is final.

When not to do this

If the only thing you need is “tell the players in this match something”, you do not need a hub. The match room already has those players connected and room.broadcast reaches them. The hub is for reaching a player who is not in the room that has the news.