Networking
How Meep replicates game state across peers using deterministic action logs, server-authoritative reconciliation, and adaptive interpolation.
Meep’s networking layer is built on the same determinism guarantee that underlies the physics engine. Because the same inputs applied in the same order produce the same world on every V8 runtime (see Determinism), the network only needs to agree on what happened — not on the resulting positions and velocities. The action stream is the primary data channel; world-state snapshots are used only for initial sync and reconciliation.
Core model: action replication
Every mutation to replicated state goes through a SimAction. An action is a small, serializable, synchronous operation that declares:
apply(world, executor)— the forward mutationaffected_components(callback, executor)— which(entity, componentClass)pairs it will touch, so the executor can capture prior state for rewindserialize(buffer)/deserialize(buffer)— the wire format
The SimActionExecutor is the single gateway for replicated mutations. On execute(action, sender_id) it:
- Calls
affected_componentsand writes prior-state bytes for every named component into the current frame’sActionLog. - Calls
action.apply. - Appends the serialized action bytes to the same frame buffer.
Anything that bypasses the executor will not be replicated, will not be reversible, and will cause desync.
SimAction.extend is a declarative factory for simple actions:
import { SimAction } from "@woosh/meep-engine/src/engine/network/sim/SimAction.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
const MoveAction = SimAction.extend({
type: 'Move',
schema: { network_id: 'uintVar', dx: 'float32', dz: 'float32' },
affects(executor) {
const entity = executor.slot_table.entity_for(this.network_id);
return entity < 0 ? [] : [[entity, Transform]];
},
apply(world, executor) {
const entity = executor.slot_table.entity_for(this.network_id);
if (entity < 0) return;
const t = world.getComponent(entity, Transform);
t.position.x += this.dx;
t.position.z += this.dz;
},
});
Schema field insertion order is the wire-byte order and the constructor positional order. Supported schema types are uint8, uint16, uint32, int8, int16, int32, uintVar, float32, float64, and bool. For more complex fields (vectors, quaternions, variable-length data) subclass SimAction directly.
Session setup
NetworkSession is the high-level facade that wires an EntityManager, a transport, and your action/component registrations into a running network session.
import { NetworkSession, NetworkSessionRole } from "@woosh/meep-engine/src/engine/network/NetworkSession.js";
const session = new NetworkSession({
entity_manager: entityManager,
transport: myTransport, // or transport_factory for auto-reconnect
role: NetworkSessionRole.Host, // or NetworkSessionRole.Client
tick_rate_hz: 60,
simulation_delay_ticks: 4, // host only — server-side input buffer
});
// Register replicated component classes (must have BinaryClassSerializationAdapter)
session.replicate(Transform, new TransformInterpolationAdapter());
session.replicate(Health);
// Register action classes
session.defineAction(MoveAction);
// Client only: install input sampler
session.defineInputSampler((frame) => {
return inputBuffer.hasPendingInput() ? [new MoveAction(localNetworkId, dx, dz)] : [];
});
await session.start();
session.connect(remotePeerId, transport);
// Per frame
session.tick(dt);
The order of replicate() calls must be identical on every peer — it determines the wire-format position of each component type in snapshots and AUTH_STATE packets.
Server-authoritative vs. peer topology
The engine ships two orchestrators, selected by role.
| Role | Orchestrator | Behaviour |
|---|---|---|
'host' | ServerAuthoritativeServer | runs the canonical simulation; maintains a per-client input buffer; rewinds and replays when late inputs arrive |
'client' | ServerAuthoritativeClient | predicts locally (with an input sampler) or spectates (without one); reconciles against AUTH_STATE from the host |
There is no built-in peer-to-peer topology. All authority flows through the host. The host’s simulation_delay_ticks parameter (default 4) holds inputs in a buffer before consuming them, absorbing typical one-way latency so clients can predict correctly without the server rejecting inputs as “too late.”
ServerAuthoritativeServer.tick(frame) processes inbound actions from all connected clients each server frame. It finds the oldest pending action, rewinds the world to end-of-(that frame − 1) using the RewindEngine, re-executes all historical and newly-arrived actions in stable sender-ID order, then drives onLocalSim for server-side game logic. The result: a client action tagged at client frame K is applied as if it ran against end-of-(K−1) server state regardless of network timing.
On the client, ServerAuthoritativeClient tracks predictions in an InputRing. When AUTH_STATE arrives for a given server frame, the client rewinds via RewindEngine, applies the server’s authoritative component bytes, then replays its unconfirmed inputs frame by frame. A reconcile_epsilon (default 1e-4) short-circuits the rewind if the predicted and authoritative states agree within tolerance, avoiding unnecessary churn on calm connections.
Network identity and ownership
An entity is replicated only when it carries a NetworkIdentity component:
import { NetworkIdentity } from "@woosh/meep-engine/src/engine/network/ecs/components/NetworkIdentity.js";
new Entity()
.add(new NetworkIdentity()) // network_id assigned automatically by NetworkSystem.link
.add(new Transform())
.build(ecd);
NetworkIdentity exposes three fields:
| Field | Type | Meaning |
|---|---|---|
network_id | number | peer-shared entity identifier; negative until NetworkSystem.link runs |
owner_peer_id | number | peer with authority; -1 means “local / server-owned” |
replication_flags | number | game-defined bitfield for priority, always-relevant, etc. |
Actions reference entities by network_id. Inside apply, use executor.slot_table.entity_for(network_id) to get the local integer entity ID. The same network_id maps to different local entity IDs on different peers.
To mutate a replicated component from outside an action, fire a "net_mutate_component" event on the entity:
dataset.sendEvent(entityId, "net_mutate_component", {
component_type: Transform,
new_state: updatedTransform, // optional; if omitted the live component is read
});
The session translates this into an internal ReplaceComponentAction and dispatches it through the executor.
Scope filtering
By default, the host sends every action to every peer. The scope_filter option on NetworkSession lets the host skip actions that touch entities irrelevant to a given peer — implementing area-of-interest culling, fog-of-war, or faction-based visibility.
Two built-in scope filters:
| Class | Behaviour |
|---|---|
AlwaysRelevantScope | send everything to everyone (the default when no filter is set) |
OwnerAwareScope | exclude entities owned by the recipient peer from the action stream; authoritative state for those reaches the client via the separate AUTH_STATE channel instead |
OwnerAwareScope is wired automatically on the host when role: 'host' and no custom scope_filter is provided. Implement the duck-typed { is_entity_in_scope(peer_id, network_id): boolean } interface to supply your own filter.
Interpolation and time sync
Remote-owned entities receive component updates at the server’s tick rate, which may arrive in bursts or with jitter. Two classes manage smooth rendering.
AdaptiveRenderDelay estimates how many frames behind the latest received frame the renderer should sit. It tracks per-frame lateness (wall clock − expected arrival time) over a rolling window, computes the spread, and multiplies by a safety_multiplier (default 2.0). The delay recommendation snaps up immediately on jitter and decays at decay_per_sample_ms (default 1 ms/sample) once conditions calm:
import { AdaptiveRenderDelay } from "@woosh/meep-engine/src/engine/network/time/AdaptiveRenderDelay.js";
const ard = new AdaptiveRenderDelay({
tick_period_ms: 1000 / 60,
min_delay_frames: 2,
max_delay_frames: 30,
initial_delay_frames: 6,
history_size: 60,
safety_multiplier: 2.0,
decay_per_sample_ms: 1.0,
});
ard.record_arrival(performance.now(), receivedFrameNumber);
const renderAtFrame = latestFrame - ard.delay_frames();
TimeDilation handles client clock drift. The server monitors each client’s input buffer depth and sends TIME_DILATION feedback. The client adjusts its tick cadence by a small factor (default max 5%) so the buffer stays at target_buffer_depth ticks. The factor is bounded so it is imperceptible:
import { TimeDilation } from "@woosh/meep-engine/src/engine/network/time/TimeDilation.js";
const td = new TimeDilation({ target_buffer_depth: 4, max_dilation: 0.05, gain: 0.05 });
const factor = td.compute(currentBufferDepth);
// factor < 1.0 → run slightly faster; > 1.0 → run slightly slower
NetworkSession creates both instances automatically from the tick_rate_hz and simulation_delay_ticks parameters. They are accessible via session.adaptive_render_delay and session.time_dilation if you need to tune them after construction.
The InterpolationLog records per-tick snapshots of all replicated components for each remote-owned entity. At render time, NetworkSession reads two bracketing frames from the log and calls each component’s BinaryInterpolationAdapter to blend between them. After rendering, normalize_if_dirty() restores canonical (latest-tick) values before the next simulation step.
Transport interface
The engine is transport-agnostic. Any object that implements the Transport duck type works:
send(bytes: Uint8Array, length: number): void
onReceive: Signal // fires (bytes, length) on inbound packet
onDisconnect: Signal // fires (reason) when the link drops
reliable: boolean
ordered: boolean
LoopbackTransport is a synchronous in-process transport shipped for tests. Bind two instances together with LoopbackTransport.bind_pair(a, b) to get a deterministic local channel with deliver_all(), drop_next(n), and reorder(i, j) for simulating packet loss and reordering without a real network.
Reconnection
NetworkSession includes a client-side reconnect ladder. When the transport drops, the session automatically retries with exponential backoff (configurable via the reconnect constructor option). On reconnect it sends RESUME_HELLO with the cached session token; the server matches it against its grace window and either resumes the action stream from the last acked frame (Tier-2 path) or delivers a fresh INITIAL_SYNC snapshot (Tier-3 path). The onReconnected, onConnectionLost, and onConnectionPermanentlyLost signals report progress to game code.
Relationship to determinism
The networking layer does not carry physics state in the action stream. It carries inputs. Because Meep’s physics is bit-exact across V8 runtimes, both the host and each client can run the same simulation from the same starting snapshot and arrive at the same world — the host sends corrections only when a client’s prediction diverges by more than reconcile_epsilon. This means bandwidth scales with the number and size of player inputs, not with entity count, velocity, or scene complexity.