# Three.js multiplayer netcode: prediction, reconciliation and snapshots

> How client-side prediction, server reconciliation, interpolation and delta snapshots work in a three.js multiplayer game, with a runnable TypeScript example that converges exactly under 80 ms latency and 5% packet loss.

Net Forge, the open-source multiplayer browser game engine built for AI coding agents, is published on npm under `@netforge` with source at https://github.com/netnetdev/net-forge.

Source: https://forge.netnet.capital/docs/guides/threejs-multiplayer-netcode  
Last updated: 2026-10-23. Checked against @netforge/engine 0.2.0.

Good three.js multiplayer netcode has four parts: the server owns the world, the client predicts its own player at once, the client reconciles when the server's snapshot arrives, and everyone else is drawn slightly in the past between two snapshots. Net Forge ships all four as `@netforge/engine/net`, and every template already uses them; `npm create netforge@latest my-game -- --template arena --yes` gives you a game where you can watch them work.

```sh
npm install @netforge/engine
```

## The four parts

| Part | What it does | Net Forge API |
|---|---|---|
| Inputs | One small command per tick with a sequence number. Each packet repeats the last 4 unacknowledged commands, so a lost packet is covered by the next. | `encodeInputPacket`, `decodeInputPacket`, `InputQueue` |
| Prediction | The client applies its own command to a local copy of the simulation the moment it is sampled. | `Predictor`, `NetClient.tick` |
| Reconciliation | On each snapshot the client resets its player to the server's state and replays every command the server has not applied yet. A difference shows as an offset that decays over about 100 ms. | `Predictor.reconcile`, inside `NetClient.receiveSnapshot` |
| Interpolation | Other players are drawn about 100 ms behind the server's estimated clock, between two snapshots. | `InterpolationBuffer`, `ServerClock`, `NetClient.render` |
| Snapshots | Bit-packed per field from a schema, as a delta against the last tick the client acknowledged. | `encodeSnapshot`, `decodeSnapshot`, `SnapshotHistory` |

## 1. Define a simulation both sides can run

Prediction is only exact when the client's step gives the same numbers as the server's. Keep state on an integer grid and make a player's movement depend only on that player's input. This adapter moves one point per player in 1/32 m units. The blocks in steps 1 to 3 form one file.

```ts group=lag
import type { EntitySchema, InputCodec, PlayerId, SimAdapter } from "@netforge/engine/net";

interface Move {
  readonly x: number; // -1, 0 or 1
  readonly z: number;
}
interface World {
  readonly players: Map<PlayerId, { id: number; x: number; z: number }>;
  nextId: number;
}

const SPEED = 4; // 1/32 m per tick
const schema: EntitySchema = {
  fields: [
    { name: "x", kind: "int", bits: 20 },
    { name: "z", kind: "int", bits: 20 },
  ],
  xField: 0,
  yField: 1,
  unitsPerWorld: 32,
};
const input: InputCodec<Move> = {
  write: (w, m) => {
    w.writeBits(m.x + 1, 2);
    w.writeBits(m.z + 1, 2);
  },
  read: (r) => ({ x: r.readBits(2) - 1, z: r.readBits(2) - 1 }),
  idle: { x: 0, z: 0 },
};

const movers: SimAdapter<World, Move> = {
  name: "movers",
  version: "1",
  tickHz: 30,
  schema,
  input,
  createWorld: () => ({ players: new Map(), nextId: 1 }),
  addPlayer: (w, p) => {
    const e = { id: w.nextId++, x: 0, z: 0 };
    w.players.set(p, e);
    return e.id;
  },
  removePlayer: (w, p) => void w.players.delete(p),
  step: (w, inputs) => {
    for (const [p, moves] of inputs) {
      const e = w.players.get(p);
      if (!e) continue;
      for (const m of moves) {
        e.x += m.x * SPEED;
        e.z += m.z * SPEED;
      }
    }
    return [];
  },
  snapshot: (w) => [...w.players].map(([owner, e]) => ({ id: e.id, kind: 1, owner, values: [e.x, e.z] })),
  applySnapshot: (w, states) => {
    for (const s of states) w.players.set(s.owner, { id: s.id, x: s.values[0] ?? 0, z: s.values[1] ?? 0 });
  },
};
```

