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 CallRejected for 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. A parse that 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.data or 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. Use CallRejected for 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.

View this page as Markdown