Skip to content

Realtime Games

Recipes for a responsive online game on realms — the latency budget, input fast lanes, why blending toward a stale sample re-adds your ping, rewind-replay prediction, tick-indexed interpolation, and how to measure any of it honestly.

Realms give a game its state plane: optimistic LWW writes for inputs, intents for atomic acts, authoritative simulation on the server, patches fanning out to every peer. What they do not do is make latency decisions for you — and in a game, latency decisions are gameplay decisions. This page is the recipe collection: where the milliseconds hide, the client patterns that work, and the one seductive pattern that mathematically cannot.

Much of this page is distilled from a real consumer diagnosis (the Dirt Derby team’s racing game), including the fixed-point analysis below — reproduced with their blessing because every integrator should be inoculated against that bug before writing a reconciler.

Between a keypress and every screen reflecting it, a realm game crosses these stations:

StationCostWhose knob
Client coalesce window0 (microtask) … full coalesceMs for a lone writeyours — see below
Uplink½ RTTgeography
Server tick sampling0 … one tick (the sim consumes “latest input” at tick boundaries)your tick rate
Server broadcastbroadcastCoalesceMs, default 0leave it at 0 for games
Downlink½ RTTgeography
Remote-entity interpolation delayyour buffer depthyours — see below

Two of those rows are knobs this library owns, and both default correctly for games — but both have a failure mode worth naming:

The client window is trailing-edge by default. coalesceMs arms a timer on the first write of a window and ships when it closes — so a lone keyboard transition pays the whole window as pure added latency before it even reaches the wire. For a game input lane, either leave coalesceMs unset (the microtask default), or use the leading edge so streams merge but transitions fly:

connectRealm(gameRealm, {
// …
coalesce: { ms: 25, leading: true }, // isolated writes send NOW; bursts still merge
});

A single hot path on an otherwise-windowed connection can instead use the per-write escape hatch, realm.update(fn, { flush: "immediate" }). Full edge semantics: Coalescing.

The server knob stays at 0. broadcastCoalesceMs exists for chatty non-game realms where per-message cost outweighs latency. A game realm should never trade the downlink for message count — if fan-out volume is the problem, fix the write cadence, not the broadcast.

The realm shape that works for continuous player input is a transient LWW region keyed by player, carrying transitions only (write accel: true on keydown, not the held state at 60 Hz). defineInputLane in @nice-code/realm/predict generates it — schema, rule pack, and the seq leaf — from one declaration:

import { defineInputLane, type IInputLaneOwnerContext } from "@nice-code/realm/predict";
const inputLane = defineInputLane({
root: "inputs", // the state key you spread it under, below
key: "playerId", // ⇒ rule patterns read `inputs.$playerId…`
shape: { accel: t.boolean(), brake: t.boolean(), steer: t.number() },
// `key` is the entry being written, already resolved. Annotating this context is what types
// `state` — and it infers the lane's type arguments, so you never write them out.
owner: ({ avatar, key, state }: IInputLaneOwnerContext<{ seats: Record<string, ISeat> }, "player">) => {
if (avatar.persistentId !== key) return err.fromId("not_your_lane");
return state.seats[key] != null ? true : err.fromId("not_seated");
},
validate: { steer: (next) => (Math.abs(next) <= 1 ? true : err.fromId("bad_steer")) },
});
defineRealm({
state: { inputs: inputLane.schema, /* … */ },
rules: (r) => [...inputLane.rules(r), /* your own */],
transient: [inputLane.transientRoot],
});

A realm’s full state type is circular with the lane living inside it, so the owner gate names the slice it reads ({ seats: … } above) rather than the whole state — that annotation is what types state, and inference does the rest.

