NET FORGE DOCS

How to run the same game simulation on the server and in the browser

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 run one game simulation on both the server and the browser, it has to be a pure function of the world and that tick's inputs: no clocks, no DOM, no network, and state that both sides round the same way. @netforge/engine-sim is built that way. It is a bitECS world with deterministic Rapier physics, a fixed tick, quantized inputs and binary snapshots, and it compiles with lib: ES2022 only, so the same import works in Node and in a browser bundle.

npm install @netforge/engine-sim

The rules that make it work

RuleHow engine-sim does it
One fixed tickstep(world, inputs) advances exactly 1 / tickRate seconds and never reads a clock
Same inputs on both sidesEvery command is quantized on entry (moves to 1/127, yaw to a 16-bit turn)
Same roundingState is snapped onto a fixed-point grid (1/1024 m) at the end of every tick
Same orderEntities iterate in netId order; the PRNG seed lives in the state
Same physicsThe deterministic Rapier build, with cross-platform determinism enabled
RestorableapplyState(world, captureState(other)) reproduces other exactly

1. Step two worlds with the same inputs

shared.ts
import { addPlayer, applyState, Button, captureState, createWorld, encodeSnapshot, initSim, step, type InputCommand } from "@netforge/engine-sim";

await initSim(); // once per process: loads the deterministic Rapier build

// The same config on both sides: one plays the server, one plays a browser.
const config = { tickRate: 30, seed: 7, enemies: { target: 500 } };
const server = createWorld(config);
const browser = createWorld(config);
for (const w of [server, browser]) {
  addPlayer(w, 1);
  addPlayer(w, 2);
}

// Ten seconds of the same inputs on both.
for (let tick = 0; tick < 300; tick++) {
  const inputs = new Map<number, InputCommand>([
    [1, { seq: tick + 1, tick, moveX: 0, moveZ: 1, yaw: tick / 30, buttons: tick % 20 === 0 ? Button.Fire : 0 }],
    [2, { seq: tick + 1, tick, moveX: 1, moveZ: 0, yaw: 0, buttons: 0 }],
  ]);
  step(server, inputs);
  step(browser, inputs);
}

const a = encodeSnapshot(captureState(server));
const b = encodeSnapshot(captureState(browser));
console.log(`snapshot ${a.length} bytes, identical: ${a.length === b.length && a.every((x, i) => x === b[i])}`);

// A late joiner: copy the server's state into a fresh world and keep stepping in lockstep.
const late = createWorld(config);
applyState(late, captureState(server));
const next = new Map<number, InputCommand>([[1, { seq: 301, tick: 300, moveX: 0, moveZ: 0, yaw: 0, buttons: 0 }]]);
step(server, next);
step(late, next);
const c = encodeSnapshot(captureState(server));
const d = encodeSnapshot(captureState(late));
console.log(`after restore, identical: ${c.length === d.length && c.every((x, i) => x === d[i])}`);

Run it with node --experimental-strip-types shared.ts on Node 22. It prints snapshot 9521 bytes, identical: true with 500 enemies and two players after 300 ticks, then after restore, identical: true.

2. Drive it from a real clock

step takes no time argument, so each side decides when to call it. FixedStepper turns wall-clock deltas into whole ticks plus a render alpha, and drops ticks past a cap so a hidden tab cannot spiral:

loop.ts
import { FixedStepper, renderView, type SimWorld } from "@netforge/engine-sim";

export function startLoop(world: SimWorld, tick: () => void, draw: (alpha: number) => void): void {
  const stepper = new FixedStepper(30);
  let last = performance.now();
  const frame = (now: number) => {
    const { ticks, alpha } = stepper.advance(now - last);
    last = now;
    for (let i = 0; i < ticks; i++) tick();
    draw(alpha);
    requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);
  void renderView(world); // dense typed arrays (x, y, z, yaw and their previous-tick values) for the renderer
}

On the server the same tick() runs from a timer instead of requestAnimationFrame. In the browser, renderView(world) gives netId, kind, positions and the previous tick's positions as typed arrays, so a three.js renderer can interpolate with alpha without touching the ECS.

3. Send snapshots

encodeSnapshot(state) is a full snapshot and encodeSnapshot(state, baseline) is a delta against a state the receiver acknowledged. Fields are varints of the difference from the baseline with a changed-field mask per entity, so a resting entity costs about 3 bytes and a walking one about 8. snapshotBaseTick(bytes) tells the receiver which baseline a delta needs, and decodeSnapshot(bytes, baseline) throws if it is given the wrong one. captureState(world, include) takes a filter per client for area of interest.

How it works in the templates

Every Net Forge template runs on this module. The hosted room server and the local host in @netforge/kit-play step the authoritative world; the browser steps a copy for its own player to predict movement and reconciles to the server's snapshots. Because the code is the same, the browser can never disagree with the server about the rules, only about things it could not know yet. Across different JavaScript engines, Math.sin and Math.cos can differ in the last bit; the server is authoritative and clients reconcile, so that is harmless. See Simulation and ECS.

Measured: in content mode the shared simulation holds 4 players and 2,000 enemies at 2.22 ms per tick on average (p99 5.21 ms) in the survivor template's playtest on an Apple M2 Pro with Node 22. Enemies steer in the ECS rather than as Rapier bodies, which is what keeps that many cheap.

Alternatives

  • Colyseus lets you supply a deterministic step function for its client prediction, alongside its schema state sync. Pick it when your server framework is Colyseus and you will write the simulation yourself.
  • bitECS, which engine-sim builds on, is a small, fast ECS for JavaScript. Pick it on its own when you want an ECS and will design determinism and snapshots yourself.
  • Rapier on its own provides cross-platform deterministic physics in JavaScript, without the ECS or the snapshot format.

FAQ

Can the same TypeScript code run on the server and in the browser?

Yes, if it avoids browser and Node APIs and never reads the clock. @netforge/engine-sim compiles with lib: ES2022 only and takes time only as a tick count, so the same module runs in both.

Is JavaScript floating point deterministic?

Basic arithmetic is, on IEEE 754 doubles. Transcendental functions such as Math.sin can differ in the last bit between engines. engine-sim snaps state onto an integer grid every tick, so tiny differences do not accumulate, and the server stays authoritative.

What is the difference between lockstep and server-authoritative?

In lockstep, every peer steps the same inputs and must stay identical. In a server-authoritative game, the server's world is the truth and clients only predict. engine-sim supports both properties: two worlds stay identical with the same inputs, and a client can always snap to the server with applyState.

How do late joiners get the current state?

Send them a full snapshot. applyState(world, captureState(server)) on the client reproduces the server's world exactly, and both keep stepping identically from there.

Does engine-sim include networking?

No. It has no network code by design. The client netcode is @netforge/engine/net; see the netcode guide.