Skip to content

Networking & Multiplayer

Web Engine Dev's networking stack (@web-engine-dev/netcode, @web-engine-dev/netcode-ecs, @web-engine-dev/netcode-server) supports authoritative client-server architectures with rollback netcode, client-side prediction, lag compensation, and optional peer-to-peer.

Architecture Overview

Client                          Server
  │                               │
  │── InputPacket (frame N) ───►  │
  │                               │  Simulate frame N
  │◄── StatePacket (frame N) ──── │
  │                               │
  │ Predict frames ahead          │
  │ Reconcile when state arrives  │

The engine implements GGPO-style rollback: clients simulate ahead using predicted input, then roll back and re-simulate if the server's authoritative state differs.

Server Setup

The dedicated server (@web-engine-dev/netcode-server) runs a headless World:

typescript
// server/src/index.ts
import { GameRoom, type RoomCreateOptions } from '@web-engine-dev/netcode-server';
import { createNodeGameServer } from '@web-engine-dev/netcode-server/node';

interface GameState {
  players: Record<string, { x: number; y: number; health: number }>;
  tick: number;
}

class MyGameRoom extends GameRoom<GameState> {
  onCreate(options: RoomCreateOptions): void {
    // Initialise authoritative game state
    this.setState({ players: {}, tick: 0 });
    // Run at 60 Hz
    this.setSimulationInterval(60);
  }

  onTick(deltaTime: number): void {
    // Server-side game logic — mutate this.state directly
    this.patchState({ tick: this.state.tick + 1 });
    // broadcastPatch is called automatically when state is dirty
  }
}

const server = createNodeGameServer({ port: 3001 });
server.define('game', MyGameRoom);
await server.start();
console.log('Game server running on port 3001');

Client Setup

typescript
import { createEngine } from '@web-engine-dev/engine';
import { configureNetwork } from '@web-engine-dev/netcode-ecs';

const engine = await createEngine({ canvas, width: 1280, height: 720 });
const { world } = engine;

configureNetwork(
  world,
  {
    type: 'dedicated-client',
    serverUrl: 'wss://game.example.com:3001',
    tickRate: 60,
    prediction: true, // enable client-side prediction
    rollback: true, // enable rollback reconciliation
    interpolation: true, // interpolate remote entities
    maxRollbackFrames: 10, // max frames to roll back
  },
  [
    InputSystem,
    ClientPredictionSystem, // runs prediction
    ReconciliationSystem, // reconciles with server
    InterpolationSystem, // smooth remote entities
    RenderSystem,
  ]
);

Connecting to a Server

typescript
import { NetworkClient } from '@web-engine-dev/netcode';

const client = new NetworkClient({ serverUrl: 'wss://game.example.com:3001' });

// Connect
await client.connect();

// Listen for connection events
client.on('connected', () => showLobbyUI(world));
client.on('disconnected', () => showDisconnectedUI(world));
client.on('error', (e) => console.error('Network error:', e));

// Receive player assignments
client.on('message', (msg) => {
  if (msg.type === 'playerAssigned') {
    world.insertResource(LocalPlayer, { id: msg.playerId });
    spawnLocalPlayer(world, msg.spawnPosition);
  }
});

Sending Input

On the client, serialize and send input every frame:

typescript
import { NetworkClientResource } from '@web-engine-dev/netcode';

function ClientInputSendSystem(world: World): void {
  const clientState = world.getResource(NetworkClientResource);
  const input = world.getResource(InputState);

  // Serialise input for the current network tick
  const packet = {
    tick: clientState.currentTick,
    moveX: input.value('moveHorizontal'),
    moveY: input.value('moveVertical'),
    jump: input.justPressed('jump'),
    attack: input.justPressed('attack'),
    aimX: input.axis2D('aim').x,
    aimY: input.axis2D('aim').y,
  };

  clientState.sendInput(packet);
}

Predicted vs. Authoritative Entities

Mark entities appropriately so the reconciliation system knows how to handle them:

typescript
import { NetworkEntity, Predicted, Interpolated } from '@web-engine-dev/netcode-ecs';

