Skip to content

Instantly share code, notes, and snippets.

@waltervargas
Created March 18, 2026 18:22
Show Gist options
  • Select an option

  • Save waltervargas/b32e9c5f706c96bb86b4ac4e168f2845 to your computer and use it in GitHub Desktop.

Select an option

Save waltervargas/b32e9c5f706c96bb86b4ac4e168f2845 to your computer and use it in GitHub Desktop.
Interpolation vs Extrapolation in Multiplayer Netcode — Transcendence Pong

Interpolation vs Extrapolation in Multiplayer Netcode

The Problem: Network Jitter

The server sends game state at 60fps (~17ms intervals), but packets don't arrive evenly. Our measured jitter range is 5ms–26ms. Without compensation, the client freezes on stale frames then snaps to new positions.

sequenceDiagram
    participant Server
    participant Network
    participant Client

    Note over Server: Tick 1 (t=0ms)
    Server->>Network: State A (ball at x=100)
    Note over Network: 15ms transit
    Network->>Client: State A arrives

    Note over Server: Tick 2 (t=17ms)
    Server->>Network: State B (ball at x=110)
    Note over Network: 26ms transit (jitter!)
    Note over Client: No new state for 26ms<br/>Renders State A repeatedly<br/>Ball appears FROZEN

    Network->>Client: State B arrives late
    Note over Client: Snaps ball from x=100 to x=110<br/>Ball appears to TELEPORT
Loading

What the Client Sees Today (No Compensation)

graph LR
    subgraph "Client Render Frames (every ~16ms)"
        F1["Frame 1<br/>ball x=100"]
        F2["Frame 2<br/>ball x=100<br/>(same!)"]
        F3["Frame 3<br/>ball x=100<br/>(same!)"]
        F4["Frame 4<br/>ball x=110<br/>(JUMP!)"]
        F5["Frame 5<br/>ball x=120"]
    end

    F1 --> F2 --> F3 --> F4 --> F5

    style F2 fill:#ff6b6b,color:#fff
    style F3 fill:#ff6b6b,color:#fff
    style F4 fill:#ffaa00,color:#000
Loading

Frames 2–3 are frozen (no new data). Frame 4 is a jump (late packet arrived).


Solution 1: Interpolation (Rendering the Past Smoothly)

Core idea: Don't render the latest state immediately. Buffer 2 states and smoothly blend between them. The client always renders ~1 tick behind real-time.

sequenceDiagram
    participant Server
    participant Client Buffer
    participant Renderer

    Note over Server: Tick 1 (t=0ms)
    Server->>Client Buffer: State A (ball x=100)
    Note over Client Buffer: Buffer: [A]<br/>Not enough to interpolate yet

    Note over Server: Tick 2 (t=17ms)
    Server->>Client Buffer: State B (ball x=110)
    Note over Client Buffer: Buffer: [A, B]<br/>Now can interpolate!

    Note over Renderer: Render time = now - 17ms<br/>(one tick behind)

    loop Every render frame (~16ms)
        Client Buffer->>Renderer: Blend A→B based on elapsed time
        Note over Renderer: t=0.0 → ball x=100<br/>t=0.25 → ball x=102.5<br/>t=0.5 → ball x=105<br/>t=0.75 → ball x=107.5<br/>t=1.0 → ball x=110
    end

    Note over Server: Tick 3 (t=34ms)
    Server->>Client Buffer: State C (ball x=120)
    Note over Client Buffer: Buffer: [B, C]<br/>Shift window forward
Loading

How Interpolation Blends Positions

graph TB
    subgraph "Interpolation: Smooth In-Between Frames"
        SA["State A<br/>ball x=100<br/>t=0ms"]
        I1["Rendered<br/>ball x=102.5<br/>t=4ms"]
        I2["Rendered<br/>ball x=105<br/>t=8ms"]
        I3["Rendered<br/>ball x=107.5<br/>t=12ms"]
        SB["State B<br/>ball x=110<br/>t=17ms"]
    end

    SA -.->|"lerp 0.25"| I1
    I1 -.->|"lerp 0.50"| I2
    I2 -.->|"lerp 0.75"| I3
    I3 -.->|"lerp 1.00"| SB

    style SA fill:#4ecdc4,color:#000
    style SB fill:#4ecdc4,color:#000
    style I1 fill:#95e6dc,color:#000
    style I2 fill:#95e6dc,color:#000
    style I3 fill:#95e6dc,color:#000
Loading

The Math (Linear Interpolation)

t = (currentTime - stateA.timestamp) / (stateB.timestamp - stateA.timestamp)
t = clamp(t, 0, 1)

renderedX = stateA.ballX + (stateB.ballX - stateA.ballX) * t
renderedY = stateA.ballY + (stateB.ballY - stateA.ballY) * t

Interpolation Handles Jitter Gracefully

sequenceDiagram
    participant Server
    participant Buffer
    participant Renderer

    Server->>Buffer: State A (t=0)
    Server->>Buffer: State B (t=17)

    Note over Renderer: Smoothly blending A→B...

    Note over Server: State C sent at t=34
    Note over Buffer: Packet delayed by jitter!<br/>Still blending A→B, no freeze

    Note over Renderer: Reaches end of A→B blend<br/>Holds at State B position<br/>(brief pause, but no teleport)

    Server->>Buffer: State C arrives late (t=34)
    Note over Buffer: Buffer: [B, C]
    Note over Renderer: Smoothly blends B→C<br/>No visible jump!
