Multiplayer
Everything below is only in games flagged multiplayer.
TipTap.net is a separate module the platform injects only into games built as multiplayer games. In every other game the namespace is not there at all, and calling into it is a TypeError on a line you will not see fail — so a single-player game must not carry a TipTap.net call “just in case”. Nothing here changes the sandbox: connect-src is still 'none' and your game still cannot open a socket. The connection lives outside the frame and the platform holds it.
Only the host writes. Everyone else asks.
This is the assumption to hold from the first line. If you have built multiplayer with Playroom, Colyseus or anything peer-to-peer, it is the one to drop. One client in the room is the host. state.set and its siblings called on any other client warn on the console, return false and change nothing — no exception, no partial write, nothing queued. A non-host player's move is a request; the host decides and writes the result. Build the game that way from the beginning, because the version built on the other assumption has three players in four who simply cannot act.
Is it there? Ask before anything else
Two different questions, and a multiplayer game needs both answered before it does anything: whether this game was built as a multiplayer game, and whether a platform is around the frame right now. The second is false every time you open the file yourself, which is most of how you will build it.
Ask this before anything else. It is true when a platform is hosting your game and can matchmake for you, and false when the file was opened directly — which is exactly what happens while you are building it. That is not an error and must not be shown as one: a multiplayer game that cannot say 'multiplayer is unavailable, here is the solo mode' has no development story at all.
They mean different things and you need both. TipTap.net is only injected into games flagged multiplayer, so `typeof TipTap.net === 'object'` is the question 'was this game built as a multiplayer game' — in any other game the whole namespace is undefined and every call below is a TypeError. isAvailable() is the question 'is a platform around this frame right now'. TipTap.apiVersion does NOT move when the multiplayer modules are injected, so it can never answer either question.
validate_game_draft runs a single client against a game with no other players in it. It can tell you the file parses, loads and does not touch a forbidden API. It cannot tell you a turn advances, a state key replicates, a host handover recovers, or a lobby fills — none of those exist with one client. A passing validation on a multiplayer game means 'nothing obviously wrong in the half we can see'. Use the Playground with two windows, and expect the multi-client behaviour to be tested by a human.
Rooms and sessions
A public match against strangers, a private room behind a code, or a rejoin after the player's phone locked. Each returns a promise that rejects, and each also takes a callback if that suits your code better.
Join a public match against strangers. opts carries minPlayers and maxPlayers and is passed to the platform's matchmaker rather than acted on here, so read the minPlayers and maxPlayers on the room you get back — they are what it actually committed to, and they can be fewer than you asked for. The promise resolves when you are IN the room, not when the match starts: a lobby exists and getPeers() is real. Wait for onStart or onAllReady before dealing cards. Design the game to be worth playing at minPlayers, because nothing tops a short room up.
const room = await TipTap.net.quickMatch({ minPlayers: 2, maxPlayers: 4 });
// you're in the lobby now — NOT started. Wait for the match to begin:
TipTap.net.onStart(() => dealCards());A room for people who already know each other. The resolved room carries a code — high-entropy, rate-limited, and retired when the match starts, so it is a door rather than an address. Show it, do not store it. joinPrivateRoom takes that code back.
Keep the group, play again. Without a rematch button every lobby dissolves back into the queue between rounds, and rebuilding a four-player game from strangers takes longer than the round did. Offer it on your end screen.
On load: 'was I in a match?' A phone locking mid-game is an ordinary event on this platform, and this is what turns a reload into a rejoin. Resolves with the room if the platform still holds a seat, and with null if it does not.
Leave the room. This FORFEITS an in-progress match and is recorded differently from a dropped connection, so wire it to a deliberate 'quit' the player chose — never to a pause, a blur, or your own idle timer.
roomKind is what decides which messaging you get: a public room carries typed schemas only. code is null unless it is a private room you created. There is no player list on this object — that is getPeers(), which is only real once you have joined.
The room, the lobby, the players in it
cb({ status, roomId, code, players, countdownMs, drainingDeadlineMs, epoch }) whenever anything about the room changes. status moves through 'idle', the relay's lobby states, 'live' and 'ended'. Draw your lobby from this rather than from your own bookkeeping — it is the one view that survives a reconnect.
Everyone in the room. seat is which slot this peer holds — the same number a simulation view calls peerIndex — so a sim game can join what it sees to who it is; net.peerAtSeat() is the other direction. displayName is filtered, and in a public room it is a platform-assigned alias, stable per game and per player: it is not the player's real name and must not be labelled as one. Three of these fields are ALWAYS the same value today and building on any of them gets you a branch that never runs: avatarDataUrl and quality are always null — the roster carries no image bytes and no per-peer band — and isBot is always false, because only an authenticated connection can hold a seat and nothing on the platform puts anything else in one. There is deliberately no per-peer rttMs: round-trip time to a known relay is a coarse geolocation of a stranger.
Who is sitting in a seat. THIS IS HOW A SIMULATION GAME REPORTS A SCORE: sim.getView() identifies players by seat (others[].peerIndex, and your own from sim.getIdentity().seat) while reportResult({ scores }) is keyed by peerId, so without this there is no way to say which player earned which score — and no way to name your opponent, who otherwise renders as "P2". The peer you get back is the same object getPeers() returns, which means the display name is the filtered one (a per-game alias in a public room) and isBot is on it. Null when the seat is empty, which happens: a seat vacated mid-match is reused by the next player to join.
Which of those peers is you. Null until you have joined a room, and opaque and per-match by design — it is not an account id and nothing about the player can be recovered from it.
cb(peer) and cb(peer, reason). A seat that empties is reused by the next arrival, so drop everything you were holding about a peer when it leaves rather than keeping it keyed by their id — their per-player state has already been discarded for you.
Lobby ready-up. This is NOT TipTap.ready(), which is the loading-screen call in the base SDK and has nothing to do with matches — the two live in different namespaces and are easy to confuse when both appear in one file.
Fires when every player in the room has readied up. This is your 'start the match' moment in a lobby you control.
onStart gives you { seed, epoch, roomTimeMs } when the relay starts the room — the seed is the input to every randomFor stream, so build the deck here and not before. onEnd gives you { reason, outcome }. Both are the relay's word, not the host's, which is why the match cannot start twice or end at different moments for different players.
Start or join a vote to remove a player. The relay adjudicates it, not the host — a host-adjudicated kick is a host who can remove whoever it likes — and it is bounded by a cooldown, a per-match cap, and a rule against kicking someone who has reported you. Every vote is recorded in the match's evidence. A public game needs this: one griefer otherwise ends the match for everybody.
Replicated state — the primitive most games actually want
Most multiplayer games are shared state rather than message pipes: a board, a deck, a canvas, a puzzle, the current question. Reach for this before you reach for messaging — and remember the rule above: only the host writes.
Read this before the methods. One client in the room is the host, and every write below is host-only: called anywhere else it warns on the console, returns false and changes nothing — no throw, no partial write, nothing thrown away later. If you have used Playroom, Colyseus or anything peer-to-peer, this is the part that is different, and it shapes the whole game rather than one call site. A non-host player never changes the world; it asks the host to, the host decides, and the result comes back to everyone as replicated state.
The core primitive of the platform. The host writes a value and everyone gets it — a board, a deck, a score table, a current question. HOST ONLY: a non-host call warns and returns false, so put it inside runOnHost() or send the host a request instead. The value is a direct authoritative assignment, not a command; the relay numbers the write so a deposed host cannot re-issue a version the new one is about to use.
// host writes; everyone receives it. Guard so non-hosts don't no-op.
TipTap.net.runOnHost(() => {
TipTap.net.state.set("board", board);
});Read the replicated value. undefined means no one has written it yet — which is the normal state of every key for the first frames of a match, so never assume a shape you have not received.
const board = TipTap.net.state.get("board") ?? emptyBoard();
// undefined for the first frames — always have a fallbackcb(key, value, prev) on every applied write, including your own and including the snapshot you are served on joining or reconnecting. Render from this. A game that draws only when it thinks something changed is a game that shows a stale board to the player who reconnected.
TipTap.net.state.onChange((key, value) => {
if (key === "board") render(value); // also fires with the join snapshot
});Per-player state: a score, a colour, a chosen character, a ready flag of your own. Writes are host-only like every other authoritative write. onPlayerChange gives you cb(peerId, key, value, prev), and peerId can be null if the write lands for a peer who has already gone. When a player leaves, everything stored against them is discarded.
A value only part of the room may see — scope is 'all', a peerId, or 'team:<id>'. This is what a card game's hand, a social-deduction role or a hidden bid needs: plain state.set replicates to everyone, so a hand written with it is visible to every opponent. A scoped value is never serialised into a stream it is not addressed to. The caveat a card game must be designed around: it is hidden from other PLAYERS, not from the HOST, who computes it. A competitive hidden-information game cannot be made fair on this architecture.
An auto-smoothed read for anything that moves. Numbers and same-length arrays of numbers are interpolated between the last two authoritative values; anything else comes back as the newest value, unsmoothed, because guessing at a shape is worse than not smoothing. Write positions at whatever rate suits the game and read them through this in your draw loop.
What a board or tile game needs instead of a physics simulation: a player asks to make a move, the host decides, and the host writes the result as state. validateCommand takes one handler — a second call replaces the first — and command returns a promise that rejects with 'no_host', 'unavailable' or 'timeout'. IMPORTANT: this rides the same opaque channel as send(), so it needs the opaque capability and does NOT work in a public room today. In a public room, carry the move as a typed message instead.
At most 256 room keys and 64 keys per player, at most 20 writes per second per key and 200 per second across the room. Sizes are in BYTES of serialised JSON, so a board full of non-Latin text is several times bigger than it looks. A refused write returns false, warns on the console and changes nothing — but the relay is counting too, and a host that keeps producing refused writes has its connection closed, which ends the match for everyone. Design inside the caps rather than discovering them.
Turn order
A second conditional module on top of the first — it arrives only in a game that declares itself turn-based, so in another multiplayer game TipTap.net exists and TipTap.net.turn does not.
It is injected only into a game that declares itself turn-based, so in a multiplayer game that did not declare it, TipTap.net exists and TipTap.net.turn is undefined. Check for it the same way you check for TipTap.net.
Whose turn it is, and whether it is yours. current() is null until an order has been set. isMine() is the call most turn games need — gate your input on it, on every client, rather than trusting the UI to be the only path in.
Set the order. Called with nothing, it uses the current peer list. Host-only in practice, because it writes replicated state — call it from inside runOnHost() when the match starts.
Advance. Host-only, like every authoritative write — a non-host that calls it changes nothing rather than getting a local turn order that disagrees with everyone else's.
cb(peerId, isMine) whenever the turn moves, including when it moves because a timer expired or because the host changed. This is where your 'your turn' banner and your input enable/disable belong.
SET THIS. The turn auto-advances when the time runs out, and without it every turn-based game deadlocks permanently the first time somebody puts their phone down — three players waiting forever is a far worse outcome than one skipped turn. The advance runs on the host against room time, so every client's countdown agrees. Set it on the host, alongside setOrder.
Milliseconds left in the current turn, for a countdown ring. 0 when there is no timer. It is computed from room time, so it reads the same on every device rather than from whenever each one happened to load.
Messaging
The way a non-host asks for anything, and the only messaging a public room carries. What you may send is fixed by the schema set APPROVED for your game — not by the set you declared, and not by the platform's — and a name outside it returns false, silently, apart from a console warning. A game with no approved set has no typed capability at all and every call returns false, which is the state every game starts in; see the schema section below, because this is the single most common reason a multiplayer game "does not work for other players". Check the return value and tell the player rather than letting a move disappear. One thing to know before you design around it: a game's own set REPLACES the platform set rather than adding to it, so a game that wants tiptap.draw.stroke as well as its own schemas declares it alongside them. opts.to is a peerId, 'host', 'all' (the default) or 'team:<id>'. The payload is encoded and range-checked before it goes out, so an out-of-range field THROWS at your call site rather than being refused four hops away — clamp your values, do not rely on the throw. It also returns false for an unknown recipient or more than 20 messages per second. Typed messages are events, not a frame loop.
// a non-host player asks the host to make a move
const sent = TipTap.net.sendTyped("mygame.move", { cell: 4 }, { to: "host" });
if (!sent) tellPlayer("Move couldn't be sent"); // false until your schema is approvedcb(schemaName, payload, fromPeerId, gap) for every typed message. fromPeerId is null when the frame came from the relay itself rather than from a player. gap is true when at least one message of that kind was dropped for you on the way — treat it as 'you have missed something' rather than ignoring it.
// on the host: receive a request, validate it, write the result
TipTap.net.onTyped((schema, payload, from) => {
if (schema === "mygame.move") applyMove(from, payload.cell);
});Arbitrary JSON to a peer. It requires the opaque capability, which is approved per game on its own merits and is NEVER present in a public room, so most games must never reach for this — an opaque payload is indistinguishable on the wire from an encoded chat protocol, which is the whole reason typed schemas exist. Where it is granted, every frame is retained as evidence as a condition of the grant. At most 20 per second and 1024 bytes per message after encoding.
cb(data, fromPeerId) for opaque messages. Same capability, same caveat — and whatever arrives here came from another player's machine, so treat it as untrusted input and never render a string out of it.
Ask one peer something and get an answer back, with the correlation and the timeout handled for you. It rides send(), so it carries the same opaque capability requirement. The promise rejects with 'unavailable' or 'timeout'; the default timeout is 5000ms.
cb(data, fromPeerId, reply) — return a value, return a promise of one, or call reply() later. There is ONE handler: a second call to onRequest replaces the first rather than adding to it, which is the opposite of every other callback on this surface.
Typed schemas may hold enumerations, booleans, bounded numbers, fixed-length vectors and platform-issued ids — no strings, no byte arrays, no unbounded collections, with a computed maximum of 512 bytes and at most 64 elements in a bounded array. This is not a size optimisation: one free string field between strangers is an unmoderated chat channel that will pass review looking like a player label. A drawing game sends bounded, quantised strokes and rasterises them locally — the pixels never cross the wire.

