Contents

  • 1. Tick Rate
  • 2. Snapshots and Update Rates
  • 3. Clock Synchronization
  • The Solution: Piggybacking
  • Key Insight: Minimum RTT
  • Implementation Strategy
  • 4. Interpolation
  • Calculating Render Time
  • Selecting Snapshots for Interpolation
  • 5. Extrapolation
  • 6. Client-Side Prediction
  • Client Input Buffer
  • 7. Reconciliation
  • 8. Lag Compensation
  • 9. The Server
  • Command Processing
  • Sending Snapshots
  • 10. Optimization
  • Binary Serialization
  • Delta Compression
  • Comments
Back to Home
December 12, 2025

Multiplayer Game Netcode Explained

#networking#gamedev

Contents

  • 1. Tick Rate
  • 2. Snapshots and Update Rates
  • 3. Clock Synchronization
  • The Solution: Piggybacking
  • Key Insight: Minimum RTT
  • Implementation Strategy
  • 4. Interpolation
  • Calculating Render Time
  • Selecting Snapshots for Interpolation
  • 5. Extrapolation
  • 6. Client-Side Prediction
  • Client Input Buffer
  • 7. Reconciliation
  • 8. Lag Compensation
  • 9. The Server
  • Command Processing
  • Sending Snapshots
  • 10. Optimization
  • Binary Serialization
  • Delta Compression
  • Comments

Prerequisites: This guide assumes you understand game loops (fixed timestep, variable rendering). If you need a refresher, check out mainloop.js and this detailed explanation by the author.

This article reflects my experience building 2D multiplayer games as a hobby alongside my day job in web development.

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.

Here's how it works.

1. Tick Rate

The server cannot process inputs continuously. Instead, it simulates time in discrete steps called Ticks1.

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 60Hz2. 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.

tickDuration=1000tickRate\text{tickDuration} = \frac{1000}{\text{tickRate}}tickDuration=tickRate1000​

2. Snapshots and Update Rates

Even at 60Hz, rendering entities only at their tick positions would look choppy to the human eye. But there's another problem: sending 60 snapshots per second to every connected player gets expensive fast. Imagine a server with 100 players; that's 6,000 snapshots per second.

To save bandwidth, servers send Snapshots3 at a lower rate (typically 20Hz). This compounds the visual problem: simulating at 60Hz but receiving updates at 20Hz makes movement significantly choppier.

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

The solution requires rendering in the past.

3. Clock Synchronization

Before interpolation can work smoothly, the client must synchronize its clock with the server. The challenge is that you cannot calculate the clock offset from one-way snapshots alone; you need to know the network latency to account for transmission delay.

The Solution: Piggybacking

Use an "echo" method that piggybacks on existing traffic:

  • Client: Includes a timestamp (Date.now()) in every input packet.
  • Server: Stores this timestamp and echoes it back in the next snapshot as ackTimestamp.
  • Client: When receiving the snapshot, calculates the Round Trip Time (RTT)4:
RTT=clientNow−ackTimestamp\text{RTT} = \text{clientNow} - \text{ackTimestamp}RTT=clientNow−ackTimestamp

The clock offset is then calculated by subtracting the latency (half the RTT) from the traversal time:

offset=clientNow−snapshot.serverTime−RTT2\text{offset} = \text{clientNow} - \text{snapshot.serverTime} - \frac{\text{RTT}}{2}offset=clientNow−snapshot.serverTime−2RTT​

The RTT2\frac{\text{RTT}}{2}2RTT​ term assumes symmetric network latency.

Key Insight: Minimum RTT

Do not average samples. Instead, track the lowest RTT seen so far and only update the offset when you find a sample with a lower RTT. The sample with the lowest RTT is the most accurate measurement possible, as it represents the physical distance with the least amount of network congestion or jitter.

Implementation Strategy

Updates should be frequent during the first few seconds (roughly 10-20 ticks) to find that "golden" low-latency packet. After that, stop updating the offset to prevent jitter. Only recalibrate if the connection drops or you detect significant, sustained drift.

