
**Prerequisites:** Familiarity with game loops (fixed timestep, variable rendering) is assumed. For a refresher, see [mainloop.js](https://github.com/IceCreamYou/MainLoop.js) and this [detailed explanation](https://www.isaacsukin.com/news/2015/01/detailed-explanation-javascript-game-loops-and-timing) by the author.

The techniques below target 2D browser multiplayer but generalize to any authoritative client-server shooter.

> **Important:** Avoid VM-like environments (WSL, Docker Desktop, VirtualBox) for multiplayer game development. These environments have poor clock synchronization, causing severe timing inconsistencies and jitter. Use a native OS installation instead.

Online multiplayer is an illusion.

In a perfect world, players would share a single state instantly. In reality, we are bound by the speed of light, unstable Wi-Fi, congested routers, and cheaters. If we simply sent data back and forth as it happened, the game would be an unplayable, jittery mess.

To create the sensation of a smooth, fair, shared reality, game engines like Valve's Source engine rely on a complex web of techniques: we delay time, we predict the future, and we rewrite the past.

## Architecture

The system is two loops communicating over a wire:

- The **server** runs an authoritative simulation on a fixed tick and broadcasts snapshots of the world several times per second (slower than it ticks).
- Each **client** predicts its own player instantly, renders every other entity slightly in the past, and corrects itself whenever a snapshot disagrees.

Each technique that follows belongs to one of these loops. They are presented in build order: the server's authoritative loop first, since it is the source of truth; then the client's prediction and interpolation; then lag compensation, which reconciles the two across network delay. Section 10 collapses both loops into a single reference.

## 1. Tick Rate

The server cannot process inputs continuously. Instead, it simulates time in discrete steps called `Ticks`[^tick].

Think of the server not as a video stream, but as a turn-based game played at lightning speed. A standard competitive shooter runs at 60Hz[^hertz]. Every ~16ms, the server wakes up, processes inputs, runs physics, and updates the world.

```
Time ────────────────────────────────────────────────────►

Server Ticks:   ■───────■───────■───────■───────■
                │ ~16ms │ ~16ms │ ~16ms │ ~16ms │
```

The tick rate keeps simulation deterministic: Tick 100 always happens before Tick 101, regardless of CPU fluctuations.

$$
\text{tickDuration} = \frac{1000}{\text{tickRate}}
$$

Crucially, the tick number is also the **shared timeline** between client and server. Every quantity that follows is expressed in ticks.

## 2. Snapshots and Update Rates

After each tick, the server captures the state of the world; every entity's position, velocity, health, rotation, and so on; into a `Snapshot`[^snapshot]. This is the packet clients receive and render from. Each snapshot is stamped with the `tick` it represents.

The obvious approach is to send a snapshot every tick. That gets expensive fast: at 60Hz, a server with 100 players is sending 6,000 snapshots per second.

So servers simulate at the full tick rate but send snapshots at a lower rate (typically 20Hz). This saves bandwidth at a cost: the client now only receives a fraction of the positions the server computed, so naively rendering each snapshot as it arrives looks choppy.

```
Server (60Hz):  ●  ●  ●  ●  ●  ●  ●  ●  ●  ●
Network (20Hz): ●──────────●──────────●───►
                (Missing data in between)
```

The solution requires rendering in the past.

## 3. Server Tick as Shared Time

To render the past, the client and server have to agree on what time it is. The tick number already gives them that, for free.

Every snapshot is stamped with the server's `tick`: an integer that only counts up, decided solely by the server. Both sides treat it as their shared clock, so there's no need to synchronize wall clocks or measure latency to line them up.

### Estimating the current tick

The client only learns a new tick when a snapshot arrives. Between snapshots, it estimates the current server tick by taking the last one it received and advancing locally at the simulation rate:

$$
\text{estimatedServerTick} = \text{latest.tick} + \frac{\text{msSinceLatestArrival}}{\text{msPerTick}}
$$

This estimate sits slightly behind the true server tick, by roughly the time the snapshot took to arrive (~RTT/2), but the interpolation buffer (Section 5) absorbs that.

In practice, don't recompute this from scratch every frame. Run a local **render clock** that advances one tick per simulation step and slews gently toward each snapshot's `tick`, snapping only on large jumps like a tab resume. This avoids the visible micro-stutter you get when packets arrive in bursts. It's Source's drift-corrected clock, expressed in ticks.

## 4. The Server

The server maintains the `authoritative`[^authoritative] game state; every client ultimately defers to it. Each tick, it processes commands from all connected players, advances the simulation, and (a few times per second) broadcasts the result.

### Command Processing

Valve's Source engine uses UDP, so commands can arrive out of order or be lost entirely. The server uses sequence IDs to sort them and discard duplicates. If you're building with WebSockets over TCP, order is guaranteed by the protocol, but you still need sequence IDs for reconciliation (Section 8).

**Important for TCP:** Disable Nagle's algorithm with `socket.setNoDelay(true)`. Nagle batches small packets to reduce overhead, but this adds latency which is unacceptable for real-time games.

Each player has an input buffer to absorb network jitter. Size it small; a server input queue's depth equals how far behind real-time the server is simulating that player. A reasonable bound is roughly one snapshot interval of ticks (about 3 ticks at a 60Hz sim with 20Hz snapshots), with overflow dropping the oldest entries. Bigger numbers feel safer but actually add permanent lag after a network burst.

The server processes **one command per player per tick**:

1. Get the next command from the buffer (FIFO).
2. If found: apply the command and update `lastAcknowledgedSequenceId`.
3. If not found (packet loss): apply your missing-input policy (see below).

### Missing-Input Policy

What happens on a tick where the player's input buffer is empty depends on your movement model:

- **Acceleration + friction model:** skip the tick. No force is applied, friction decelerates naturally, the player slows. This is the conventional default.
- **Direct-velocity model:** persist the previous tick's velocity (or repeat the last command). Zeroing velocity on every missed tick produces visible stutter-stops during normal jitter, because there's no friction to mask it.

Either way, add a **timeout**: after N silent ticks for a player, zero their velocity. Without it, a disconnected client whose last input was a movement key keeps drifting forever.

If the buffer overflows (client flooding faster than the server can drain), the server drops the oldest commands. This automatically resyncs the client by discarding stale inputs.

### Sending Snapshots

After simulating the tick, the server bundles the world state into a snapshot containing positions, velocities, health, and other entity data. It stamps each snapshot with:

- The server's current `tick` (the shared timeline from Section 3).
- `lastAcknowledgedSequenceId` for each player (the last input command processed).

The server then sends snapshots to all clients at the configured update rate (typically 20Hz), even though it's simulating at a higher tick rate (60Hz). Snapshots are sent at regular intervals, not every tick, to save bandwidth.

## 5. Interpolation

A client receives a snapshot only every ~50ms but renders at full frame rate. Displaying each snapshot the moment it arrives produces visibly choppy motion for remote entities.

The fix is to not render the most recent snapshot at all. Instead, the client renders the world **in the past**, a fixed number of ticks behind its estimate of the server's current tick, and interpolates between the two snapshots that bracket that point.

Pick an **interpolation delay** as your feel knob: how far in the past to render. 100ms is the classic value, matching Source's `cl_interp 0.1`.

That delay converts into a number of ticks, the **interpolation buffer**:

$$
\text{interpolationBufferTicks} = \text{interpolationDelay} \times \text{tickRate}
$$

At 100ms and 60Hz that's 6 ticks. The client renders that many ticks behind its estimate of the server's current tick:

$$
\text{renderTick} = \text{estimatedServerTick} - \text{interpolationBufferTicks}
$$

The snapshot buffer only has to hold enough snapshots to cover that delay. At 20Hz, snapshots arrive every 50ms, so 100ms spans 2 of them; keep a couple extra for jitter and packet loss, and storing ~4 snapshots is plenty.

Pick the delay first and let the buffer sizes follow from it, not the other way around. If you treat a buffer count as the knob instead, changing your snapshot rate silently changes how far in the past remote players appear.

> **One catch:** the delay has to be larger than the gap between snapshots, or the client keeps advancing past the newest snapshot it has and stutters. 100ms of delay against 50ms snapshots leaves comfortable room. If you lower your snapshot rate later, raise the delay to match. (Some engines tune the delay automatically from measured network jitter; that "adaptive interpolation" is a topic of its own, with its own caveats.)

### Selecting Snapshots for Interpolation

The client searches its snapshot buffer for the two snapshots that bracket `renderTick`:

1. The newest snapshot with `tick` $\leq \text{renderTick}$ (the older snapshot)
2. The oldest snapshot with `tick` $> \text{renderTick}$ (the newer snapshot)

If both exist, the client uses `interpolation`[^interpolation] between them:

```
Snapshots Recv:       S1         S2         S3
Ticks: ───────────────●──────────●──────────●──────►
                      │          │
Current renderTick:   │     X    │ (a few ticks behind)
                      │    /     │
                      └───/──────┘
               Interpolate smoothly here
```

The blend factor $t$ and final position are:

$$
t = \frac{\text{renderTick} - \text{tick}_{\text{older}}}{\text{tick}_{\text{newer}} - \text{tick}_{\text{older}}}
$$

$$
\text{currentPos} = \text{lerp}(P_{\text{older}}, P_{\text{newer}}, t)
$$

Where $t = 0.5$ means exactly halfway between the two snapshots.

This delay is why players see enemies where they were, not where they are now. But because the movement is smooth, it feels real-time.

## 6. Extrapolation

When a packet is lost, the client runs out of snapshots to interpolate between.

We could freeze the entity in place, but that looks jarring. Instead, the engine switches to `Extrapolation`[^extrapolation]; the client uses the entity's last known velocity to predict where they should be if they kept moving in a straight line.

```
Interpolation:      S1 ─────── S2 (Path is known)

Extrapolation:      S2 ─ ─ ─ ─ ─ ─ ► ? (Path is guessed)
                    (Velocity × Δticks)
```

This is `Dead Reckoning`[^dead-reckoning]. The formula is simple physics:

$$
P_{\text{predicted}} = P_{\text{last}} + V_{\text{last}} \cdot \Delta t
$$

If the player turned during the lost packet, the prediction will be wrong and the entity snaps when real data arrives. To prevent extreme warping, engines typically limit extrapolation to ~15 ticks (250ms at 60Hz) before freezing.

> **Naming note:** "Extrapolation" is a *client-side rendering* concept; it has nothing to do with how the server handles missing input commands (Section 4). They're separate problems that both happen to be "what to do when data is missing."

## 7. Client-Side Prediction

Interpolation renders remote players in the past. Applying the same delay to local input would make the controls feel sluggish, so the local player is handled differently: it is simulated immediately and reconciled with the server afterward.

On each tick, the client samples input, runs its local physics simulation, and applies the result instantly. In the same tick it sends the input command to the server:

```
{ seq: 200, keys: ['up'] }
```

The sequence ID lets the server order inputs and tell the client which commands it has processed. That's all the command needs; there's no timing information on it, because ticks (Section 3) already give both sides a shared clock.

> **The rule that makes all of this work:** the client and server must run the *exact same* simulation. Local prediction, reconciliation replay (Section 8), and the server's authoritative tick should all call **one shared `simulate(state, input, dt)` function**. If the two sides compute movement even slightly differently, every reconciliation will "correct" a phantom error and the player rubber-bands constantly. Write the simulation once, share it between client and server, and keep it deterministic: no wall-clock time, no per-frame randomness, same result for the same inputs.

### Client Input Buffer

The client maintains an input buffer (typically implemented as a ring buffer) that serves two purposes:

1. **Command Sending:** The client runs simulation at the same rate as the server (e.g., 60Hz), but throttles command sending to a lower rate (e.g., 30Hz) to save bandwidth. Each tick, the client stores its input in the buffer. When it's time to send, the client batches recent inputs from the buffer and sends them to the server. Batching does not merge them: every input keeps its own sequence number, so the server still applies them individually, one per tick, in order.

2. **Reconciliation:** When the server sends back a snapshot with `lastAcknowledgedSequenceId`, the client needs to replay inputs that occurred after that sequence. The same buffer stores these historical inputs.

For simplicity, size it to hold a couple seconds of inputs. At 60Hz that's about 120 commands; plenty of headroom for reconciliation.

## 8. Reconciliation

The client and server usually agree. But sometimes they diverge due to collisions, server-side hit detection, anti-cheat corrections, or unstable network conditions. (If you've built optimistic UIs in single-page applications, this is the same pattern: update immediately, then reconcile with the server's response.)

When the server sends a snapshot, it includes `lastAcknowledgedSequenceId`: the last input command it processed. Everything after that sequence is still unconfirmed; the client predicted it, but the server hasn't weighed in yet.

Rather than checking for an error first, the client rebuilds its predicted state from the server's authority on every snapshot:

<Mermaid chart={`
sequenceDiagram
    participant C as Client
    participant S as Server

    Note over C: Predicted position: (10, 5)

    S->>C: Snapshot (seq 200 acknowledged)
    Note over S: Authoritative position at seq 200: (9, 5)

    Note over C: 1. Rewind to seq 200 → (9, 5)
    Note over C: 2. Replay inputs 201-205
    Note over C: 3. Result: (10, 5)
    Note over C: Compare result vs predicted → correction size

    alt Difference is tiny
        Note over C: Ignore (avoid jitter)
    else Difference is moderate
        Note over C: Smooth over frames
    else Difference is large
        Note over C: Snap immediately
    end

`} />

The reconciliation process:

1. **Rewind:** Set client position to the server's authoritative position at the acknowledged sequence.
2. **Replay:** Re-run the physics simulation for all unacknowledged inputs (after the acknowledged sequence).
3. **Reconcile the visual:** Compare where replay lands against where you were already rendering, and correct based on the size of the gap.

The replay runs **every snapshot, unconditionally**; you don't wait to detect an error first. If prediction was perfect, replaying from the server's state lands you exactly where you already were and nothing visibly changes. If it diverged, the leftover gap is your correction.

How you apply that correction depends on its size:

- **Tiny** (a fraction of a unit): ignore it. Correcting on every sub-unit difference would jitter constantly. This deadzone is what keeps a well-predicted player visually stable.
- **Moderate:** ease toward the corrected position over a few frames.
- **Large:** snap immediately; smoothing a big jump just looks like sliding.

This is what players experience as rubber-banding: when the client's prediction diverges from the server's reality, the player's character snaps or smoothly corrects back to the authoritative position.

> **Where reconcile sits in your tick loop:** run it **before** you apply this tick's input, not after. Reconciliation corrects *past* state. If you apply and record the current input first, then reconcile, the current input ends up in the replay buffer and gets applied twice; the result is one tick of double-stepping every time a snapshot arrives.
>
> The correct order each fixed tick is: **reconcile → sample input → apply locally → ship to server + record in history**.

## 9. Lag Compensation

Since clients render enemies in the past, direct hit detection would fail. By the time you click, the enemy has already moved.

The server solves this with `Lag Compensation`[^lag-compensation]. The client already knows the tick it was rendering when it fired, so it can simply send that tick along with the shot. The server then rewinds the world to where it was at that tick.

There are two ways the client can tell the server which tick to rewind to:

- **Stamp the shot itself.** The shot message carries the `renderTick` the client used when raycasting.
- **Stamp the input frame.** If shots are folded into the input stream (as a held "firing" bit + aim direction), every input frame carries the tick it was sampled at, and the server uses that tick for rewind on any frame where the fire bit is set.

The second approach inherits the input stream's loss recovery and ordering for free, but either works.

Rewinding requires the server to retain recent history: a ring buffer holding one entry per tick, each recording every collidable entity's position and hitbox. The buffer is sized to the maximum rewind distance derived below.

The server then:

1. **Read the shot's tick** (sent by the client).
2. **Find the recorded world at that tick** in its history buffer.
3. **Rewind:** Move target hitboxes[^hitbox] to their positions at that tick.
4. **Raycast:**[^raycast] Check for collision.
5. **Restore:** Return players to current positions.

This is why players die "behind walls." From the victim's perspective, they're safe. From the shooter's perspective (rendering some ticks in the past), the victim was exposed. The server favors the shooter.

> **Rewind the targets, not the shooter.** You rewind everyone the shooter could have *hit* to the shot's tick, but not the shooter themselves; they see their own player in the present and only others in the past. Rewinding the shooter too fires the ray from a stale position: edge shots miss while strafing, and the shooter appears to fire "through" cover they had already moved behind on their own screen.

> **Bound the rewind window.** Don't let clients rewind arbitrarily far. Two separate bounds do different jobs:
>
> - **A hard cap on how far back you'll go**, sized at the interpolation delay plus a ceiling on one-way lag (say 100ms + 200ms = 300ms, roughly 18 ticks at 60Hz). A player laggier than the cap isn't denied; their rewind just *clamps* at the limit, so some shots they saw as hits will miss. That graceful degradation is the norm, and it's exactly what Source's `sv_maxunlag` does (default 1s).
> - **A per-shot sanity check against measured latency.** A client on a 40ms link has no legitimate reason to claim a 400ms-old tick. Reject claims older than their own RTT allows; otherwise a lag-switching client can rewind to whenever it likes and shoot players who have already moved. This one *does* deny the shot, because no honest client would make the claim.

### Sub-tick precision (and why not timestamps)

By default the tick you send is an integer, so lag comp rewinds to whole-tick boundaries; fine for most games. If you want to rewind to the exact instant *between* two ticks (a shot fired mid-tick), you might reach for a wall-clock timestamp. You don't need one.

Send the tick as a float instead (e.g. `152.37`). It carries sub-tick precision, both sides already agree what it means (it's anchored to the tick rate), and the server interpolates its tick-keyed history to that exact point. No clock sync. The only thing that genuinely needs real time is *measuring* latency itself, and even that needs just the client's own timer plus an echo, never a comparison between the two machines' clocks.

> **Not the same as CS2 "subtick."** A fractional tick here only picks a more precise rewind point for lag compensation; the simulation still runs on whole ticks. CS2's subtick goes further: it records the exact instant within a tick that *every* input fires and feeds that timing into the simulation itself. Same instinct (don't discard intra-tick timing), much broader feature.

## 10. Putting It Together

Here are both loops in one place. Everything above is a piece of one of these:

```
// ── SERVER ── every tick (e.g. 60Hz)
for each player:
  command = inputQueue[player].shift()       // one per tick, FIFO
  applyInput(player, command)                // else missing-input policy
simulate(dt)                                  // shared deterministic step
recordWorldHistory(tick)                      // for lag compensation
for each shot fired this tick:
  rewind targets to shot.tick; raycast; restore
if tick % (tickRate / snapshotRate) == 0:     // e.g. every 3rd tick
  broadcast snapshot { tick, entities, lastAckedSeq per player }

// ── CLIENT ── fixed tick (same rate as server)
reconcile()                                   // snap to server state, replay un-acked inputs
input = sampleInput()
applyInput(localPlayer, input)                // predict immediately
record(input); send(input)                    // store for replay, ship (batched)

// ── CLIENT ── render frame (variable rate)
renderTick = estimatedServerTick - interpolationBuffer
for each remote entity:
  interpolate between the two snapshots bracketing renderTick
  (or extrapolate if there's nothing ahead)
draw()
```

Build the server loop first; it is the source of truth. Then add client prediction, then interpolation for everyone else, and finally lag compensation once both sides agree on ticks.

## 11. Optimization

To save more bandwidth and improve performance, apply the following techniques:

### Binary Serialization

Replace JSON with binary formats using typed arrays (`Float32Array`, `Uint32Array`). This eliminates string parsing and field name overhead, making packets significantly smaller and faster to encode/decode.

### Delta Compression

Send only what changed since the last acknowledged snapshot instead of full world state every update. The server tracks the last snapshot each client received and sends diffs. Handle packet loss by falling back to full snapshots when needed.

## Implementation

A concrete, code-first build of these techniques in TypeScript: [Implementing Netcode in TypeScript for a Topdown Shooter](/articles/implementing-netcode-typescript-topdown-shooter).

[^tick]: A tick is a single step in the game's simulation loop. The server processes all game logic once per tick.
[^hertz]: Hz (Hertz) measures frequency; 60Hz means 60 times per second.
[^snapshot]: A snapshot is a complete picture of the game state at a specific moment, containing positions, health, and other relevant data for all entities.
[^interpolation]: Interpolation calculates intermediate values between two known points to create smooth transitions.
[^extrapolation]: Extrapolation predicts future values based on existing data by extending a known trend.
[^dead-reckoning]: Dead reckoning is a navigation technique that estimates current position based on a previously known position and velocity.
[^lag-compensation]: Lag compensation is a server technique that rewinds time to validate shots based on what the shooter actually saw on their screen.
[^hitbox]: A hitbox is an invisible shape used to detect collisions, typically simplified geometry that approximates a character's body.
[^raycast]: A raycast is a technique that traces an invisible line through the game world to detect what it intersects, commonly used for bullet hit detection.
[^authoritative]: Authoritative means the server has final say over game state. When client predictions conflict with server state, the server's version is always correct.
