TipTap Games
← Multiplayer

Real-time simulation

A step beyond replicated state: a deterministic simulation, rolled back and reconciled for you, 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.

You write the simulation — this is the creator-logic tier. Your game’s rules live in one TypeScript file, compiled server-side to WebAssembly and checked for determinism rather than trusted to be deterministic. There is no fixed menu of genres to pick from: the arena, the bodies, the projectiles and the rules are yours. The platform supplies the rollback runtime that steps your logic identically on every client, and the drawing half of your game reads the state back with sim.getView().

The full authoring guide — how to write and compile a logic.ts, the packages you can import, and how to publish it — lives under Creator logic. This page is the SDK surface the drawing document talks to.

Two 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 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 SDK surface the drawing document uses. Nothing in the document simulates anything — the simulation is your compiled WebAssembly, stepped by the platform’s rollback runtime, and the document supplies intent every frame and paints the state it gets back. 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.