NET FORGE DOCS

How to add authoritative multiplayer to a three.js game

Last updated Checked against @netforge/engine 0.2.0View as Markdown

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

To add authoritative multiplayer to a three.js game, move the rules into one fixed-tick simulation that the server runs as the authority and each browser runs again to predict its own player, and have every client send only its inputs. With Net Forge that simulation is one game file: the same @netforge/engine-sim code steps the world on the room server and in the browser, so there is no separate server codebase to keep in sync, and three.js only draws what the simulation says.

npm create netforge@latest my-game -- --template arena --yes
npm install @netforge/engine @netforge/engine-sim

The first line starts from a finished multiplayer game, where the rules are src/game.ts and the server is already written. The second adds the netcode packages to a three.js project of your own, which is what the rest of this page does. A runnable version of that, a plain three.js scene with the netcode under it, is in the example.

What moves where

In your three.js game todayWith an authoritative server
requestAnimationFrame moves objects by deltaA fixed tick (tickHz) moves them by a constant step, on the server and in the browser
Input handlers change positions directlyInput handlers build one small command per tick, and the server applies it
Collision and hit checks in the browserThe same checks inside the simulation's step, decided by the server
mesh.position is the truthThe simulation's state is the truth, and mesh.position copies it every frame
Floats everywherePositions on a fixed-point grid, so the client's prediction matches the server exactly

Clients send intentions, not outcomes

A client never tells the server where it is. It sends what the player wants to do this tick, and the server decides what happens. In Net Forge the intention is an InputCommand:

intent.ts
import type { InputCommand } from "@netforge/engine-sim";

// What the browser sends for one tick: an intention, never a position.
const intent: InputCommand = { seq: 42, tick: 1200, moveX: 1, moveZ: 0, yaw: 0, buttons: 0 };

moveX and moveZ are the stick or keys from -1 to 1, yaw is where the player faces, and buttons is a bitmask (fire, dash and so on). On the wire the engine packs the move axes as signed bytes, the facing as a 16-bit turn and the buttons as 16 bits, and each input packet repeats the last few unacknowledged commands so a lost packet costs nothing. The server's InputQueue ignores a command it already has, applies one per tick for engine-sim games, and gives a player only as many commands as its credit allows, so sending more inputs never moves anyone faster.

 browser, every tick                               room server, every tick (authoritative)
 keys -> InputCommand { seq, moveX, moveZ,  ---->  InputQueue -> step(world, inputs)
         yaw, buttons }                            speed, walls, physics, hits decided here
 predict: step(own world, [own input])                        |
 draw: predicted self, interpolated others  <----  delta snapshot (positions, health, flags)

The tick is the template's tickRate: 30 Hz for the survivor-like and the shared simulation's default, 60 Hz for the arena shooter, platformer and kart racer. The local room host in @netforge/kit-play sends snapshots 20 times a second. Because the browser predicts with the same step function the server runs, its guess and the server's answer agree unless something the client could not know happened, such as another player pushing it; then the next snapshot corrects it, and the correction is smoothed over about 100 ms.

1. Describe the game as a SimAdapter

A SimAdapter is the narrow seam the netcode drives: create a world, add and remove players, step one tick with each player's inputs, and report every networked entity. Keep positions on an integer grid (here 1/32 m) so a predicted step on the client gives the same numbers as the server. The three blocks in steps 1 to 3 form one file.

import type { EntitySchema, InputCodec, PlayerId, SimAdapter } from "@netforge/engine/net";

/** What one player pressed in one tick. */
interface Move {
  readonly x: number; // -1, 0 or 1
  readonly z: number;
}

/** The world: one position per player, in 1/32 m units so the client predicts it exactly. */
interface World {
  readonly players: Map<PlayerId, { id: number; x: number; z: number }>;
  nextId: number;
}

const UNITS_PER_METRE = 32;
const SPEED = 4; // units per tick: 4/32 m at 30 Hz is 3.75 m/s