Three things it does that are easy to get wrong by hand:

  • It emits the rules that can actually match — one per leaf. Realm rules are consulted per leaf: every write expands to the leaf paths it covers, so a whole-entry add, a whole-entry replace and a single-leaf transition all land on the leaf rules. A rule written at the entry itself (inputs.$playerId) matches nothing, and a lane built that way default-denies every write — including the first. The tempting fix, a permissive ** catch-all, is what actually unguards the lane.
  • It writes no type checks, because they already happen. Every value is validated against its schema node before any rule runs, on both sides — a t.boolean() leaf rejects a string with value_invalid whether the client is buggy or hostile. validate is only for what the schema can’t express: ranges, enums, cross-field checks.
  • It generates and guards the seq leaf — the protocol half of prediction. createLaneWriter (below) stamps it; your authoritative tick copies the consumed input’s seq onto the simulated body (body.inputSeq = input.seq — one line, at the one point that knows which input a body consumed). That echo is proof of consumption: it is what lane.lastAppliedSeq and lane.unackedInputs read, so you can see how far ahead of the server you are. Note what it is not: a replay input. Replay is indexed by tick, not by seq — ordering a replay on seq is a tempting wrong turn. The library rejects a NaN, Infinity or negative seq for you — all three pass a t.number() check and would quietly corrupt the counter that everything above reads.

Transient means input storms cost zero storage writes and a hibernation wake starts clean — which is exactly why the entry must be re-owned on every recovery (presence). createLaneWriter owns that too:

import { createLaneWriter } from "@nice-code/realm/predict";
const lane = createLaneWriter(inputLane, { // takes the lane: root + entry type come from it
key: myId,
initial: { accel: false, brake: false, steer: 0 },
// Annotating `state` types it and infers the writer's state type — the realm's full state type
// is nameable here, unlike inside the (circular) lane definition.
canOwn: (state: TMatchState) => state.seats[myId] != null, // not seated ⇒ don't re-own
appliedSeq: (state) => state.bodies[myId]?.inputSeq, // the tick's echo
});
const realm = connectRealm(matchRealm, {
// …
coalesce: { ms: 25, leading: true }, // transitions are event-shaped — see the budget above
assertOnAttach: lane.assertOnAttach, // stable reference, safe to pass before attach
});
lane.attach(realm);
lane.write({ accel: true }); // stamps the next seq, skips no-ops, sends immediately
lane.current; // the freshest local input — simulate from this
lane.unackedInputs; // how far ahead of the server you are: the replay window

The re-own seeds from the writer’s own live value, not from schema defaults — that distinction is the difference between a wake being invisible and a wake snapping the player’s throttle shut mid-corner. And write suppresses no-ops, so a key-repeat costs nothing and never burns a seq: the counter advances only on a real transition, which is what makes unackedInputs meaningful.

Note the writer takes the lane itself, not a root string: the record it writes to and the entry type it stamps are read from the definition, so a writer can’t drift from the lane it writes to — rename the lane’s root, or add a leaf to its shape, and the writer follows or fails to compile.

Here is the pattern nearly every first reconciler uses. It type-checks, renders plausibly, and re-adds your entire ping as input lag:

// ❌ On every flush: softly pull the predicted body toward the latest authoritative sample.
body.x += 0.2 * (auth.x - body.x);
body.y += 0.2 * (auth.y - body.y);

The authoritative sample is inherently L seconds old, where L ≈ RTT + coalesce + tick sampling — it reflects your inputs from L ago. While moving at speed v, the authoritative position trails your true (locally simulated) position by d = v·L. Per flush, with e = how far the rendered body lags the true local body:

e ← 0.8·e + 0.2·(v·L) → fixed point e* = v·L

The blend converges to the full latency offset. Prediction wins only the first instants after an input transition; then the correction drags the body back onto the server’s stale timeline — at 250 px/s and L = 250 ms that’s a steady-state error of ~62 px, and heading obeys the same recurrence during a turn. The trap is invisible on localhost (L ≈ 5 ms ⇒ e* ≈ 1 px), which is exactly why it ships: fine locally, laggy deployed is this bug’s signature. No blend factor fixes it — a smaller factor only slows convergence to the same fixed point.

