Ownership
An entity instance is owned by a client or the server. Clients write their own instances; the server can validate those writes and update any instance. Use server-owned state for scores and other game outcomes.
Assigning an owner
Ownership is set when the instance is created, which for a per-player instance means onJoin.
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
export default defineRoom(schema, {
mode: 'tick',
tickRate: 20,
onJoin(state, ctx) {
if (ctx.reconnecting) return;
state.players.add(
ctx.clientId,
{ x: 0, y: 0, name: ctx.name || 'anon', score: 0 },
{ owner: ctx.clientId },
);
},
onLeave(state, ctx) {
state.players.remove(ctx.clientId);
},
tick(_state, _dt, _room) {},
}); irtio creates no per-player instance for you. Three details in that call have to line up:
- The id is
ctx.clientId, so the client can find its own instance asroom.state.players[room.me]. { owner: ctx.clientId }makes it writable by that client. Leave the option out and the server owns it.- The
ctx.reconnectingguard stops a resumed session adding a second copy.
Get any of the three wrong and room.state.players[room.me] is undefined in the browser, which is
the most common first-run failure there is.
Writing what you own
On the client, an instance you own is a writable object. Write it like local state.
// main.ts
const me = room.state.players[room.me];
if (me) {
me.x = event.clientX;
me.y = event.clientY;
} Writes are batched. Every field you touch inside one flush window leaves as a single frame. The
window is an animation frame in a browser or writeIntervalMs (default 50 ms), whichever comes
first. room.flush() sends immediately if you need it.
The instance is undefined for a frame or two after the page loads, because the server creates it
in onJoin. Guard it.
Everything else is closed to you:
- A
serverOwnedcollection or any singleton is read-only at compile time. Writing one is a type error. - An instance owned by another client is a no-op at runtime, with a one-time console warning naming the actual owner.
Incoming deltas never overwrite fields of an instance you own. Only a correction does, and a correction is the server overruling you. See Server authority and validation.
The server writes anything
Ownership gates client writes and nothing else. The room may write any instance, including one a client owns, and no check consults the owner on the way.
// irtio/room.ts, inside tick() or an RPC handler
const me = state.players.get(clientId);
if (me) me.x = 0; // legal, even though the client owns this instance Use it for the cases the owner does not get to decide: teleporting a player, snapping a body back inside the arena, zeroing a value at the start of a round.
A server write racing a client write
The server wins, in the same tick and every tick.
- Client writes arrive at the top of the tick. Each one runs through
validateand, if accepted, is applied to the state right away. The client keeps its own local copy of what it wrote. - Your handlers run and may write the same field.
- At the flush, irtio compares every changed field of a client-owned instance against what that owner’s write left there. Any field that no longer matches goes to the owner as a correction as well as to everyone else in the ordinary delta.
So the owner does not silently keep a stale value. It gets told, and the correction snaps its local copy. This is the same path server authority uses everywhere. See Server authority and validation.
Two limits on the client side of the race, both enforced when the write arrives:
- A client write may only update an existing instance. Adding and removing are server-only.
- A client write may not change the owner. Ownership moves only from room code.
Server-owned state
Mark the collection in the schema and no client can ever own an instance of it.
// irtio/schema.ts
players: entity({ name: str(24), score: u16, answered: bool }, { serverOwned: true }), The room writes it freely. The client sees it as DeepReadonly and cannot even attempt a write.
This is the right default for anything that decides the game: scores, decks, phases, loot, hit
results. Clients ask for changes through RPCs, and the handler decides what
actually happened.
Moving ownership at runtime
Ownership is not fixed at creation. The server can hand an instance to a client, take it back, or give it to the server itself.
state.cards.setOwner(id, ctx.clientId); // the client drags it
state.cards.setOwner(id, ''); // '' is the server
state.cards.ownerOf(id); // '' for the server, undefined if there is no such instance The empty string is the server’s owner id. @irtio/schema exports it as SERVER_OWNER if you
prefer to name it.
After an ownership change, re-read the instance on the client. Object identity is not guaranteed across one.
Clients asking for ownership
requestOwnership is a built-in RPC, so a client can ask for a contested instance without you
declaring anything.
// main.ts
const granted = await room.requestOwnership('cards', 'c1');
if (granted) startDragging('c1'); The room decides. With no handler, the request is granted when nobody owns the instance and refused
otherwise. To take over the decision, implement onOwnershipRequest.
onOwnershipRequest(state, entity, id, ctx) {
if (entity !== 'cards') return false;
if (state.match.phase !== 'play') return false;
return state.cards.ownerOf(id) === '';
} The granted value the client receives is not your return value. irtio checks ownerOf(id) === ctx.clientId after your handler returns. Returning true makes irtio call setOwner for you. Calling setOwner yourself and returning false still reports true to the
client, because the client does now own it. Set the owner or return a boolean, not both in
disagreement.
Choosing an owner
Use client ownership when the value is a report about that client and a lie costs the game nothing, and when input latency is what you care about most: cursors, avatars, camera angles, emotes, a piece being dragged. Add a validator once the lies start costing something.
Use server ownership when the value is an outcome rather than a report: score, turn order, damage, what is in the deck, who won. If a value cannot be recomputed from something the server already knows, that is the strongest sign it belongs to the server.
Related
- Server authority and validation for validators and the tick.
- RPCs for asking the server to do something.