TipTap Games
← Multiplayer

Real-time simulation

A step beyond replicated state: a physics simulation the platform runs deterministically in a Worker, so a fast-twitch game stays in sync without you writing netcode. It rides the same connection as the rest of TipTap.net — rooms, state, turns and chat behave identically whether or not you use it.

Walk through a simulation game first →Declare, define, and run the input/view loop on arena2d — in four steps.
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.Shippable 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.Preview — in Labs. It compiles, gates and runs, but you cannot publish a game on it yet. Read about it.

Which should you build on now? Modules. If the genre 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 — creator logic is where that changes, and it is worth reading, 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, 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, a 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. 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) 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.

Two more limits worth knowing. 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 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 — a row of parked cars on a racing grid.

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. The other three modules have no autopilot.

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 settles as simulation unavailable about fifteen seconds later. The same is true of a file you open yourself. Expected, not broken — worth knowing before 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.

Example
// every frame: record intent (the SDK owns the 60Hz tick + prediction)
TipTap.sim.input({
  moveX: stick.x, moveY: stick.y, facing: aimAngle,
  buttons: firing ? TipTap.sim.BUTTON.FIRE : 0,
});
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.

Example
// draw this and nothing else — it's already predicted and interpolated
const v = TipTap.sim.getView();
drawPlayer(v.self.pos, v.self.facing);
for (const p of v.others) drawPlayer(p.pos, p.facing);
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.