# Smooth Multiplayer Game Rendering: Interpolation, Prediction, and Lag-Hiding Techniques with PixiJS

> How to hide network latency and keep sprites gliding.

## TL;DR 📝

Use a small interpolation buffer for remote entities, predictable movement math for prediction, and a modest smoothing factor to blend authority and responsiveness.

[![GitHub Code](https://img.shields.io/badge/Check_The_Code-2ea44f?style=for-the-badge&logo=github align="center")](https://github.com/ElBartt/Sheperd_2)

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1761012593903/63257bc6-ce24-4788-b348-3f39bd769c65.jpeg align="center")

[![Play Now](https://img.shields.io/badge/Play%20Now-Online-blue.svg?style=for-the-badge align="center")](https://oslo.ovh/game)

### Related Articles

[Optimizing Boids in multiplayer](https://arnauld-alex.com/flocks-that-scale-engineering-efficient-boids-with-spatial-grids-and-behavioral-layers)  
[Building Multiplayer Game in TS](https://arnauld-alex.com/guiding-the-flock-building-a-realtime-multiplayer-game-architecture-in-typescript)

### Who this is for

Developers aiming to improve perceived responsiveness in realtime apps — particularly those building multiplayer games with observable entity motion.

## Perception vs. Reality

In real-time multiplayer, packets arrive in bursts, not perfectly spaced frames. If you render raw server positions as they arrive, motion jitters. **Shepherd's World** uses a small *interpolation buffer* plus local prediction to make everything feel continuous while still respecting server authority.

Imagine two snapshots landing 30ms apart, then a gap of 80ms. Without buffering, the entity would jump, pause, then jump again. With a ~50–100ms time shift we *slide* between earlier, known points — your brain sees continuity where the network delivered lumpiness.

### Guiding Principles

| Principle | Plain Explanation |
| --- | --- |
| Time Shift | Render slightly in the past to interpolate between known states |
| Smoothing Factor | Blend toward target instead of teleporting |
| Shared Determinism | Use identical movement code on client/server |
| Local Prediction | Apply input instantly for responsiveness |
| Visual Polish | Use z-index, facing, and UI feedback to reinforce quality |
| Fail Gracefully | Degrade smoothly under packet loss |

## Rendering Subsystems (Façade Pattern)

Rather than a single mega-render loop, `RenderManager` delegates tasks:

* Background: `BackgroundManager`
    
* Entities: `PlayerSpriteManager`, `BoidSpriteManager`
    
* Interpolation & smoothing: `AnimationManager`
    
* UI overlays: `UIManager`
    
* Layer ordering: `ZIndexManager`
    
* View scaling: `LetterBoxingManager`
    

Facade excerpt:

```typescript
// RenderManager.initialize
await this.letterBoxingManager.initialize();
await this.assetManager.preloadAssets();
this.zIndexManager.enableSorting();
this.backgroundManager.setupBackground();
this.uiManager.initialize();
```

### Deep Dive: Responsibility Segregation

Specialized managers isolate complexity. Changing interpolation logic never risks breaking UI overlays. This modularity improves maintainability and clarity, enabling iterative performance improvements without large refactors.

## Interpolation Strategy: Time-Shift & Lerp

The client renders a position from ~50ms in the past (`TIMING_CONFIG.INTERPOLATION_BUFFER`). With two historical snapshots surrounding that time, we interpolate.

Interpolation snippet:

```typescript
const renderTime = performance.now() - TIMING_CONFIG.INTERPOLATION_BUFFER;
const interpolatedPos = this.getInterpolatedPosition(boidSprite.positionHistory, renderTime);
container.x += (interpolatedPos.x - container.x) * TIMING_CONFIG.SMOOTHING_FACTOR;
container.y += (interpolatedPos.y - container.y) * TIMING_CONFIG.SMOOTHING_FACTOR;
```

Interpolation function:

```typescript
// AnimationManager.getInterpolatedPosition
for (let i = 0; i < history.length - 1; i++) {
    const p1 = history[i];
    const p2 = history[i + 1];
    if (p1.time <= renderTime && p2.time >= renderTime) {
        const t = (renderTime - p1.time) / (p2.time - p1.time);
        return new Vector2(p1.pos.x + (p2.pos.x - p1.pos.x) * t, p1.pos.y + (p2.pos.y - p1.pos.y) * t);
    }
}
return history[history.length - 1].pos;
```

### Deep Dive: Interpolation Buffer

By rendering a short time behind real “now,” we nearly always have two snapshots to interpolate between. This transforms discrete network updates into continuous motion with minimal complexity. The slight visual latency is imperceptible compared to the smoothness gained.

## Local Prediction: Instant Feedback

While interpolation smooths remote entities, prediction improves **your own** avatar’s responsiveness.

Player prediction call:

```typescript
// RenderManager.updateLocalPlayerPrediction
updateLocalPlayerPrediction(input, deltaTime) {
  this.playerSpriteManager.updateLocalPlayerPrediction(input, deltaTime);
}
```

Shared movement logic ensures consistency:

```typescript
// MovementSystem.applyMovement
if (movement.x !== 0 && movement.y !== 0) movement.multiply(0.707); // prevent diagonal speed boost
movement.multiply(moveSpeed * (deltaTime / 1000));
newState.position.add(movement);
```

### Deep Dive: Prediction vs. Reconciliation

Prediction renders immediate movement based on local input. Later server snapshots confirm or correct. Because movement math is deterministic (same inputs + same delta time → same results), corrections are tiny, preventing visible snapping. Determinism is the secret to making prediction *feel* authoritative.

## Facing Direction & Visual Polish

Simple motion cues like facing left/right increase believability.

```typescript
// BoidSpriteManager.updateBoidPosition
const deltaX = current.pos.x - previous.pos.x;
if (Math.abs(deltaX) > 0.5) {
    boidSprite.facingLeft = deltaX < 0;
    boidSprite.container.scale.x = boidSprite.facingLeft ? -1 : 1;
}
```

Z-Index based on Y for faux depth:

```typescript
this.zIndexManager.setEntityZIndex(boidContainer, boid.position.y);
```

Letterboxing adapts canvas size across devices while preserving aspect ratio.

### Deep Dive: Smoothing Factor

A simple linear blend `(current + (target - current)*factor)` avoids oscillation while remaining cheap. Choosing too high a factor causes rubber-band overshoot; too low feels sluggish. A moderate constant (e.g., 0.2) balances convergence speed and fluidity.

## Timeline Overview

```mermaid

timeline
  title Interpolation & Prediction Flow
  section Input
    KeyPress: Local input captured
    Send: MovementInput sent to server
  section Prediction
    LocalApply: MovementSystem.applyMovement
  section Interpolation
    BufferShift: renderTime = now - buffer
    Lerp: Interpolate remote entities
  section Reconciliation
    Snapshot: Server state arrives
    Adjust: Minor corrections if drift
```

## Common Rendering Pitfalls & Fixes

| Pitfall | Symptom | Solution Here |
| --- | --- | --- |
| Direct State Jumps | Jitter | Time-shift + interpolation |
| Over-Prediction | Large snapback | Deterministic shared movement |
| Depth Confusion | Visual overlap | Y-based z-index sorting |
| Aspect Stretching | Distorted view | Letterboxing with bounds |
| Frame Spikes | Stutter | Simplicity + limited per-frame allocations |

## Key Takeaways

* Interpolation + time-shift smooths remote motion.
    
* Prediction keeps local input instant and satisfying.
    
* Deterministic movement logic minimizes correction artifacts.
    
* Small polish (facing, z-index, letterboxing) amplifies perceived quality.
    
* Keep managers focused—clarity aids performance.
    
* Tune buffer size with metrics, not feel alone.
    
* Provide debug overlays early (entity history dots, latency graph).
    

## **What could be next ?**

1. Add sprite animation blending (walk vs. idle) triggered by velocity magnitude.
    
2. Implement lag simulation slider to test robustness.
    
3. Blend reconciliation via easing instead of hard correction.
    
4. Introduce network loss % and track visual artifact frequency.
    
5. Add camera follow with dead-zone smoothing.
    
6. Record average interpolation error over time and auto-adjust smoothing.
    
7. Add fallback to extrapolation when only one snapshot available.
    

## Glossary

| Term | Simple Definition |
| --- | --- |
| Interpolation | Estimating a position between two known states |
| Prediction | Client estimating immediate future locally |
| Reconciliation | Adjusting predicted state to match server truth |
| Smoothing Factor | Portion of difference applied per frame |
| Time Shift | Rendering slightly behind real time |
| Deterministic | Same inputs always produce same outputs |
| Extrapolation | Estimating forward when future snapshot not yet arrived |
| Jitter | Variation in packet arrival timing |
| RTT (Round Trip Time) | Time for a message to go to server and back |
| Drift | Difference between predicted and authoritative positions |

## FAQ

**Q: Why not always extrapolate instead of interpolating?**  
Extrapolation guesses future motion; when guess is wrong, corrections are large. Interpolation relies on *known* snapshots — smoother under typical jitter.

**Q: What if only one snapshot is in history?**  
Use temporary extrapolation for a single frame, then snap to next real position once available.

**Q: Can I use cubic interpolation?**  
You can, but linear with light smoothing is cheaper and usually indistinguishable for small positional deltas.

**Q: Why not send velocity and skip history?**  
Velocity alone doesn’t capture sudden direction changes; history lets you retroactively align motion.

**Q: How do I choose smoothing factor?**  
Start at 0.15–0.25; plot drift reduction vs. responsiveness; avoid &gt;0.35 unless buffer is tiny.

## Closing Reflection

Smooth rendering isn’t a single trick — it is layering: deterministic prediction, modest interpolation, and small polish elements that reinforce believability. Measure, tune, *then* add complexity only where the numbers justify it.

---

**Note about process**: I used AI to help write parts of code, [but](vscode-file://vscode-app/c:/Users/arnau/AppData/Local/Programs/Microsoft%20VS%20Code%20Insiders/resources/app/out/vs/code/electron-browser/workbench/workbench.html) I made the conception, design choices, reviewed and tested the code as well as written the majority of it. It was a great tool to iterate over ideas.

---

> Thank you for reading my blog ! If you enjoyed this post and want to stay connected, feel free to connect with me on LinkedIn. I love networking with fellow developers, exchanging ideas, and discussing exciting projects.
> 
> [Connect with me on LinkedIn](https://www.linkedin.com/in/your-linkedin-profile) 🔗
> 
> Looking forward to connecting with you ! 🚀