Advanced Topics: For competitive shooters or high-frequency trading, methods like Clock Slewing, NTP Stratum Models, or Kalman Filters add unnecessary complexity for browser games.

4. Interpolation

To make movement look smooth as if no one was lagging, the client does not render the most recent snapshot it received. Instead, it renders the world in the past.

By buffering incoming snapshots, the client sets its renderTime behind the server. The interpolation delay is a configured value (typically 100ms) that determines how far back in time to render. The snapshot buffer must be large enough to store snapshots covering this delay:

snapshotBufferSize=⌈interpolationDelay×updateRate⌉+safetyMargin\text{snapshotBufferSize} = \left\lceil \text{interpolationDelay} \times \text{updateRate} \right\rceil + \text{safetyMargin}snapshotBufferSize=⌈interpolationDelay×updateRate⌉+safetyMargin

Where the safety margin (typically 1-2) accounts for network jitter and packet loss. For example, with a 0.1s interpolation delay and 20Hz update rate: ⌈0.1 × 20⌉ + 2 = 4 snapshots. This ensures the client always has two snapshots to interpolate between, plus extras for resilience.

Calculating Render Time

Each frame, the client calculates its current render time:

renderTime=currentServerTime−interpolationDelay\text{renderTime} = \text{currentServerTime} - \text{interpolationDelay}renderTime=currentServerTime−interpolationDelay

Where currentServerTime is the client's local time converted to server time using the clock offset. This ensures the client is always rendering 100ms in the past (or whatever delay is configured).

Selecting Snapshots for Interpolation

The client searches its snapshot buffer to find the two snapshots that bracket renderTime:

  1. Find the newest snapshot with timestamp ≤renderTime\leq \text{renderTime}≤renderTime (the older snapshot)
  2. Find the oldest snapshot with timestamp >renderTime> \text{renderTime}>renderTime (the newer snapshot)

If both snapshots exist, the client uses interpolation5 to mathematically smooth the movement between these two known points:

Snapshots Recv:       S1         S2         S3
Time: ────────────────●──────────●──────────●──────►
                      │          │
Current Render Time:  │     X    │ (100ms behind real time)
                      │    /     │
                      └───/──────┘
               Interpolate smoothly here

The client calculates how far between two snapshots it is rendering, then lerps the position:

t=renderTime−toldertnewer−toldercurrentPos=lerp(Polder,Pnewer,t)t = \frac{\text{renderTime} - t_{\text{older}}}{t_{\text{newer}} - t_{\text{older}}} \quad\quad \text{currentPos} = \text{lerp}(P_{\text{older}}, P_{\text{newer}}, t)t=tnewer​−tolder​renderTime−tolder​​currentPos=lerp(Polder​,Pnewer​,t)

Where t=0.5t = 0.5t=0.5 means exactly halfway between snapshots.

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

5. 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 Extrapolation6; 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 × Time)

This is Dead Reckoning7. The formula is simple physics:

Ppredicted=Plast+Vlast⋅ΔtP_{\text{predicted}} = P_{\text{last}} + V_{\text{last}} \cdot \Delta tPpredicted​=Plast​+Vlast​⋅Δ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 ~0.25 seconds before freezing.

6. Client-Side Prediction

If we're rendering other players 100ms in the past, why does local input feel instant?

Because waiting for server confirmation would make controls feel sluggish. The client doesn't wait: when you press a key, it runs its own local physics simulation immediately, applying changes instantly. Simultaneously, it sends that input command to the server:

{ sequenceId: 200, input: 'up', timestamp: 1500 }

The sequence ID allows the server to process inputs in order and acknowledge which commands it has validated.

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 frame, the client stores its input in the buffer. When it's time to send (based on the command send rate), the client batches recent inputs from the buffer and sends them to the server.

  2. Reconciliation: When the server sends back a snapshot with lastAcknowledgedSequenceId, the client needs to replay inputs that occurred after that sequence. The same input buffer stores these historical inputs, allowing the client to rewind and replay them during reconciliation.

For simplicity, the buffer can store up to a few seconds worth of inputs:

