Rooms
Calls
Ask the room to do something and get its answer, with names, args and replies typed from one declaration.
A call is a request from one client that the room answers: buy an item, claim a seat, look something up. The client awaits the reply, or the error the room threw. Use calls for actions that need an answer; for changes everyone sees, the answer can be the state itself, and for fire-and-forget events, send a message.
Declaring calls
Calls are declared once, in the schema module the room and its clients
both import. defineCalls takes the calls’ signatures as a type, each
with at most one argument and its reply:
// schema.ts
import { defineCalls, defineSchema, entity } from "@irt/room";
export const prices = { sword: 50, shield: 30 };
export type Item = keyof typeof prices;
export const Wallet = entity("wallet", { coins: "u32" });
export const schema = defineSchema({
private: Wallet,
calls: defineCalls<{
buy(args: { item: Item }): { coins: number };
balance(): number;
}>({
buy(raw) {
const item = (raw as { item?: unknown } | null)?.item;
if (typeof item !== "string" || !Object.hasOwn(prices, item)) throw new Error("unknown item");
return { item: item as Item };
},
}),
});
The object passed to defineCalls is optional and holds a parse
function for any call that needs one. The types are only types: args
come from the client, and a modified client can send anything. A
parse function runs on the room before the handler, gets the decoded
JSON, or the bytes, and returns the args or throws. Without one, the
handler gets the decoded JSON unchecked, so a call without one must
not trust its args; balance above takes none.
In the room
The room’s calls has a handler for each declared call. TypeScript
checks that none is missing, that args has the declared type, and that
the handler returns, or resolves to, the declared reply.
// room.ts
import { CallRejected, defineRoom } from "@irt/room";
import { prices, schema } from "./schema.js";
export default defineRoom({
schema,
onJoin(room, conn) {
const wallet = room.private(conn);
if (wallet.coins === 0) wallet.coins = 100;
},
calls: {
buy(room, conn, { item }) {
const wallet = room.private(conn);
if (wallet.coins < prices[item]) throw new CallRejected("not enough coins");
wallet.coins -= prices[item];
return { coins: wallet.coins };
},
balance: (room, conn) => room.private(conn).coins,
},
});
- What a handler throws becomes the client’s error, with its message, and the room keeps running.
- Throw
CallRejectedfor a rejection that’s part of the game, such as “not enough coins” or “the stairs are sealed”: the client gets its message and the room logs nothing. Any other error is logged at error level with the room, player, tick and stack, because it’s taken for a bug. Aparsethat throws rejects the call without logging too. - A handler may be async; the reply goes out when its promise settles.
- Handlers run as calls arrive, between ticks. A call that changed room
state (shared or private state, entities, ownership, bans) replies
after the next tick has saved the change and sent its state update,
up to a tick later: a reply means the change is saved outside the VM
and a room host crash can’t lose it, and the client already has it.
A call that changed nothing replies at once, unless another change
is waiting for the same tick. Errors go out at once. What isn’t room
state, such as
room.dataor a stepper’s own state, isn’t covered. - A call that changes room state just as the room hibernates fails rather than replies, as any call in flight when the connection drops does.
On the client
// client.ts
import { connect, IrtError } from "@irt/client";
import { schema } from "./schema.js";
const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "shop-1", schema });
try {
const { coins } = await room.call("buy", { item: "sword" });
console.log("coins left", coins);
} catch (e) {
if (e instanceof IrtError) console.log("couldn't buy:", e.message);
else throw e;
}
const balance: number = await room.call("balance");
console.log(balance);
call takes only declared names, with their args, and resolves to the
declared reply. It rejects with an IrtError when the room throws,
after a timeout of 10 seconds by default
(room.call("balance", undefined, { timeout: 2000 })), and when the
connection drops or the room restarts before the reply. In those last
cases the call may or may not have run, and the SDK doesn’t retry it.
The error’s code is 422 when the room answered with an error, such as
a CallRejected, and 0 when no answer came; see
Errors.
Common mistakes
- Rejecting with a plain
Error. It works, but every rejection is logged as an error with a stack, which buries real bugs in normal traffic. UseCallRejectedfor expected outcomes. - Trusting args without
parse. The declared type is a promise the client makes, not one the room can rely on. Check anything that indexes, loops or spends. - Retrying a call that changes state. After a dropped connection the
room may already have run it. Check with another call, such as
balance, or make the call safe to repeat. - Waiting on a call for state everyone sees. Change the entity in the handler and let every client get it from the state update; reply with what only the caller needs.