Rooms
Several people in the same game at once, and who is allowed to decide what
A room puts several people in the same game at the same time. Join one with useRoom and you stay connected for as long as the component is mounted.
import { useRef } from "react"
import { View, mount, useFrame, useRoom, input } from "oj"
function Game() {
const me = useRef({ x: 0, y: 0 })
const others = useRef(new Map())
const room = useRoom("lobby", {
onMessage: (from, data) => others.current.set(from, data),
onLeave: (id) => others.current.delete(id),
})
useFrame(() => {
me.current = { x: input.mouse.position.x, y: input.mouse.position.y }
room.send(me.current)
})
return <View style={{ width: "100%", height: "100%" }} />
}
mount(<Game />)That is close to the whole API. The rest of this page is about what a room does not do, which is the part that decides how you write the game.
The server is a relay
The site holds one small object per room. It keeps the sockets, hands each arrival an id, and passes messages between them. It does not know what a message means, it does not simulate anything, and it cannot be asked to.
That is the shape of the platform rather than a missing feature. A game here is a JavaScript bundle you can read, fork and open in Unity. A game whose rules ran on the server would be two programs, in two languages, in two repositories, deployed separately, and the moment that is true, forking a game stops meaning anything.
Every client is the authority on itself and on nothing else
A client broadcasts where it is. It decides when it has been hit, eaten or scored on. Nobody else can decide that for it, because a message that does it is not one the game accepts.
Stated as a rule you can apply while writing:
You may only report your own death.
The version that feels natural is the opposite one: the bigger player notices the overlap and announces the kill. It is one line shorter and it hands every client in the room the ability to eat anyone, at any distance, forever. Checking from the victim's side costs nothing and closes it.
What a liar can do under the honest rule is refuse to die. That makes them strange to watch and harms nobody else's game. Under the other rule, a liar empties the room.
| A client may assert | A client may not assert |
|---|---|
| Where it is, how big it is, what it is holding | Where anybody else is |
| That it took damage, died, or lost a life | That it killed somebody |
| That it picked up something belonging to nobody | That it took something from another player |
Here is the shape it produces. Each fish checks whether it is the smaller one and reports being eaten; the eater learns it grew from that report, not by claiming anything:
// Being eaten. Checked from this side only.
for (const [id, peer] of peers) {
if (canEat(peer, me)) {
room.send({ k: "died", by: id })
respawn()
break
}
}and on the other side of the wire:
onMessage: (from, data) => {
if (data.k === "died") {
// They are telling us they were eaten, and by whom. Believed about
// themselves only: if they say we ate them, we grow.
const victim = peers.get(from)
if (victim === undefined) return
if (data.by === room.id) me.size = growBy(me.size, victim.size)
victim.size = START_SIZE
}
}The host owns what belongs to nobody
Positions belong to the player they describe, but a game usually has things that belong to no one player: the round clock, spawn positions, a field of pickups, a shared deck, who won. If every client decided those for itself, no two players would see the same world.
One peer owns them, and that peer is the host. Everyone else owns only themselves, which is the authority rule above, unchanged.
The server designates the host, and a game reads it rather than working it out.
const room = useRoom("arena")
useFrame((dt) => {
if (!room.isHost) return
clock -= dt
if (clock <= 0) room.send({ k: "round", n: ++round, spawns })
})room.isHost |
Whether this peer is the host |
room.hostId |
Which peer is the host, or null |
isHost is true when you are alone, and also before the welcome arrives. That is deliberate: a solo player still runs the clock, and a game that lays out its first level on the first frame should do that rather than wait for a room it may never reach. You do not need a separate single-player path.
onHost fires whenever the answer changes, including your own promotion after the previous host drops:
useRoom("arena", {
onHost: (isHost, hostId) => {
if (isHost) takeOverTheClock()
},
})hostId is what lets everyone else tell an authoritative message from an ordinary one:
onMessage: (from, data) => {
if (from !== room.hostId) return // only the host sets the round
if (data.k === "round") startRound(data.n, data.spawns)
}Do not elect a host from the peer list. Picking the lowest peer id, or any other rule computed on the client, looks like it works and is not safe: clients disagree about who is present, and a rule evaluated against a socket that has already gone silently produces two hosts or none. This documentation previously recommended exactly that, and it was the cause of a real desync. The server knows which sockets are live and which arrived first; read
room.isHost.
A late arrival still has to be caught up, since it missed everything sent before it connected. Ask, and let the host answer:
const room = useRoom("pond", {
onOpen: () => { if (!room.isHost) room.send({ k: "hello" }) },
onMessage: (from, data) => {
if (data.k === "hello" && room.isHost) room.send({ k: "field", p: pellets })
if (data.k === "field" && from === room.hostId) pellets = data.p
},
})Sending
Send state on a clock and events as they happen. Position at fifteen times a second is plenty; sixty is four times the traffic for motion nobody can see.
const sinceBroadcast = useRef(0)
useFrame((dt) => {
sinceBroadcast.current -= dt
if (sinceBroadcast.current <= 0) {
sinceBroadcast.current = 1 / 15
room.send({ k: "me", x: Math.round(me.x), y: Math.round(me.y) })
}
})Fifteen updates a second looks smooth if you draw peers where they are heading rather than where they last were. Infer the velocity from the gap between updates instead of sending it: it halves the message and cannot disagree with the positions it came from.
const gap = Math.max(0.001, (now - peer.lastSeen) / 1000)
peer.vx = (data.x - peer.x) / gap
peer.vy = (data.y - peer.y) / gapThen advance each peer by its own velocity every frame, with a little damping so a player whose tab froze coasts to a stop rather than flying off. Drop a peer that has gone quiet for several seconds: the room removes a dead socket too, but it takes up to 90 seconds to be sure, and a game usually wants to stop drawing somebody sooner than that.
Three habits that keep messages small: round coordinates before sending them, refer to shared objects by index rather than by value, and batch. A field of pickups refilled one message per tick never catches up with a busy game; eight in one message does.
Sending to one peer
Pass a peer id as a second argument and only they receive it. Catching up a late arrival is the usual case, and it saves telling everybody else something they already know:
onMessage: (from, data) => {
if (data.k === "hello" && room.isHost) room.send({ k: "field", p: pellets }, from)
}When a message goes nowhere
The room refuses a message that is too large or that arrives too fast, and it tells you rather than eating it silently:
useRoom("arena", {
onDropped: (reason, detail) => console.warn(`[room] ${reason}: ${detail}`),
})reason is "too-large" or "too-fast". Worth wiring up while developing: without it, a game that crosses a limit just quietly misses events, which looks like a logic bug anywhere but the place it actually is.
Staying in the room
The client sends a heartbeat every 25 seconds and the room drops anything it has not heard from in 90. There is nothing to do about this, and it is why a closed tab leaves the room within a minute and a half rather than lingering as a peer forever. It also means a turn-based game that sits silent between rounds is not mistaken for a dead one: traffic is not what keeps you in.
When there is no site
An ejected game runs in a Unity project with nothing behind it. Rooms degrade rather than fail: room.connected stays false, send() does nothing, and no handler fires. A single-player game built on a room still runs, it is just alone.
That is deliberate, so a game does not need two code paths. If you want to show something different, isOnline() tells you whether there is a site at all.
The API
const room = useRoom(name, handlers)The room name is per game, so two games both using "lobby" are two different rooms. Names match [A-Za-z0-9][A-Za-z0-9._-]{0,47}, and validRoomName() checks one for you.
| Handler | Called when |
|---|---|
onOpen(id, peers) |
The connection opened. id is yours, peers is everyone already there |
onJoin(id) |
Somebody arrived. Not called for peers present when you joined |
onMessage(from, data) |
Somebody sent something. from is their peer id |
onLeave(id) |
Somebody left, or their connection did |
onClose(reason) |
Your connection went away. A reconnect is already being attempted |
onHost(isHost, hostId) |
The room named a host. Fires on join, and on any change |
onDropped(reason, detail) |
Something you sent went nowhere. reason is "too-large" or "too-fast" |
| On the room | |
|---|---|
room.id |
Your peer id, or 0 before the welcome arrives |
room.peers |
Everybody else, most recent last |
room.connected |
Whether the socket is up |
room.isHost |
Whether you own the shared state. True when alone |
room.hostId |
Which peer is the host, or null |
room.send(data, to?) |
Sends to everyone else, or to one peer when to is given. Dropped silently while offline |
room.close() |
Leaves for good. The hook does this on unmount |
The returned object is stable across renders and its fields are live, so a frame loop can read room.peers sixty times a second without the hook resubscribing. connected and peers changing also triggers a render, so a player list stays current.
Handlers are read fresh on every message, so they can close over current state without tearing the connection down. Only the room name is a dependency: a parent rerendering does not disconnect anybody.
A dropped connection retries on its own, backing off from 0.4 seconds to 8. You do not need to handle that.
Limits
| Players in a room | 24 |
| One message | 8 KB |
| Messages per second, per player | 60 |
| Idle before disconnect | 90 seconds |
Small on purpose. These are games with friends, and the numbers are what a relay can carry without becoming something that needs operating.
When you need a real server
Some games cannot be built this way, and it is better to know early. If cheating has stakes, if the world has to keep running when nobody is looking at it, or if a player must not be able to see something they have not earned yet (every card game with hidden hands), then the rules have to live somewhere no player controls. That is a server, and a server is a different product from this one.
What a relay is good at is everything else: co-op, racing, party games, shared sandboxes, anything where the worst outcome of a lie is that one player looks odd.