inputBufferSize=seconds×tickRate\text{inputBufferSize} = \text{seconds} \times \text{tickRate}inputBufferSize=seconds×tickRate

For example, storing 2 seconds at 60Hz requires 120 commands. This provides enough history for reconciliation while keeping the implementation straightforward.

7. 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. The client compares: did my predicted position at that tick match the server's?

If they don't match, a prediction error occurred. The client must accept the server's authority:

Loading diagram...

The reconciliation process:

  1. Rewind: Set client position to server's authoritative position at the acknowledged sequence
  2. Replay: Re-run the physics simulation for all unacknowledged inputs (after the acknowledged sequence)
  3. Smooth: If the final error is small, smooth the correction over several frames; otherwise snap immediately

This correction process 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.

8. Lag Compensation

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

The server solves this with Lag Compensation8. When a shot command arrives, the server calculates when the client actually fired, then rewinds all players to their positions at that moment:

tshot=tserver−latency−interpolationDelayt_{\text{shot}} = t_{\text{server}} - \text{latency} - \text{interpolationDelay}tshot​=tserver​−latency−interpolationDelay

The server then:

  1. Calculate Shot Time: Use the formula above
  2. Find History: Search position buffer for tshott_{\text{shot}}tshot​
  3. Rewind: Move all player hitboxes9 to historical positions
  4. Raycast:10 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 (100ms in the past), the victim was exposed. The server favors the shooter.

9. The Server

While clients predict locally, the server maintains the authoritative11 game state. Each tick, it processes commands from all connected players.

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.

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 handle network unreliability. The buffer size is calculated as:

bufferSize=batchSize×margin\text{bufferSize} = \text{batchSize} \times \text{margin}bufferSize=batchSize×margin

Where the batch size is how many commands arrive per network update, and the margin (typically 2) accounts for missing packets. For example, if clients send commands at 60Hz but network updates arrive at 20Hz, each batch contains 3 commands, so the total buffer is 3 × 2 = 6 commands.

The server processes one command per player per tick:

  1. Get the next command from the buffer (FIFO) based on expected sequence ID
  2. If found: apply the command and update lastAcknowledgedSequenceId
  3. If not found (packet loss): skip processing for this tick. The player simply doesn't perform that action.

If a movement command is lost, the player stops moving (which is what actually happened from the server's perspective). The client's prediction will handle the visual smoothness, and reconciliation will correct any divergence.

Alternative: You can record the last received command and repeat it when a command is lost. This can feel smoother for continuous inputs like movement, but it can mask input bugs and make debugging harder.

If the buffer overflows (client sending commands faster than the server can process), the server takes only the most recent commands up to the buffer limit. 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:

  • Timestamp (when this state existed, serverTime)
  • ackTimestamp for each player (the echoed client timestamp from their last input, used for clock synchronization)
  • 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.

10. 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.

Footnotes

  1. A tick is a single step in the game's simulation loop. The server processes all game logic once per tick. ↩

  2. Hz (Hertz) measures frequency; 60Hz means 60 times per second. ↩

  3. A snapshot is a complete picture of the game state at a specific moment, containing positions, health, and other relevant data for all entities. ↩

  4. RTT (Round Trip Time) is the total time it takes for a packet to travel from client to server and back. It measures network latency and is essential for calculating clock offset. ↩

  5. Interpolation calculates intermediate values between two known points to create smooth transitions. ↩

  6. Extrapolation predicts future values based on existing data by extending a known trend. ↩

  7. Dead reckoning is a navigation technique that estimates current position based on a previously known position and velocity. ↩

  8. Lag compensation is a server technique that rewinds time to validate shots based on what the shooter actually saw on their screen. ↩

  9. A hitbox is an invisible shape used to detect collisions, typically simplified geometry that approximates a character's body. ↩

  10. 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. ↩

  11. Authoritative means the server has final say over game state. When client predictions conflict with server state, the server's version is always correct. ↩

Comments

Download as Markdown
Get the raw markdown content

More Articles

Dependency Inversion: Where It All Starts

December 8, 2025

© 2025 Donny Roufs. All rights reserved.