Full SDK reference
Every member of window.TipTap and TipTap.net, on one page, generated from the same source as the pages that explain them.
Index
- TipTap.apiVersion
- TipTap.canPlayAudio
- TipTap.event
- TipTap.getChallenge
- TipTap.getDailySeed
- TipTap.getInputClass
- TipTap.getPlayer
- TipTap.hideLeaderboard
- TipTap.isArcade
- TipTap.isFocused
- TipTap.isMuted
- TipTap.isPaused
- TipTap.isReducedMotion
- TipTap.loadState
- TipTap.net.addBot
- TipTap.net.autoBalance
- TipTap.net.chat.onPing
- TipTap.net.chat.onQuick
- TipTap.net.chat.ping
- TipTap.net.chat.pingTypes
- TipTap.net.chat.presets
- TipTap.net.chat.quick
- TipTap.net.chat.show
- TipTap.net.createPrivateRoom
- TipTap.net.debug.forceHostLoss
- TipTap.net.debug.overlay
- TipTap.net.debug.simulateLatency
- TipTap.net.friends.accept
- TipTap.net.friends.invite
- TipTap.net.friends.list
- TipTap.net.friends.onInvite
- TipTap.net.getLatency
- TipTap.net.getLocalPeerId
- TipTap.net.getPeers
- TipTap.net.getRegion
- TipTap.net.getSeed
- TipTap.net.isAvailable
- TipTap.net.isHost
- TipTap.net.joinPrivateRoom
- TipTap.net.leave
- TipTap.net.now
- TipTap.net.onAfk
- TipTap.net.onAllReady
- TipTap.net.onDisconnect
- TipTap.net.onEnd
- TipTap.net.onError
- TipTap.net.onHostChange
- TipTap.net.onMessage
- TipTap.net.onPeerJoin
- TipTap.net.onPeerLeave
- TipTap.net.onQualityChange
- TipTap.net.onReconnect
- TipTap.net.onRequest
- TipTap.net.onRoomState
- TipTap.net.onStart
- TipTap.net.onTyped
- TipTap.net.peerAtSeat
- TipTap.net.quickMatch
- TipTap.net.randomFor
- TipTap.net.ready
- TipTap.net.recoverSession
- TipTap.net.rematch
- TipTap.net.reportResult
- TipTap.net.request
- TipTap.net.runOnHost
- TipTap.net.scheduleAt
- TipTap.net.send
- TipTap.net.sendTyped
- TipTap.net.setAfkPolicy
- TipTap.net.setGracePeriod
- TipTap.net.setJoinPolicy
- TipTap.net.setTeams
- TipTap.net.shuffleFor
- TipTap.net.sim.applyResource
- TipTap.net.sim.BUTTON
- TipTap.net.sim.define
- TipTap.net.sim.getIdentity
- TipTap.net.sim.getVersion
- TipTap.net.sim.getView
- TipTap.net.sim.input
- TipTap.net.sim.onEvent
- TipTap.net.sim.onMatchState
- TipTap.net.sim.query
- TipTap.net.sim.queryLocal
- TipTap.net.sim.start
- TipTap.net.spectate
- TipTap.net.state.command
- TipTap.net.state.get
- TipTap.net.state.getPlayer
- TipTap.net.state.interpolated
- TipTap.net.state.onChange
- TipTap.net.state.onPlayerChange
- TipTap.net.state.set
- TipTap.net.state.setPlayer
- TipTap.net.state.setScoped
- TipTap.net.state.validateCommand
- TipTap.net.turn.current
- TipTap.net.turn.isMine
- TipTap.net.turn.next
- TipTap.net.turn.onChange
- TipTap.net.turn.remainingMs
- TipTap.net.turn.setOrder
- TipTap.net.turn.setTimer
- TipTap.net.unready
- TipTap.net.voteKick
- TipTap.onAchievement
- TipTap.onArcadeChange
- TipTap.onChallenge
- TipTap.onDailySeed
- TipTap.onFocusChange
- TipTap.onLeaderboardClose
- TipTap.onMuteChange
- TipTap.onPauseChange
- TipTap.onResult
- TipTap.onStateError
- TipTap.ready
- TipTap.saveState
- TipTap.setAchievementProgress
- TipTap.setArcade
- TipTap.setLoadProgress
- TipTap.share
- TipTap.showLeaderboard
- TipTap.submitScore
- TipTap.takeKeyboardFocus
- TipTap.unlockAchievement
- TipTap.updateScore
- TipTap.version
- TipTap.vibrate
Scores, challenges and daily seeds
Write the player's score to the leaderboard. This is SILENT — it shows no UI and does not interrupt your game. Triggers onResult with rank and percentile. Pass { showLeaderboard: true } to raise the leaderboard in the same call. Returns 400 if the score exceeds the game's scoreMax.
Call during gameplay to show a live score in the platform top bar. No database write — fire and forget.
Register a callback to receive the result after submitScore is processed. The callback receives the payload described below.
Raise the results and leaderboard overlay. Your game is paused underneath and is not unloaded. Wire this to your own 'Leaderboard' button, or call it right after submitScore for a classic arcade end screen.
Dismiss the overlay and resume. Equivalent to the player pressing Continue.
Fires when the player dismisses the overlay with Continue, X or Escape. Your game state is untouched — unpause and carry on. Also fires as the window event 'tiptap:leaderboardclose'.
The score this player was sent here to beat, non-null only when they followed a shared score link. handle is the sharer's public handle and may be null; score is always a number. The platform signs these, so a score that reaches you is one somebody really got.
It is not there on the first line your script runs. Read it when you build the run, or subscribe with onChallenge, which fires immediately if it is already known.
A game that ignores this still shows the player what to beat, so implement it when you can do something better than a chip: a ghost line on the track, a marker on the score bar, a 'you need 400 more' at the end.
One string per game per UTC day, identical for every player that day and different tomorrow. date is that day as YYYY-MM-DD. Every game gets one automatically — there is nothing to switch on and nothing to configure. Like getChallenge() it lands with the handshake, so TipTap.onDailySeed(cb) covers the case where you need it before your first frame.
Do not use Math.random() for anything the daily needs to reproduce, or two players comparing scores are comparing different games. A small deterministic generator seeded from the string is all it takes.
The schedule on your game's Details tab starts empty and the automatic seed runs forever without it. Adding an entry swaps in your own string for that one date and leaves every other day on the automatic seed, so you can hand-build Friday's puzzle and ignore the rest of the week.
Once a date has arrived, people have played it — so an authored seed for today or any earlier date can no longer be edited or removed. Schedule the seed you mean, and only dates still ahead stay changeable.
Because a date can be hand-authored, the seed may be a hash today and a puzzle definition on Friday. Never parse it and never assume its length.
Achievements
Unlock one of the achievement keys you declared for your game. Safe to call every frame: only a player's FIRST unlock counts, so repeat calls are ignored and the player sees exactly one notification. Unknown keys are ignored.
Shows '3/5' on the locked achievement in the results overlay, turning a padlock into a reason for another run. Describes the current run and is not stored. It does not unlock anything — keep calling unlockAchievement(key) for that.
Fires only on a genuine first unlock, with { key, name, description, icon }. Use it for your own in-game celebration on top of the platform's toast.
Game lifecycle
Call with a value from 0 to 1 while your game is preparing assets. This opts your game into the platform loading screen: the player sees your title and a progress bar instead of a blank panel, and your game stays paused until it's ready.
Loading is finished — the platform drops the loading screen and starts the game. Required if you called setLoadProgress. Games that are instantly playable should call neither; the platform starts them as soon as the document loads.
True only when your game is on screen AND the player has not muted all games. This is the single check you want before starting music or a sound effect.
True when your game is the one the player is actually looking at. False when it has scrolled out of view, when the results overlay is up, or when the browser tab is in the background. This is visibility, for audio — it is NOT DOM focus and says nothing about whether keydown will fire. That one is takeKeyboardFocus(), and the platform handles it for you.
True when the player has muted all games with the platform's global sound toggle. Respect it — your game's own mute button should stay in sync with this.
True while the platform has your game paused.
Callback forms of the window events below, if you prefer callbacks to listeners. Each receives the new boolean value.
Opens the platform's share sheet. Every field is optional. There is no callback and no result — you are never told whether the player went through with it, so never gate a reward on it.
A data: URL of type png, jpeg or webp, at most 2 MB decoded. Anything else is dropped with a console warning and the sheet still opens with the platform's generated score card, so a bad snapshot degrades rather than failing. Build it on a separate offscreen canvas around 1080px on the long edge — your live canvas is sized for a phone and rarely reads well shared.
Request that the platform enter or leave arcade view, where your game fills the whole viewport with no feed chrome. Players can also toggle it themselves from the action rail.
Read or subscribe to arcade state — useful if your layout should change when it has the full screen. Also fires as 'tiptap:arcadechange'.
The player and their device
The current player's public identity. Also returns a Promise where one exists, so await works. The reply is cached for the life of the document, so calling it again is free.
The callback always receives an object, so the player itself never needs a null check — but handle, displayName and avatarDataUrl each do. A signed-out player has no handle and no avatar, and a signed-in one may still have no avatar. avatarDataUrl, when present, is a 96×96 data: URL.
The player's language tag (e.g. 'en-GB') and IANA timezone (e.g. 'Europe/London'), each also nullable. Use them for formatting numbers and dates, or for a sky that matches their local hour — not for anything they would notice being wrong. An engine without Intl answers null rather than guessing.
Persists this game's save data for this player. Calls are debounced and coalesced, so call it whenever your state changes — every pickup is fine — and one write goes out shortly after with the latest value. It is flushed automatically when the player leaves the panel.
Reads it back. Also returns a Promise where one exists, so await works, and the reply is cached for the life of the document. Always handle null — most players are new — and always handle a save written by an older version of your game, which happens the first time you ship an update.
Nothing is stored and nothing is truncated: half a save is worse than none. You get a console warning and an onStateError callback with reason 'too_large', 'not_json' or 'rejected'. Save progress, not scenery — a level number, a currency, a list of unlock ids.
Fires when a save could not be stored, so your game can degrade honestly — tell the player their progress is not being kept, stop offering to continue — rather than promising a continue that will not be there.
A number of milliseconds, or an array alternating buzz and pause. Total buzz time is capped at 1000ms and roughly one call every 300ms gets through. A no-op on desktop, on iOS Safari, wherever the browser declines, and for players who asked for reduced motion — there is no way to detect success and you should not try. A 20ms tick on a hit lands beautifully; a buzz every frame is why people turn their phone face down.
The player's system setting. Honour it. Screen shake, parallax, particle bursts, full-screen flashes and long easing are what this is about — for a lot of people they are not a preference but the difference between playing and feeling ill. Keep the feedback and change its form: a flash of colour instead of a shake. Haptics are suppressed for you automatically.
A hint for layout — bigger targets and on-screen controls for touch, cursor affordances and keyboard hints for pointer. It is not a promise about which events will fire: a tablet with a trackpad and a laptop with a touchscreen both exist, so keep handling both.
Take DOM keyboard focus for your game. You almost certainly do not need it: keydown only fires while something inside your iframe holds focus, and the platform claims it for you on every tap in the game and whenever its own overlays close. Call it after tearing down an overlay of YOUR OWN that took focus. Ignored while your game is off-screen or paused, so a game one swipe away can never take keys from the one being played.
Custom events
Counts one thing that happened, for your own analytics. Counts only — there is no value and no way to attach one. You see, per day, how many times each key happened, which is what answers 'does anyone ever find the boss' and 'is the shield being used'. Calls are batched into a flush every 5 seconds, so firing one from a gameplay branch inside your loop costs nothing.
The cap is on keys that are ACTIVE, counted across every player and every day. Name the event, not the instance — level_complete, never level_7_complete. event('level_' + n) fills all 20 slots in 20 levels and everything after is dropped until a slot is free, so bucket your levels. Keys that were refused are named back to you on the Analytics tab. Never put a player's name, a score or anything about a person in a key — it is stored as a plain label on a public counter.
Retire any key from your game's Analytics tab: it stops counting against the 20, and every count it recorded stays exactly where it is, still visible in past date ranges and marked retired. Nothing is deleted — that is a different action and this is not it. A key that records nothing for 90 days goes quiet by itself and stops counting too, which is what clears the dead vocabulary of a rewritten game. Either way, if your game records the key again and a slot is free, it comes straight back. "Start fresh" retires the lot in one step for a redesign.
Excess calls are dropped and the SDK warns once per key on the console. Do not call this every frame: a counter fired from a render loop measures your frame rate, not your game, and it buries every other number on your Analytics tab. Fire it at the moment the thing actually happens.
Multiplayer
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Typed schemas — what a player is able to send at all
Every typed message is an instance of a schema: a named record with a fixed set of fields, each drawn from a closed set of types. You write the schemas as a JSON document and submit them with the game. Nothing else can be sent — there is no escape hatch, no free-form field and no 'just this once'. Design the schema first: it is the whole vocabulary of a non-host player, and a schema that cannot express the move is a move the player cannot make.
That list is closed and complete. bool is a flag. enum is a choice from a fixed list you ship, with 2 to 65536 variants, and only the index travels. uint and int are whole numbers and each REQUIRES bits (8, 16 or 32) and a min and a max — an undeclared range is a field nobody range-checks, and it is refused. fixed is the only fractional type: bits 16 or 32 plus a scale, min and max, and it exists because there is no float on this wire. peer is a player in this room. preset is an index into a platform table. array needs a max — always, no default — and an element. struct is a record with at least one named field, and every schema document is a struct at the root.
No string, bounded or otherwise — a free string between strangers is an unmoderated chat channel that passes review looking like a player label. No bytes, which is an image channel with extra steps. No map, whose keys are strings the sender picks. No any or variant, which is every other exclusion in disguise. No float, because determinism is settled as fixed-point and a float desync reproduces on one device and nowhere else. No array without a max, and no unbounded nesting. There is NO EXTENSION HOOK and no flag that turns one of these on: adding a leaf type is a safety change that goes through the review the type set exists to enforce. If your design needs one of these, the design needs changing, not the schema.
These two exist because the honest cases do. When a message has to name a player, it carries peer: the id the platform issued for this room, never a name and never anything about them. When a message has to say something, it carries preset: a 16-bit index into a table the platform supplies and moderates centrally (quickchat, ping, palette), so the words are ours and the choice is the player's. Quick chat is built out of preset and out of enum, not out of a chat API — send the index of a phrase from a list you ship and render your own copy of the text at the other end. There is no text field in a public room's messaging and there is not going to be one.
Every schema document carries a schemaId, a name and a schemaVersion. Ids below 256 are reserved for platform schemas — tiptap.draw.stroke lives there — and a game's own ids start at 256. Two different games both using 256 is normal and correct: ids are scoped to one game's approved set, not to the platform. The name is a dotted registry key in lowercase (yourgame.move), not a label and not somewhere to put a sentence. sendTyped takes either the name or the id.
A game's whole approved set holds at most 32 schemas, structs nest at most 8 deep, and each schema's maximum encoded size must come out at 512 bytes or under. That size is computable from the schema alone and you can work it out before you submit: tiptap.draw.stroke, the platform's drawing schema, is an array of at most 64 points of two 16-bit fixed values, plus a 16-variant enum and one byte of width — (1 + 64×4) + 1 + 1 = 259 bytes, whatever anyone draws. That is the property the whole rule turns on: the size depends on the schema and never on the data. A set that breaks any of these is refused whole, with a reason per document, rather than one refusal per resubmission.
Two separate steps, and the gap between them is where creators get stuck. Submitting the game stores what you declared: it is checked against the type rule and kept, and an inadmissible schema does not fail the upload — the game uploads with no schemas and the reasons come back with the result, so read them. Approval is a second, human decision recorded separately, and until it exists the game has no typed capability: sendTyped returns false for every name, and a public match is refused outright because a public room with no typed channel would leave three players in four unable to act. IF YOUR GAME WORKS FOR YOU AND NOT FOR OTHER PLAYERS, THIS IS ALMOST ALWAYS WHY. It is not a bug in your code and there is nothing in the game to fix — the set is waiting on review. Changing a declared schema after approval ends the approval, because a review is of a specific set; reordering or reformatting the same schemas changes nothing and keeps it.
It is total for FORM. No player can transmit a byte you did not declare a slot for, every field is range-checked before it leaves the sender and again at the relay, and the maximum size of every message is known before anyone plays. That is worth having and it is the whole of it. It is not moderation and does not pretend to be: someone can still draw something vile with 64 perfectly legal points, and two players who agree offline that bid 7 means something can carry that meaning through a bounded integer. What the rule buys is that every byte on this path is structured, size-bounded, decodable and retainable as evidence — which is exactly what an opaque blob is not. Design as though a determined player will misuse whatever you declare, because they can, and keep what you declare to what the game needs.