Two amplifiers to check for even before the real fix: reconciling on every store flush (including your own input-write echoes) instead of only on fresh authoritative data — this is what listenToPatchesmeta.authoritative is for — and a hard-snap threshold that e* alone can push the total error past, which reads as rubber-banding.

Gate the reconciler on authority, not on "confirm"

Section titled “Gate the reconciler on authority, not on "confirm"”

Before the fix itself, the predicate it runs on — because the natural-looking one is wrong in a way that, like the trap above, only shows up under latency.

realm.listenToPatches((patches, meta) => {
if (!meta.authoritative) return; // ✓ your own echo and local rollbacks — nothing new from the server
if (state.race.tick === lastTick) return; // ✓ nothing actually advanced
reconcile(patches);
});

Not meta.cause === "confirm". That cause means “authoritative and your outbox happened to be empty” — and a driving client’s outbox is never empty for long, since every input write is in flight for about a round trip. A consumer census over identical 20-second races found the split moves entirely with latency:

Fresh authoritative ticks (n = 400)0 ms+200 ms
arrive as "confirm"400 (100 %)160 (40 %)
arrive as "rebase" — real server truth a confirm-gate drops0240 (60 %)
arrive as "optimistic"00

At +200 ms a confirm-gated reconciler throws away three of every five things the server says — and at 0 ms it throws away nothing and looks perfect. Fine locally, laggy deployed: the same signature as the blend trap, one layer up. The bottom row is the other half of the argument: an optimistic flush never carries authoritative state, at any latency, so skipping "optimistic" (or equivalently gating on meta.authoritative) drops 100 % of the echo class and 0 % of the truth.

The standard, game-agnostic shape. @nice-code/realm/predict ships this as createPredictedEntity — read this section anyway, because it is what the helper is doing and you cannot debug a predictor you don’t picture:

  1. Simulate your own entity locally with a fixed-timestep integrator (the same step function the server runs, if you share the sim).
  2. Keep a bounded history of your input transitions indexed by tick (a second or two is plenty).
  3. On each fresh authoritative sample (meta.authoritative, and only when the authoritative tick actually advanced — see the gate): take the auth body and re-run the fixed-step integrator over the ticks between that sample and now, feeding each tick the input that was in force at it. The replayed result is the render body.
  4. The residual between replayed and previously-rendered state is now genuine misprediction only (a server-side collision, a shove) — smooth that with a small blend or snap policy. Residual smoothing finally does the job the stale-sample blend was pretending to do.
  5. Steady-state error is e* ≈ 0 at any ping: latency no longer appears while your inputs are the only force acting on you.

Peg your tick clock to the server’s — never let it free-run. The clock you replay against is localTick = serverTick + leadTicks, re-derived from every authoritative sample. The tempting alternative — run localTick off the wall clock at your nominal rate and let a controller nudge it — drifts by construction, because the authoritative cadence is whatever the server actually delivers, not what your constant says. A consumer whose server was quietly running at ~16 Hz (see setInterval) watched a free-running 20 Hz client clock gain ~2.8 ticks/second until the predicted car was three seconds ahead of reality and snapped back at every corner. Pegging is drift-proof and costs one assignment.

Two more details that only show up in a real integrator: anchor the residual correction to the last drawn position and set it (=, not +=) so it can’t accumulate across samples, and cap any between-sample presentation extrapolation (~3 tick-steps) so a link hiccup glides instead of launching the entity.

The replay is cheap: at a 20 Hz tick and 250 ms of latency it’s ~5 fixed steps per authoritative sample, for one entity. Bound the replay window (~1 s) with a snap fallback. Use the fixed tick dt in the replay even though your render loop is variable — clamped integrators don’t commute across step sizes, and the drift between a variable-step replay and the fixed-step server accumulates.

