NET FORGE DOCS

The shared simulation: ECS, Rapier physics and a fixed tick

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.

Net Forge runs one simulation module, @netforge/engine-sim, in two places: on the server, where it is the authority, and in the browser, where it predicts the local player. Because both sides run the same code on the same quantized inputs, there is no second copy of the game rules to keep in sync.

npm install @netforge/engine-sim
sim.ts
import { initSim, createWorld, addPlayer, step, captureState, encodeSnapshot, Button } from "@netforge/engine-sim";

await initSim(); // once per process: loads the Rapier WebAssembly build
const world = createWorld({ tickRate: 30, seed: 7, enemies: { target: 2000 } });
addPlayer(world, 1);

// One fixed tick, with every player's input for that tick.
const { tick, events } = step(world, new Map([[1, { seq: 1, tick: 0, moveX: 0, moveZ: 1, yaw: 0, buttons: Button.Fire }]]));
const snapshot = encodeSnapshot(captureState(world)); // a full snapshot as bytes
console.log(tick, events.length, snapshot.byteLength);

What is in it

PartWhat it does
ECS worldbitECS entities and components, iterated in netId order so both sides agree.
PhysicsRapier's deterministic WebAssembly build (@dimforge/rapier3d-deterministic-compat): bodies, triggers, joints, raycasts and a kinematic character controller.
Fixed tickstep(world, inputs) advances exactly one tick of 1 / tickRate seconds and never reads a clock. Templates run at 30 Hz.
InputsInputCommand { seq, tick, moveX, moveZ, yaw, buttons }, quantized on entry (moves to 1/127, yaw to a 16-bit turn) and 15 bytes encoded.
StatecaptureState returns integers only (positions in 1/1024 m). The sim snaps itself to that grid every tick.
SnapshotsencodeSnapshot(state, baseline?) writes a full or delta snapshot: zigzag varints with a changed-field mask, about 3 bytes for a resting entity and 8 for a moving one.

There is no DOM, timer, network or renderer in the package. It compiles with lib: ES2022 only, so the same file runs in Node and in a browser worker.

Why one simulation

A multiplayer game usually needs the rules twice: once on the server that decides what happened, and again on the client so the player's own movement feels instant. When they drift apart, players see rubber-banding and wrong hits. Net Forge removes the second copy. The browser imports the same step the server runs, applies its own inputs ahead of the server, and when the server's snapshot arrives it restores that state and replays the inputs the server has not seen yet. See Netcode and prediction.

Determinism, honestly

On one JavaScript engine the same inputs give the same snapshot bytes; the package tests this over 1,000 ticks. Across engines, Math.sin and Math.cos can differ in the last bit. That is acceptable because the server is authoritative and clients reconcile to it, rather than the other way round. Randomness comes from a seeded generator stored in the state, never from Math.random.

Fixed tick and rendering

FixedStepper turns frame deltas into a whole number of ticks plus a render alpha. The browser feeds it from requestAnimationFrame and the server from its own loop. renderView(world) returns typed arrays of positions with the previous tick's values, so the renderer interpolates with alpha without touching the ECS.

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

const stepper = new FixedStepper(30);
let last = performance.now();
function frame(now: number): void {
  const { ticks, alpha } = stepper.advance(now - last);
  last = now;
  for (let i = 0; i < ticks; i++) {
    // step(world, inputs) once per tick
  }
  // draw, interpolating by alpha
  void alpha;
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

How much it costs

Enemies steer inside the ECS on a uniform grid rather than as Rapier bodies, which is what lets a survivor-like keep thousands alive. Measured with the survivor template's playtest bots on an Apple M2 Pro under Node 22: 2,000 enemies and 4 players cost 2.22 ms per tick on average and 5.21 ms at the 99th percentile, against a 33 ms budget at 30 Hz.

Game code never touches this layer

A game's src/game.ts does not import @netforge/engine-sim. It declares prefabs, weapons and rules with the typed game API, and the template compiles those declarations into sim content. You use engine-sim directly when you write a template or a tool, or when you want the physics world on its own: see How to add deterministic Rapier physics to a three.js game.

Full API: @netforge/engine-sim reference.

FAQ

Is the Net Forge simulation deterministic?

Yes on one JavaScript engine: the same inputs produce the same snapshot bytes, and the package tests this. Across different engines trigonometry can differ in the last bit, so the server stays authoritative and clients reconcile to it.

What ECS does Net Forge use?

bitECS, with entities iterated in netId order so the server and every client process them in the same sequence.

What physics engine does Net Forge use?

Rapier, in its deterministic cross-platform WebAssembly build, version 0.21. It provides rigid bodies, triggers, joints, raycasts, shape casts and a kinematic character controller with coyote time, jump buffering and dash.

What tick rate does Net Forge run at?

The templates run the simulation at a fixed tick: 30 Hz by default, 60 Hz in the arena shooter, kart racer and platformer. The local room host sends snapshots to clients 20 times a second.

Can I run the simulation in a Web Worker or on a server?

Yes. The package has no DOM or timer dependency and compiles against lib: ES2022, so it runs in Node, a Web Worker or on the main thread.