const schema: EntitySchema = {
  fields: [
    { name: "x", kind: "int", bits: 20 },
    { name: "z", kind: "int", bits: 20 },
  ],
  xField: 0,
  yField: 1,
  unitsPerWorld: UNITS_PER_METRE,
};

const input: InputCodec<Move> = {
  write(w, m) {
    w.writeBits(m.x + 1, 2);
    w.writeBits(m.z + 1, 2);
  },
  read(r) {
    return { 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, player) {
    const e = { id: w.nextId++, x: 0, z: 0 };
    w.players.set(player, e);
    return e.id;
  },
  removePlayer(w, player) {
    w.players.delete(player);
  },
  step(w, inputsByPlayer) {
    for (const [player, moves] of inputsByPlayer) {
      const e = w.players.get(player);
      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. Run the authoritative room

The room owns the world. It decodes input packets into a per-player queue, steps the world, and sends each player a delta snapshot against the last tick that player acknowledged. The queue ignores commands it already has and applies at most what its credit allows, so a modified client cannot move faster by sending more inputs.

import { decodeInputPacket, encodeSnapshot, InputQueue, quantizeEntity, SnapshotHistory, type QEntity } from "@netforge/engine/net";

interface Conn {
  readonly entity: number;
  readonly queue: InputQueue<Move>;
  readonly history: SnapshotHistory;
  readonly send: (bytes: Uint8Array) => void;
  ackTick: number;
}

/** The authoritative side: owns the world, takes inputs, sends each player a delta snapshot. */
class Room {
  readonly world = movers.createWorld(1, "server");
  private tickN = 0;
  private readonly conns = new Map<PlayerId, Conn>();

  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 [player, c] of this.conns) inputs.set(player, 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;
      const header = { tick: this.tickN, baselineTick: baseline ? c.ackTick : 0, ackSeq: c.queue.lastAppliedSeq, self: c.entity };
      c.send(encodeSnapshot(movers.schema, header, all, baseline));
      c.history.put(this.tickN, all);
      c.history.pruneBefore(c.ackTick);
    }
  }
}

3. Connect clients

NetClient predicts its own player, sends input packets (each one repeats the last four unacknowledged commands), reconciles when a snapshot arrives and interpolates everyone else. Here the network is a direct function call, so the example runs in Node as it is:

import { NetClient } from "@netforge/engine/net";

const room = new Room();
let now = 0;

// Two players. In a real game `send` is a WebSocket; here the calls are direct.
const clients = [1, 2].map((player) => {
  const holder: { client?: NetClient<World, Move> } = {};
  const entity = room.join(player, (bytes) => holder.client?.receiveSnapshot(bytes, now));
  const client = new NetClient(movers, player, entity, (bytes) => room.receive(player, bytes));
  holder.client = client;
  return client;
});

// One second of play: player 1 walks right, player 2 walks forward.
for (let t = 0; t < 30; t++) {
  clients[0]?.tick({ x: 1, z: 0 }, now);
  clients[1]?.tick({ x: 0, z: 1 }, now);
  room.step();
  now += 1000 / movers.tickHz;
}

for (const c of clients) console.log(`player ${c.player}: predicted`, c.predicted()?.values, "server", c.authoritativeSelf()?.values);

Running it prints the same position from the client's prediction and from the server for both players. In a browser, send writes to a WebSocket and receiveSnapshot is called from its message handler.

4. Draw it with three.js

Each frame, ask the client what to draw and copy it onto your meshes. Your own player comes from prediction, with any correction smoothed over about 100 ms. Everyone else is interpolated about 100 ms in the past.

import type { NetClient } from "@netforge/engine/net";

/** Anything with a three.js-style position, such as a THREE.Mesh. */
interface Drawable {
  readonly position: { set(x: number, y: number, z: number): void };
}

const UNITS_PER_METRE = 32;

export function drawFrame<W, I>(client: NetClient<W, I>, meshFor: (entityId: number) => Drawable, nowMs: number): void {
  const { self, others } = client.render(nowMs);
  for (const e of self ? [self, ...others] : others) {
    const x = (e.values[0] ?? 0) / UNITS_PER_METRE;
    const z = (e.values[1] ?? 0) / UNITS_PER_METRE;
    meshFor(e.id).position.set(x, 0, z);
  }
}

Call client.tick(input, now) on your fixed tick, using client.tickIntervalMs() as the interval (it nudges the input clock to keep the server's queue short), and call drawFrame from requestAnimationFrame.

5. Put the room behind a server

The room above does not care about the transport. Net Forge's own local host in @netforge/kit-play runs the same kind of loop inside a Colyseus room on one port, with per-player area of interest and snapshots at 20 Hz. The hosted Net Forge room server adds lag compensation for hitscans, a run log and replay. If you want those without writing them, start from a template, where all of it is already wired.

Run the example

The engine repository has this page as a runnable three.js scene: packages/engine/examples/threejs-scene. It is an ordinary three.js scene (a WebGLRenderer, a grid and two capsule meshes) where the arrow keys become InputCommand intentions, an authoritative room steps the real @netforge/engine-sim world at 30 Hz behind a simulated 60 ms link, and the scene draws your player from prediction and the other player interpolated. From a clone of the public repository:

git clone https://github.com/netnetdev/net-forge
cd net-forge
pnpm install
pnpm build
pnpm --filter @netforge/engine example:threejs

It opens on http://localhost:5312. The room runs in the page so the example needs no server; in a real game the two net.send calls in its room.ts become a WebSocket and the room runs in Node. A test in the same package (test/example-threejs-scene.test.ts) runs the room and two clients headless and checks that each prediction lands where the server says.

When Colyseus on its own is the better fit

Colyseus is an open-source multiplayer framework for Node.js with rooms, matchmaking and binary state sync, and since 0.18 it runs client prediction, reconciliation and server rewind loops for you while you supply the deterministic step function (Netcode). It also lists open-source three.js example games (three.js examples). Pick it on its own when you want to keep your own client loop and state model, when the same server must serve clients in other engines through its SDKs, or when you want its managed hosting. Net Forge's rooms are built on Colyseus too; what Net Forge adds is the shared simulation and the finished games. See Net Forge and Colyseus.

How it works

The netcode follows the client-side prediction and server reconciliation model: inputs never wait for a round trip, and the client converges on the server's state. Snapshots are bit-packed per field from the schema, and a delta carries only the fields that changed since the acknowledged baseline. If a baseline is missing, the client acknowledges nothing until a full snapshot arrives. When your game needs real physics, use deterministic Rapier on the same fixed tick. See Netcode and prediction.

Alternatives

  • Colyseus is a full multiplayer server framework with rooms, matchmaking, schema state sync and client prediction, plus SDKs for many client engines. Pick it when you want a server framework and will keep your own client loop.
  • Nakama runs authoritative match handlers on an open-source game backend that also provides accounts, storage and social features.
  • Socket.IO gives you event-based messaging. You write the tick, the authority and the prediction yourself, which suits a turn-based or low-rate game.

Pick Net Forge when you want prediction, reconciliation, interpolation and delta snapshots as a TypeScript library that shares code between the browser and the server, or a finished template that already uses them.

FAQ

Can I keep my existing three.js scene?

Yes. three.js stays the renderer. What changes is who decides where things are: the simulation does, on a fixed tick, and the scene copies its state every frame.

Why does the simulation use integers instead of floats?

So the client can predict exactly what the server will compute. When both sides step the same integers with the same inputs, prediction errors come only from things the client could not know, such as another player pushing you.

Do the server and the client have to be the same language?

With Net Forge, yes, and that is the point: the same TypeScript module steps the world on the server in Node and in the browser.

How much bandwidth does this use?

The snapshot codec packs each field in the bits the schema gives it and sends only changed fields. In the engine's shared simulation a resting entity costs about 3 bytes and a walking one about 8 per snapshot.

Does Net Forge include a production server I can deploy?

The public repository includes the local host in @netforge/kit-play for your computer and network. Production rooms with lag compensation and replay run on the hosted Net Forge service. See Deploy and host your game.