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

> @netforge/engine-sim is one TypeScript module that steps the same world on a Node server and in the browser: fixed tick, quantized inputs, integer state and snapshots that restore bit-exact. A runnable example proves two worlds stay identical.

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/shared-client-server-simulation  
Last updated: 2026-10-23. Checked against @netforge/engine 0.2.0.

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.

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

## The rules that make it work

| Rule | How engine-sim does it |
|---|---|
| One fixed tick | `step(world, inputs)` advances exactly `1 / tickRate` seconds and never reads a clock |
| Same inputs on both sides | Every command is quantized on entry (moves to 1/127, yaw to a 16-bit turn) |
| Same rounding | State is snapped onto a fixed-point grid (1/1024 m) at the end of every tick |
| Same order | Entities iterate in `netId` order; the PRNG seed lives in the state |
| Same physics | The deterministic Rapier build, with cross-platform determinism enabled |
| Restorable | `applyState(world, captureState(other))` reproduces `other` exactly |

## 1. Step two worlds with the same inputs

```ts title=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:

```ts title=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](https://forge.netnet.capital/docs/concepts/simulation-ecs.md).

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](https://docs.colyseus.io/netcode)** lets you supply a deterministic step function for its [client prediction](https://docs.colyseus.io/netcode/client-prediction), alongside its schema state sync. Pick it when your server framework is Colyseus and you will write the simulation yourself.
- **[bitECS](https://github.com/NateTheGreatt/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](https://rapier.rs/docs/)** on its own provides [cross-platform deterministic](https://rapier.rs/docs/user_guides/javascript/determinism) 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](https://forge.netnet.capital/docs/guides/threejs-multiplayer-netcode.md).