Deterministic-by-construction tests for this shape — script the delivery of authoritative frames N ticks late and assert the replayed body lands where the unlagged sim would have — are what createTestRealm’s frame-hold controls exist for.

createPredictedEntity — the above, without writing it

Section titled “createPredictedEntity — the above, without writing it”

Everything in that section is a real thing to get right, and @nice-code/realm/predict ships it. It is an extraction, not a design: it was taken from a shipped, measured racing game’s predictor, and every default in it exists because that game measured the alternative.

import { createPredictedEntity } from "@nice-code/realm/predict";
const predictor = createPredictedEntity<IVehicleBody, IDriverInput>({
tickRate: 20,
// Your physics — the same function the server's tick runs. Movement only.
step: (body, input, dt) => stepVehiclePhysics(body, input, params, dt, locked),
input: () => lane.current, // the input-lane writer's live value
predict: (body) => !body.destroyed && body.place === 0, // a wreck mirrors authority
residual: {
distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y),
displace: (body, from, to, amount) => ({
...body,
x: body.x + (from.x - to.x) * amount,
y: body.y + (from.y - to.y) * amount,
heading: body.heading + wrapAngle(from.heading - to.heading) * amount,
}),
snapDistance: 110, // a mine's shove should arrive, not ooze
},
});
predictor.attach(realm, {
tick: (state: TMatchState) => state.race.tick,
body: (state) => state.race.vehicles[myId],
});
// in your render loop:
const body = predictor.present(); // pegged fixed step + capped sub-tick + residual glide

attach is the whole realm-facing surface: it reconciles on flushes that carried fresh server state and moved your tick, so there is no listener to write and no predicate to get wrong. The lead calibrates itself from realm.stats.rttMs with no wiring.

What stays yours is your physics (step) and the shape of your body (the residual’s two callbacks). The library never introspects your fields — the same boundary createInterpolationBuffer draws around lerp. What it owns is the list above that is wrong-by-default when hand-rolled: the pegged clock, the authority gate, the lead controller, the capped extrapolation, the residual anchor — and one thing the hand-rolled reference did not do, below.

The tick clock cannot free-run, structurally

Section titled “The tick clock cannot free-run, structurally”

present() takes no delta time and cannot advance a tick. It moves sub-tick position only; ticks arrive from the server or not at all. That is not a documented rule you could ignore — there is no API that would let you.

The obvious shortcut is to stamp the current input across the whole replay window. It is wrong whenever the input changed inside the window, and the error scales with the window — which is to say, with latency:

one-way latencyflat-stamped replay errorcreateInputTimeline
50 ms0.000
100 ms0.170
200 ms1.620
400 ms3.490

(Toy fixed-step integrator, units arbitrary, held-then-released input. The point is the shape.) Note the first row: at 50 ms the shortcut is exactly right, which is why it survives review and ships. createInputTimeline stores transitions, not samples — a held key costs one entry, not one per tick — and the replay reads it back as the step function an input actually is.

One subtlety it handles that is easy to get wrong by hand: an input is filed at the tick the server will apply it (about a round trip ahead of the sample you can see), not at your local tick. Filing at the local tick is wrong by lead − RTT, which is ~0 on a bad link and 2+ ticks on a good one — it doubled the measured misprediction at 0 ms when we tried it.

Swapping the reference implementation onto the toolkit, in that game, on its own harness through a TCP lag proxy — same session, three runs a side:

hand-rolledcreatePredictedEntity
misprediction @ 0 ms3.01 px2.60 px
misprediction @ +200 ms8.05 px3.79 px
worst-case peak @ +200 ms~30 px (past a car length)~15 px

The mean roughly halves at latency; the peak matters more, because a peak past a car length is the one the player actually feels. Read predictor.stats for those numbers live — correction (and correctionPeak) is the number that says whether prediction is working, and the property to hold it to is that it should not scale with the round trip.

createServerClock — what tick is the server on now?

Section titled “createServerClock — what tick is the server on now?”