## 2. Write the authoritative loop

```ts group=lag
import { decodeInputPacket, encodeSnapshot, InputQueue, quantizeEntity, SnapshotHistory, type QEntity } from "@netforge/engine/net";

class Room {
  readonly world = movers.createWorld(1, "server");
  private tickN = 0;
  private readonly conns = new Map<PlayerId, { entity: number; queue: InputQueue<Move>; history: SnapshotHistory; send: (b: Uint8Array) => void; ackTick: number }>();

  join(player: PlayerId, send: (bytes: Uint8Array) => void): number {
    const entity = movers.addPlayer(this.world, player);
    this.conns.set(player, { entity, queue: new InputQueue<Move>(), history: new SnapshotHistory(), send, ackTick: 0 });
    return entity;
  }

  receive(player: PlayerId, bytes: Uint8Array): void {
    const c = this.conns.get(player);
    if (!c) return;
    const packet = decodeInputPacket(movers.input, bytes);
    if (packet.ackTick > c.ackTick && packet.ackTick <= this.tickN) c.ackTick = packet.ackTick;
    c.queue.receive(packet.commands);
  }

  step(): void {
    this.tickN++;
    const inputs = new Map<PlayerId, Move[]>();
    for (const [p, c] of this.conns) inputs.set(p, c.queue.consume().map((cmd) => cmd.input));
    movers.step(this.world, inputs);
    const all: QEntity[] = movers.snapshot(this.world).map((e) => ({ id: e.id, kind: e.kind, owner: e.owner, q: quantizeEntity(movers.schema, e) }));
    for (const c of this.conns.values()) {
      const baseline = c.ackTick > 0 ? (c.history.get(c.ackTick) ?? null) : null;
      c.send(encodeSnapshot(movers.schema, { tick: this.tickN, baselineTick: baseline ? c.ackTick : 0, ackSeq: c.queue.lastAppliedSeq, self: c.entity }, all, baseline));
      c.history.put(this.tickN, all);
      c.history.pruneBefore(c.ackTick);
    }
  }
}
```

## 3. Play it over a bad network

`VirtualNet` is a deterministic simulated network with a virtual clock, latency, jitter that reorders packets, and loss. It models unreliable datagrams, which is harder than a WebSocket.

```ts group=lag
import { NetClient, VirtualNet, type LinkOptions } from "@netforge/engine/net";

// 80 ms each way, up to 20 ms of jitter and 5% loss.
const link: LinkOptions = { latencyMs: 80, jitterMs: 20, loss: 0.05 };
const net = new VirtualNet(7);
const room = new Room();
const tickMs = 1000 / movers.tickHz;

const holder: { client?: NetClient<World, Move> } = {};
const entity = room.join(1, (bytes) => net.send(link, () => holder.client?.receiveSnapshot(bytes, net.now)));
const client = new NetClient(movers, 1, entity, (bytes) => net.send(link, () => room.receive(1, bytes)));
holder.client = client;

// Walk right for two seconds, stop for one, walk forward for two, then idle while packets land.
for (let t = 0; t < 160; t++) {
  const move = t < 60 ? { x: 1, z: 0 } : t < 90 || t >= 150 ? { x: 0, z: 0 } : { x: 0, z: 1 };
  client.tick(move, net.now);
  room.step();
  net.advanceTo(net.now + tickMs);
}

console.log("server:", room.world.players.get(1), "predicted:", client.predicted()?.values);
console.log("packets:", net.sent, "dropped:", net.dropped, "snapshot bytes:", client.stats.snapshotBytes, "in", client.stats.snapshots, "snapshots");
```

