A notifications hub
A hub is one room per project that is not a game: somewhere for “your friend invited you”, “your match is ready”, “the bracket moved”. Every client joins it alongside whatever else it is doing, so a message reaches a player sitting in a menu or in a different match.
It uses three things together: a room type so the hub is not
pretending to be a game, the smallest size class so it costs little 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 room 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 clients, not a simulation, so it declares the least memory a room
// may ask for. That puts it in the Standard size class, which is what makes leaving it running
// affordable.
class: 'standard',
// 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. A keyed write makes the repeat land on the same row
// instead of showing the player the same invite twice.
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() {},
}); Reaching the hub from a game room
// 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 in that snippet are easy to miss.
The hub does not have to be awake. If nobody has touched hub:global for a while it is
asleep and costing nothing. send wakes it, and the handler runs. Your game room does not check.
No client is involved. The message goes from your match room to your hub, inside your project, without passing through anybody’s browser. 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 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
noticescollection so a client only sees rows whoseplayermatches its own identity. The hub keeps one collection and irtio does the filtering. - Player storage. Park the notification in
room.kvunder 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, player storage for what should still be there tomorrow.
What it costs
The hub uses the Standard room class. An awake hub consumes room-hours continuously; check the pricing page for rates and plan allowances.
When not to use a hub
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.