A spectator, a HUD, or a replay scrubber needs the server’s clock without predicting anything:

import { createServerClock } from "@nice-code/realm/predict";
const clock = createServerClock({ tickRate: 20 });
realm.listenToPatches((_patches, meta) => {
if (!meta.authoritative) return; // never sample your own echo
clock.sample(realm.store.state.race.tick);
});
clock.tick; // the server's tick, now — anchored, clamped, never free-running
clock.measuredHz; // what it ACTUALLY delivers, against the tickRate you claimed

The part that is trivially wrong hand-rolled: you cannot smooth “server tick” directly, because the thing you are averaging moves between samples — an EWMA over raw tick numbers averages the server’s motion along with its jitter and lags by design. Subtract the tick-rate term first and smooth the offset, which holds still:

offset = serverTick − now / tickMs // stationary — smoothing this means something
tick(now) = floor(now / tickMs + offset)

That is the whole helper, plus a clamp (maxLeadTicks, and isStale) so a dead link cannot invent ticks the server never sent, and a re-anchor so a fresh round’s tick reset does not spend seconds easing down through tick numbers that never existed.

Remote entities: interpolate on tick indices

Section titled “Remote entities: interpolate on tick indices”

Your own entity predicts; everyone else renders slightly in the past, interpolated between authoritative samples. Two rules make it robust:

  • Index by server tick, not arrival time. Arrival timestamps carry all the network’s jitter; tick indices are the server’s own even clock. Buffer samples keyed by tick and render at latestTick - delay.
  • Size the delay from measured jitter, not a constant. A fixed buffer (say 120 ms) is either wastefully deep on a good link or stuttery on a bad one. The robust sizing is p95 of the samples’ clock jitter plus one tick — measured live, grown fast when the link degrades and shrunk slowly when it recovers.

Both rules are packaged as createInterpolationBuffer in @nice-code/realm/predict — the jitter estimation is exactly the part a hand-rolled version gets subtly wrong:

import { createInterpolationBuffer } from "@nice-code/realm/predict";
const remoteCar = createInterpolationBuffer<{ x: number; y: number }>({
tickRate: 20, // the server sim's tick rate
lerp: (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }),
});
realm.listenToPatches((_patches, meta) => {
if (!meta.authoritative) return; // fresh server state only — never your own echo
const state = realm.store.state;
remoteCar.push(state.race.tick, state.cars.p2); // indexed by the SERVER's clock
});
// In the render loop:
const rendered = remoteCar.sample(); // interpolated, delayTicks behind the newest sample

The index just has to be a monotone source clock — state that streams without a server tick (a peer’s cursor or aim lane) can carry a sender-side Date.now() stamp and use tickRate: 1000; our tank-shooter demo renders enemy barrels exactly this way. buffer.latest carries the newest un-delayed value for discrete fields (a selected weapon should switch, not morph), and buffer.delayTicks is chartable if you want to watch the buffer breathe.

Note the push gate is meta.authoritative, for a sharper reason than correctness-in-general: feed the buffer on cause === "confirm" and it silently receives its samples in bursts with latency-sized gaps (see the gate) — and since the delay is sized from measured jitter, the buffer reads those gaps as a bad link and inflates delayTicks, pushing remote entities further behind truth exactly when the local player is most active. Feeding every authoritative flush gives it the clean, monotone stream it wants.

Smooth remote motion with the interpolation itself (or a short CSS transition for DOM cursors) — never by writing faster. And apply interpolation only to remote entities: your own predicted body already updates every frame, so smoothing it just adds fixed visual lag with nothing to hide (the render-side rules).

