A card game with RPCs
A card game keeps a secret deck and strict rules, so every collection is serverOwned, no client
writes shared state, and every move is a typed RPC the room decides. Use RPCs whenever the game
decides something rather than the player: deals, turn order, scores, legality.
Use owned writes for the opposite case, where a player reports a fact about themselves and lying about it gains nothing.
Two examples are built this way. Word post is a correspondence word game with hidden racks and seats that outlive a connection. Party quiz is the same shape with a shared screen and phones.
Hidden state
Visibility is granted per collection, not per field and not per client. There is no setting that shows one player their own hand and hides it from the others. Read Visibility for what the modes do.
That leaves two mechanisms. A real card game uses both.
A collection scoped to a role nobody joins as is persistent but invisible. It survives hibernation like any other state, and no value in it ever reaches a player:
hidden: singleton(
{ deck: str(104), hand0: str(26), hand1: str(26), hand2: str(26), hand3: str(26) },
{ serverOwned: true, visibility: 'role', roles: ['dealer'] },
), The property is still there on the client. State frames are positional over the schema’s
collections, so a client whose role cannot see one gets the slot filled in rather than left out: a
hidden singleton arrives as its declared defaults ('', 0, false), and a hidden entity
collection arrives empty. No data leaks, but presence tests do not tell you anything:
if (room.state.hidden) { /* always true, even for a player */ }
if (room.state.hidden.deck !== '') { /* the dealer's view, and only the dealer's */ } Gate on a field that carries meaning, or on the role you joined as. Never on the key.
An RPC’s return value goes only to the client that made the call. That is how a player learns their own hand: it rides back on the call that changed it.
play: server({ params: { card: str(2) }, returns: { hand: str(26) } }), Public facts about a hidden thing stay in ordinary synced state. Opponents can see how many cards
you hold without seeing which, because handCount is a normal field on a visible collection.
The schema
// irtio/schema.ts
import { defineSchema, entity, enumOf, singleton, str, u8 } from '@irtio/schema';
import { rpc } from './rpc.js';
export const schema = defineSchema(
{
// One instance per claimed seat, keyed '0'..'3' so seat order is play order.
// `clientId` is the connection currently bound to the seat, not an identity.
seats: entity(
{ name: str(24), handCount: u8, clientId: str(40) },
{ serverOwned: true },
),
// Room-wide lifecycle. Everything here is a fact about the game, never about a player.
match: singleton(
{
status: enumOf('open', 'playing', 'over'),
seatCount: u8,
turn: u8, // seat index whose move it is
top: str(2), // the card face up on the pile, e.g. '7H'
drawCount: u8,
winner: str(24),
},
{ serverOwned: true },
),
// Server-private. `dealer` is a declared role no client ever joins as.
hidden: singleton(
{
deck: str(104),
hand0: str(26),
hand1: str(26),
hand2: str(26),
hand3: str(26),
key0: str(40),
key1: str(40),
key2: str(40),
key3: str(40),
},
{ serverOwned: true, visibility: 'role', roles: ['dealer'] },
),
},
{
project: 'p_0dd0cafe00000004',
roles: ['player', 'dealer'] as const,
rpc,
},
); Cards are two characters ('7H'), a hand is those characters concatenated, and a deck is the
same. Whole-string fields are cheap here because the wire diffs a whole-field write at the byte
level, and a move touches one or two cards.
The moves
// irtio/rpc.ts
import { server, str, u8 } from '@irtio/schema';
export const rpc = {
// Claim (or reclaim) a seat. `key` is a client-generated secret, so the same player
// coming back in a new session gets their seat and their hand back.
sit: server({ params: { key: str(40), name: str(24) }, returns: { seat: u8, hand: str(26) } }),
// Shuffle and deal. Any seated player may start once two seats are claimed.
deal: server(),
// Play one card. Rejects with a readable message for a card you do not hold,
// a card that does not follow, or a move out of turn.
play: server({ params: { card: str(2) }, returns: { hand: str(26) } }),
// Take one from the deck and pass the turn.
draw: server({ returns: { hand: str(26) } }),
// Re-fetch your own hand, for a fresh session or after a reconnect.
myHand: server({ returns: { seat: u8, hand: str(26) } }),
}; defineRoom checks these names against the room’s rpc implementations exactly. A missing or
misspelled handler is an error at bundle time, not a runtime surprise.
The room
// irtio/room.ts
import type { State } from '@irtio/schema';
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
type GameState = State<typeof schema>;
const MAX_SEATS = 4;
const HAND_SIZE = 5;
const HANDS = ['hand0', 'hand1', 'hand2', 'hand3'] as const;
const KEYS = ['key0', 'key1', 'key2', 'key3'] as const;
const RANKS = '23456789TJQKA';
const SUITS = 'CDHS';
const cards = (s: string): string[] => s.match(/.{2}/g) ?? [];
/** All 52 cards, rank then suit, two characters each: '2C3C...AS'. */
function fullDeck(): string {
let deck = '';
for (const suit of SUITS) for (const rank of RANKS) deck += rank + suit;
return deck;
}
/** `rand` is the room's seeded source, so the same seed deals the same deck. */
function shuffle(deck: string, rand: () => number): string {
const out = cards(deck);
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out.join('');
}
const handOf = (state: GameState, seat: number): string => state.hidden[HANDS[seat]!];
/** Writes a hand and keeps its public card count in step, so the two can never disagree. */
function setHand(state: GameState, seat: number, hand: string): void {
state.hidden[HANDS[seat]!] = hand;
const s = state.seats.get(String(seat));
if (s) s.handCount = hand.length / 2;
}
/** Seat currently bound to this connection, or -1. */
function seatOf(state: GameState, clientId: string): number {
for (const [id, seat] of state.seats) {
if (seat.clientId === clientId) return Number(id);
}
return -1;
}
type TurnCtx = { clientId: string; deny(reason: string): never };
/** Refuses the call unless it is the caller's turn in a live game. */
function requireTurn(state: GameState, ctx: TurnCtx): number {
if (state.match.status !== 'playing') ctx.deny('the game is not in progress');
const seat = seatOf(state, ctx.clientId);
if (seat === -1) ctx.deny('you are not seated');
if (seat !== state.match.turn) ctx.deny('not your turn');
return seat;
}
/** Matches on rank or suit. */
const follows = (card: string, top: string): boolean => card[0] === top[0] || card[1] === top[1];
export default defineRoom(schema, {
// Nothing moves on its own between moves, so the room hibernates when it goes quiet
// and wakes with the deal intact.
mode: 'event',
maxClients: 8,
onCreate(state) {
state.match.status = 'open';
},
onJoin(_state, ctx) {
// `dealer` exists only to scope the hidden singleton. A client presenting it gets nothing.
if (ctx.role === 'dealer') ctx.room.kick(ctx.clientId, 'dealer is not a joinable role');
},
// No onLeave bookkeeping. A seat outlives a connection, so a closed laptop is not
// a forfeit.
rpc: {
sit(state, { key, name }, ctx) {
if (key.length < 8) ctx.deny('sit: key too short');
const player = name.trim().slice(0, 24) || 'anon';
// Same key, new session: rebind the seat this player already has.
for (let i = 0; i < state.match.seatCount; i++) {
if (state.hidden[KEYS[i]!] === key) {
const seat = state.seats.get(String(i));
if (seat) {
seat.clientId = ctx.clientId;
seat.name = player;
}
return { seat: i, hand: handOf(state, i) };
}
}
if (state.match.status !== 'open') ctx.deny('sit: this game has already started');
if (state.match.seatCount >= MAX_SEATS) ctx.deny('sit: all seats are taken');
const i = state.match.seatCount;
state.seats.add(String(i), { name: player, handCount: 0, clientId: ctx.clientId });
state.hidden[KEYS[i]!] = key;
state.match.seatCount = i + 1;
return { seat: i, hand: '' };
},
deal(state, _params, ctx) {
if (state.match.status !== 'open') ctx.deny('deal: already dealt');
if (state.match.seatCount < 2) ctx.deny('deal: need at least two players');
// `room.random()` is seeded, so a test that passes the same `seed` to testRoom deals
// the same cards. `Math.random()` is not seeded by the room.
let deck = shuffle(fullDeck(), () => ctx.room.random());
for (let i = 0; i < state.match.seatCount; i++) {
setHand(state, i, deck.slice(0, HAND_SIZE * 2));
deck = deck.slice(HAND_SIZE * 2);
}
state.match.top = deck.slice(0, 2);
state.hidden.deck = deck.slice(2);
state.match.drawCount = state.hidden.deck.length / 2;
state.match.turn = Math.floor(ctx.room.random() * state.match.seatCount);
state.match.status = 'playing';
},
play(state, { card }, ctx) {
const seat = requireTurn(state, ctx);
const hand = handOf(state, seat);
if (!cards(hand).includes(card)) ctx.deny('play: you do not hold that card');
if (!follows(card, state.match.top)) {
ctx.deny(`play: ${card} does not follow ${state.match.top}`);
}
const next = hand.replace(card, '');
setHand(state, seat, next);
state.match.top = card;
if (next === '') {
state.match.status = 'over';
state.match.winner = state.seats.get(String(seat))?.name ?? '';
} else {
state.match.turn = (state.match.turn + 1) % state.match.seatCount;
}
return { hand: next };
},
draw(state, _params, ctx) {
const seat = requireTurn(state, ctx);
if (state.hidden.deck === '') ctx.deny('draw: the deck is empty');
const next = handOf(state, seat) + state.hidden.deck.slice(0, 2);
state.hidden.deck = state.hidden.deck.slice(2);
state.match.drawCount = state.hidden.deck.length / 2;
setHand(state, seat, next);
state.match.turn = (state.match.turn + 1) % state.match.seatCount;
return { hand: next };
},
myHand(state, _params, ctx) {
const seat = seatOf(state, ctx.clientId);
if (seat === -1) ctx.deny('myHand: you are not seated');
return { seat, hand: handOf(state, seat) };
},
},
}); Turn order, legality and secrecy all live in one file no client can reach. Handlers are synchronous: a handler returns and the result is encoded, so two moves cannot interleave halfway through.
The client
// main.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';
// The seat key. A secret this browser generates, kept so the same player gets the same
// seat back tomorrow.
const key =
localStorage.getItem('cards:key') ?? `k${crypto.randomUUID().replace(/-/g, '')}`;
localStorage.setItem('cards:key', key);
const room = await joinRoom(schema, { role: 'player', name: 'you' });
let hand = '';
let mySeat = -1;
const seated = await room.call.sit({ key, name: 'you' });
mySeat = seated.seat;
hand = seated.hand;
async function playCard(card: string): Promise<void> {
try {
const result = await room.call.play({ card });
hand = result.hand;
} catch (err) {
// A ctx.deny reason arrives as 'deny: <reason>'. Anything else is not the room's text.
const message = (err as Error).message;
showMessage(message.startsWith('deny: ') ? message.slice(6) : 'something went wrong');
}
}
// A session that cannot be resumed comes back with a new client id, so the seat has to
// be rebound and the hand refetched. `sit` with the same key is idempotent, so calling it
// on every reconnect is safe.
room.on('status', (status) => {
if (status !== 'connected') return;
void room.call.sit({ key, name: 'you' }).then((r) => {
mySeat = r.seat;
hand = r.hand;
});
});
function frame(): void {
const m = room.state.match;
render({
top: m.top,
yourTurn: m.status === 'playing' && m.turn === mySeat,
yourHand: cards(hand),
// Everyone else: name and card count, which is all the wire ever carried about them.
opponents: [...room.state.seats].filter(([id]) => Number(id) !== mySeat),
winner: m.status === 'over' ? m.winner : '',
});
requestAnimationFrame(frame);
}
requestAnimationFrame(frame); room.call.<name>() returns a promise. It resolves with the handler’s return value, rejects with deny: <reason> when the handler calls ctx.deny, and rejects with rpc <name> timed out after 10
seconds if no reply arrives. Every rejection is worth handling: an illegal move and a lost
connection both reach the player through this one path.
Limits worth designing around
Identity is a secret in local storage. The browser generates a seat key and stores it there.
Clearing site data loses the seat, and a player moving to a phone has to carry the key. Word post
puts it in a personal #k= link. For a built-in account that survives browser sessions, use identity: true and key seats by ctx.playerId. If your game
already has logins, sign a JWT instead and the seat can key off a stable ctx.playerId. See Identity.
Reclaiming is not automatic. A client id changes when a session cannot be resumed, so the seat has to be rebound by a call the client makes, as above.
Lists are replaced whole, not diffed. A list field sends all of its elements when any one
of them changes. That is fine at the sizes a card game uses, and it is why the samples above use
strings instead of lists for hands and decks.
Event mode hibernates. A quiet room hibernates after idleMs (30000 by default, set in irtio/room.ts) and wakes on the next join with all of its state, hidden collections included.
Anything you keep outside state, such as a module-level Map, does not survive that.