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
colorabove 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
ownedByandmine, and its owner-only fields reach it. ownerOnlylists 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, andsetOwner(entity, undefined)gives it back to the room.ownerOf(entity)returns the player, orundefinedfor the room, andownedBy(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
setOwnerinonLeaveif 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 aTypeError.- 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.
onChangeruns for the fields, thenonCorrect, 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
onEnterbefore the owner op arrives. An entity given to a player mid-game becomes writable when its owner change is applied; useonOwnerorminerather than assuming.