@genex-ai/multiplayer
v0.17.1
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';Wire protocol. This SDK speaks Genex relay protocol 3 over Colyseus 0.18. The language-neutral
contract — room state, messages, limits, close codes, reconnection, interpolation — is
PROTOCOL.md, and its machine-readable constants ship as
@genex-ai/multiplayer/protocol (the shared room schema as @genex-ai/multiplayer/schema).
Another engine's client conforms to that, not to this README.
0.17.0 is a breaking release: it needs a protocol-3 relay (Colyseus 0.18). 0.16.x speaks protocol 2 on Colyseus 0.17 and is refused by the new relay, and 0.17.0 cannot join an old one — rebuild and republish a game to move it across. The game-facing API is unchanged except for the additions marked 0.17.0 below.
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.
Each player carries p.uid (0.17.0): their stable id in this game — the same across reloads,
devices and rooms of the game, different in every other game, never the account id. Key anything
that outlives a session (scores, colours, saves, "is this me") by uid, never by id, which is a
session id and changes on every reload. It is opaque; never parse it. p.team (0.17.0) is the
team index the relay assigned (-1 = none): the open preset's teams: N knob (kept by uid for the
room's life) or the teams preset's current round.
Each player also carries p.avatarUrl — the VRM avatar that player picked on their genex profile (guests get a per-session pick), verified server-side from their identity token so it can't be spoofed. It's the fallback body, not the default one: in a game that generated its own character, every player wears that character, and p.avatarUrl renders each player's own look only when the game has none. The genex controller kit's loadRemotePlayerCharacter({ avatarUrl: p.avatarUrl }) makes that decision for you and shares one parsed base across remotes. The field is '' when unknown (older relay/API), so always keep a bundled fallback. Don't put an avatar URL in your me.set state — this field replaces that.
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: the SDK ignores an unowned call and warns
once per object/operation. Await claimConfirmed() and check accepted before an irreversible
write; 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. claim(), release() and remove() are
fire-and-forget: they send the same request as the confirmed methods and ignore the answer — a lost
claim() reverts its optimistic ownership. Use the confirmed methods when feedback, impulses, seats,
scoring, release, or removal must not lie. Every request carries a stable request id and is resent
after a reconnect, so an accepted operation is idempotent. A rejected result reports the current
owner and, for held / rate-limited, 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.
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. isHost is false while the session is reconnecting, until
the fresh snapshot says who the host is now (0.17.0), so a write behind it is never a stale
ex-host's; host is the raw field.
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('room:open', ({ open }) => {}); // the host opened/closed the room to newcomers (setRoomOpen)
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 Colyseus client reconnects the same room in place, with backoff (~0.25s→4s, nine attempts, the last one ~24s in — inside the grace). 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.
Every drop takes this path, however long the session has been up. reconnecting fires once per
attempt; reconnected fires only after the room's state is current again, so isHost, owners and the
roster are safe to read in it. Nothing you send while disconnected is queued: player and object state
self-heal on the next publish, and lost send() events / inputs are counted in a console warning.
Render reconnecting/reconnected as an overlay and keep your loop running. disconnect is terminal
(server restart, revoked session 4401, or the link never recovered 4003): 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 — do not reconnect automatically on it: the player is playing in the newer
tab), 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, pings. The relay's
own names are refused with a warning: state, shared, claim, release, destroy, obj,
queue, seat, anything starting __, match:, server: or rate:, and names over 128 characters.
room.send('hit', { from: room.id, target: 'xyz', dmg: 10 });Text chat has its own channel — use room.chat, not this.
chat — room text chat
room.chat.send('on my way');
room.chat.on((m) => { // fires once per line you hadn't seen
addLine(`${m.name}: ${m.text}`); // m.text is already sanitized — render as TEXT
});
room.chat.history; // every known line, oldest first — render on mount
room.chat.mute(playerId); // local only; nothing is sent, nobody else is affectedChat is not send — three differences that matter:
- Your own lines come back to you, in server order with the server's timestamp. Render only
what arrives in
on; appending your own line locally on submit shows it twice. - The relay stamps
fromandnamefrom the verified session. A player cannot post under someone else's name, so the name is safe to display as-is. - History replays on connect (last ~50 lines), so a player joining mid-game sees the recent
conversation.
onfires for those too, de-duplicated byid, and a reconnect backfills exactly what the blip missed — a handler that just appends stays correct.
The relay trims each line, strips control and bidi characters, and clamps to 500 code points. Chat has its own rate budget (~2 lines/second), separate from gameplay traffic, so a busy room can't starve it and a chat flood can't starve movement.
Render
m.textwithtextContent, neverinnerHTML. It is another player's input.
voice — mesh voice chat
import { getShell } from '@genex-ai/embed-sdk'; // 0.32.0+
// Voice is not available inside the Genex app yet — do not draw the button there.
joinBtn.hidden = getShell() === 'app';
// MUST be inside a click/tap handler — browsers gate the mic prompt AND playback on a gesture.
joinBtn.onclick = async () => {
try { await room.voice.join(); }
catch (err) {
// The page around the game withheld the microphone and the player was never asked:
// this build did not declare it (see below). Nothing the player can allow.
if ((err as Error).name === 'VoiceNotDelegatedError') showNotice('Voice chat is not available here');
else showNotice('Microphone needed for voice'); // the player said no, or has no microphone
}
};
room.on('voice', (participants) => renderVoiceHud(participants)); // joined/left/speaking/connected
room.on('voice:rejected', (info) => showNotice(`Voice is full (${info.limit})`));
room.voice.setMicEnabled(false); // push-to-talk gate — instant, no renegotiation
room.voice.mute(playerId); // local only
room.voice.leave(); // releases the mic (turns the recording indicator off)The microphone is declared per build. On its Genex page a game runs in a frame, and the
page gives that frame a microphone only when the build said it needs one. A build that did
not is refused at once, with no prompt — while the very same build works on the game's own
link and on a dev server, which is why it is easy to ship without noticing. The declaration
is made at genex preview and genex publish (CLI 1.36.6+), which read the game's own
source and declare the microphone when they find .voice.join() called with nothing or
with an options object written at the call — on any .voice, so mm.session.voice.join()
counts. Both print what the build is given on its Genex page, so a missing declaration
shows before a player meets it. A call the CLI cannot see (voice.join(opts) with the
options in a variable, const { voice } = room; voice.join(), or code outside the game's
src/) is declared by hand, in the game's package.json:
"genex": {
"capabilities": ["microphone"]
}The declaration belongs to the build, so preview or publish again after adding it. The
spatial pack (npx genex controller voice) declares the microphone by itself.
The refusal names itself: since 0.16.2, join() rejects with an error whose name is
VoiceNotDelegatedError when it was the page, not the player, that said no (the browser's
own error is its cause). Chromium browsers can make that distinction; in the others the
page's refusal arrives as the browser's own error, the same as a player's.
Not in the Genex app yet. Inside the Genex mobile app the microphone is refused for
every game, declared or not, and join() rejects as if the player had said no. Hide the
voice button there, as above.
Party-sized, not room-sized — max 6. Audio goes peer-to-peer in a full mesh: everyone
uploads to everyone. The relay carries no audio at all, only the roster and the WebRTC
handshake. Past a small party what breaks first is not bandwidth but reliability — a joiner
must negotiate with everyone already present, and every leg can fail. The 7th join() is
refused with voice:rejected; the room still holds 64 players.
TURN is automatic — you do not configure it. Roughly 15% of players sit behind symmetric
NAT, carrier CGNAT, or a firewall that blocks UDP, and cannot connect peer-to-peer at all. In
a mesh that is not a private problem: they fail every leg, so nobody in the party hears them
and they hear nobody, while everyone else works fine. join() fetches short-lived relay
credentials from the Genex relay before it opens any peer connection, so this is handled for
you with no key to obtain, nothing to put in your bundle, and no code to write.
Pass iceServers only to run your own TURN — it is an override, and it suppresses the
platform request entirely:
await room.voice.join({ iceServers: [{ urls: 'turn:…', username: '…', credential: '…' }] });Spatial voice (npx genex controller voice) is the version worth shipping in a 3D game:
join with { spatial: true } and the SDK stops playing audio flat, handing you p.stream
per participant to drive a THREE.PositionalAudio. Distance falloff also softens the cap —
six audible neighbours read as a populated world rather than as a limit.
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.
Experimental options (off by default)
These are A/B arms of the netcode bench (PROTOCOL.md §15). Each stays off by default
until it wins its gate, and one that loses is marked rejected below; leave them alone unless you are
running that experiment. They are accepted by
connect(), matchmake(), createPrivate() and joinPrivate().
pose: true — typed pose (PROTOCOL.md §6.6). The reserved keys x y z yaw vx vy vz anim of
me.set() / me.snap() travel as typed relay fields (float32 deltas instead of the whole JSON state
re-sent on every tick); everything else is data, sent only when it changes. Omit vx/vy/vz and they
are derived from successive positions. Turn it on for every client of a game at once. With it on,
remote players interpolate only the pose (yaw in radians, along the shortest arc; anim switches
when the rendered pose reaches it) and snap everything else — a rotation synced as a quaternion is
no longer smoothed, so put the heading in yaw. A remote posed player's state always carries all
eight pose keys. Needs a relay that accepts __pose.
const room = await connect<State>({ urls, room: gameId, auth, pose: true });
room.me.set({ x, y, z, yaw, anim: 'run', hp }); // x..anim → typed pose, hp → datasmoothing: 'predict' — REJECTED, bench-only. Do not ship it. It lost its A/B: with the clock
off, remote players under it showed 18–23 teleports and a speed CV of about 1.9–2.0 on broadband,
mixed-ping skew and a background-tab sender, against 0 teleports and about 0.40 for the default engine,
and on a burst stall a 753 ms freeze (default 436 ms). The numbers are in the
netcode-bench README. The option stays only so the bench
can rerun the comparison; games keep the default 'genex'. (It hands posed players' numeric pose to
Colyseus Predict: receive-time stamps, a fixed delay, no dead reckoning past the newest sample.)
Server clock (PROTOCOL.md §16). Against a relay that publishes its clock, every session sends a
small heartbeat at 10 Hz and exposes room.serverNow() (ms since the room was created), room.rtt
(smoothed round trip in ms) and room.clockSynced. Against any other relay they are undefined /
false and nothing is sent. room.timeUntil(epochMs) works everywhere: milliseconds until a relay
deadline, on the server clock once synced and on Date.now() before. Remote smoothing never uses the
server clock.
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() */});
mm.on("disconnect", (code) => {/* *0.17.0*: the match seat ended with 4409 (opened in another tab or
device) or 4401 (signed out). The handle stops and does NOT requeue —
tell the player; mm.retry() only if they choose to play here again */});
// 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(). A relay that refuses this
SDK's protocol (error code 426) emits error at once — the game needs an SDK update, and retrying
cannot help (connect() rejects with the same code).
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, and the relay's deadlines: startsAt / endsBy (epoch ms, or null) with startsInMs / endsInMs — the time left as of this read, measured on the relay's server clock once it is synced, so a countdown is right even when the player's device clock is wrong. Draw countdowns from startsInMs / endsInMs, never from startsAt − Date.now(). championId/challengerId are 1v1 aliases. During an automatic reconnect the room keeps its decoded state, 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), and teams (0.17.0; 2…min(8, maxPlayers): the relay puts each seated player on the smallest team and reports it as p.team and in matchmaking.teams, never moves a seated player, and gives a returning player — matched by uid — the team they had; any other value means no teams). 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";
import { share, getEntry } from "@genex-ai/embed-sdk"; // 0.32.0+
const opts = { urls, room: slug, auth: () => getColyseusAuth() };
// host
const lobby = await createPrivate<State>(opts);
showCode(lobby.code); // show the code as well: the way in when a link will not do
// host's Invite button. share() has to start inside the click, before any await.
inviteBtn.onclick = async () => {
const { status, url } = await share({ text: "Join my lobby", params: { lobby: lobby.code } });
if (status === "copied") toast("Invite link copied");
if (status === "unavailable") showLink(url); // no share sheet and no clipboard: show the link
};
// friend: one way in, for the code from a link and for a code typed by hand
async function enter(code: string) {
if (!/^[\w-]{1,32}$/.test(code)) return null; // anyone can write a link: take a code, nothing else
try {
return await joinPrivate<State>(code, opts);
} catch {
showNotice("That lobby is no longer open"); // closed, or out of this player's reach
return null;
}
}
// at boot, after waitForPlayer(): were they sent an invite link?
const invited = getEntry().params.get("lobby");
const joined = invited ? await enter(invited) : null; // null: the menu, with its "Enter code" field
// then identical to matchmake(): .session, .matchmaking, eliminated()/score()/finish(), cancel()share() builds the link itself: the game's page on Genex plus ?lobby=<code> (on a
creator's own domain, that address). getEntry() gives the parameter back to whoever opens
it, on the game page, on the game's own link and across the sign-in round trip. Do not
build the link from location.href: inside the game page that is the frame's bare address,
not the page a friend should land on. Any parameter name works except the platform's own
(embed, ref, s, from, remix, slug, and everything starting genex_).
joinPrivate rejects when the code does not lead to a lobby this player can reach: it has
closed, or (only where the regional relays do not share presence) its host is on another
region's relay than this player's nearest one. Catch it
and show the menu. Keep the typed code as the second way in: a shell that does not pass the
link's parameter on yet opens the game without it.
Engine bridge — @genex-ai/multiplayer/bridge
For engines that build to the web and cannot hold JS objects: Rust (wasm-bindgen), Godot web
exports. A flat facade over this SDK: a session is a numeric handle, every value crosses as a JSON
string, a number or a boolean, and drainEventsJson(handle) hands over everything that happened
since the last call, once per frame. Identity comes from @genex-ai/embed-sdk (an optional peer of
this package; install it to use the bridge), with a fresh token for every join. The contract is
sdks/rust/BRIDGE.md.
import { connect, setMe, playersJson, drainEventsJson } from '@genex-ai/multiplayer/bridge';
const h = await connect(JSON.stringify({ gameId: 'my-game', name: 'ada', urls: [] }));
setMe(h, JSON.stringify({ x: 1 }));
for (const e of JSON.parse(drainEventsJson(h))) { /* { type: 'playerJoined', id }, … */ }Pages with no bundler load dist/bridge.global.js (the SDK and embed-sdk bundled in), which sets
window.GenexMultiplayerBridge. A JS game should use the SDK directly, not the bridge.
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.
