TipTap Games

Writing a game’s logic

You write one file. The platform compiles it, checks it, meters it, and runs it on every player’s device at the same instant with the same result.

  • One file
  • No networking in it
  • Lag handled for you

Status: in development. This tier compiles and runs, but it is not yet reachable from the Creator Studio — you cannot publish a game with it today.

There is no networking in this file. Not hidden somewhere — there is nowhere to put any. Inputs arrive as an array, state is a plain struct, and everything about lag happens to your code without its knowledge.

The shape

game.ts

import { defineGame, f32, u8, array, bytes, axis, DT } from "@tiptap/sim";

export default defineGame({
  mode: "shared",
  tickRate: 60,
  seats: { min: 2, max: 8 },

  state: {
    players: array(8, { x: f32, y: f32, hp: u8 }),
  },

  setup(state, ctx) {
    for (let s = 0; s < ctx.seats; s++) {
      state.players[s].x = 8 + s * 6;
      state.players[s].hp = 100;
    }
  },

  step(state, inputs, ctx) {
    for (let s = 0; s < ctx.seats; s++) {
      const p = state.players[s];
      p.x += axis(inputs[s].axes.x) * 9 * DT;
    }
  },
});

setup runs once. step runs every tick. That is the whole lifecycle.

Two of those fields are a closed set rather than a free choice. tickRate is 60, 30 or "event", and mode is "shared" or "private" — the subject of hidden information. Anything else is rejected before your game compiles, along with a seat range that does not make sense and a state block with nothing in it.

Four rules, and all four are checked rather than trusted

Fixed capacity

array(8, …)is eight, always. There is no way to declare a growable collection, because a fixed layout is what makes a snapshot a byte copy and a rewind a byte copy back — and rewinding is what hides other players’ lag from your game.

No clock, no randomness, no I/O

Not a rule you follow. Your compiled game has no imports at all, so there is nothing to call. That is checked three times over: the import allowlist is empty, the compiler runs with no runtime to import against, and the artifact is instantiated with an empty import object — so a module that needed anything would fail to link rather than run with a capability. For randomness, use @tiptap/math’s Rng seeded from something every client already agrees on — the match seed, the tick, an entity id.

step must finish

Every loop and every call is metered, and a game that runs past its budget ends the match with a stated reason. The budget is 200,000 instructions per tick, and publishing refuses at half of it — so the figure you are judged against is 100,000, and the runtime trap stays nearly unreachable. Your worst case is measured and shown to you at publish time, as a number and a percentage.

For scale: the reference water shooter — eight seats, sixty-four projectiles, every one of them tested against every player — measures 92,372, which publishes with 8% to spare. Instructions are counted, not milliseconds, so the same code costs the same on every device and the meter is not something a slow phone can trip.

Plain arithmetic is fine

Float arithmetic is deterministic by WebAssembly specification. The one exception is NaN payloads — the bits inside a not-a-number — and those are settled by the deterministic profile of the runtime your game executes in, not by anything you write. Write p.x += speed * DT and stop thinking about it.

The reason this holds where it would not in JavaScript: WebAssembly has no transcendental instructions. A sin in your game is yourcompiled code, identical everywhere, rather than the browser’s — and browsers genuinely disagree in the last bit.

Two things that surprise people

You never see a missing input

When a packet has not arrived, the runtime hands you that seat’s previous input and corrects itself later if it guessed wrong. inputs[s] is always there. You never write if (inputs[s]).

This is the opposite of what a networked game usually forces on you, and it is possible because being wrong is cheap: your step is simply run again with the real input once it lands.

The opening ticks are the neutral input.An input is stamped with the tick it should land on, a couple of ticks ahead, so the first two ticks of a match are ones nobody stamped — every seat reads all-zero there, and a repeat of the previous input everywhere after. Nothing is missing; it is just that “nobody has pressed anything yet” is a value. Your setup state has to look right before anyone has moved.

Events arrive late; state does not

ctx.emit runs again every time a tick is re-simulated, so a naive runtime would play the same gunshot six times. Instead, events are held until their tick is final and everyone agrees on it — roughly one round trip behind the state you are drawing.

Draw from state. Use events for sound, particles, and anything where forty milliseconds does not matter. A health bar driven by an event lags the world; one read from state is right on the frame it changes.

An event from a tick that was later re-simulated differently is never delivered at all. That is correct: it did not happen in the world everybody ended up agreeing on. Re-running a tick replaces that tick’s events rather than adding to them, which is the whole of the mechanism.

One tick may emit 64 events. Past that they are counted and dropped — a tick announcing more than sixty-four things is describing something nobody can perceive, and the cap is what stops the runtime’s buffer being a function of a game’s ambition.

Mutable terrain, which used to be impossible

A bytes() slot is memory you own outright. The runtime snapshots it, rewinds it and checksums it without ever knowing what a tile is.

state: { ground: bytes(64 * 64) }

Dig a hole, flood a room, burn a forest. Terrain is state, so it can change, so standing in it can mean something.

Hidden information

Declare mode: "private" and add one function:

visibleTo(state, seat) { /* return a redacted copy */ }

The server simulates the true world and sends each player only what your function says they may know. Bytes a player is not allowed to see are never encoded, so they never reach that device and are not in its memory to be found.

One trap, and it is the whole difficulty: absence is information.Zeroing an enemy’s position still tells the client an enemy exists. For fog of war, a hidden entity has to be indistinguishable from an empty slot — not a blanked one.

