Rooms

Ownership

Let players write the values they control, such as a cursor or a dragged card, while the room decides what everyone sees.

Every entity has an owner: the room, or a player. The room owns everything unless it gives an entity to a player, and the owner of an entity may write the fields its kind declares writable straight from the client, with no round trip. The room still checks each write and always has the last word.

Use ownership for values a player sets directly: a cursor, aim, a camera, a piece being dragged, per-player settings. Movement the room simulates stays room-owned and goes through prediction. Scores, hit results and anything else that decides the game stay room-owned too.

Declaring writable fields

// schema.ts
import { defineSchema, entity } from "@irt/room";

export const Cursor = entity("cursor", {
  x: "f32",
  y: "f32",
  color: "u8",
}, { writable: ["x", "y"] });

export const Settings = entity("settings", { volume: "u8", coins: "u32" }, { writable: ["volume"] });

export const schema = defineSchema({ private: Settings, entities: [Cursor] });
  • Only the listed fields can be written by the owner. The rest are the room’s, even on an entity a player owns, so color above is set by the room alone.
  • Any kind that isn’t hidden can be given to a player, with or without writable fields. The owner of a kind with none can’t write it, but the entity is always in its view, it’s listed by ownedBy and mine, and its owner-only fields reach it.
  • ownerOnly lists fields only the owner’s connections are sent; every other connection holds their defaults and gets no changes to them. A predicted car’s velocity, which only its driver’s replay reads, is one: entity("car", { x: "f32", y: "f32", vx: "f32", vy: "f32" }, { ownerOnly: ["vx", "vy"] }). A new owner gets them right after the ownership change; the previous owner keeps the values it had. They save bandwidth, and hide only values a player never owned. Room state saves them as any field, and they’re part of the schema hash.
  • The shared state, room.state, is always the room’s and can’t have writable fields.
  • A player owns its own private state, so it can write the private kind’s writable fields.
  • Writable fields are part of the schema hash: a client built with different writable fields fails to connect with 4004.

In the room

// room.ts
import { defineRoom } from "@irt/room";
import { Cursor, schema } from "./schema.js";

export default defineRoom({
  schema,
  onJoin(room, conn) {
    if ([...room.entities.ownedBy(conn.player, Cursor)].length > 0) return;
    room.entities.create(Cursor, { color: conn.player.length % 8 }, { owner: conn.player });
  },
  onWrite(room, conn, entity, next) {
    if (entity.kind !== Cursor) return next;
    const x = next.x as number | undefined;
    if (x !== undefined && (x < 0 || x > 800)) return { ...next, x: Math.min(Math.max(x, 0), 800) };
    return next;
  },
});
  • create(kind, values, { owner }) gives a new entity to a player. room.entities.setOwner(entity, player) moves one, and setOwner(entity, undefined) gives it back to the room. ownerOf(entity) returns the player, or undefined for the room, and ownedBy(player, kind) iterates what a player owns.
  • Owners are player IDs, not connections. Ownership survives reconnects and is saved in room state, so it survives hibernation. Leaving releases nothing; call setOwner in onLeave if the game needs that.
  • The room may write any field of any entity, including fields a player owns.

onWrite

onWrite(room, connection, entity, next) runs for each owner write at the start of the tick, before the stepper. next holds the fields written. Return:

Return Applies
next the write as sent
other values those values, such as next clamped to the board
undefined nothing; the write is rejected

onWrite must be synchronous. If it throws, the error is logged and the write is rejected. Without onWrite, writes apply as sent: a writable field is then a claim the room accepts from the player.

Before onWrite runs, the room drops writes to entities the player doesn’t own, to fields that aren’t writable, and values of the wrong type.

On the client

// client.ts
import { connect } from "@irt/client";
import { Cursor, schema } from "./schema.js";

const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "board-1", schema });

addEventListener("pointermove", (e) => {
  for (const cursor of room.entities.mine(Cursor)) {
    cursor.x = e.clientX;
    cursor.y = e.clientY;
  }
});

room.private.volume = 5;

room.entities.onCorrect(Cursor, (cursor, fields) => console.log("the room moved", cursor.id, fields));
room.entities.onOwner(Cursor, (cursor) => console.log(cursor.id, "is now", room.entities.ownerOf(cursor) ?? "the room's"));
  • room.entities.mine(kind) iterates the entities of a kind the local player owns. Setting a writable field changes the client’s copy at once; writing any other field, or an entity the player doesn’t own, throws a TypeError.
  • The client sends its writes at most once per room tick. Within that time the last write to a field wins.
  • Clients can’t create or delete entities, or change an owner. Send the room a message and let it decide.

Corrections

The room wins. A client keeps its own values for the writable fields it owns and ignores the room’s ordinary changes to them. When the room’s value differs from what the owner wrote, because onWrite clamped or rejected it or room code wrote the field, the owner gets a correction with the room’s value:

  • It overwrites the client’s copy and drops the client’s unsent writes to those fields.
  • onChange runs for the fields, then onCorrect, where a game can snap or smooth.
  • Every other client only ever sees the room’s value.

A correction arrives with the rest of its tick’s state update. On a reconnect or a change of generation, unsent writes are dropped and the full state update brings the room’s values.

Interest

A player always sees the entities it owns, whatever the room’s interest says. They count toward the cap on entities a connection sees.

Common mistakes

  • Making fields the stepper writes writable. The room overwrites the owner’s value every tick and sends a correction each time. Keep simulated movement room-owned and use prediction.
  • Trusting writable fields without onWrite. A modified client can write any value of the field’s type. Bound what’s possible, such as a speed or the board’s edges.
  • Writing in onEnter before the owner op arrives. An entity given to a player mid-game becomes writable when its owner change is applied; use onOwner or mine rather than assuming.

View this page as Markdown