Relay inputs for a deterministic sim

Most of the netcode guidance here starts from synchronized state: the server owns the world, clients predict it, and corrections pull a client back when the two disagree. See client prediction for that model.

This page covers the other pattern, the one a deterministic fighting game or RTS usually wants. Every client runs the same simulation from the same inputs, so instead of streaming state you stream inputs. The server does not decide the world. It carries each seat’s inputs to the others in order, and broadcasts a periodic checksum so a client that has drifted can tell. This is the classic lockstep and rollback shape.

When to pick this

You havePrefer
A sim that is expensive to send but cheap to re-run from inputs, and must stay bit-identical across clientsAn input relay
A world where the server is the only honest party, or clients cannot be trusted to run the rulesServer-owned state and prediction

An input relay trades the server’s authority for latency and bandwidth. Inputs are small and go out every frame, and each client sees its own input apply at once rather than waiting for a round trip. The cost is that correctness now lives in your simulation being deterministic, and in every client having run it on the same inputs. irtio does not enforce either; it moves the inputs and gives you a place to check agreement.

The shape

Use a coded tick room. Declare the per-frame input and the checksum as typed messages, and keep one server-owned singleton for the frame the room has reached.

// irtio/schema.ts
import { defineSchema, singleton, u32 } from '@irtio/schema';

export const schema = defineSchema(
  {
    match: singleton({ frame: u32 }, { serverOwned: true }),
  },
  {
    messages: {
      input: { frame: u32, bits: u32 },   // one seat's input for one frame
      checksum: { frame: u32, hash: u32 }, // the server's desync guard
    },
  },
);

On the client, send one input per frame and run the sim locally from the inputs you have, your own included. Rolling back and re-simulating when a remote input lands for a frame you already ran past is the rollback half of the pattern, and it is entirely client-side code.

// main.ts, once per frame
room.messages.input.send('all', { frame, bits: readButtons() });
advanceLocalSim(frame); // your deterministic step, from buffered inputs

The room observes every input through onMessage and keeps a per-seat buffer. Its tick advances an authoritative copy of the same sim one frame at a time, and only when every seat’s input for the next frame is in hand. Every so often it broadcasts a checksum of that authoritative state.

// irtio/room.ts
onMessage(state, from, target, bytes, ctx, typed) {
  if (typed?.name === 'input') inputs.record(from, typed.value.frame, typed.value.bits);
  // return nothing, and irtio relays the input on to the other seats
},

tick(state, dt, room) {
  const seats = room.clients.map((c) => c.clientId);
  while (inputs.haveAll(seats, state.match.frame + 1)) {
    state.match.frame += 1;
    stepAuthoritative(state.match.frame, inputs.forFrame(state.match.frame));
  }
  if (state.match.frame % 30 === 0) {
    room.messages.checksum.send('all', { frame: state.match.frame, hash: hashState(state) });
  }
},

Each client compares an incoming checksum against its own sim at that frame. A mismatch means the two simulations have diverged, from a non-deterministic step, an unseeded random, or a dropped input, and the client resyncs from a full snapshot you send on request. Keep the authoritative frame in the singleton so a late joiner and the inspector can both read how far the pump has gone.

Keep the input stream gap-free

This is the failure the pattern invites, and it does not announce itself. When the server advances the sim only once it has every seat’s input for a frame, the stream of inputs from each seat must be gap-free. A single missing or non-contiguous input frame stalls the pump forever: frame N never completes, so frame N+1 is never reached, and every seat waits on the one that never arrived.

So a seat that has nothing to report for a frame must still send something for that frame, a repeat of its last input or an explicit idle, rather than skipping the send. Buffer or skip on the client, where a dropped frame can be filled in; do not leave holes in what reaches the server. Typed messages from one sender arrive in order, so the gap you have to prevent is a frame a client never sent, not one that overtook another.

A stalled pump looks like a live room doing nothing. The dev inspector at http://localhost:7070/__irt/ (see the CLI reference) makes it findable: the room’s state is there, match.frame has stopped climbing, and /__irt/ticks.json shows the tick counter frozen while clients are still connected. From there you can see which frame the pump is stuck on and which seat owes an input for it.

  • Messages for the typed input and checksum channels and their ordering.
  • Choose an authority model for when a server-owned world fits better.
  • Relay rooms for the codeless relay, when you want peers to exchange inputs with no server sim at all.