# How to Build a Real-Time Multiplayer Game: Colyseus + PixiJS Architecture Explained

> From WebSocket handshake to happy sheep — a gentle walk through an authoritative Colyseus + PixiJS architecture.

## TL;DR 📝

Learn how a small set of clear architectural rules (authoritative server, deterministic movement, fixed timestep, and segregation of responsibilities) makes Shepherd's World feel responsive and consistent across clients.

[![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

[Rendering and Client Prediction](https://arnauld-alex.com/smooth-and-responsive-rendering-interpolation-and-client-side-prediction-in-a-multiplayer-game)  
[Optimizing Boids in multiplayer](https://arnauld-alex.com/flocks-that-scale-engineering-efficient-boids-with-spatial-grids-and-behavioral-layers)

### Who this is for

Beginner→intermediate devs building networked games or realtime apps who want practical, code-oriented architecture guidance… Or just curious people 😊

### PixiJs & Colyseum

I chose PixiJS and Colyseus because they complement each other for building a modern, real‑time multiplayer web game and they offered the exact kind of learning challenge I wanted: **PixiJS** is a lightweight, battle‑tested 2D renderer that exposes WebGL performance with a simple, sprite‑and‑display‑object API—perfect for fast, visually rich client rendering, smooth animations, and fine control over the game loop; **Colyseus** is a focused Node/TypeScript multiplayer framework that handles rooms, authoritative server state, efficient state diffing and synchronization, and matchmaking patterns so you can concentrate on game logic instead of low‑level networking. Together they enable a clean client‑server separation (client: rendering/input with PixiJS; server: deterministic state and replication with Colyseus), and I picked them out of curiosity and to stretch my skills—learning a high‑performance renderer plus a purpose‑built multiplayer stack is both practical for this project and a rewarding technical challenge.

[![Colyseus](https://img.shields.io/badge/Colyseus-FF6B6B?style=for-the-badge&logo=websocket&logoColor=white align="center")](https://colyseus.io/)

[![PixiJS](https://img.shields.io/badge/PixiJS-E91E63?style=for-the-badge&logo=pixi&logoColor=white align="center")](https://pixijs.com/)

## Why Multiplayer Games Feel Magical

Real‑time multiplayer feels effortless when it works: you press a key, your avatar moves, others react, and the world stays consistent. Underneath that fluidity sits a choreography of server ticks, message routing, deterministic logic, and careful rendering. In **Shepherd's World**, players cooperate to herd autonomous sheep (boids) into a goal zone. We'll peel back the layers so you can replicate this style of architecture without drowning in complexity.

Picture two players joining seconds apart: one is mid‑stride pushing a cluster of sheep, the other loads in and instantly sees motion that *already started* before they arrived. There’s no awkward jump, no half-loaded entities. That seamlessness is **intentional design** — achieved by clear separation of responsibilities and time discipline.

![Amis masculins jouant ensemble](https://plus.unsplash.com/premium_photo-1723867354403-8298fc0fcff3?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NXx8bXVsdGlwbGF5ZXJ8ZW58MHx8MHx8fDA%3D&fm=jpg&q=60&w=3000 align="left")

### Herding Chaos Into Order

Early prototypes spawned boids randomly and applied forces directly in the room class. Within minutes the code turned into an entangled ball: movement logic mixed with networking callbacks; rendering tried to compensate for jitter by brute-forcing sprite positions. The breakthrough came when I stopped asking "How do I make the sheep move?" and instead asked: **"Which subsystem should care about each step in that movement?"**

### Core Ideas

* **Authoritative server**: The server is the source of truth for positions and game outcomes.
    
* **Deterministic movement**: Both client and server run the same simple movement math for prediction and reconciliation.
    
* **Separation of concerns**: Each subsystem owns one job (messaging, player lifecycle, rendering, flock updates).
    
* **Incremental trust**: Clients optimistically predict local movement; server corrections keep things honest.
    

## The Core Actors

Let's introduce the cast:

* `src/server/GameRoom.ts` — Orchestrates server simulation (`onCreate`, `fixedUpdate`, `onJoin`).
    
* `src/server/MessageManager.ts` — Validates and routes incoming messages (`setupMessageHandlers`).
    
* `src/server/PlayerManager.ts` — Creates/removes players (`createPlayer`).
    
* `src/shared/MovementSystem.ts` — Deterministic movement logic (`applyMovement`).
    
* `src/client/NetworkManager.ts` — Connects and listens for state + events (`connect`, `sendMovement`).
    
* `src/client/GameStateManager.ts` — Reconciles server state locally (`handleStateChange`).
    
* `src/client/RenderManager.ts` — Facade that delegates rendering tasks (`initialize`, `updateLocalPlayerPrediction`).
    

### High-Level Architecture

```mermaid
flowchart LR
  subgraph Client
    IM[InputManager] --> NM[NetworkManager]
    NM --> GSM[GameStateManager]
    GSM --> RM[RenderManager]
    RM --> SM[Sprite Managers]
  end
  subgraph Server
    GR[GameRoom] --> PM[PlayerManager]
    GR --> MM[MessageManager]
    GR --> BM[BoidsManager]
  end
  IM -. movement input .-> GR
  GR -. state updates .-> NM
```

## Tick & Flow: The Simulation Heartbeat

The server runs a **fixed timestep loop** to keep updates predictable even if machine load fluctuates.

Snippet (accumulator pattern):

```typescript
// GameRoom.setupGameLoop excerpt
let elapsedTime = 0;
let fixedTimeStep = COMPUTED_TIMING.SERVER_TICK_RATE;
this.setSimulationInterval(deltaTime => {
    elapsedTime += deltaTime;
    while (elapsedTime >= fixedTimeStep) {
        elapsedTime -= fixedTimeStep;
        this.fixedUpdate(fixedTimeStep); // deterministic world advance
    }
    this.update(deltaTime); // lightweight checks
});
```

**Why this matters:** Without a fixed step, physics may behave differently frame to frame, making client prediction harder and debugging inconsistent.

### More About Fixed Time Step

A fixed timestep decouples simulation from render timing. By accumulating variable `deltaTime` from Colyseus and consuming it in uniform chunks (e.g. 16ms), every movement calculation produces identical results given the same inputs. This determinism reduces divergence between predicted client motion and authoritative server positions, simplifying reconciliation.

## The Input Journey: Key Press → Predicted Frame → Authoritative Correction

1. Player presses a key → client builds a `MovementInput`.
    
2. Client **sends** input immediately via WebSocket.
    
3. Client locally **predicts** movement for responsiveness (using the same movement logic as server).
    
4. Server queues and processes all inputs in order during the next `fixedUpdate`.
    
5. Updated authoritative state is broadcast; client reconciles if drift appears.
    

Client sending & server validation:

```typescript
// Client: NetworkManager.ts
this.room.send(MESSAGE_TYPES.MOVE, input);

// Server: MessageManager.ts
room.onMessage(MESSAGE_TYPES.MOVE, (client, input) => {
    if (this.validateMoveInput(input)) {
        this.events.onMoveMessage(client, input);
    }
});
```

Server applies movement deterministically:

```typescript
// GameRoom.fixedUpdate excerpt
while (player.inputQueue.length > 0) {
    const input = player.inputQueue.shift();
    const state = MovementSystem.applyMovement(
        { position: player.position, velocity: new Vector2(0, 0) },
        input,
        deltaTime
    );
    player.position.x = state.position.x;
    player.position.y = state.position.y;
}
```

Movement logic (shared):

```typescript
// MovementSystem.applyMovement
const movement = new Vector2(input.moveX, input.moveY);
if (movement.x !== 0 && movement.y !== 0) {
    movement.multiply(0.707);
}
const moveDistance = moveSpeed * (deltaTime / 1000);
movement.multiply(moveDistance);
newState.position.add(movement);
```

### Deep Dive: Deterministic Movement

By using a simple, branch-light function with no randomness (`applyMovement`), both sides compute identical outcomes from the same input sequence. This drastically reduces visual corrections: the client’s predicted position usually already matches the broadcast state, so “rubber-banding” is minimized.

![Quelqu’un joue à un jeu d’arcade.](https://plus.unsplash.com/premium_photo-1744139468578-a5e14de3b6b1?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MXx8cGxheWVyJTIwaW5wdXR8ZW58MHx8MHx8fDA%3D&fm=jpg&q=60&w=3000 align="left")

## State Synchronization: Joining, Leaving, Updating

When a player joins:

* `GameRoom.onJoin` creates a `Player` via `PlayerManager.createPlayer`.
    
* Static metadata (username, color) is broadcast for UI.
    
* Herding zone info and current timer state sent to newcomer.
    

State change handling client-side:

```typescript
// GameStateManager.handleStateChange
state.players.forEach((playerData, playerId) => {
    if (this.players.has(playerId)) {
        this.events.onPlayerUpdated(playerId, playerData);
    } else {
        this.players.set(playerId, playerData);
        this.events.onPlayerAdded(playerId, playerData);
    }
});
```

**Approach:** Track additions, updates, removals separately to keep sprite lifecycle clean.

### Deep Dive: Incremental State Diff

Instead of rebuilding all sprites each update, the manager compares current ids to incoming ones. This keeps garbage collection pressure low and preserves interpolation history for smoother transitions.

## Lightweight Rendering Orchestration

`RenderManager` acts as a façade; it doesn’t draw sheep itself—it delegates.

* Asset prep & viewport: `LetterBoxingManager`, `AssetManager`.
    
* Background & ambiance: `BackgroundManager`.
    
* Entities: `PlayerSpriteManager`, `BoidSpriteManager`.
    
* Interpolation/prediction: `AnimationManager` + prediction helpers.
    
* UI overlays: `UIManager` (timer, completion message).
    

Initialization sequence:

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

**Why a façade?** Keeps the game loop simple: higher-level code just calls `renderManager.updateBoidsInterpolation()` instead of managing subtleties.

### Responsibility Segregation

Clear boundaries reduce accidental coupling. When interpolation changes, only `AnimationManager` and sprite managers adjust—no need to sift through networking or UI code. This containment accelerates iteration and onboarding.

## Putting It Together: End-to-End Flow

```mermaid
sequenceDiagram
  actor P as Player
  participant CNet as Client NetworkManager
  participant CState as Client GameStateManager
  participant Render as RenderManager
  participant Room as GameRoom
  participant Msg as MessageManager
  P->>CNet: MovementInput
  CNet->>Room: MOVE
  Room->>Msg: validate & queue
  Room->>Room: fixedUpdate (applyMovement + boids)
  Room->>CNet: Broadcast state
  CNet->>CState: onStateChange
  CState->>Render: events (added/updated/removed)
  Render->>Render: interpolate & predict
```

## Common Pitfalls (And How This Architecture Avoids Them)

| Pitfall | Symptom | Mitigation Here |
| --- | --- | --- |
| Jittery motion | Sprites snap | Interpolation + prediction layers |
| Input lag | Delayed feedback | Immediate local prediction |
| Desync | Diverging positions | Deterministic shared movement logic |
| Spaghetti responsibilities | Hard to add features | Manager pattern & façade |
| Performance spikes | Lag under load | Fixed timestep & batched input processing |

## Key Takeaways

* Keep the server authoritative but let the client feel snappy.
    
* Determinism is your friend for prediction.
    
* Separate concerns early—avoid one giant god class.
    
* Process inputs in batches inside a fixed simulation loop.
    
* Broadcast only what clients need to render & reconcile.
    

## What could be next ?

1. Add latency simulation (artificial 150ms delay) to test robustness.
    
2. Introduce entity spawn/despawn events for power-ups.
    
3. Add simple lag compensation (timestamped inputs, server-side reconciliation).
    
4. Implement chat channel with rate limiting mirroring movement handling.
    
5. Add rollback prototype for severe latency spikes.
    

## Glossary (Quick Reference)

| Term | Simple Definition |
| --- | --- |
| Authoritative Server | Final source of truth for game state |
| Prediction | Client temporarily estimates future state locally |
| Reconciliation | Correcting predicted state to match server updates |
| Fixed Timestep | Constant-size simulation steps for determinism |
| Interpolation Buffer | Slight delay to blend between two known states |
| Deterministic | Same inputs always produce same outputs |

---

## Architecture FAQ

**Q: Why batch inputs instead of applying as they arrive?**  
To guarantee order and keep movement deterministic; mid-frame updates risk diverging motion under network jitter.

**Q: What if a client sends invalid movement values?**  
`MessageManager.validateMoveInput` rejects them early; never trust the client.

**Q: Why not authoritative client for its own movement?**  
Leads to easy speed hacks and desync; server checkout is cheap and secure.

**Q: How big should the interpolation buffer be?**  
Start at 50–100ms; tune based on average RTT + jitter percentiles.

**Q: Can I skip prediction and rely only on interpolation?**  
Yes for slow games, but fast directional movement feels sluggish without prediction.

## Closing Reflection

Architectures that feel effortless are rarely accidental. Shepherd's World works because each layer has an explicit, narrow mandate. You now have the mental model to decompose your own multiplayer ideas into testable, swappable pieces instead of a fragile monolith.

---

**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 ! 🚀
