TipTap Games

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.netcall “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.

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.

TipTap.net.isAvailable() → boolean

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.

Two different checks: TipTap.net exists, and TipTap.net.isAvailable()

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.

A multiplayer game cannot be tested by one client

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.

TipTap.net.quickMatch(opts) → Promise<room>

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.

TipTap.net.createPrivateRoom(opts) / TipTap.net.joinPrivateRoom(code)

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.

TipTap.net.rematch()

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.

TipTap.net.recoverSession()

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.

TipTap.net.leave()

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.

The resolved room: { roomId, region, tier, roomKind, code, minPlayers, maxPlayers }

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

TipTap.net.onRoomState(cb)

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.

TipTap.net.getPeers() → [{ peerId, seat, displayName, team, isLocal, isHost, isBot, ready, avatarDataUrl, quality }]

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.

TipTap.net.peerAtSeat(seat) → peer | null

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.

TipTap.net.getLocalPeerId() → string | null

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.

TipTap.net.onPeerJoin(cb) / TipTap.net.onPeerLeave(cb)

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.

TipTap.net.ready() / TipTap.net.unready()

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.

TipTap.net.onAllReady(cb)

Fires when every player in the room has readied up. This is your 'start the match' moment in a lobby you control.

TipTap.net.onStart(cb) / TipTap.net.onEnd(cb)

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.

TipTap.net.voteKick(peerId)

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.

Only the host writes. Everyone else asks.

If you have built multiplayer with Playroom, Colyseus or anything peer-to-peer, this is the assumption to drop first. One client in the room is the host. state.set and its siblings called on any other client warn on the console, return falseand change nothing — there is no exception, no partial write and nothing queued for later. A non-host player's move is a request; the host decides and writes the result. Structure the game that way from the beginning, because the version built on the other assumption has three players in four who simply cannot act.

Only the host writes. Everyone else asks.

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.

TipTap.net.state.set(key, value) → boolean

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.

TipTap.net.state.get(key) → value | undefined

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.

TipTap.net.state.onChange(cb)

