Multiplayer — the rest
Rooms, replicated state and turn order — what a game needs to work at all — are on Multiplayer. This is what you reach for once those are in place: agreeing on time, host authority, resilience, friends, chat, the current gaps, and how to actually test it.
Time, and randomness that agrees
The two things every multiplayer game gets wrong on its first attempt. Date.now() disagrees between real devices by seconds, and one Math.random() in a path two clients both run is a desync with no error attached to it. Nothing in the upload path checks for either — there is no lint and no warning — so a game that gets them wrong validates clean and breaks only once somebody else joins.
There is no lint, no warning and no failing check anywhere in the upload path for Math.random() or Date.now() in code two clients both run. A game that gets this wrong passes validation, plays perfectly in the one browser window you tested it in, and quietly disagrees with itself the moment a second player joins — and a desync has no error message attached to it, on any client, ever. Nobody is going to tell you. That is why the two rules below are worth more attention than their length suggests.
Room time in milliseconds, shared by everyone in the match. Use it for every deadline, every timestamp and every duration you compare across players. Date.now() is wall time and real devices disagree about it by SECONDS, so a countdown built on it ends at a different moment on each phone.
Fire at the same room time on every client — to within a display frame, never better, and always late. The clock estimate is stable to 0.15ms, so the network is not the limit: setTimeout clamping lands every fire 8.7–15.0ms late, always late, and a backgrounded tab clamps to 1 second. Design for 'everyone flips at about the same moment' and never for 'everyone flips at the same instant' — if simultaneity has to be exact, make the host decide the outcome and replicate it as state.
A deterministic random stream every client in the room computes identically. rng() gives a float in [0,1), rng.int(n) an integer in [0,n), rng.pick(arr) one element. Results depend on the scope and the event id and never on how many draws came before, so a late joiner, a reconnecting player and a client that took a different UI path all agree. NOTHING DERIVED FROM IT IS SECRET: every participant knows the room seed, so a deck shuffled this way is computable by everyone at the table. Hidden information must be host-rolled and delivered through setScoped.
The same stream as a shuffle, returning a new array and leaving yours alone. Same secrecy caveat: this is the shuffle everyone can reproduce, which is what makes it agree, and is exactly why it cannot hide a deck.
The room seed, for a game that wants to drive its own generator from it. Empty until the room is joined. It comes from the relay, never from the host — a host-chosen seed is a host that knows every shuffle before it happens, and that cheat leaves no trace at all.
A single shared sequential stream requires every client to call it in exactly the same order, and that fails on late join, on reconnect, on a conditional UI path, on host migration, on a bot that behaved differently, and at any frame-dependent call site. One client draws a card the others do not and the game is silently desynced, with no error anywhere. Use randomFor with a stable scope and event id, or have the host roll and replicate the result as state.
Authority
One client is the host and decides; everyone else reads the decision. The host can change mid-match — somebody's phone locks — and the next host resumes from state the platform kept, not from anything you saved.
Run something on exactly one machine. Every authoritative decision belongs in here — dealing, scoring, resolving a collision, advancing a turn — because the alternative is all four clients running the same branch and disagreeing about the result. Returns true if it ran.
// every authoritative decision runs on exactly one machine
TipTap.net.runOnHost(() => {
const winner = resolveRound();
TipTap.net.state.set("winner", winner); // and the host writes the result
});Whether this client is currently the host, and a callback for when that changes. The host CAN change mid-match — the previous one closed their phone — and the new host resumes from the relay's retained state. Never infer host identity from who last sent you something.
Host only, once, at the end of a relay-tier match. What you send is carried to the platform verbatim and recorded as the HOST'S CLAIM — it is never merged with what the relay witnessed, and being signed proves it was submitted, not that it is true.
TipTap.net.runOnHost(() => {
TipTap.net.reportResult({
outcome: "win",
scores: { [TipTap.net.peerAtSeat(0).peerId]: 12 }, // keyed by peerId
});
});Resilience — handled, not yours
Phones lock, tabs go to the background, Wi-Fi hands off to cellular. On a feed of games played on phones that is most sessions, and it is the SDK's problem rather than yours.
There is no connect, no retry, no socket, no ticket and no close code on this surface, and that is deliberate: reconnection, credential refresh, state resync and liveness are handled for you. The SDK pings every 2 seconds and treats 6 seconds of silence as a dead socket — a browser will otherwise hold a half-dead connection open for ten seconds while telling the page nothing — then reconnects up to 6 times with backoff and is served the room's retained state on arrival. A hand-rolled reconnect on top of this fights it and loses.
These exist so you can show a banner and pause — nothing else. cb({ reason, recoverable, message }) on the way out: recoverable true means the SDK is already working on it and you should say 'reconnecting', recoverable false means the match is over for this player and you should say so. onReconnect takes no argument and means your state has been resynced for you.
cb({ code, message, retryable, detail }) when the relay refuses something without ending the session — an oversized write, a rate cap, a stale write from a host that has just been replaced. The offending frame is dropped and play continues. Log it while you are building; a steady stream of these is a bug in your write pattern that ends in a closed connection.
About YOURSELF, never about anyone else. rttMs is the mean of the last few clock probes and jitterMs their spread. lossPct counts unanswered probes across the whole session rather than right now, so it climbs after a bad patch and does not come back down — read it as 'has this connection been bad', not as a live meter.
cb('good' | 'fair' | 'poor') for the local player. There is no band for other players today — the field is on the peer record and is always null — so a connection-quality dot next to somebody else's name is not something you can build.
The room's region, not any player's. Useful in a debug readout and for explaining a bad match to a player; it says nothing about where any individual is.
In a single-player game an idle player ruins only their own run; in a match they ruin everyone's. onAfk fires once when this player has not touched anything for the window — 60 seconds by default, and only after they have interacted at least once. Use it to warn them, substitute a bot, or forfeit gracefully. setAfkPolicy changes your own warning window only: the relay has its own idle timeout and closes the connection itself regardless of what you set here.
Friends and invites
Only in a game that declares private rooms. It is how somebody fills a room with people they already know instead of with strangers — and it is a picker, not a permission.
It arrives only in a game that declares private rooms — a game that only ever calls quickMatch has nobody to invite, so it is not handed a social graph it never asked for. And a friend here is a mutual follow, which is two clicks between strangers with no acceptance step and no age check behind it. That makes this list a row in an invite picker rather than a permission: it opens no channel, grants no capability, and changes nothing about the room somebody walks into.
Resolves with [{ ref, name, online }] — everyone who follows this player back. ref is a per-game, per-viewer reference rather than a user id, resolved by the platform at the moment an invite is sent, so it cannot be used to join two games' player bases together and a reference to somebody who has since unfollowed or blocked resolves to nothing. It resolves with an EMPTY LIST rather than rejecting when there is no platform around the frame or the player is signed out — both are ordinary states and neither is a failure a lobby should have to catch. online is true, false, or null; null is the default and today the only value the platform ever sends, it means not shared, and it must not be drawn as offline.
invite takes a ref from list() and needs a private room to invite into — a public room is filled by matchmaking and its join code is not a thing to hand out. The invite is platform-rendered and platform-delivered as a notification to that account, subject to their settings and their parental controls, so it may simply not arrive; a refusal deliberately does not say why, because a block, an unfollow and a stale reference are one answer by design. onInvite fires with { inviteId, fromName, gameId }, including for an invite that landed before the handler was registered. accept resolves with the room exactly as joinPrivateRoom does, because that is what it calls — and it grants nothing: the room's capabilities were fixed when it was created and are unchanged by who walked in.
Quick chat, pings and the chat panel
Only in a game that declares comms. This is the whole of what two players in a public room can say to each other, and it is deliberately narrow: a fixed list of phrases the platform writes, and a marker dropped on a position. Your game re-renders them and never authors one — which is exactly what lets a public room refuse free text without leaving strangers with no way to interact at all.
It arrives only in a game that declares comms, and declaring it is not the grant: quick chat and pings ride the quickchat and ping capabilities, which are withheld for parental controls, for a moderation decision or for a multiplayer ban. So the namespace is present in every game that declared it and each call returns false with a reason on the console when the room was not granted the capability — which is a far better failure than a method that exists for some players and not others. This is the other half of the no-free-strings rule rather than an exception to it: two strangers matched into a room with no sanctioned way to interact push harder on everything else, so the channel that says 'good game' in twelve fixed phrases is what makes refusing the free-text one hold.
presets() returns [{ id, category, text }] — twelve phrases the PLATFORM authors, localised into six languages and moderated once, centrally. A game re-renders them in its own style and can never write one: a preset a creator could author is a free-text field with a deploy step in front of it. quick(id) sends the INDEX, and no phrase crosses the wire in either direction — each reader's client looks the id up in the READER's language, so there is no language in which a phrase says something it does not say. onQuick fires with { presetId, text, category, fromPeerId, roomTimeMs }, and that text was looked up locally rather than received. It returns false when the room has no quickchat capability, when the id is not on the platform's table, or when this client is inside 1200ms of its last message or over 5 in 15 seconds — the relay enforces the same bounds, so this one is a courtesy rather than the fence.
five contextual world markers — a position and a type id, and nothing else. pingTypes() returns [{ id, category, text }] in the reader's language, with category holding the ping's kind. pos is normalised 0..1 in your own space; it is quantized onto a 1024-step grid on the wire and clamped into range, for the reason a drawing game sends quantised strokes — a float on the wire is an unbounded numeric with a friendly name. onPing gives back { typeId, kind, text, pos, fromPeerId, roomTimeMs } with pos back in 0..1. Same refusals as quick chat, at 800ms apart and 8 in 15 seconds.
Raises the PLATFORM's chat panel over your game, outside the frame, with the platform's own report and mute controls in it. There is deliberately nothing here a game could render peer text with: no message list, no callback, no strings. Free text inside that panel is gated on peerTextAllowed — a moderator decision, off by default — and this frame is never told which way it went, because a game that could read that flag would branch its UI on a moderation state. The panel is the platform's to draw either way: where free text is off it says so and offers the sanctioned phrases instead. Returns false only when there is no platform around the frame.
The platform's overlay always renders quick chat and pings — a bubble lasts 5 seconds — and always honours this player's mute list. A game may re-render onQuick and onPing in its own style, and that is an ADDITIONAL surface rather than a replacement: the mute list is not visible from inside the frame, by design, because who somebody has muted is a fact about other players. The consequence is stated rather than hidden — a game that re-renders a phrase draws it for a muted peer too. So never make your own rendering the only surface, and never render anything a peer supplied that did not come out of the platform's own tables.
What is not there yet
Written down rather than left to be discovered. Some of these names exist on the object and do nothing, which is friendlier than a crash and is still not a feature you can build on.
They exist on the object, warn once on the console and return false. The wire protocol carries no frame for assigning a team, setting a join policy, adding a filler player or spectating, and setGracePeriod is the relay's decision rather than yours. They are present rather than absent so a game that calls one gets a clear console message instead of a TypeError — but a lobby built on any of them does not work. Teams: keep them in your own replicated state. Filler players when a room is short: the platform provides none and no seat is ever held by anything but a real connection, so they are entirely yours — the host runs them and writes them into replicated state like any other part of the world, and your game has to label them, because platform chrome can only mark a real player and yours are not one.
Shared-state games: boards, decks, tiles, drawing, quizzes, party rounds, turn games, anything where one authoritative world is read by everybody. That is what all of the above is built for and it is a large space. What it cannot be made to do is real-time action where exact positions decide outcomes — there is no prediction, no reconciliation and no authoritative hit detection anywhere in this API, and approximating them with fast state writes produces a game that looks fine on one machine and disagrees with itself on four. If your idea needs a hitbox to be right, it is not buildable here yet.
Testing it
Two browser windows on the Playground is the smallest real test, and the platform's validator is not one — it runs a single client, so it can tell you the file loads and can tell you nothing at all about whether the match works.
Use these while building or you will ship something tested only at 0ms. simulateLatency delays and drops what this client SENDS — nothing it receives — so run it on one window of a two-window test and watch the other. overlay raises the platform's own readout of RTT, loss, state size, epoch and host identity. forceHostLoss really does end your participation in the room: it is how you exercise a handover, not a simulation of one.