Run the file with `node --experimental-strip-types` (Node 22). It prints the same position from the server and from the client's prediction (`228, 240` with this seed), after 10 of 320 packets were dropped: the repeated commands in each input packet covered every loss. The 153 snapshots took 2,742 bytes, about 18 bytes each, because each one is a delta against what the client acknowledged. The first few inputs are not predicted, because the client starts predicting once its first snapshot arrives (about 80 ms here).

## 4. Draw it

In a three.js client, call `client.tick(input, now)` on the fixed tick and, each animation frame, `client.render(now)`. It returns `self` (the predicted player, with any correction smoothed) and `others` (interpolated). Copy their values onto mesh positions, dividing by `unitsPerWorld`. The [three.js authoritative multiplayer guide](https://forge.netnet.capital/docs/guides/threejs-authoritative-multiplayer.md) has the drawing code.

## How it works

- **Why replay works.** A player's own fields depend only on that player's inputs and the static world, so replaying the unacknowledged commands on top of the server's state gives the server's answer. Interactions the client cannot know about, such as another player pushing you, are corrected by the next snapshot and smoothed.
- **Why the input clock drifts.** For simulations that take exactly one command per tick (the engine's physics sims), the snapshot header reports how many commands are waiting in the server's queue. `NetClient.tickIntervalMs()` runs the client's input clock up to 8% faster or slower to hold that queue near 2, which keeps added latency low without starving the server.
- **Why remote players are 100 ms behind.** Drawing between two received snapshots needs both to have arrived. 100 ms at a 20 to 30 Hz snapshot rate leaves room for one late or lost packet.
- **What the templates add.** Hitscan weapons in the arena shooter are lag compensated on the hosted server, which rewinds other players to what the shooter saw (capped at 200 ms). Area of interest sends each player only nearby entities. The survivor template's 2,000 enemies use a per-kind field subset so each costs only a few bits.

See [Netcode and prediction](https://forge.netnet.capital/docs/concepts/netcode-prediction.md) for the concepts in more depth.

## Alternatives

- **[Colyseus](https://docs.colyseus.io/netcode)** offers [client prediction](https://docs.colyseus.io/netcode/client-prediction) on top of its room and schema state sync. Pick it when you want a full server framework with SDKs for many client engines.
- **[Nakama](https://heroiclabs.com/docs/nakama/concepts/multiplayer/authoritative/)** provides authoritative match handlers inside a complete open-source game backend.
- Writing your own on **[Socket.IO](https://socket.io/docs/v4/)** or raw WebSockets is reasonable for slow or turn-based games, where prediction matters less.

Pick Net Forge when you want the netcode as a TypeScript library whose simulation runs unchanged in the browser and on the server, or ten finished games that already use it.

## FAQ

### What is client-side prediction?

The client applies the player's input to its own copy of the game immediately instead of waiting for the server, so movement feels instant even with 100 ms of latency. The server still decides the result.

### What is server reconciliation?

When the server's snapshot arrives, the client rewinds its player to the server's state and replays the inputs the server has not processed yet. If the server agreed, nothing visible changes. If not, the client moves toward the server's answer smoothly.

### Why not just send positions from the client?

A client that sends its own position can send any position. Sending only inputs means the server decides where everyone is, which removes speed and teleport cheats.

### What is a delta snapshot?

A snapshot that encodes only what changed since a snapshot the client has confirmed it received. If the client never got that baseline, it acknowledges nothing until a full snapshot arrives.

### Does this work over WebSockets?

Yes. The templates use WebSockets through a Colyseus room. The netcode is written for the harder unreliable case too, which is why the example above survives reordering and loss.

### Do I need three.js for this?

No. `@netforge/engine/net` has no renderer dependency. The templates draw with three.js, but any renderer can read `client.render(now)`.
