Multiplayer Architecture Documentation
This document explains the multiplayer implementation for the Grid Builder game, covering the architecture, networking concepts, and code implementation details.
This document explains the multiplayer implementation for the Grid Builder game, covering the architecture, networking concepts, and code implementation details.
The multiplayer system uses a client-server architecture with server-authoritative movement. This means:
graph TB
subgraph Clients
C1[Client 1<br/>Browser Window]
C2[Client 2<br/>Browser Window]
C3[Client N<br/>Browser Window]
end
subgraph Server
GS[Game Server<br/>WebSocket :3001]
GL[Game Loop<br/>20 ticks/sec]
PS[Player States]
WS[World State<br/>Blocks]
end
subgraph Persistence
ST[Strapi CMS<br/>:1337]
end
C1 <-->|WebSocket| GS
C2 <-->|WebSocket| GS
C3 <-->|WebSocket| GS
GS --> GL
GL --> PS
GL --> WS
WS <-->|REST API| ST
src/
├── network/
│ ├── NetworkProtocol.ts # Shared message types
│ └── NetworkManager.ts # Client-side WebSocket handler
├── entities/
│ ├── Character.ts # Local player visual
│ └── RemotePlayer.ts # Remote player visual + interpolation
├── core/
│ ├── PlayerState.ts # Serializable player state
│ └── PlayerController.ts # Local player movement
└── main.ts # Game integration
server/
└── GameServer.ts # WebSocket game server with Strapi integration
graph LR
subgraph Client Side
IM[InputManager] -->|actions| NM[NetworkManager]
NM -->|remote states| RP[RemotePlayer]
NM -->|blocks| PS[PlacementSystem]
NM -->|initial state| PC[PlayerController]
PC -->|local movement| CH[Character]
end
subgraph Server Side
WS[WebSocket] --> GS[GameServer]
GS --> PL[Player Loop]
PL -->|physics| ST[State]
ST -->|broadcast| WS
GS <-->|persist| DB[(Strapi)]
end
NM <-->|WebSocket| WS
All messages are JSON-serializable TypeScript interfaces with a type discriminator for routing.
// src/network/NetworkProtocol.ts
// Player joins the game
interface PlayerJoinMessage {
type: "player:join";
playerId: string;
state: PlayerState;
color: string;
}
// Player leaves the game
interface PlayerLeaveMessage {
type: "player:leave";
playerId: string;
}
// Server broadcasts player position/rotation
interface PlayerStateMessage {
type: "player:state";
playerId: string;
state: PlayerState;
timestamp: number;
}
// Client sends inputs to server
interface PlayerInputMessage {
type: "player:input";
playerId: string;
inputs: PlayerInputs;
timestamp: number;
}
// Block placement/removal
interface BlockPlacedMessage {
type: "block:placed";
playerId: string;
block: NetworkBlock;
}
interface BlockRemovedMessage {
type: "block:removed";
playerId: string;
position: Vector3;
}
// Initial world state on connection (includes player's initial state)
interface WelcomeMessage {
type: "welcome";
playerId: string;
color: string;
state: PlayerState; // Player's server-assigned initial state
worldState: WorldStateMessage;
}
The PlayerState interface is designed to be minimal and serializable:
// src/core/PlayerState.ts
interface PlayerState {
position: { x: number; y: number; z: number };
rotation: number; // Y-axis rotation in radians
velocity: { x: number; y: number; z: number };
isMoving: boolean;
isGrounded: boolean;
}
Instead of sending positions, clients send their current input state:
interface PlayerInputs {
moveForward: boolean;
moveBackward: boolean;
moveLeft: boolean;
moveRight: boolean;
jetpackUp: boolean;
jetpackDown: boolean;
sprint: boolean;
cameraYaw: number; // Camera direction for movement calculation
}
The server runs a fixed-timestep game loop at 20 ticks per second (50ms intervals).
// server/GameServer.ts
const TICK_RATE = 20;
const TICK_INTERVAL = 1000 / TICK_RATE; // 50ms
class GameServer {
private wss: WebSocketServer;
private players: Map<string, ConnectedPlayer> = new Map();
private blocks: Map<string, NetworkBlock> = new Map();
private strapiAvailable: boolean = false;
constructor() {
this.wss = new WebSocketServer({ port: 3001 });
this.initialize();
}
private async initialize(): Promise<void> {
await this.loadWorldFromStrapi();
this.setupServer();
this.startGameLoop();
this.startAutoSave();
}
private startGameLoop(): void {
setInterval(() => {
const deltaTime = TICK_INTERVAL / 1000;
this.updatePlayers(deltaTime);
this.broadcastPlayerStates();
}, TICK_INTERVAL);
}
}
sequenceDiagram
participant C as Client
participant S as Server
participant O as Other Clients
C->>S: WebSocket Connect
S->>S: Create Player ID, Color & Position
S->>C: WelcomeMessage (id, color, state, worldState)
C->>C: Sync local position to server state
S->>O: PlayerJoinMessage (new player)
loop Game Loop (20 Hz)
C->>S: PlayerInputMessage
S->>S: Process Inputs
S->>S: Update Physics
S->>O: PlayerStateMessage
end
C->>S: WebSocket Disconnect
S->>O: PlayerLeaveMessage
The server processes player inputs and calculates movement:
private updatePlayers(deltaTime: number): void {
for (const player of this.players.values()) {
if (!player.inputs) continue;
const state = player.state;
const inputs = player.inputs;
// Calculate movement direction from inputs
let moveX = 0, moveZ = 0;
if (inputs.moveForward) moveZ -= 1;
if (inputs.moveBackward) moveZ += 1;
if (inputs.moveLeft) moveX -= 1;
if (inputs.moveRight) moveX += 1;
// Normalize diagonal movement
const length = Math.sqrt(moveX * moveX + moveZ * moveZ);
if (length > 0) {
moveX /= length;
moveZ /= length;
}
// Transform to world space using camera yaw
const yaw = inputs.cameraYaw;
const forwardX = -Math.sin(yaw);
const forwardZ = -Math.cos(yaw);
const rightX = Math.cos(yaw);
const rightZ = -Math.sin(yaw);
const worldMoveX = forwardX * -moveZ + rightX * moveX;
const worldMoveZ = forwardZ * -moveZ + rightZ * moveX;
// Apply velocity
const speed = inputs.sprint ? this.moveSpeed * 2 : this.moveSpeed;
state.velocity.x = worldMoveX * speed;
state.velocity.z = worldMoveZ * speed;
// Update rotation to face movement direction
if (state.isMoving) {
state.rotation = Math.atan2(state.velocity.x, state.velocity.z);
}
// Apply gravity/jetpack
if (inputs.jetpackUp) {
state.velocity.y = this.jumpForce;
} else if (inputs.jetpackDown) {
state.velocity.y = -this.jumpForce;
} else if (!state.isGrounded) {
state.velocity.y -= this.gravity * deltaTime;
}
// Update position
state.position.x += state.velocity.x * deltaTime;
state.position.y += state.velocity.y * deltaTime;
state.position.z += state.velocity.z * deltaTime;
// Ground collision
if (state.position.y < 0) {
state.position.y = 0;
state.velocity.y = 0;
state.isGrounded = true;
}
}
}
The client-side NetworkManager handles WebSocket communication:
// src/network/NetworkManager.ts
export interface NetworkManagerConfig {
serverUrl: string;
onConnected?: (playerId: string, color: string, state: PlayerState) => void;
onDisconnected?: () => void;
onPlayerJoined?: (player: NetworkPlayer) => void;
onPlayerLeft?: (playerId: string) => void;
onPlayerStateUpdate?: (playerId: string, state: PlayerState, timestamp: number) => void;
onBlockPlaced?: (playerId: string, block: NetworkBlock) => void;
onBlockRemoved?: (playerId: string, x: number, y: number, z: number) => void;
onWorldState?: (blocks: NetworkBlock[], players: NetworkPlayer[]) => void;
}
export class NetworkManager {
private ws: WebSocket | null = null;
private currentInputs: PlayerInputs = createPlayerInputs();
private readonly INPUT_SEND_RATE = 50; // 20 Hz
connect(): void {
this.ws = new WebSocket(this.config.serverUrl);
this.ws.onopen = () => {
this.startInputSending(); // Begin sending inputs
this.startPinging(); // Measure latency
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
}
// Called every frame from game loop
updateInputs(inputs: Partial<PlayerInputs>): void {
Object.assign(this.currentInputs, inputs);
}
// Send inputs at fixed rate (not every frame)
private startInputSending(): void {
setInterval(() => {
this.send({
type: "player:input",
playerId: this.playerId,
inputs: { ...this.currentInputs },
timestamp: Date.now(),
});
}, this.INPUT_SEND_RATE);
}
}
When connecting to the server, the client syncs its local position to the server-assigned position:
// src/main.ts
onConnected: (playerId, _color, state) => {
this.localPlayerId = playerId;
this.isMultiplayer = true;
// Sync local player position with server-assigned position
this.playerController.setPosition(
state.position.x,
state.position.y,
state.position.z
);
this.playerController.setRotation(state.rotation);
// Update character visual to match
const pos = this.playerController.getPosition();
this.character.setPositionFromVector(pos);
this.character.setRotation(state.rotation);
// Update camera to follow new position
this.cameraSystem.setPlayerPosition(pos);
// Clear local blocks - server world will be loaded via onWorldState
this.placementSystem.clearAll();
},
// src/main.ts
private gameLoop = (): void => {
requestAnimationFrame(this.gameLoop);
const deltaTime = this.clock.getDelta();
// Update local player
this.playerController.update(deltaTime, cameraYaw);
this.character.setPositionFromVector(playerPos);
// Send inputs to server
this.sendInputsToServer();
// Update remote players (interpolation)
this.updateRemotePlayers(deltaTime);
this.renderer.render(this.scene, this.camera);
};
private sendInputsToServer(): void {
if (!this.networkManager || !this.isMultiplayer) return;
this.networkManager.updateInputs({
moveForward: this.inputManager.isActionActive("moveForward"),
moveBackward: this.inputManager.isActionActive("moveBackward"),
moveLeft: this.inputManager.isActionActive("moveLeft"),
moveRight: this.inputManager.isActionActive("moveRight"),
jetpackUp: this.inputManager.isActionActive("jetpackUp"),
jetpackDown: this.inputManager.isActionActive("jetpackDown"),
sprint: this.inputManager.isKeyPressed("shift"),
});
this.networkManager.setCameraYaw(this.cameraSystem.getYaw());
}
Network updates arrive at irregular intervals (typically 50ms apart at 20 tick/sec). Without interpolation, remote players would "teleport" between positions, creating jerky movement.
graph LR
subgraph Without Interpolation
A1[Pos A] -->|jump| B1[Pos B] -->|jump| C1[Pos C]
end
subgraph With Interpolation
A2[Pos A] -->|smooth| AB[...] -->|smooth| B2[Pos B] -->|smooth| BC[...] -->|smooth| C2[Pos C]
end
Remote players maintain a buffer of recent state snapshots:
// src/entities/RemotePlayer.ts
interface StateSnapshot {
state: PlayerState;
timestamp: number;
}
export class RemotePlayer {
private stateBuffer: StateSnapshot[] = [];
private readonly MAX_BUFFER_SIZE = 10;
private readonly interpolationTime = 100; // 100ms buffer
// Called when server sends new state
receiveState(state: PlayerState, timestamp: number): void {
this.stateBuffer.push({ state, timestamp });
// Sort by timestamp (handle out-of-order packets)
this.stateBuffer.sort((a, b) => a.timestamp - b.timestamp);
// Trim old states
while (this.stateBuffer.length > this.MAX_BUFFER_SIZE) {
this.stateBuffer.shift();
}
}
}
The render time is set slightly behind real time to ensure we always have two states to interpolate between:
update(_deltaTime: number): void {
if (this.stateBuffer.length < 2) {
// Not enough data, use latest state
return;
}
// Render 100ms behind current time
const renderTime = Date.now() - this.interpolationTime;
// Find two states that bracket renderTime
let fromState: StateSnapshot | null = null;
let toState: StateSnapshot | null = null;
for (let i = 0; i < this.stateBuffer.length - 1; i++) {
if (this.stateBuffer[i].timestamp <= renderTime &&
this.stateBuffer[i + 1].timestamp >= renderTime) {
fromState = this.stateBuffer[i];
toState = this.stateBuffer[i + 1];
break;
}
}
if (fromState && toState) {
// Calculate interpolation factor (0 to 1)
const duration = toState.timestamp - fromState.timestamp;
const elapsed = renderTime - fromState.timestamp;
const t = Math.max(0, Math.min(1, elapsed / duration));
// Lerp position
this.currentPosition.x = this.lerp(
fromState.state.position.x,
toState.state.position.x,
t
);
// ... same for y, z
// Lerp rotation (handle angle wrap-around)
this.currentRotation = this.lerpAngle(
fromState.state.rotation,
toState.state.rotation,
t
);
}
}
private lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
private lerpAngle(a: number, b: number, t: number): number {
let diff = b - a;
// Normalize to [-PI, PI]
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
return a + diff * t;
}
gantt
title Interpolation Timeline
dateFormat X
axisFormat %L ms
section Server States
State 1 :s1, 0, 50
State 2 :s2, 50, 50
State 3 :s3, 100, 50
State 4 :s4, 150, 50
section Render Time (100ms behind)
Interpolating :active, r1, 100, 150
sequenceDiagram
participant L as Local Client
participant S as Server
participant R as Remote Client
Note over L: Player presses W key
L->>L: InputManager detects key
L->>L: NetworkManager.updateInputs()
L->>S: PlayerInputMessage {moveForward: true}
Note over S: Server tick (50ms)
S->>S: Process inputs
S->>S: Calculate new position
S->>R: PlayerStateMessage {position, rotation}
Note over R: Remote client receives
R->>R: RemotePlayer.receiveState()
R->>R: Add to interpolation buffer
Note over R: Render frame
R->>R: RemotePlayer.update()
R->>R: Interpolate position
R->>R: Update mesh position
sequenceDiagram
participant L as Local Client
participant S as Server
participant ST as Strapi
participant R as Remote Client
L->>L: Click to place block
L->>L: PlacementSystem.confirmPlacement()
L->>S: BlockPlacedMessage {x, y, z, blockId}
S->>S: Store block in world state
S->>S: Mark world as dirty
S->>L: BlockPlacedMessage (confirmation)
S->>R: BlockPlacedMessage
R->>R: PlacementSystem.placeBlockFromNetwork()
R->>R: Add block to scene
Note over S: Auto-save interval (10s)
S->>ST: PUT /api/saves (blocks array)
ST->>S: 200 OK
The server uses Strapi CMS as the persistent storage for the world state. When the server starts, it loads the world from Strapi. Changes are auto-saved every 10 seconds.
// server/GameServer.ts
const STRAPI_URL = "http://localhost:1337";
const STRAPI_SAVE_ENDPOINT = `${STRAPI_URL}/api/saves`;
const SAVE_INTERVAL = 10000; // Auto-save every 10 seconds
interface SaveData {
version: number;
timestamp: string;
blocks: Array<{ blockId: string; x: number; y: number; z: number }>;
}
private async loadWorldFromStrapi(): Promise<void> {
this.strapiAvailable = await this.checkStrapiAvailable();
if (!this.strapiAvailable) {
console.log("Strapi not available, starting with empty world");
return;
}
try {
// Get the most recent save
const response = await fetch(
`${STRAPI_SAVE_ENDPOINT}?sort=updatedAt:desc&pagination[limit]=1`
);
if (!response.ok) {
console.log("No saves found in Strapi, starting fresh");
return;
}
const result = await response.json();
if (!result.data || result.data.length === 0) {
console.log("No saves found in Strapi, starting fresh");
return;
}
const saveEntry = result.data[0];
this.strapiDocumentId = saveEntry.documentId;
const saveData = saveEntry.data;
// Load blocks into memory
if (saveData.blocks && Array.isArray(saveData.blocks)) {
for (const block of saveData.blocks) {
const key = `${block.x},${block.y},${block.z}`;
this.blocks.set(key, {
x: block.x,
y: block.y,
z: block.z,
structureId: block.blockId,
rotation: 0,
});
}
console.log(`Loaded ${this.blocks.size} blocks from Strapi`);
}
} catch (e) {
console.error("Failed to load from Strapi:", e);
}
}
private startAutoSave(): void {
setInterval(() => {
this.saveWorldToStrapi();
}, SAVE_INTERVAL);
// Also save on process exit
process.on("SIGINT", async () => {
console.log("\nShutting down...");
this.worldDirty = true; // Force save
await this.saveWorldToStrapi();
process.exit(0);
});
}
private async saveWorldToStrapi(): Promise<void> {
if (!this.worldDirty || !this.strapiAvailable) return;
try {
const blocks = Array.from(this.blocks.values()).map((b) => ({
blockId: b.structureId,
x: b.x,
y: b.y,
z: b.z,
}));
const saveData: SaveData = {
version: 1,
timestamp: new Date().toISOString(),
blocks,
};
let response: Response;
if (this.strapiDocumentId) {
// Update existing save
response = await fetch(
`${STRAPI_SAVE_ENDPOINT}/${this.strapiDocumentId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: { data: saveData } }),
}
);
} else {
// Create new save
response = await fetch(STRAPI_SAVE_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: { data: saveData } }),
});
}
if (response.ok) {
const result = await response.json();
this.strapiDocumentId = result.data.documentId;
this.worldDirty = false;
console.log(`Saved ${this.blocks.size} blocks to Strapi`);
}
} catch (e) {
console.error("Failed to save to Strapi:", e);
}
}
When Strapi is not available, the server starts with an empty world. Block changes are kept in memory but won't persist across server restarts.
Why? Prevents cheating and ensures consistency across all clients.
Client: "I want to move forward" → Server
Server: "You are now at (10, 0, 5)" → All Clients
If clients sent positions directly, a cheater could teleport anywhere.
When a client connects, the server assigns a random spawn position. The client must sync its local position to match:
// Server assigns position
state.position.x = Math.random() * 10 - 5;
state.position.z = Math.random() * 10 - 5;
// Client receives and syncs
onConnected: (playerId, color, state) => {
this.playerController.setPosition(
state.position.x,
state.position.y,
state.position.z
);
}
Without this sync, each client would start at different positions locally, causing coordinate mismatches.
Our implementation uses a hybrid approach:
This means the local player has instant feedback but may briefly desync from server state.
The server runs at exactly 20 ticks/second regardless of client frame rates:
setInterval(() => {
updatePlayers(0.05); // Always 50ms delta
broadcastStates();
}, 50);
This ensures deterministic physics across all clients.
We use WebSockets (TCP) for simplicity. Trade-offs:
| WebSocket (TCP) | UDP |
|---|---|
| Guaranteed delivery | May lose packets |
| Ordered packets | Out-of-order possible |
| Higher latency | Lower latency |
| Browser support | Requires WebRTC |
For a building game, reliability matters more than raw speed.
Symptom: In one browser, players appeared face-to-face. In another browser, they appeared far apart.
Cause: The local client started at a hardcoded position (8, 0, 8) while the server assigned random positions between (-5, 5). Each client thought they were at different positions than the server did.
Solution: Added state field to WelcomeMessage and synced local player position on connect:
// NetworkProtocol.ts
interface WelcomeMessage {
type: "welcome";
playerId: string;
color: string;
state: PlayerState; // Added this
worldState: WorldStateMessage;
}
// main.ts - on connect
this.playerController.setPosition(
state.position.x,
state.position.y,
state.position.z
);
Symptom: When Player A moved toward Player B, on Player B's screen Player A appeared to be moving away.
Cause: This was related to Issue 1 - the coordinate systems weren't aligned. When positions don't match, relative movement appears inverted.
Solution: Same as Issue 1 - proper position synchronization on connect.
Symptom: Characters and structures didn't align properly between clients.
Cause: Same root cause - each client had different local positions, so when they placed blocks or moved, the world coordinates didn't match.
Solution: All clients now start at server-assigned positions, ensuring consistent world coordinates.
Potential Issue: If client and server have different physics constants, movement will diverge.
Prevention: Both client (PlayerController.ts) and server (GameServer.ts) use identical values:
// Both use:
moveSpeed = 5;
sprintMultiplier = 2.0;
gravity = 20;
jumpForce = 8;
Add debug logging to server movement calculations:
console.log(`Player ${playerId}: yaw=${yaw}, velocity=(${vx}, ${vz}), rotation=${rot}`);
Check initial positions in browser console:
Initial position: (2.34, 0.00, -1.56)
Verify Strapi connection:
Strapi: connected
Loaded 13645 blocks from Strapi
# Install dependencies
npm install
# Start both servers
npm run dev:all
# Or separately:
npm run server # WebSocket server on :3001
npm run dev # Vite dev server on :5200
Open multiple browser windows to http://localhost:5200 to test multiplayer.