three.js

Multiplayer Architecture Documentation

This document explains the multiplayer implementation for the Grid Builder game, covering the architecture, networking concepts, and code implementation details.

Table of Contents

  1. Overview
  2. Architecture
  3. Network Protocol
  4. Server Implementation
  5. Client Implementation
  6. Interpolation & Lag Compensation
  7. Data Flow
  8. World Persistence with Strapi
  9. Key Concepts
  10. Troubleshooting & Issues Resolved

Overview

The multiplayer system uses a client-server architecture with server-authoritative movement. This means:

  • The server is the single source of truth for game state
  • Clients send inputs to the server (not positions)
  • Server calculates positions and broadcasts to all clients
  • Clients interpolate remote player positions for smooth movement
  • World state is persisted to Strapi CMS
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

Architecture

File Structure

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

Component Responsibilities

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

Network Protocol

All messages are JSON-serializable TypeScript interfaces with a type discriminator for routing.

Message Types

// 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;
}

Player State

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;
}

Input State

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
}

Server Implementation

Game Server Overview

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);
  }
}

Connection Flow

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

Server-Side Physics

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;
    }
  }
}

Client Implementation

NetworkManager

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);
  }
}

Position Synchronization on Connect

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();
},

Integration with Game Loop

// 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());
}

Interpolation & Lag Compensation

The Problem

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

Interpolation Buffer

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();
    }
  }
}

Interpolation Algorithm

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;
}

Interpolation Timeline

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

Data Flow

Complete Message Flow

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

Block Placement Flow

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

World Persistence with Strapi

Overview

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 Configuration

// 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 }>;
}

Loading World from Strapi

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);
  }
}

Auto-Save to Strapi

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);
  }
}

Offline Mode

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.


Key Concepts

1. Server-Authoritative Movement

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.

2. Initial Position Synchronization

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.

3. Input Prediction (Hybrid Approach)

Our implementation uses a hybrid approach:

  1. Local player moves immediately using client-side physics (for responsiveness)
  2. Inputs are sent to server
  3. Server calculates authoritative state for remote players
  4. Remote players see smooth interpolated movement

This means the local player has instant feedback but may briefly desync from server state.

4. Fixed Timestep Server

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.

5. WebSocket vs UDP

We use WebSockets (TCP) for simplicity. Trade-offs:

WebSocket (TCP)UDP
Guaranteed deliveryMay lose packets
Ordered packetsOut-of-order possible
Higher latencyLower latency
Browser supportRequires WebRTC

For a building game, reliability matters more than raw speed.


Troubleshooting & Issues Resolved

Issue 1: Players at Different Positions

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
);

Issue 2: Remote Player Appears to Move Backward

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.

Issue 3: World Coordinates Misaligned with Structures

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.

Issue 4: Physics Constants Mismatch

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;

Debugging Tips

  1. Add debug logging to server movement calculations:

    console.log(`Player ${playerId}: yaw=${yaw}, velocity=(${vx}, ${vz}), rotation=${rot}`);
  2. Check initial positions in browser console:

    Initial position: (2.34, 0.00, -1.56)
    
  3. Verify Strapi connection:

    Strapi: connected
    Loaded 13645 blocks from Strapi
    

Running the Multiplayer System

# 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.


Future Improvements

  1. Client-Side Prediction - Full reconciliation with server state
  2. Server-Side Block Collision - Currently only ground collision
  3. Delta Compression - Reduce bandwidth usage
  4. Room/Lobby System - Multiple game instances
  5. Player Names - Display floating name tags
  6. Chat System - Player communication
  7. Latency Compensation - Improve experience for high-latency players
Built with
Strapi
TanStack Start
RetroUI
View source on GitHub