@genex-ai/multiplayer
v0.11.0
Published
The multiplayer client wrapper + shared room schema for genex.
Readme
@genex-ai/multiplayer
Tiny realtime multiplayer for genex games.
Relay model: whatever you write to me or shared is synced to everyone; everything else stays local. The server does no physics, no prediction — you run your own game logic, this just shares state. Remote players are smoothed automatically (client-side entity interpolation — see Smoothing remote players).
import { connect } from '@genex-ai/multiplayer';Connect
import { waitForPlayer, getColyseusAuth, getColyseusUrls } from '@genex-ai/embed-sdk';
type State = { x: number; y: number; z: number }; // YOUR per-player state — any JSON shape
await waitForPlayer(); // identity gate — resolves for guests AND accounts
const room = await connect<State>({
urls: getColyseusUrls(), // regional relays for this session — SDK joins the fastest
room: '<game-id>', // everyone with the same id shares a room (use the project slug)
name: 'ada', // optional display name
auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect
});auth is required — the relay rejects tokenless joins. Get it from @genex-ai/embed-sdk:
await waitForPlayer() first (never waitForAuth() — that resolves only for signed-in accounts
and keeps guests out forever), then pass () => getColyseusAuth() so each explicit
connect() reads it fresh — tokens rotate every ~10 min, so never cache the object
across reconnects. Never log it.
Different room ids are fully isolated — players in game A never see game B.
connect() joins immediately. Use it for one ongoing drop-in world. If the game has a
Play/Online/Local/Bots menu, call it only after the online click and call room.leave() when the
player returns to the offline menu. Immediate connect + spawn is valid only when loading the game
already means joining the always-online world; do not put a fake Play screen in front of a player
who is already present. Use matchmake() below for fresh bounded sessions with queue/quorum/fair
start/backfill behavior.
me — publish your own state
Replaces your state wholesale. Call on a fixed tick (~10–20Hz), not every render frame — each call is one network message.
room.me.set({ x, y, z });
room.me.snap({ x: spawnX, y: spawnY, z: spawnZ }); // respawn/teleport: hard-reset remote historyplayers — everyone in the room
A fresh Map each read; includes you (skip with id === room.id). For remote players p.state is auto-smoothed (interpolated); for yourself it's live. p.stateRaw is always the latest raw snapshot (use it for hit-tests / logic). A reserved reconnecting seat stays in players with p.connected === false; use room.activePlayers when gameplay needs the live transport quorum.
for (const [id, p] of room.players) {
if (id === room.id) continue;
draw(p.name, p.state.x, p.state.y, p.state.z); // p.state is typed as State
}Remote players glide instead of teleporting between network updates — see Smoothing remote players.
shared — shared room state
A key/value store (any JSON) synced to everyone. Use for world state, scores, the current round, etc.
room.shared.set('round', 3);
room.shared.get('round'); // 3
room.shared.keys(); // ['round', ...]objects — shared objects nobody owns until claimed
For a ball, a puck, an NPC — a moving thing that isn't any one player. Exactly one client
owns an object at a time (the server enforces it); the owner simulates it and sets it, and
everyone else reads it auto-smoothed, on the same interpolation as remote players. Ownership
survives the owner leaving — the object is reassigned to the room host.
// Legacy fire-and-forget claim: optimistic locally, corrected by the relay if rejected.
if (iTouched) room.objects.claim('ball');
// Irreversible action: wait for the authoritative result.
const result = await room.objects.claimConfirmed('ball');
if (result.accepted) applyKick();
else if (result.reason === 'held') retryWhileStillTouching(result.retryAfterMs);
if (room.objects.get('ball')?.isMine) {
room.objects.set('ball', simulateBall()); // only the owner's writes land
}
const ball = room.objects.get('ball'); // { owner, isMine, epoch, state, stateRaw }
drawBall(ball.state); // smoothed for everyone (live for the owner)
room.objects.release('ball'); // give it up (stays until reclaimed)
room.objects.remove('bullet'); // destroy transient objects when done
await room.objects.releaseConfirmed('ball'); // acknowledged + idempotent under reconnect
await room.objects.removeConfirmed('bullet'); // acknowledged owner-only removal
// Deliberate discontinuity: one hard reseed, never an old-to-new interpolation sweep.
room.objects.snap('ball', { x: 0, y: 1, z: 0, q: [0, 0, 0, 1] });Keep object state flat (top-level numbers + a 4-number quaternion) so it smooths, same as a
player. state is render-only and smoothed; stateRaw is the raw latest for every gameplay read
(hit-tests, reach, pickups, physics adoption, discrete values). objects.ids() lists them.
objects.set() and objects.snap() are owner-only: SDK 0.10.2+ ignores an unowned call and warns
once per object/operation. Await claimConfirmed() and check accepted before an irreversible
write; legacy claim(); set() remains valid because claim() is optimistic locally.
The relay enforces a short minimum ownership hold to prevent contact thrash. A current-owner
reassert is a true no-op: it does not extend that hold. Legacy claim() remains optimistic for
0.8.x compatibility; use the confirmed methods when feedback, impulses, seats, scoring, release,
or removal must not lie. Confirmed requests have stable request ids internally and are resent after
a reconnect, so an accepted operation is idempotent. A rejected result reports the current owner and,
for held, retryAfterMs.
A timeout result means the outcome is unknown, not "failed": the relay may have applied the
operation and only the answer was lost. Shared owner truth (objects.get(id)?.isMine /
owner) converges through the state mirror regardless — treat it, not the timed-out promise, as
authoritative before tearing anything down. On a relay that predates protocol v2, me.snap /
objects.snap degrade gracefully: the discontinuity write itself is not stored (peers converge on
your next ordinary set, without the history reset) — which is why the relay must be upgraded
before games ship an SDK that relies on snaps (see the migration note).
claimConfirmed(id) is an ordinary fair player claim, including when the caller happens to be host.
Only host-owned lifecycle work such as initial seeding or a goal reset should request
claimConfirmed(id, { authority: 'host' }); the relay accepts that bypass only from the current host.
When it actually bypasses an active hold the result reason is host-authority; a non-host request is
rejected as not-host. Do not use host authority for ordinary host-player collisions.
me.snap and objects.snap are for discontinuities only: respawn, teleport, reset, or a player mode
edge such as exiting a vehicle. Ordinary movement must use set, otherwise motion will visibly snap.
isHost / host — the elected room authority
One client is the host — the earliest signed-in player still present (guests host only while no account is in the room), re-elected automatically. The host can change on join as well as leave: the first account entering a guest-hosted room takes over. Use it to pick a single writer for scores/rounds or a single simulator for NPCs.
if (room.isHost) room.shared.set('round', nextRound); // only the host advances the round
room.on('host', (id) => {}); // host changed (migration)isHost/host settle within the first patch after connect() — read them in your loop (or react
to on('host')), not once at startup.
on — events
room.on('join', (id, state) => {}); // a player appeared (also fires for YOU on connect)
room.on('change', (id, state) => {}); // a player updated their me-state
room.on('leave', (id) => {}); // a player left
room.on('shared', (key, value) => {}); // a shared key changed
room.on('object', (id) => {}); // an object's owner changed — the render glides across it (soft handoff)
room.on('host', (id) => {}); // the room host changed
room.on('hit', (payload) => {}); // a custom event someone sent (see `send`)
room.on('reconnecting', ({ attempt }) => {}); // connection dropped — auto-reconnect underway
room.on('reconnected', () => {}); // back in the SAME seat: id, objects, host intact
room.on('disconnect', (code) => {}); // terminal — connect() fresh to play againon(...) returns an unsubscribe function.
Reconnection (automatic)
A dropped socket (Wi-Fi blip, brief signal loss) is not immediately a leave: the relay holds your seat for a grace window (~30s) and the SDK reconnects with backoff. A short blip keeps the same session id, owned objects, and host status. Simulation availability is a separate lease: if the disconnected seat is host for longer than the relay's short host-failover grace, an active peer becomes host and host-owned objects move once. A returning old host keeps its seat but does not steal authority back. Host-tick callbacks pause while their socket is reconnecting and resume only if that session still owns the host role. After a long reconnect, remote interpolation histories are rebased to current truth so they cannot draw a whole-map catch-up streak.
Render reconnecting/reconnected as an overlay and keep your loop running. disconnect is terminal
(server restart, revoked session, or the link never recovered): read a fresh token and connect()
again. A deliberate leave() never reconnects. The relay also broadcasts server:restart right before
a deploy — flush saves when you see it.
One seat per player: the relay enforces a single session per identity per room — a
second tab / device / reloaded page supersedes the previous session (old one gets
disconnect, code 4409), so the same player never appears twice and reloads never leave
a ghost. Reload/close sockets ("going away") get only a ~5s seat-hold; genuine network
drops keep the full reconnection grace.
inputs + onHostTick — host-authoritative physics (contested objects)
Claim-on-touch fits any ownable body (a ball, a box, a prop) — even when players take turns
bumping it, because the ownership handoff glides (a soft handoff, since 0.8.4) instead of
teleporting; give the object a Rapier proxy that's dynamic while you own it and a kinematicPosition
follower of state when you don't. Reserve host-authoritative for a genuine simultaneous contest
(two players pushing one crate against each other, sumo): let ONE neutral simulation own it — everyone
sends inputs, the relay routes them to the current host only, and the host applies them on a fixed
tick and publishes results via objects (auto-smoothed for everyone):
if (pushing) room.inputs.send({ obj: 'crate', push: dir }); // any client → host only
room.inputs.on((fromId, payload) => queue.push(payload)); // fires only while YOU host
room.onHostTick(30, (dtMs) => { // runs only on the host,
// adopt objects + seed from stateRaw on first tick, apply // auto-migrates with the
// queued inputs to YOUR one physics world, then: // host election
room.objects.set('crate', { x, y, z, q });
});onHostTick auto-starts on election, pauses during reconnect, resumes after reconnect only if still
host, and stops on demotion, deliberate leave(), or terminal disconnect. The disposer is still useful
when the game removes that subsystem earlier. A new host must confirm an explicit host-authority claim
and seed its world from each object's stateRaw, so simulation survives host migration without a snap.
send — custom one-off events
Fire-and-forget to all other clients (not stored in state). For shots, emotes, chat, pings.
room.send('hit', { from: room.id, target: 'xyz', dmg: 10 });leave
room.leave();Smoothing remote players
Remote players are smoothed automatically — nothing to configure. room.players gives each remote player's state rendered slightly in the past and interpolated between snapshots, so motion glides between network updates (you send ~10–20Hz but render at 60). Interpolation runs on the sender's clock (each update is timestamped and mapped to your local clock), the way Valve/Unity/Mirror do it — so jitter doesn't distort the motion. The delay self-tunes to both the live snapshot rate and the link's measured jitter, so it stays smooth whether you send at 5Hz or 30Hz and whether the connection is a clean LAN or a jittery mobile link — a deeper buffer is used only where the jitter demands it.
Do not add another lerp, snapshot buffer, or remote physics predictor on top. state is already the
render value. This relay does not implement classic client-side prediction plus server reconciliation:
your own player is client-authoritative and renders locally; remote entities use interpolation and
bounded extrapolation.
player.stateis the smoothed value (what you draw);player.stateRawis the latest raw snapshot — use it for hit-tests / discrete logic.- Your own player is never delayed — you always render yourself live.
- Numbers are interpolated; a 4-element
[x, y, z, w]quaternion is slerped (shortest-arc); anything else snaps. - Send rotation as a quaternion:
state.q = mesh.quaternion.toArray(), then on the remotemesh.quaternion.fromArray(p.state.q). The package slerps it and output-smooths it so it glides through turns. - Late/dropped packets keep moving: position dead-reckons forward from its last velocity (capped ~0.25s, Source-style) until fresh snapshots arrive; rotation also continues the turn but on a tighter cap (~0.15s), so a remote's heading keeps moving through a brief gap instead of freezing and then snapping when the next packet lands — the short cap plus output-smoothing bound any overshoot. This is what holds up on high-latency links.
No client-side prediction/reconciliation: in this relay you're authoritative over yourself (you render instantly, the server never corrects you), so there's nothing to predict — smoothing remote players is the piece that matters.
Complete minimal example (move a cube, see others as cubes)
import { connect } from '@genex-ai/multiplayer';
import { waitForPlayer, getColyseusAuth, getColyseusUrls } from '@genex-ai/embed-sdk';
type S = { x: number; z: number };
await waitForPlayer(); // identity gate (guest OR signed-in) — see Connect above
const room = await connect<S>({
urls: getColyseusUrls(),
room: GAME_ID,
auth: () => getColyseusAuth(), // REQUIRED — resolved fresh for this connect
});
const me = { x: 0, z: 0 };
// publish my position ~15Hz (NOT per frame)
setInterval(() => room.me.set(me), 66);
// render loop (your engine / three.js): draw every player at their latest position
function frame() {
for (const [id, p] of room.players) {
const cube = cubeFor(id); // create-or-reuse a mesh per id
cube.position.set(p.state.x ?? 0, 0, p.state.z ?? 0); // auto-smoothed
}
requestAnimationFrame(frame);
}
frame();
// drop a player's cube when they leave
room.on('leave', (id) => removeCube(id));
// input just mutates `me`; the tick above syncs it
onKey('right', () => { me.x += 1; });matchmake — competitive matches (server-owned)
When players should be matched into separate capped rooms instead of sharing one big room (1v1, FFA, teams, invite lobby), use matchmake() instead of connect(). The queue, roles, winner-stays, forfeit, timeout, and win condition are all server-owned — the client declares nothing and runs no matchmaking logic.
import { matchmake } from "@genex-ai/multiplayer";
// Pass `auth` as a FUNCTION — a handle re-joins many times (re-search/requeue) and tokens rotate
// (~10 min); a function is read fresh each join, a static object goes stale mid-session.
const mm = await matchmake<State>({ urls, room: slug, auth: () => getColyseusAuth() });
// `session` is null WHILE SEARCHING — render your own "finding a match…" HUD until it goes live.
// each frame:
if (mm.session) renderGame(mm.session); // a live Session (same API as connect())
else renderSearchingHud(mm.matchmaking);
mm.on("matched", () => {/* session is live */});
mm.on("matchStart", ({ roundId }) => {/* round began */});
mm.on("matchEnded", ({ winnerId, scores, draw }) => {/* result screen */});
mm.on("spectate", () => {/* DEPRECATED — never fires: there are no server-side spectators anymore.
An overflow searcher now waits in the queue instead of watching. */});
mm.on("error", (e) => {/* queue join failed even after the SDK's automatic retries — persistent
(broken auth / server unreachable); fix the cause, then mm.retry() */});
// Report ONLY your own outcome — the server adjudicates. Pick the call that matches the win condition:
mm.eliminated(); // I'm out (lastStanding)
mm.score(1); // I scored (firstToScore / highScoreInTime)
mm.finish(); // I finished the race (firstToFinish)
mm.retry(); // re-enter the queue after a terminal 'error' (no-op in any other state)
mm.cancel(); // stop searching / leave the matchA failed queue join is retried automatically with backoff (~10s total), so transient failures
(a token mid-rotation, identity still settling on page load) never surface. Only a persistent
failure emits error; the handle then idles until you call mm.retry().
mm.matchmaking (read every frame for your HUD): status (searching | waiting | countdown | playing | ended; the legacy spectating is never emitted — server-side spectators were removed), queue ({ position, size }), players (reserved seats), connectedPlayers (live transports), opponents, teams, myTeam, scores, winCondition, config (numeric knobs), roundId, winnerId, message. championId/challengerId are 1v1 aliases. During an automatic reconnect, the SDK retains the last coherent roster and match view until the replacement room hydrates, so status does not transiently regress to waiting.
Configure once in the game's package.json (reported at genex preview AND genex publish;
removing it clears the stored config on the next preview/publish — never sent from the client):
"genex": {
"matchmaking": {
"preset": "arena", // open | duel | arena | teams | private
"winCondition": "firstToScore", // lastStanding | firstToScore | highScoreInTime | firstToFinish
"config": { "scoreTarget": 20, "maxPlayers": 8 } // numeric knobs, optional
}
}Presets: open (a room of N players, your rules — see below), duel (1v1 winner-stays), arena (N-player FFA, join-anytime), teams (balanced N-v-N), private (invite-code lobby). Win conditions (for the batteries-included presets): lastStanding, firstToScore, highScoreInTime, firstToFinish; a round that hits the time cap undecided is a draw.
open — bring your own rules. The minimal preset: the server owns only seating, capacity, and refill; you build teams, rounds, scoring, and win logic yourself on session.shared + host election. It runs no match loop, so status only goes waiting→playing (never ended), scores/winnerId stay empty, and nobody is ever evicted (no matchEnded, no requeue). Config knobs: minPlayers (quorum to flip to playing; default 1), maxPlayers (seat cap, clamped 2–64), fill (1 = keep the room full — grow to max and refill a seat a leaver frees, the default; 0 = lock the room for good once it is simultaneously full (max players at once) — a match with no substitutes; a freed seat stays closed). Pick open when you want your own rules; pick duel/arena/teams for a ready-made match loop.
Private lobbies — createPrivate / joinPrivate
For preset: 'private' games, skip the queue: a host mints an invite code, friends join it, and everyone lands in the SAME persistent lobby (rounds replay, nobody is evicted). Both handles have the same shape as matchmake(), but session is live on return (no searching phase).
import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
// host
const lobby = await createPrivate<State>({ urls, room: slug, auth: () => getColyseusAuth() });
share(lobby.code); // e.g. show it in the UI
// friend (elsewhere)
const joined = await joinPrivate<State>(code, { urls, room: slug, auth: () => getColyseusAuth() });
// then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()Rules of thumb
me/sharedare synced; everything else is local. Movement input → mutate a local object →me.setit on the tick.- Tick
me.setat ~10–20Hz, render at your own framerate. Oneme.set= one network message. room(game id) must be stable per game — use the project slug. Same id = same room; different id = isolated.- Remote players are smoothed automatically — read
stateRawwhen you need the raw latest (hit-tests). No server-side prediction — you render yourself live. me.setreplaces your whole state object — send the full object each time, not a partial.
Persistent worlds (optional)
The relay is in-memory — room state is gone when everyone leaves or the server restarts. To persist world state across restarts, use @genex-ai/embed-sdk's state helpers (they own auth tokens, guest handling, and write-conflict decoding):
import { loadWorldState, saveWorldState } from '@genex-ai/embed-sdk';
// load on boot
const { data, version } = await loadWorldState();
initWorld(data ?? defaultWorld());
// save (from ONE authority — the elected host — debounced, with ifVersion so
// races surface as { conflict: true } instead of silently clobbering)
if (room.isHost) await saveWorldState(world, { ifVersion: version });The relay prefers signed-in players when electing the host (guests cannot write saves), so host-driven persistence works whenever any account is in the room. Per-player progression belongs in savePlayerState() (each player's own slot), never in the shared world blob.