Netcode instruments fail quietly and flatteringly: every trap below reports excellent numbers from a broken measurement. Each one has cost a real team a false conclusion.

  • Measure at 0 ms and at latency, and compare — the comparison is the measurement. Either column alone is decoration. Every metric here (and the cause census above) reports a clean bill of health on localhost whether the netcode is right or wrong; that is precisely the property that ships both the blend trap and the confirm-gate. A number that doesn’t move with latency is either a fix or a broken instrument, and you cannot tell which from one column.
  • Prove the entity was moving and alive before you believe any prediction metric. A parked car predicts perfectly. So does a dead one. A consumer’s harness measured 18 seconds of flawless prediction on a wreck, and a stuck-against-a-wall run produced flat, beautiful errors at both latencies — indistinguishable from the bug they had just fixed. Drive with your own game AI, and refuse to report unless the entity moved.
  • Shape the network for real. Chrome DevTools throttling does not shape WebSocket traffic — it will show you a lie. Use a real remote deployment, a local shaper (clumsy on Windows), or ~40 lines of TCP relay that delays every byte both ways — scriptable, and the version you can put in CI.
  • realm.stats is the built-in RTT/jitter estimate (EWMA, fed by write confirms and probe answers, coalesce window excluded) — poll it from the HUD tick and show the ping badge; players diagnose “is it me or the game” with it too. Details.
  • Draw the truth. A dev-only overlay rendering the raw authoritative body and the rendered body as two outline rects (plus authTick / error px as text) makes the stale-sample drag — and the fix — visible in seconds. Keep it behind a query flag permanently; it’s your regression detector.
  • Expect the right metric to get “worse”. After a correct rewind-replay fix, the gap between the stale authoritative sample and your drawn entity grows with latency (it is v·L — the latency, now visible instead of hidden). A flat gap across latencies is the blend trap wearing a lab coat. The number that should stay small is the prediction error at each rebase.
  • Know your budget’s fixed floor. With a 20 Hz server tick, 0–50 ms of input-to-sim sampling and a 50 ms reconciliation quantum are inherent; measure your improvements against that floor, not against zero.

Don’t run the authoritative loop on setInterval

Section titled “Don’t run the authoritative loop on setInterval”

setInterval(fn, 50) schedules a gap between runs, not a period: each tick’s own cost — the sim step, plus a realm commit that broadcasts to every client — lands on top of the 50 ms. A consumer measured their nominal 20 Hz race loop actually delivering 15.8 Hz: a race running at 79 % speed, a “3 second” countdown taking 3.8 s, and it scales with player count and host load.

It is nearly invisible in-game — every speed, cooldown and timer scales together, so it reads as “the cars feel sluggish” rather than as a clock bug — and it silently poisons any client that assumes the nominal rate (see the pegged-clock rule above; a free-running client clock against a slow server loop diverges without bound). Schedule against an absolute wall-clock grid so the delay subtracts the work:

let nextTickAt = Date.now();
const loop = () => {
runTick();
nextTickAt += TICK_MS; // the grid, not "now + TICK_MS"
setTimeout(loop, Math.max(0, nextTickAt - Date.now()));
};
  • Input lane: transient, transitions-only, owner-gated on every leaf (an entry-level rule on a record of objects never matches — see the input fast lane), monotone seq, body.inputSeq echo in the tick
  • coalesce: { ms, leading: true } (or no window) on the game connection; broadcastCoalesceMs left at 0
  • Reconciler reacts to meta.authoritative + tick-advance only — never cause === "confirm" (drops truth under load), never its own echo
  • Own entity: fixed-step rewind-replay; residual-only smoothing with a bounded snap; the client tick clock pegged to the server’s (localTick = serverTick + lead), never free-running — or createPredictedEntity, which is all of that with the free-running version made unrepresentable
  • Remote entities: tick-indexed interpolation, jitter-sized delay, fed on meta.authoritative
  • Authoritative loop on a wall-clock grid, not setInterval; measured Hz ≈ nominal Hz
  • Ping badge from realm.stats; overlay for auth-vs-rendered; tested under a real shaper
  • Every number above checked at 0 ms and at ~200 ms, and compared — and the probe entity proven to be moving and alive when they were taken