Spectators need their own answer, and if you do not write one your game has no spectators. That is deliberate: defaulting to “show everything” would leak your hidden state through a feature you forgot you had.

Hidden information is the whole of it: what the server does per tick, the shape of the mistake to check for before shipping, and what the round trip costs you.

When something goes wrong

What you seeWhat it means
Refused at publish with a fuel figureYour worst tick is too expensive — over 100,000 instructions. Profile the heaviest path — every projectile alive, every player touching — not the average one. Over 200,000 is a different message, because that game would not merely be tight, it would end its own match
Held with “run the fuzzed measurement”Not a refusal. Your game has loops, so reading the bytecode cannot say how many times they run — the artifact is built and metered, it just is not cleared yet, and the dynamic pass finishes the job. Every game with a loop in it takes this path, which is every game
logic_overran mid-matchA tick used its whole budget. It ends the match identically on every client, and it should be nearly unreachable — publishing refuses well before the limit
logic_trappedYour code did something impossible: read outside your state, divided by zero, hit an unreachable. Not a performance problem
Two players seeing different worldsSomething in your step is not deterministic. The usual causes are gone by construction, so look for a value you kept somewhere other than declared state

The last row is the one worth dwelling on. Every ordinary source of divergence — the clock, Math.random, iteration order, floating-point differences between devices — is removed by construction, so the one that is left is structural: only what you declared in state is snapshotted, and only what you declared is rewound. A counter you kept beside your game rather than inside its state does not travel backwards when the world does, and from the next tick on your simulation and everybody else’s are running different arithmetic.

Unwritten state is not the culprit, whatever your instinct says. The arena is zeroed before your setup runs and zeroed again on every rewind, padding included, so a field you never wrote reads as zero on every device at once. That is wrong, possibly, but it is wrong identically — and identical is the only property divergence cares about.

How you debug this today

The table above is what a failure looks like after it has happened. This is how you find one before it does, and it is worth reading before you start rather than at the point you are stuck — because the first two things most people reach for are not here.

There is no console.log

Not disabled, not stripped in production — absent. Your compiled game has no imports, and a console is something the outside world would have to hand in. It is the same property that makes your game deterministic: with nothing to call there is no clock, no randomness, no I/O, and nowhere for two devices to disagree.

So this is not a gap to file a bug against or a flag to ask for. A logging import would be a capability, and one capability is all it takes for the guarantee underneath this whole tier to stop being a guarantee.

Your step runs many times per frame

This is the part that catches people even when they have found something to print with. Rollback re-simulates: a late input rewinds the world and runs every tick since, so one logical tick can be executed several times before it settles. In a local run of a match with one lossy seat — 300 ticks, fifteen rewinds — 180 ticks were simulated more than once.

Anything you use as a poor substitute for a log — a counter in state, a value you watch — fires repeatedly for the same tick, and the repetitions are correct. A tick that ran six times is a tick that got corrected five times. Read that as the system working, not as your instrumentation misbehaving.

Your whole world is one flat block of bytes at a known layout, and the host holds it outside your game where nothing you do can hide it. That is the debugging story: run the game locally, read the block at whatever tick you care about, and print the numbers. You declared the state, so you know which byte is which.

The four scripts we use to develop this tier all do exactly that, and each one prints a different thing because each one is answering a different question:

What you want to knowWhat gets printed
Does it run at all, and what does the worst tick cost?Ticks completed, peak fuel against the 200,000 budget, and whether the state changed at all. Every seat holds every button with the aim sweeping, because a polite input never reaches the expensive tick
Is the game doing what I think it is doing?Fields read straight out of the arena by offset — how many players are standing and each one’s health, how many terrain cells changed of the 4,096 — plus a count of every event kind emitted
Does it survive being rewound?Rollbacks and re-simulated ticks, the state after them, and the checksum. One seat’s input is held back and delivered fifteen ticks late on purpose, so the run genuinely rewinds instead of merely stepping
Does a whole match hold together?Frames published, events actually delivered, and how far the confirmed tick advanced — the three that catch a game which simulates correctly and shows the player nothing. The clock is turned by hand rather than by a timer, because a timer is the one part of a simulation that cannot be reproduced from a log

Two habits from those scripts are worth stealing whatever you run your game with. Hold a seat’s input back deliberately and deliver it late — a run that never rewinds has only proved your game can step, and rollback is the half most likely to surprise you. And fail the run on the result that would otherwise look plausible: state that did not change, zero events delivered, zero rollbacks. Each of those reads as a quiet match until you look at what produced it.

Nine defects were found building this tier and every one of them surfaced by running something — none by reading the code. Two produced values that were identical on every client, so no amount of cross-checking between players would ever have caught them; what caught them was a number printed somewhere a person could read it. One of those was a match where every player started dead, and it printed as hp 0,0,0,0 for two commits because that looked like a tight game. A test asserting a plausible number is worth less than a program that prints one.

What is not built yet

The feature this tier actually needs is deterministic replay: record the input trace of a match, replay it exactly, and suppress logging during re-simulation so one logical tick reports once instead of six times. Both halves are known to be necessary, and the design names them together for that reason — replay without the suppression gives you the same confusing output on demand.

It does not exist. It is not partially built, behind a flag, or in a branch — it is an open design question with no answer yet, and no date. Until it lands, what is written above is the whole of it: run the game, read the bytes, print the numbers.