cb(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.setPlayer(peerId, key, value) / TipTap.net.state.getPlayer(peerId, key) / TipTap.net.state.onPlayerChange(cb)

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.

TipTap.net.state.setScoped(scope, key, value)

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.

TipTap.net.state.interpolated(key)

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.

TipTap.net.state.validateCommand(fn) on the host, TipTap.net.state.command(data) on everyone

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.

Keys match ^[A-Za-z0-9_.:-]{1,64}$; 8KB per value, 256KB per room

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.

TipTap.net.turn is a separate module again

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.

TipTap.net.turn.current() / TipTap.net.turn.isMine()

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.

TipTap.net.turn.setOrder(peerIds?)

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.

TipTap.net.turn.next()

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.

TipTap.net.turn.onChange(cb)

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.

TipTap.net.turn.setTimer(ms)

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.

TipTap.net.turn.remainingMs()

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

TipTap.net.sendTyped(schemaIdOrName, payload, opts?)

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.

TipTap.net.onTyped(cb)

cb(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.

TipTap.net.send(data, opts?)

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.

TipTap.net.onMessage(cb)

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.

TipTap.net.request(peerId, data, timeoutMs?) → Promise<reply>

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.

TipTap.net.onRequest(cb)

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.

A public room carries no free strings at all

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.

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.

The validator warns on the next two — a warning, not a gate

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.

TipTap.net.now() → number

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.

TipTap.net.scheduleAt(roomTimeMs, cb) → { cancel() }

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.

TipTap.net.randomFor(scope, id) → rng

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.

TipTap.net.shuffleFor(scope, id, arr) → new array

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.

TipTap.net.getSeed() → string

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.

There is deliberately no net.random()

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.

TipTap.net.runOnHost(fn) → boolean

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.

TipTap.net.isHost() / TipTap.net.onHostChange(cb)

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.

TipTap.net.reportResult({ outcome, scores })

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.

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.

Never write reconnection logic

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.

TipTap.net.onDisconnect(cb) / TipTap.net.onReconnect(cb)

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.

TipTap.net.onError(cb)

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.

TipTap.net.getLatency() → { rttMs, jitterMs, lossPct }

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.

TipTap.net.onQualityChange(cb)

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.

TipTap.net.getRegion() → string | null

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.

TipTap.net.onAfk(cb) / TipTap.net.setAfkPolicy({ timeoutMs })

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.

TipTap.net.friends is a third conditional module, and being on the list grants nothing

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.

TipTap.net.friends.list()

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.

TipTap.net.friends.invite(ref) / TipTap.net.friends.onInvite(cb) / TipTap.net.friends.accept(inviteId)

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.

TipTap.net.chat is the only communication a public room has — and it is why a public room needs no other

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.

TipTap.net.chat.presets() / TipTap.net.chat.quick(presetId) / TipTap.net.chat.onQuick(cb)

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.

TipTap.net.chat.pingTypes() / TipTap.net.chat.ping(pos, type) / TipTap.net.chat.onPing(cb)

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.

TipTap.net.chat.show()

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.

Mute is applied where peer content is DRAWN, which is why platform chrome draws quick chat too

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.

TipTap.net.setTeams, TipTap.net.autoBalance, TipTap.net.setJoinPolicy, TipTap.net.setGracePeriod, TipTap.net.addBot and TipTap.net.spectate DO NOTHING today

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.

What this surface is for, and what it cannot be made to do

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.

TipTap.net.debug.simulateLatency({ ms, jitter, lossPct }) / TipTap.net.debug.overlay(on) / TipTap.net.debug.forceHostLoss()

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.

Real-time simulation

There are two ways to get a simulation on this platform, and today only one of them is reachable. The choice is confined to this section: everything above it is TipTap.net, and rooms, replicated state, turn order, messaging and chat behave identically either way.

tierwhat you writewhere it stands
ModulesA fixed menu of four platform-authored simulations. You pick one and configure it; the physics, the tick and the netcode are ours.What you use today. Declarable in Creator Studio, refereed by the relay, and the tier every simulation game here runs on.
Creator logicA simulation you write. One TypeScript file, compiled server-side to WebAssembly and checked for determinism rather than trusted to be deterministic.In development. It compiles, gates and runs, and it is not reachable from the Creator Studio — no route loads it, and it has not yet run in a browser.

Which one should you build on right now? Modules. Not because they are the better design — the whole point of the other tier is that a fixed menu of four genres is a ceiling — but because you cannot publish a game on creator logic today, and you can publish one on a module. If what you want is on the menu below, take it. If it is not, the honest answer is that this platform cannot host your game yet, and the tier that will is not ready to be waited on with a deadline.

Nothing below is deprecated. The four modules are shipped, supported, and pinned bit-for-bit by a determinism test because the games running on them have to keep producing the same match tomorrow that they produced today. There is no removal date and no migration being asked of you. Creator logic → is worth reading now if you are choosing what to build next; it is not worth blocking on.

Four modules ship, and a game declares exactly one. arena2d — top-down 60 Hz, circular bodies, walls, projectiles, resources and respawns, for arenas and shooters. turnphysics — one actor at a time simulated to rest, for pool, golf and artillery; no prediction is involved at all, which makes it the most forgiving of a bad connection. platformer2d — tilemaps, gravity, one-ways, coyote time, wall jump, and kinematic moving platforms a rider inherits correctly inside prediction. racer — vehicle bodies, tire model, spline tracks, checkpoints and laps, with correction smoothing tuned so a contested overtake does not spin the car, and the only module that will drive its own empty seats.

Turn it on in Creator Studio under Advanced → Multiplayer SDK, or with PUT /api/games/[id]/multiplayer, or multiplayer_modules on update_game_metadata. Declaring an unknown or unbuilt name is refused by name and tells you what you may declare instead.

There is no 3D. arena3d is named in the spec as the flagship first-person module and it is not built; declaring it is refused. Neither physics (general rigid bodies — stacking, joints) nor fighter exists either. If you were asked for a 3D shooter, you cannot build one here — say so rather than shipping a 2D game that pretends. Creator logic is where that eventually changes, because a genre stops being a platform release once you write the simulation yourself; that is a reason to expect the ceiling to move, and not a date.

Two more limits worth knowing before you design around them. Play again currently reloads the game document — a simulation session cannot span two matches. And sim.query resolves on the host and travels, so it is authoritative but it is not free; poll it at a rate you have measured, and pass { timeoutMs } rather than relying on the five-second default.

A room’s seats are its ceiling, not its attendance. Every module lays out one body per seat, so two people matched into an eight-seat room get eight bodies and nobody is driving six of them. In a racing game that is a row of parked cars on the grid, and it is the first thing a player hits.

racer can fill them: config.botSeats is a bitmask of the seats the module drives with its own autopilot, and config.botStartTick holds them until your countdown is over. Build the mask from net.peerAtSeat(i) === null immediately before sim.define, because it has to be part of the config — every client simulates every car, so a bot’s input must be something all of them derive rather than something one of them sends. The other three modules have no autopilot, so there this is a design decision you have to make deliberately rather than one to discover on the grid.

A dev session never gets a simulation. A room opened against a dev-session grant is served at relay tier deliberately, whatever the game declares — so TipTap.sim is injected, every call queues, and each one settles as simulation unavailable about fifteen seconds later. The same is true of a file you open yourself. That is the expected result rather than a broken setup, and it is worth knowing before you spend an evening on the wrong theory.

What follows is the shape the tier has. Nothing in the game document simulates anything — the simulation is WebAssembly in a Worker the platform owns, and the game supplies geometry once and intent every frame. Design it gently or the netcode gets blamed for the design: slow time-to-kill, generous hitboxes, a small map, short rounds.

TipTap.sim — a physics engine that is already multiplayer. Declare one module.

The other tier. TipTap.net gives you a pipe and you write the game; TipTap.sim gives you a world that already knows how to be multiplayer, and the platform owns every hard networking problem underneath it. You never compute a position, a collision or a hit; never assign a sequence number; never write reconciliation; never see a tick, a snapshot or a byte. Turn it on by declaring a module on your game — Creator Studio → Advanced → Multiplayer SDK, or PUT /api/games/[id]/multiplayer, or multiplayer_modules on update_game_metadata. A game declares exactly ONE simulation module (§7.1), and the four are: arena2d — top-down 60Hz, circular bodies, walls, projectiles, resources and respawns, for arenas and shooters. turnphysics — one actor at a time simulated to rest, for pool, golf and artillery; no prediction is involved at all, which makes it the most forgiving of a bad connection. platformer2d — tilemaps, gravity, one-ways, coyote time, wall jump, and kinematic moving platforms a rider inherits correctly inside prediction. racer — vehicle bodies, tire model, spline tracks, checkpoints and laps, with correction smoothing tuned so a contested overtake does not spin the car; it also drives empty seats itself, through config.botSeats, so a half-full room is a race rather than a row of parked cars. Where it runs: in every player's browser, as WebAssembly in a Worker the platform creates. One player is the host and the host's answer is the truth; the relay only forwards bytes and never simulates. Two things worth knowing before you build. Play-again currently needs the frame to remount, so a rematch reloads the game document. And there is no 3D module, no general rigid-body physics and no fighting-game module — arena3d, physics and fighter are named in the spec and not built, and declaring one is refused by name.

await TipTap.sim.define(moduleId, config) → { seat, players, isHost, simVersion } · await TipTap.sim.start()

define() fixes the arena for the match: bounds, bodyRadius, accel, friction, maxSpeed, the projectile constants, the resource schema, and the boxes and circles that are its cover. Once, and then immutable. It is validated by the same Rust that validates it on the relay, so a config refused here is refused everywhere and the rejection names the field — a silent fallback to a default would put a room on geometry nobody chose. It does NOT take the seat, the room size, the seed or who hosts: those come from the relay through the platform, and a define() that accepted them would be the game frame declaring its own authority. It resolves with them instead. start() begins the 60Hz loop.

TipTap.sim.getIdentity() → { seat, players, isHost } · TipTap.sim.getVersion()

seat is this client's index, and it is the index every peerIndex in a view, an event and a query result is in. players is the room's size, fixed for the match. isHost is the relay's designation and the only honest source of it. Before define() resolves the seat is null, which is a real state: a game's script runs before the room exists. getVersion() returns the module id, its simVersion, the ABI and what the Worker reported — a diagnostic, not something to branch on.

TipTap.sim.input({ moveX, moveY, facing, buttons })

Intent, every frame. moveX and moveY are -1..1 and a diagonal is normalised for you, so there is no diagonal speed bug to write. facing is radians. buttons is a bitmask; TipTap.sim.BUTTON.FIRE is the one arena2d reads. Calling it does not send a tick — it RECORDS what this player wants, and the SDK turns that into exactly 60 inputs per second whatever the display is doing. That matters: one input is one predicted tick, so a game that sent one per frame would predict at twice the host's rate on a 120Hz phone and be corrected on every snapshot. Calling it twice in a frame is harmless; not calling it means the same as last frame, which is what a held key means anyway.

TipTap.sim.getView() → { self, others, projectiles, match, local }

Already predicted and already interpolated: draw this and nothing else. self is your own body from the predictor. An ordinary correction is ramped over 100ms so it reads as a drift — but a difference larger than the module says you could have travelled in that window is SNAPPED instead, deliberately, because sliding a respawn or a recovery across the map is worse than teleporting: it draws your character somewhere it never was, at a speed it cannot move. others are interpolated from the host's snapshots and are deliberately a fraction of a second behind, because a client that extrapolated a stranger would render them somewhere they never were. Each body is { peerIndex, pos, vel, facing, alive, cooldown, resources }. Three numbers are worth a debug key: local.pendingInputs, local.corrections, and local.snaps — how many of those corrections were snaps rather than ramps, which is the one that tells you whether the link is merely lossy or something is relocating you. Every field is safe to read before the first frame — the shape is the same shape when it is empty.

await TipTap.sim.query(q, { timeoutMs }) · TipTap.sim.queryLocal(q)

Four kinds: { kind:'hitscan', origin, direction, maxDistance, ignore }, { kind:'overlap', center, radius }, { kind:'nearest', from, count, ignore } and { kind:'lineOfSight', from, to }. It is AUTHORITATIVE: on the host it resolves there because the host is the authority, and on every other client the question travels to the host and the answer comes back resolved against state rewound for that asker's own measured latency. The signature does not change with who is hosting, which is the point of it being async everywhere. The second argument is optional and takes one field, timeoutMs — YOUR deadline for this call. The SDK's own timeout is a failure path, generous on purpose, and it is the wrong order of magnitude for a query you poll on the frame loop: one dropped answer with no deadline of your own stalls whatever you gated on it for seconds. Say what late means for this call — 500 for a poll a few times a second — and the Promise rejects on your schedule, with a message that says the deadline was yours, and the SDK stops holding the abandoned question. Without it nothing changes. queryLocal() is synchronous, resolves against your own predicted state, is never newer than getView(), and is COSMETIC: use it for a crosshair, never for a hit.

TipTap.sim.onEvent(cb) · TipTap.sim.onMatchState(cb)

The simulation reporting what it derived: { type:'hit', source, target, resource, amount }, { type:'death', peer, killer }, { type:'respawn', peer }, { type:'collide', peer } and { type:'expired', owner }, each with a tick. Every event carries a `predicted` flag and it decides what you may do with it. A predicted event is your own client's guess, delivered immediately so a hitmarker does not wait for a round trip — draw it. An authoritative event came from the host on frame 0x05 — draw it AND count it. Scoring off predicted events gives two players two different scores for the same match, and neither is the one the host has. onMatchState is the other half: it hands you the RECORD { phase, timeRemainingMs, epoch } — the same three fields, under the same names, as getView().match — on every transition the relay makes. Register it wherever you like. If the room already has a phase when you register, you are handed it immediately rather than waiting for the next transition, so a player who joined a match in progress is told 'live' instead of sitting in 'warmup' for ever.

TipTap.sim.applyResource() — rejects

Not implemented for arena2d, and it rejects saying so rather than failing as a malformed query. Resources are changed by the simulation's own rules — a projectile that connects — and there is no authoritative mutation over this port. Host migration is also not implemented: if the relay re-designates the host mid-match the simulation stops rather than continuing with a role that has become a lie.