How to Build a Real-Time Multiplayer Game: Colyseus + PixiJS Architecture Explained
Explore Techniques for Building Real-Time Multiplayer Games

I am a dedicated AI game programmer, former software engineer in aeronautics domain.
With a strong passion for AI, game programming, and full-stack development. I thrive on learning new technologies and continuously improving my skills. As a supportive and collaborative person, I believe in adding value to every project and team I work with.
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.

Related Articles
Rendering and Client Prediction
Optimizing Boids in multiplayer
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.
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.
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
Tick & Flow: The Simulation Heartbeat
The server runs a fixed timestep loop to keep updates predictable even if machine load fluctuates.
Snippet (accumulator pattern):
// 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
Player presses a key → client builds a
MovementInput.Client sends input immediately via WebSocket.
Client locally predicts movement for responsiveness (using the same movement logic as server).
Server queues and processes all inputs in order during the next
fixedUpdate.Updated authoritative state is broadcast; client reconciles if drift appears.
Client sending & server validation:
// 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:
// 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):
// 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.
State Synchronization: Joining, Leaving, Updating
When a player joins:
GameRoom.onJoincreates aPlayerviaPlayerManager.createPlayer.Static metadata (username, color) is broadcast for UI.
Herding zone info and current timer state sent to newcomer.
State change handling client-side:
// 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:
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
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 ?
Add latency simulation (artificial 150ms delay) to test robustness.
Introduce entity spawn/despawn events for power-ups.
Add simple lag compensation (timestamped inputs, server-side reconciliation).
Implement chat channel with rate limiting mirroring movement handling.
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 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.
Looking forward to connecting with you ! 🚀





