SDK / Beta
Multiplayer SDK
Build small authenticated online games for 2–8 players without shipping your own rooms, presence, realtime backend, or matchmaking service.
Premium Creator, and every multiplayer participant must be signed in. The server resolves entitlement from the game owner; iframe code cannot supply or override it.Availability and operator pauses
ThreeJSGames.multiplayer.isAvailable() returns a synchronous cached server hint, initially false while a read-only check runs. Poll from lobby UI; checks are coalesced and cached for 15 seconds. False can also mean signed out, unknown/network failure, non-Premium owner, or unenrolled game. Do not wait forever without a visible unavailable/retry state. Calls still enforce current server policy; a true hint never guarantees admission.
Global/per-game pauses reject new create, join and Quick Match with MULTIPLAYER_UNAVAILABLE. Existing members may heartbeat, reconnect within grace, send state/events, finish matches and leave normally. A pause does not destroy history. The previous v4 artifact remains supported but its isAvailable() only checks embedding; it still receives server admission errors.
Install SDK v4
Vendor the unchanged official artifact at 3jsgames-sdk/v4/3jsgames.js and load it before game code.
<script src="./3jsgames-sdk/v4/3jsgames.js"></script>Quick Match
Quick Match atomically joins the oldest compatible open public room for the same game, mode, and capacity, or creates one. Beta does not use rating, region, party, or skill buckets for matchmaking.
const room = await ThreeJSGames.multiplayer.quickMatch({
mode: "default",
maxPlayers: 4
});
room.on("playerStateChanged", ({ player, state }) => {
// Interpolate the remote player's rendered object toward state.
});
room.setPlayerState({ position: [0, 1, 0], animation: "run" });Private rooms
A six-character code is a discovery convenience, not authorization. A code from Game A cannot join a room in Game B; signed game context, authenticated identity, Premium entitlement, and server-created membership remain mandatory.
// Player A
const room = await ThreeJSGames.multiplayer.createRoom({
visibility: "private",
mode: "co-op",
maxPlayers: 4
});
console.log(room.code);
// Player B, in the same published game
const joined = await ThreeJSGames.multiplayer.joinRoom({ code: roomCode });Room and participant model
A room exposes id, code, players, player, isHost, connectionState, status, sharedState, and match. Public participants contain only an opaque room-member ID, public username, join time, and host flag—never email, provider data, tokens, or internal account IDs.
room.on("playerJoined", handler)room.on("playerLeft", handler)room.on("hostChanged", handler)- Every
oncall returns an unsubscribe function.
Player state is not rendering FPS
setPlayerState publishes only the current player's bounded transient state. Network at up to 15 updates/second; render at requestAnimationFrame frequency and interpolate remote objects locally. Intermediate movement values may be superseded—the latest value matters.
room.setPlayerState({
position: [1, 2, 3],
rotation: 1.2,
animation: "run"
});Durable shared host state
Only the canonical current host can call setSharedState. The complete bounded JSON object is stored as a room snapshot, survives reconnect and host migration, and is returned before live events resume.
if (room.isHost) {
await room.setSharedState({ round: 4, timer: 52, seed: 38192 });
}Custom events
Use state when the latest value matters. Use send(name, payload) when an action occurred, such as shoot, ready, or door-opened. Event IDs and sender sequences support recipient deduplication.
await room.send("shoot", { direction: [0, 0, -1] });
room.on("event", ({ name, player, payload }) => {
// React to one live gameplay action.
});send confirms that Supabase Realtime accepted the broadcast, not that every recipient received it. Delivery is ordered on the sender's live channel, but is not durable, exactly-once, or guaranteed to disconnected recipients. Receivers deduplicate recent event IDs.Reconnect, snapshots, and lifecycle
Connection states are connecting, connected, reconnecting, disconnected, and closed. Membership has a 30-second grace. Reconnect resumes the same member, subscribes before fetching a canonical revisioned snapshot, then applies only newer control changes. Permanent timeout frees the slot.
Heartbeats pause while the realtime transport is reconnecting, so a broken socket cannot keep a membership alive indefinitely. Recovery renews the membership before applying its snapshot. Player-state streams have a fresh sender epoch after a page reload; old packets cannot rewind the new stream. Transient movement is not persisted or replayed, so publish current player state again after reconnect.
- Waiting rooms expire after 30 minutes.
- Started matches expire after 4 hours.
- Closed room metadata is cleaned after 7 days; match/rating history remains separate.
- Heartbeat cadence is 10 seconds while connected.
Host authority and migration
The room creator is initial host. When the host leaves or exceeds grace, the server deterministically elects the oldest remaining membership, ordered by join time and member ID. Memberships still inside grace remain eligible. Shared state stays intact and clients receive hostChanged. Clients cannot vote or mark themselves host.
Ranked 1v1 and Elo
Creators explicitly configure a mode as ranked in Studio. Ranked Beta is limited to two participants and starts at 1500 Elo with K=32. The host can submit only a winner member ID or draw after startMatch(); it cannot submit ratings. The server validates the canonical room, host, match, and participants, finalizes once, locks both rating rows, updates Elo transactionally, and projects the result to the existing public leaderboard UI.
if (room.isHost) await room.startMatch();
// At the authoritative match end. Pass null for a draw.
await room.finalizeMatch({ winnerId: winningPlayer.id });Host-reported outcomes prevent outsiders, participant substitution, duplicate finalization, and direct rating writes. They do not prove that the host reported honest gameplay.
Limits
- 2–8 players in unranked rooms; rated modes are 1v1 only.
- Player state: 8 KiB, 15/second.
- Custom events: 8 KiB, 10/second sustained, burst 30.
- Host shared state: 32 KiB, 4/second.
- Active rooms: 100/game and 200/creator.
- Waiting room TTL: 30 minutes; started match TTL: 4 hours.
- Reconnect grace: 30 seconds; heartbeat: 10 seconds.
- Closed room metadata retention: 7 days; match/rating history is separate.
- JSON depth 16, at most 2000 values; SDK request timeout 12 seconds.
HTTP quotas are per authenticated viewer/game and per operation: create 10/hour; join, Quick Match, start, finalize, leave, and disconnect 60/10 minutes; heartbeat, snapshot and read-only availability 120/minute; shared state 4/second. The separate client-address quota is max(2 Ă— operation limit, 60) in the same window. Request JSON is capped at 48 Ki characters. Mode configuration is 20/user and 40/client per minute.
Security boundary
The uploaded iframe never receives a Supabase token, raw client, channel topic, user ID, game ID selector, Premium flag, or host authority. The trusted parent owns private Realtime channels. Every participant sends on a membership-specific topic that RLS binds to auth.uid(); receivers derive identity from that canonical topic instead of trusting payload fields. Durable mutations still cross the signed game-context server boundary.
Per-packet limits protect the supported game-iframe/parent-bridge boundary. They are not a transport-level quota against an authenticated account deliberately bypassing the platform client. Supabase caches channel authorization until rejoin or token refresh; this Beta does not promise instant revocation of an already-authorized hostile raw socket. Project-level Realtime quotas and monitoring remain necessary before a public rollout.
Stable error codes
Catch error.code; never parse messages. Codes include: MULTIPLAYER_PREMIUM_REQUIRED, AUTH_REQUIRED, INVALID_GAME_CONTEXT, CONTEXT_EXPIRED, GAME_NOT_AVAILABLE, INVALID_PROTOCOL, UNSUPPORTED_VERSION, INVALID_REQUEST, INVALID_ROOM_CODE, INVALID_MODE, ROOM_NOT_FOUND, ROOM_FULL, ROOM_CLOSED, ROOM_EXPIRED, NOT_ROOM_MEMBER, NOT_HOST, NOT_ENOUGH_PLAYERS, MATCH_ALREADY_STARTED, MATCH_NOT_FOUND, MATCH_ALREADY_FINALIZED, RANKED_MODE_REQUIRED, RESULT_INVALID, RATE_LIMITED, PAYLOAD_TOO_LARGE, INVALID_PAYLOAD, ACTIVE_ROOM_LIMIT_REACHED, CONNECTION_FAILED, REQUEST_TIMEOUT, MULTIPLAYER_UNAVAILABLE.
Complete small example
// isAvailable() is a synchronous cached hint. First call starts a read-only
// check and returns false; refresh the lobby UI while that check completes.
const status = document.querySelector("#status");
const play = document.querySelector("#play");
const remoteTargets = new Map(); // Your render loop interpolates toward these positions.
const timer = setInterval(() => {
play.disabled = !ThreeJSGames.multiplayer.isAvailable();
status.textContent = play.disabled ? "Checking / unavailable — sign in or ask the creator" : "Ready";
}, 1000);
play.onclick = async () => { try {
const room = await ThreeJSGames.multiplayer.quickMatch({
mode: "default",
maxPlayers: 2
});
const offJoin = room.on("playerJoined", player => console.log(player.username));
const offState = room.on("playerStateChanged", ({ player, state }) => {
remoteTargets.set(player.id, state); // Render with local interpolation.
});
room.on("hostChanged", ({ host }) => console.log("Host:", host?.username));
room.on("connectionStateChanged", state => console.log(state));
room.on("event", event => { status.textContent = "Live event: " + event.name; });
await room.setPlayerState({ x: 0, y: 0 }); // Send actual movement below the documented rate.
await room.send("ready", { ready: true });
if (room.isHost) {
await room.setSharedState({ round: 1, seed: 38192 });
}
// Put this cleanup in your Leave button handler, not immediately after join.
document.querySelector("#leave").onclick = async () => {
try { await room.leave(); } catch (error) { status.textContent = error.code; return; }
offJoin();
offState();
};
} catch (error) { status.textContent = error.code; } };
// On page teardown, clearInterval(timer). See the complete Orb Duel sample
// for movement interpolation, game-over/reset, and subscription cleanup.Playable sample: Orb Duel
test-games/multiplayer-orb-duel is a small public-SDK-only two-player arena: collect orbs, move with WASD/arrows, send a live wave, share host-owned score/round state, reconnect, reset and leave. Copy the official SDK unchanged into its documented vendored path. See its README for local verification and upload instructions. It is a casual example, not anti-cheat or a production room service.
Beta boundaries
Available in Beta
- Authenticated rooms for 2–8 players
- Private room codes and public Quick Match
- Presence, player state, custom events, and durable host state
- Deterministic host migration and 30-second reconnect grace
- Optional server-managed 1v1 Elo modes
Deferred
- Dedicated authoritative game servers or server physics
- Rollback netcode, voice chat, spectators, or persistent worlds
- Skill-bracket matchmaking, parties, tournaments, or teams
- Cheat-proof host results or prize-bearing competition