Realtime Games
Recipes for a responsive online game on realms: the latency budget, a fast lane for input, 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 shared state. Inputs are optimistic last-write-wins (LWW) writes, atomic acts are intents, the server runs the authoritative simulation, and patches fan out to every peer. What realms do not do is make latency decisions for you, and in a game, latency decisions are gameplay decisions. This page collects the recipes: where the milliseconds hide, the client patterns that work, and one tempting pattern that mathematically cannot.
Much of this page comes from a real consumer diagnosis (the Dirt Derby team’s racing game), including the fixed-point analysis below. It is reproduced with their blessing, because every integrator should know that bug before writing a reconciler.
The latency budget
Section titled “The latency budget”Between a keypress and every screen reflecting it, a realm game crosses these stations:
| Station | Cost | Whose knob |
|---|---|---|
| Client coalesce window | 0 (microtask) … full coalesceMs for a lone write | yours — see below |
| Uplink | ½ RTT | geography |
| Server tick sampling | 0 … one tick (the sim consumes “latest input” at tick boundaries) | your tick rate |
| Server broadcast | broadcastCoalesceMs, default 0 | leave it at 0 for games |
| Downlink | ½ RTT | geography |
| Remote-entity interpolation delay | your buffer depth | yours — see below |
Two of those rows are knobs this library owns. Both default correctly for games, but each has 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. A lone keyboard transition therefore 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 go out at
once:
connectRealm(gameRealm, { // … coalesce: { ms: 25, leading: true }, // isolated writes send NOW; bursts still merge});A single hot path on an otherwise-windowed connection can use the per-write escape hatch instead,
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 input fast lane
Section titled “The input fast lane”The realm shape that works for continuous player input is a transient LWW region keyed by player
that carries transitions only: write accel: true on keydown, not the held state at 60 Hz.
defineInputLane in @nice-code/realm/predict generates the schema, the 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 withvalue_invalidwhether the client is buggy or hostile.validateis only for what the schema can’t express: ranges, enums, cross-field checks. - It generates and guards the
seqleaf, the protocol half of prediction.createLaneWriter(below) stamps it, and 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 whatlane.lastAppliedSeqandlane.unackedInputsread, so you can see how far ahead of the server you are. It is not a replay input. Replay is indexed by tick, and ordering a replay on seq is a tempting wrong turn. The library also rejects aNaN,Infinityor negative seq for you. All three pass at.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. That
is exactly why the entry must be re-owned on every recovery (presence).
createLaneWriter owns that too:
import { createLaneWriter } from "@nice-code/realm/predict";
// `myId` is the perId you opened the connection with. The lane is built before the realm// attaches, so it can't come from the handle yet. Once attached it equals realm.avatar.persistentId.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 immediatelylane.current; // the freshest local input — simulate from thislane.unackedInputs; // how far ahead of the server you are: the replay windowThe re-own seeds from the writer’s own live value, not from schema defaults. That is the
difference between a wake being invisible and a wake snapping the player’s throttle shut mid-corner.
write also 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. It reads the record it writes to and the entry type it stamps from the definition, so a writer can’t drift from its lane. Rename the lane’s root, or add a leaf to its shape, and the writer follows or fails to compile.
The trap: blending toward a stale sample
Section titled “The trap: blending toward a stale sample”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 you move 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·LThe 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. One is reconciling on every store flush,
including your own input-write echoes, instead of only on fresh authoritative data. That is what
listenToPatches’
meta.authoritative is
for. The other is 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, look at the predicate it runs on. 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”. 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 drops | 0 | 240 (60 %) |
arrive as "optimistic" | 0 | 0 |
At +200 ms a confirm-gated reconciler throws away three of every five things the server says. 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 fix: rewind-replay prediction
Section titled “The fix: rewind-replay prediction”This is the standard, game-agnostic shape. @nice-code/realm/predict ships it as
createPredictedEntity. Read this section
anyway: it is what the helper does, and you cannot debug a predictor you can’t picture.
- Simulate your own entity locally with a fixed-timestep integrator (the same step function the server runs, if you share the sim).
- Keep a bounded history of your input transitions indexed by tick (a second or two is plenty).
- 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. - The residual between replayed and previously-rendered state is now genuine misprediction only, such as a server-side collision or 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.
- Steady-state error is
e* ≈ 0at any ping: latency no longer appears while your inputs are the only force acting on you.
Peg your tick clock to the server’s and never let it free-run. The clock you replay against is
localTick = serverTick + leadTicks, re-derived from every authoritative sample. The tempting
alternative is to run localTick off the wall clock at your nominal rate and let a controller nudge
it. That 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 outrun it by ~4 ticks/second. Their controller could pull back only 1 tick/second, so
the lead still grew ~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, so the drift between a variable-step replay and the fixed-step server
accumulates.
createTestRealm’s frame-hold controls exist for deterministic tests of
this shape: script the delivery of authoritative frames N ticks late, and assert the replayed body
lands where the unlagged sim would have.
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 rather than a fresh 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 },});
const myId = realm.avatar.persistentId;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 glideattach is the only call that touches the realm. 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, which is 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. It also does one thing the hand-rolled reference did not,
described 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. This is not a documented rule you could ignore. There is
no API that would let you.
It replays the inputs you actually issued
Section titled “It replays the inputs you actually issued”The obvious shortcut is to stamp the current input across the whole replay window. That is wrong whenever the input changed inside the window, and the error scales with the window, which means it scales with latency:
| one-way latency | flat-stamped replay error | createInputTimeline |
|---|---|---|
| 50 ms | 0.00 | 0 |
| 100 ms | 0.17 | 0 |
| 200 ms | 1.62 | 0 |
| 400 ms | 3.49 | 0 |
(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, so a held key costs one entry, not one
per tick. 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.
What it measured
Section titled “What it measured”We swapped 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-rolled | createPredictedEntity | |
|---|---|---|
| misprediction @ 0 ms | 3.01 px | 2.60 px |
| misprediction @ +200 ms | 8.05 px | 3.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-runningclock.measuredHz; // what it ACTUALLY delivers, against the tickRate you claimedThe part that is easy to get wrong by hand: 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 somethingtick(now) = floor(now / tickMs + offset)The helper adds two things to that. A clamp (maxLeadTicks, and isStale) stops a dead link
inventing ticks the server never sent. A re-anchor stops a fresh round’s tick reset spending 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,
while 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 sampleThe index only 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 general correctness. Feed
the buffer on cause === "confirm" and it silently receives its samples in bursts with
latency-sized gaps (see the gate). 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 adds fixed visual lag with nothing to hide (the render-side rules).
Measure before and after — honestly
Section titled “Measure before and after — honestly”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
causecensus above) reports a clean bill of health on localhost whether the netcode is right or wrong, and that is how both the blend trap and the confirm-gate get shipped. 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, so 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. The relay is scriptable, and it is the version you can put in CI.
realm.statsis 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 pxas 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 in disguise. 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”Don’t trust setInterval(fn, 50) to hold a 50 ms period on your host. A consumer running their race
loop in a Durable Object (under wrangler dev) saw it behave like a gap between runs: each
tick’s own cost (the sim step, plus a realm commit that broadcasts to every client) landed on top
of the 50 ms. Their nominal 20 Hz loop actually delivered 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. It also 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()));};Checklist
Section titled “Checklist”- 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.inputSeqecho in the tick -
coalesce: { ms, leading: true }(or no window) on the game connection;broadcastCoalesceMsleft at 0 - Reconciler reacts to
meta.authoritative+ tick-advance only — nevercause === "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 — orcreatePredictedEntity, 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