// Local player: predicted by client, corrected by server
world.insert(localPlayerEntity, NetworkEntity, {
  id: networkId,
  ownerId: localPlayerId,
});
world.insert(localPlayerEntity, Predicted, {});

// Remote players: received from server, interpolated smoothly
world.insert(remotePlayerEntity, NetworkEntity, { id: networkId });
world.insert(remotePlayerEntity, Interpolated, {
  maxDelay: 0.1, // 100ms interpolation buffer
});

State Synchronization

The server sends authoritative state snapshots; clients apply them:

typescript
// On the server — inside your GameRoom subclass
class MyGameRoom extends GameRoom<GameState> {
  onCreate(options: RoomCreateOptions): void {
    this.setState({ players: {}, tick: 0 });
    this.setSimulationInterval(60);
  }

  onTick(deltaTime: number): void {
    // Mutate state then call patchState to mark it dirty
    const nextTick = this.state.tick + 1;
    this.patchState({ tick: nextTick });

    // Update each connected player's position from their last input
    for (const [clientId, client] of this.clients) {
      const input = client.userData as PlayerInput;
      if (input) {
        const player = this.state.players[clientId];
        if (player) {
          this.patchState({
            players: {
              ...this.state.players,
              [clientId]: {
                ...player,
                x: player.x + input.moveX,
                y: player.y + input.moveY,
              },
            },
          });
        }
      }
    }
    // State is automatically broadcast to clients when dirty
  }
}

Lag Compensation (Hitscan)

The server rewinds entity positions to when the client fired:

typescript
import { LagCompensation } from '@web-engine-dev/netcode';
import { PhysicsWorld3DResource } from '@web-engine-dev/physics3d';

// Instantiate with history size matching your tick rate
const lagComp = new LagCompensation({
  maxHistoryFrames: 128,
  tickRate: 60,
});

// Record a position snapshot every server tick
function RecordPositionsSystem(world: World): void {
  const query = world.query().with(Position, NetworkIdentity).build();
  const snapshot: Array<{ id: string; pos: Position }> = [];
  for (const {
    components: [pos, netId],
  } of world.run(query)) {
    snapshot.push({ id: netId.networkId, pos });
  }
  lagComp.recordSnapshot(currentTick, snapshot);
}

// On the server when processing a hitscan shot
function ProcessHitscanSystem(world: World): void {
  const physicsWorld = world.getResource(PhysicsWorld3DResource);

  for (const shoot of pendingShootEvents) {
    // Rewind tracked positions to when the client actually fired
    const rewindedPositions = lagComp.rewind(shoot.shooterTick);

    // Perform the raycast against rewound positions
    const hit = physicsWorld.raycast(shoot.aimRay, { positions: rewindedPositions });

    if (hit && world.has(hit.entity, Health)) {
      world.eventWriter(DamageEvent).send({ target: hit.entity, amount: 25 });
    }
  }
}

Matchmaking

typescript
import { Matchmaker } from '@web-engine-dev/matchmaking';

const matchmaking = new Matchmaker({
  serverUrl: 'https://matchmaking.example.com',
});

// Find or create a match
const ticket = await matchmaking.queue({
  mode: 'ranked-1v1' as GameModeId,
  player: { id: localPlayer.id, skill: player.mmr },
  region: 'auto' as RegionId,
});

matchmaking.on('matched', async (event) => {
  await connectToMatch(world, event.serverUrl, event.sessionToken);
});

matchmaking.on('timeout', () => {
  showQueueTimeoutUI(world);
  matchmaking.cancel(ticket.ticketId);
});

Lobby System

typescript
import { LobbyManager } from '@web-engine-dev/matchmaking';

const lobbyManager = new LobbyManager({
  serverUrl: 'https://lobby.example.com',
});

const lobby = await lobbyManager.create({
  name: `${localPlayer.name}'s Game`,
  maxPlayers: 4,
  mode: 'coop' as GameModeId,
  visibility: 'public',
});

// Invite a friend
await lobbyManager.invite(lobby.lobbyId, friendUserId);

// Start when ready
lobbyManager.on('allReady', () => {
  lobbyManager.start(lobby.lobbyId);
});

Next Steps

Proprietary software. All rights reserved.