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:
// 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
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
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:
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:
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:
// 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:
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
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
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
- Performance, network bandwidth optimization
- Save & Load, cloud save integration
- Deployment, deploying the game server