Loading

Trade-offs

Aspect Value
Visual smoothness Excellent — always smooth motion
Added latency ~17ms (one tick behind real-time)
Handles jitter Yes — smooths over gaps
Handles packet loss Partial — holds last position until next packet
Complexity Low
Accuracy Always shows positions the server actually computed

Solution 2: Extrapolation (Predicting the Future)

Core idea: When no new state arrives, use the last known velocity to predict where objects should be. Correct when the real state arrives.

sequenceDiagram
    participant Server
    participant Client

    Note over Server: Tick 1 (t=0ms)
    Server->>Client: State A (ball x=100, vx=600)

    Note over Client: No new state yet...<br/>PREDICT: ball x = 100 + 600 * dt

    loop Every render frame
        Note over Client: t=5ms → x=103<br/>t=10ms → x=106<br/>t=15ms → x=109<br/>t=20ms → x=112 (predicted)
    end

    Note over Server: Tick 2 (t=17ms)
    Server->>Client: State B (ball x=110, vx=600)
    Note over Client: Predicted x=112, actual x=110<br/>SNAP CORRECTION (2px jump!)
Loading

The Prediction Error Problem

graph TB
    subgraph "Extrapolation: Prediction vs Reality"
        SA["Last Known State<br/>ball x=100, vx=600"]
        P1["Predicted<br/>x=103"]
        P2["Predicted<br/>x=106"]
        P3["Predicted<br/>x=109"]
        P4["Predicted<br/>x=112"]
        REAL["Server Reality<br/>x=110"]
        SNAP["SNAP!<br/>x=112 → x=110"]
    end

    SA -->|"+3"| P1 -->|"+3"| P2 -->|"+3"| P3 -->|"+3"| P4
    P4 -->|"correction"| SNAP
    REAL -.->|"reality diverged"| SNAP

    style P4 fill:#ff6b6b,color:#fff
    style SNAP fill:#ffaa00,color:#000
    style REAL fill:#4ecdc4,color:#000
Loading

When Extrapolation Fails Badly: Ball Bounce

graph LR
    subgraph "Wall Bounce — Extrapolation Disaster"
        direction TB
        WALL["WALL (y=0)"]
        PRED["Predicted path<br/>(goes THROUGH wall)"]
        ACTUAL["Actual path<br/>(bounces off wall)"]
    end

    subgraph "Timeline"
        T0["t=0<br/>ball y=20, vy=-600"]
        T1["t=17ms<br/>Predicted: y=9.8"]
        T2["t=34ms<br/>Predicted: y=-0.4 !!<br/>Actual: y=0.4 (bounced)"]
        T3["Server correction<br/>RUBBER BAND!"]
    end

    T0 --> T1 --> T2 --> T3

    style T2 fill:#ff6b6b,color:#fff
    style T3 fill:#ffaa00,color:#000
Loading

The client predicts the ball going through the wall, then snaps back when the server says it bounced. This is called rubber-banding and feels terrible.

Trade-offs

Aspect Value
Visual smoothness Good during straight motion, bad at bounces
Added latency None — renders ahead of server
Handles jitter Partially — fills gaps, but corrections are jarring
Handles packet loss Better than nothing, but accumulates error
Complexity Medium (need physics duplication or velocity projection)
Accuracy Shows positions the server may NOT have computed

Side-by-Side Comparison

graph TB
    subgraph "Interpolation (Recommended)"
        direction LR
        IA["State A<br/>t=0ms"] --> IB["Smooth blend<br/>..."] --> IC["State B<br/>t=17ms"]
        note1["Always accurate<br/>+17ms delay<br/>Never rubber-bands"]
    end

    subgraph "Extrapolation"
        direction LR
        EA["State A<br/>t=0ms"] --> EB["Predicted<br/>..."] --> EC["Correction!<br/>SNAP"]
        note2["Zero delay<br/>But prediction errors<br/>cause rubber-banding"]
    end

    style IB fill:#4ecdc4,color:#000
    style EB fill:#ff6b6b,color:#fff
    style EC fill:#ffaa00,color:#000
Loading

Decision for Transcendence Pong

flowchart TD
    Q1{Is 17ms extra latency<br/>acceptable?}
    Q1 -->|"Yes — imperceptible<br/>for Pong"| INTERP["Use Interpolation"]
    Q1 -->|"No — need zero<br/>added delay"| Q2{Can you duplicate<br/>physics on client?}
    Q2 -->|"Yes"| EXTRAP["Use Extrapolation<br/>(with reconciliation)"]
    Q2 -->|"No"| HYBRID["Use Hybrid:<br/>Interpolate ball<br/>Predict own paddle"]

    style INTERP fill:#4ecdc4,color:#000,stroke:#333,stroke-width:3px
    style EXTRAP fill:#ff6b6b,color:#fff
    style HYBRID fill:#ffaa00,color:#000
Loading

Recommendation: Interpolation. Pong ball speed is ~570px/sec (from the trace data). At that speed, 17ms of added delay = ~9.7 pixels behind real-time. On an 800px-wide canvas, that's ~1.2% — invisible to the human eye but completely eliminates stutter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment