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
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
Frames 2–3 are frozen (no new data). Frame 4 is a jump (late packet arrived).
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
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
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
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!
| 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 |
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!)
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
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
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.
| 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 |
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
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
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.