Networking
How Meep replicates game state across peers using deterministic action logs, server-authoritative reconciliation, adaptive interpolation, a six-adapter transport stack, and a token-authenticated reconnect ladder.
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.
Register an action class before you execute it.
SimAction.type_idis-1untilNetworkSession.defineAction(orSimActionRegistry.register) assigns a wire id, andexecute()assertsaction.constructor.type_id >= 0before it touches the frame buffer:execute: action class 'Move' has no wire type_id - register it (NetworkSession.defineAction, or SimActionRegistry.register) before executing itWithout the assertion, an unregistered action applies locally and is then silently discarded by every peer - a desync that only shows up on the other machine. The assertion is an
assert.*call, which means@rollup/plugin-stripremoves it from a production build; develop with assertions on.
SimAction.extend is a declarative factory for simple actions:
import { SimAction } from "@woosh/meep-engine/src/engine/network/sim/SimAction.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { t64_announce_change } from "@woosh/meep-engine/src/engine/ecs/transform/t64_announce_change.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, Transform64]];
},
apply(world, executor) {
const entity = executor.slot_table.entity_for(this.network_id);
if (entity < 0) return;
const t = world.getComponent(entity, Transform64);
t.setTranslation(t.translation_x + this.dx, t.translation_y, t.translation_z + this.dz);
t64_announce_change(world, entity);
},
});
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.
At most 256 action types can be registered in one session - type_id is a single byte on the wire.
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(Transform64, 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. replicate, defineAction and defineInputSampler all throw once start() has run.
| Option | Default | Meaning |
|---|---|---|
role | 'client' | NetworkSessionRole.Client or NetworkSessionRole.Host |
local_peer_id | 0 (host) / 1 (client) | must be unique across peers |
tick_rate_hz | 60 | simulation cadence |
simulation_delay_ticks | 4 | host only; server-side input buffer depth |
frame_capacity | 32 | action-log ring size: rollback depth + back-fill range + retransmit window. Raise for high-RTT or lossy links |
scope_filter | OwnerAwareScope on the host | see Scope filtering |
server_resume_grace_ms | 30000 | host only; how long a dropped peer’s state is retained |
connection_timeout_ms | 10000 | inbound-silence budget before a peer is reaped. 0 disables. Mainly for connectionless transports, which never report a disconnect of their own |
reconnect | see Reconnection | client-side back-off ladder policy |
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. A retransmission of a record the log already holds is dropped on arrival, so pending holds only input the log does not, and a rewind is never spent on a copy.
Input and derived output
A frame’s action records have one of two provenances, and a replay treats them differently. Input - actions that arrived from a peer, and actions the host authored - happened once, so a replay reapplies them from the log. Derived output - everything an onLocalSim handler executes - is recomputed: the handler runs again on every replay of the frame, against a world that now includes the late input that forced the replay, and the previous pass’s records are dropped rather than applied a second time. So an onLocalSim handler is a function of the frame’s post-input state and the frame number, and must not latch (if (already_fired) return): a latch makes it emit nothing on replay, and the action is lost the first time a rollback crosses its frame. Derived records still replicate, and their prior state is still captured so a rewind undoes them, which is why a handler may subtract hit points through an action but must only clamp when it writes a component directly.
A genuine one-shot decided outside the simulation - a lobby spawn, an admin command, a timer - goes through session.send(action) with no frame open. On a host that queues it (ServerAuthoritativeServer.enqueue_action) as input for the next fresh frame, applied once and replayed from the log thereafter; calling enqueue_action from inside onLocalSim throws. Called with a frame open - from onLocalSim on a host or onPredict on a client - send executes immediately into that frame.
The record’s sender_id byte is that provenance: a peer id, SENDER_LOCAL (255) for an action authored here, or SENDER_DERIVED (254) for local-sim output. Both sentinels are reserved, so peer ids must be in [0, 253] (MAX_PEER_ID); execute and connect_peer throw on one above it. The byte never crosses the wire.
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 Transform64())
.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.
Nothing is replicated into existence: a client builds its entities itself, and the snapshot then corrects each owner_peer_id. That correction also re-decides whether the entity is treated as remote - blended at render time and normalized before simulation - so a pool built before connecting ends up owned by the host once the snapshot names it.
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: Transform64,
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, both named exports of src/engine/network/replication/ScopeFilter.js:
| 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. Render delay, a smoothed playhead and clock correction manage their timing.
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, // required
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();
RenderPlayout combines that delay with a wall-clock playhead and returns the two frames to blend. NetworkSession uses it internally; other fixed-period streams can use the same helper:
import { RenderPlayout } from "@woosh/meep-engine/src/engine/network/time/RenderPlayout.js";
const playout = new RenderPlayout({ tick_period_ms: 1000 / 60 });
// On each received frame:
playout.record_arrival(performance.now(), receivedFrameNumber);
// Once per render:
const { tick_a, tick_b, t } = playout.window(performance.now());
The playhead is clamped between zero and the latest received frame. tick_b can
refer to the next, not-yet-received frame; InterpolationLog snaps to the sample
it has. window() returns a reused object, so consume its fields immediately.
reset() clears the playhead and delay estimator for a reconnect or a new sender.
For custom streams registered with InterpolationSystem.registerSource, call
unregisterSource(sourceId) when that source leaves; remaining entities keep
their pose until their marker is removed or a source is registered again.
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 its delay estimator, playout and time dilation automatically from the tick_rate_hz and simulation_delay_ticks parameters. The delay and dilation are accessible via session.adaptive_render_delay and session.time_dilation if you need to tune them after construction.
The InterpolationLog (session.interpolation_log) 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.
Transports
Transport is a concrete base class, not a duck type: instanceof Transport works, and a subclass inherits the packet counters, the disconnect latch and the default lifecycle methods. Subclasses must override send; everything else has a working default.
import { Transport } from "@woosh/meep-engine/src/engine/network/transport/Transport.js";
class MyTransport extends Transport {
send(bytes, length) {
this.count_out(length); // keeps getStats() honest
wire.write(bytes.subarray(0, length));
}
}
| Member | Kind | Notes |
|---|---|---|
send(bytes, length) | method | must be overridden. The caller may reuse bytes immediately; copy if you defer |
connect() / disconnect() | method | optional lifecycle; both default to a no-op |
onReceive | Signal | fires (bytes, length). The array is valid for the duration of the call only |
onDisconnect | Signal | fires (reason) when the link drops |
reliable / ordered | boolean | both default to false. Higher layers skip retransmit and reorder work when they are true |
count_in(n) / count_out(n) | protected | maintain the shared bytes_in/bytes_out/packets_in/packets_out counters |
getStats() | method | a copied snapshot of those four counters |
fire_disconnect_once(reason) | protected | reports a lost link at most once per transport. Underlying APIs are not disciplined about this - an RTCDataChannel can raise error then close for one failure, and subscribers run the reconnect ladder |
latch_disconnect() | protected | suppresses a future disconnect report, for adapters whose object fires a close event on the way down |
Six adapters ship, all named exports under src/engine/network/transport/:
| Class | Module | Construct with |
|---|---|---|
LoopbackTransport | LoopbackTransport.js | LoopbackTransport.bind_pair(a, b) |
SimulatedTransport | adapters/SimulatedTransport.js | { latency_ms = 60, jitter_ms = 15, loss_pct = 2, clock, random_seed = 1337 }, plus bind_pair |
WebRTCDataChannelTransport | adapters/WebRTCDataChannelTransport.js | { data_channel } |
WebTransportTransport | adapters/WebTransportTransport.js | { wt, url, options } - HTTP/3 datagrams |
NodeUDPTransport | adapters/NodeUDPTransport.js | { bind_address = '0.0.0.0', bind_port = 0, remote = null } |
WebSocketTransport | adapters/WebSocketTransport.js | { socket, url } - lobby and chat only, not game state: TCP head-of-line blocking makes a lost packet stall every packet behind it |
LoopbackTransport is a synchronous in-process transport for tests. A bound pair gives a deterministic local channel with deliver_all(), drop_next(n) and reorder(i, j), so packet loss and reordering are reproducible without a network. SimulatedTransport is the same idea with a clock: tick(now_ms) releases packets that have served their latency, force_drop_next(n) and dropped_count() make the loss deliberate, its random source is seeded so a run repeats, and transport.config is live-tunable mid-session.
Channel, reliable commands, and fragmentation
Transports assume UDP semantics: unreliable, unordered, no flow control. Three layers sit above them.
Channel (transport/Channel.js, new Channel({ transport, max_in_flight = 1024 })) adds a 9-byte header carrying an outgoing sequence number, the most recent sequence received from the peer, a 32-bit bitfield of the 32 before it, and an ack_present flag. That is positive acknowledgement of up to 33 packets per inbound packet, so a single dropped ack usually does not matter - a later packet carries the same information. Channel does not retransmit; it reports. onPacketAcked(seq), onPacketLost(seq) (the sequence aged out of the 33-packet window unacked) and onPayload(bytes, length) (header stripped) are the whole surface.
ReliableCommandPipeline (new ReliableCommandPipeline({ channel, packet_type, max_unacked = 64, max_received_history = 64, max_retries = 16 })) builds at-least-once delivery on those notifications, for messages that must arrive: chat, lobby and room state, level transitions, kick notices. Each command carries a sender-assigned logical_seq; an onPacketLost re-sends the same command on a fresh channel sequence, and the receiver de-duplicates by logical_seq over a sliding window. Ordering is not guaranteed - if you need it, key the payload with a counter and reorder in your handler. send() throws when max_unacked outstanding commands have piled up, which is real backpressure rather than a tuning problem. A payload may be at most MAX_RELIABLE_COMMAND_PAYLOAD_BYTES (1180) because the pipeline does not fragment.
Fragmentation (transport/fragments/) splits a logical message that will not fit one packet. send_fragmented(...) emits chunks with a 5-byte header (message_id, chunk_index, total_chunks) and FragmentAssembler puts them back together; FragmentRetention holds sent chunks so a NACK can be answered. A message that fits one packet is sent whole, with no fragment header at all.
| Constant | Value | Meaning |
|---|---|---|
MTU_BYTES | 1200 | target transport MTU, chosen to survive tunnelling and IPv6 extension headers |
CHANNEL_HEADER_BYTES | 9 | Channel’s per-packet header |
MAX_CHANNEL_PAYLOAD_BYTES | 1191 | payload in one unfragmented packet |
MAX_FRAGMENT_CHUNK_BYTES | 1186 | payload per fragment |
MAX_CHUNKS_PER_MESSAGE | 255 | total_chunks is a uint8 |
MAX_LOGICAL_MESSAGE_BYTES | 302430 | the largest message this scheme can carry |
INITIAL_SYNC snapshots routinely exceed the MTU; fragmentation is what carries them.
The action stream
Each tick the sender packs the frames a peer has not yet confirmed - from last_acked + 1, or the action log’s ring floor if that is newer - into up to max_packets_per_tick packets (default 8, an orchestrator option forwarded to NetworkPeer), each bounded to one channel packet and carrying a 9-byte slice header: the frame range it covers and whether it is the first slice of the tick. A slice is confirmed only when every transport packet it went out under is acked; one lost fragment forfeits the whole slice and leaves its frames owed, so the next tick’s pack picks them up again. The receiver applies slices in frame order, holding a later slice until the earlier ones land, and applies a head slice - nothing before it will be sent again - at once. Replicator.pack_for_peer(peer_id, start, end, buffer, max_bytes) returns the last frame it packed; unpack_from_peer(peer_id, buffer, end, slice) takes the slice header, and applies a packet without one as a head.
session.delivery_stats(peer_id) returns { skipped_unapplied, skipped_duplicate }. The second is the stream’s own redundancy arriving and being ignored, and is expected to be large. The first counts frames that reached this peer and were dropped below the applied watermark without ever running - it should be zero, and it climbs under jitter with max_packets_per_tick: 1.
A client joining a match in progress has to tag its inputs near the host’s frame, or the host trims every one of them silently. NetworkSession does this itself on INITIAL_SYNC - it seeks to the host’s frame plus one plus target_buffer_depth - and session.seek_to_frame(frame) is the manual door for a caller computing its own lead. session.remote_entity_count is the number of entities this session treats as somebody else’s; zero on a connected client that has received a snapshot means nothing will be interpolated and the client is about to send the host its own state back.
Reconnection
When the transport drops, a client session retries with exponential back-off. Automatic reconnect needs a transport_factory - a zero-argument function returning a fresh transport per attempt. Without one, a transport-level disconnect falls straight through to onConnectionPermanentlyLost.
reconnect field | Default |
|---|---|
enabled | true |
max_attempts | 8 |
base_delay_ms | 200 |
max_delay_ms | 5000 |
exponential_factor | 2.0 |
total_timeout_ms | 60000 |
accept_state_resync | true |
The RESUME_HELLO handshake
A host that loses a peer keeps that peer’s state for server_resume_grace_ms (default 30 s). A returning client claims it with a RESUME_HELLO:
uintVar local_peer_id
uintVar last_acked_frame
uint8 has_token // 0 = the token bytes below carry nothing
16 bytes session_token // from a prior INITIAL_SYNC
The token bytes are always present - the layout is fixed and the parser does not branch - and they are meaningless when has_token is 0. Correspondingly, NetworkPeer.send_resume_hello(peer_id, local_peer_id, last_acked_frame, has_token, session_token) takes has_token as its fourth argument (asserted boolean), and onResumeHello fires with five arguments: (peer_id, local_peer_id, last_acked_frame, has_token, session_token).
A tokenless claim exists because staying silent is worse than being rejected. A client whose INITIAL_SYNC never arrived holds no token and cannot prove continuity, but if it says nothing the host sits on the id for the whole grace window waiting for a packet that is not coming, while the client’s own ladder runs out against it. So a client announces itself on any connect that could be a claim on an id the host still holds: it has a token, or it has connected before and lost the token it was issued. Only a first-ever connect with no token holds its peace - the host has nothing to reconcile and queues an INITIAL_SYNC unprompted.
The host answers with RESUME_ACCEPT (the action stream resumes from last_acked + 1 on the next host tick) or RESUME_REJECT carrying one byte:
ResumeRejectReason | Value | Meaning |
|---|---|---|
UnknownPeer | 0 | the host holds no record for that id |
GraceExpired | 1 | the record was reaped before the claim arrived |
TokenMismatch | 2 | the token does not match the record |
PeerIdCollision | 3 | the id is in use by somebody else |
StaleFrame | 4 | the token validated, but last_acked_frame has aged out of the host’s action-log ring, so the stream cannot back-fill the gap |
NoSessionToken | 5 | the claim arrived with has_token = 0 against an id the host is holding in a grace window |
NoSessionToken is what both a client whose first sync never arrived and an id-squatter look like. Neither may reclaim the record; both are owed an answer rather than a timeout.
On a reject the client’s behaviour depends on accept_state_resync. Left on (the default), it drops its unconfirmed predictions, clears its token and waits for the host to deliver a fresh INITIAL_SYNC. Turned off, a reject ends the session with onConnectionPermanentlyLost("resume_rejected:<code>").
An INITIAL_SYNC also completes a reconnect that never got a RESUME_ACCEPT at all: a host that has already finished with the old session readmits the client as a fresh peer, so the snapshot arrives with nothing ahead of it. Either way the client reports onReconnected({ state_resynced: true }) - a world that was wholly replaced should not be reported as a stream that merely resumed.
Client and host disagree about connect() on purpose
connect(remote_peer_id, transport) behaves differently by role when that peer id already has a transport bound:
- Client: the newest transport wins. The existing binding is released, and the interpolation log, session token and predicted-action ledger are left untouched so the new transport can claim continuity. This is what makes the ladder work at all: attempts fire on a back-off timer rather than on the previous attempt’s failure, so attempt N’s socket is routinely still open - hanging on a connect that never completed - when attempt N+1 comes around. Left in place it would make every attempt after the first throw, and the ladder swallows the throw.
- Host: the call throws. Which transport owns a peer id is the matchmaking layer’s decision, not a race between whatever connections happen to arrive.
Signals
| Signal | Fires with | When |
|---|---|---|
onConnectionLost | (reason) | client: the link dropped and the ladder has started |
onReconnectAttempt | (attempt) | client: each rung of the ladder |
onReconnected | ({ state_resynced }) | client: back in. false is the happy path where the action stream simply resumed; true means a full INITIAL_SYNC was taken |
onConnectionPermanentlyLost | (reason) | client: the ladder is exhausted, reconnect.enabled is false, or the host sent an explicit DISCONNECT. No further attempts |
onPeerLost | (peer_id, reason) | host: a peer dropped and entered the grace window |
onPeerPermanentlyDropped | (peer_id, reason) | host: drop_peer(), a grace timeout, or a peer-initiated disconnect |
A host DISCONNECT is final by design - the client surfaces onConnectionPermanentlyLost and does not retry. A client-initiated disconnect() tells the host to free the peer immediately instead of waiting out the grace timer, and disables reconnect for that session.
Network diagnostics
Three tools under src/engine/network/diagnostics/, plus one function. None of them run on their own; you call them. For CPU frame timings and the FPS overlay, see Diagnostics.
BandwidthMeter aggregates getStats() snapshots from any number of named sources - a Transport, a Channel, or session.peer.channel_for(peerId) - and reports throughput over a sliding window. It owns no timer; call sample(now_ms) whenever you want a data point.
import { BandwidthMeter } from "@woosh/meep-engine/src/engine/network/diagnostics/BandwidthMeter.js";
const meter = new BandwidthMeter({ window_seconds: 5 });
meter.add_source("server", session.peer.channel_for(serverPeerId));
// per tick
meter.sample(performance.now());
console.log(meter.rate_bytes_out(), meter.rate_packets_out(), meter.per_source());
ReplayLog is an append-only record of per-frame action bytes: record(frame, bytes, length) copies them in, for_each_in_range(start, end, cb) walks them back out, and serialize_to_buffer(buffer) / ReplayLog.deserialize_from_buffer(buffer) move a whole session in and out of a BinaryBuffer. The bytes are in the same format Replicator.unpack_from_peer consumes, so a recorded session replays by feeding them back through a peer. Persisting the buffer is left to you.
fingerprint_world(world, component_registry, scratch) hashes every replicated component on every entity into one 32-bit FNV-1a value. It iterates entities in dataset order and components in type_id order, so two peers that iterate identically produce comparable numbers. Not cryptographic - it answers “did the world change?”, not “did somebody tamper with it?”.
SyncTest wraps that fingerprint into the assertion shape a rewind bug needs:
import { SyncTest } from "@woosh/meep-engine/src/engine/network/diagnostics/SyncTest.js";
const sync = new SyncTest({ world: ecd, component_registry: session.peer.component_registry });
sync.checkpoint(); // fingerprint the world now
// ... run ticks ...
// the RewindEngine lives on the orchestrator: session.client (client) or session.server (host)
sync.assert_recoverable_to_checkpoint(session.client.rewind_engine, currentFrame, checkpointFrame);
assert_recoverable_to_checkpoint rewinds and throws with both fingerprints when the world does not come back. Full GGPO-style nondeterminism detection (save, advance, load, advance, diff) needs your tick logic to be re-runnable, which the harness does not arrange for you - it supplies the fingerprint and compare primitives.
The package also ships src/engine/network/README.md and src/engine/network/CONGESTION_CONTROL.md alongside the 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.