Client Prediction (TypeScript)

A client that waits for the server before moving its own player feels a round trip of lag on every input. Prediction runs the same simulation locally: each input is applied at once, and when the authoritative snapshot arrives the client rewinds to it, replays the inputs the server has not applied yet, and smooths out the visual difference.

@altruist/prediction packages that machinery, generic over the game:

TypeScriptC# counterpartWhat it does
FixedStepClock, OverrunPolicy, parseOverrunFixedStepClock, OverrunPolicyframe times to fixed steps, bit-identical step counts
runFixedSteps(client only)the per-frame step loop with per-step input cutoffs
PredictedSession(client only)send with sequence numbers, newest-snapshot-wins, rewind and replay, pacing, smoothing
CorrectionSmoother, decayOffset, clampOffset(client only)render offsets that fade after a correction
InterpolationBuffer, lerp, lerpAngle(client only)draw remote states between two received ones

The package lives at Clients/TypeScript/prediction and builds on @altruist/input (sequencing, packets, pacing).

The Fixed-Step Clock

FixedStepClock is the server's accumulator (see the fixed-step clock) in TypeScript: the same hz, maxFrameDelta, maxStepsPerFrame and overrun policy give the same steps, clamped times, overrun flags and accumulator values, bit for bit. The step length is Math.fround(1 / hz), the C# 1f / hz.

import { FixedStepClock, runFixedSteps } from '@altruist/prediction';

const clock = new FixedStepClock(60, { maxFrameDelta: 0.25, maxStepsPerFrame: 6, overrun: 'drop' });

function frame(frameSeconds: number) {
  runFixedSteps(clock, frameSeconds, timeScale, performance.now(), (until) => {
    sim.step(sequencer.next(performance.now(), until));
  });
  draw(lerpFrame(prev, curr, clock.alpha));
}
PolicyWhen a frame needs more than maxStepsPerFrame steps
drop (default)the rest is discarded: after a hitch the game resumes at once (best for clients)
carrythe backlog is stepped in later frames (bounded by maxFrameDelta)
slow-motiona frame never adds more than maxStepsPerFrame steps of time

advance(frameSeconds, scale) takes an optional TypeScript-only scale (time scale, input pace) applied after the clamp; with 1 it is exactly the C# arithmetic.

Predicted Sessions

PredictedSession<TSim, TInput, TSnap, TFrame, TEvent> owns timing, sequencing, rewind and replay. The game supplies hooks:

import { PredictedSession } from '@altruist/prediction';

const session = new PredictedSession(sim, {
  header: (s) => ({ tick: s.tick, ackSeq: s.ack, inputDepth: s.inputDepth, inputTarget: s.inputTarget }),
  applySnapshot: (sim, s) => sim.applySnapshot(s),
  step: (sim, input) => sim.step(new Map([[localId, input]])),  // returns events
  capture: (sim) => captureFrame(sim),
  lerpFrame,
  sampleInput: (until) => quantizePad(sequencer.next(performance.now(), until)),
  send: (packet) => socket.send(inputPacket(packet.lastSeq, packet.inputs)),
  isTeleport: (prev, next) => prev.round !== next.round,       // snap instead of glide
  onSnapshot: (prev, next) => prev && emitScoreEvents(prev, next),
  bodies: (f) => f.players.map((p) => ({ id: p.id, pos: [p.x, p.y], angle: p.a, rate: p.id === localId ? 14 : 7 })),
  applyOffset: (f, id, o) => { const p = f.players.find((q) => q.id === id); if (p) { p.x += o.pos[0]; p.y += o.pos[1]; p.a += o.angle; } },
}, { hz: 60, clock: { maxStepsPerFrame: 6 }, inputs: { redundancy: 2 } });

socket.onSnapshot((s) => session.receive(s));
function onFrame(dt: number) {
  const events = session.update(dt);  // reconcile, step, send
  draw(session.frame());              // interpolated, with correction offsets
}

What happens:

  1. Receive: only the newest snapshot counts. One with a tick at or below the newest received or applied one is ignored, so late and reordered snapshots never rewind time.
  2. Reconcile (start of update): the depth report feeds the pacer, onSnapshot sees the previous and the new snapshot, the simulation is reset to the snapshot, acknowledged inputs leave the pending list and the rest are replayed. Events of replayed steps are discarded (they were reported when predicted).
  3. Step: runFixedSteps at the pacer's pace; each step samples, numbers, sends and predicts.
  4. Smooth: the difference between the poses before and after the reconcile becomes a per-object offset that decays every frame; a teleport or a pose flagged snap zeroes it.

Prediction is exact when the local simulation is deterministic and the input is quantized like the wire format: after a reconcile the replayed state equals the predicted one and the correction is 0. A correction then only comes from what the client could not know (other players' new inputs, a speculative step whose real input differed).

Smoothing options

correction optionDefaultMeaning
rate14Decay rate per second (about 1/rate seconds to fade); a pose's rate overrides it
maxSpeedInfinityFastest an offset moves the drawn object (world units/s), unless minRate is faster
minRate4Slowest decay rate while maxSpeed limits
maxOffsetInfinityLonger offsets are cut back (a desync that large is better shown than chased)

The distance limits depend on your world's scale, so they are off by default. A car game in metres might use maxSpeed: 12, maxOffset: 10.

Interpolating Remote States

For objects you do not predict (spectating, an interpolated client), keep the received states and draw them a little in the past:

import { InterpolationBuffer, lerp, lerpAngle } from '@altruist/prediction';

const ship = new InterpolationBuffer<Ship>();
ship.push(snap.tick, snap.ship);
const drawn = ship.sample(ship.newestTime! - 2.5, (a, b, t) => ({ x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), a: lerpAngle(a.a, b.a, t) }));

Time is any monotonic unit (ticks, seconds) used consistently. Before the oldest state the oldest is returned; after the newest the newest, or an extrapolation of up to maxExtrapolation of the last gap.

Golden Vectors

ClientTwinGoldenVectorTests (C#) runs eight clocks (60/30/120/144/50/7 Hz, every overrun policy, frame caps from 1 to 8) over 300 frame times each (steady refresh rates, jitter, hitches, zero and negative frames) and writes Clients/TypeScript/prediction/tests/golden/fixed-step-clock.json. The TypeScript test replays the frames and compares steps, clamped times, overrun flags and accumulators with Object.is. Regenerate with ALTRUIST_WRITE_GOLDEN=1 dotnet test --filter ClientTwinGoldenVectorTests.