Networking
Fixed-step networking with server-authoritative state, component corrections, action logs, adaptive interpolation, six transport adapters and token-authenticated reconnection.
Meep’s networking layer shares the engine’s fixed-step clock. Clients send inputs and predict the entities they own; the host publishes authoritative state, including the changes physics and other systems report. Clients compare that state with their predictions and apply component-specific corrections. Deterministic action-log rewind and replay is available for simulations expressed entirely as actions; see Determinism.
Core model: action replication
Player inputs and explicit replicated commands go 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
SimActionExecutor applies and records actions. 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.
A replicated component written directly - by physics, a controller, a game system - travels only when the writer reports the write with a net_mutate_component event (see Network identity and ownership). The session never looks for changes by itself, so a tick costs what changed and what is owned, not how many networked entities there are, and a write nobody reports stays on the host. Action-log replay re-executes actions only, so a simulation that relies on direct system writes should use the default correction mode.
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";
import { replicate_transform64 } from "@woosh/meep-engine/src/engine/network/adapters/replicate_transform64.js";
import { HOST_VALUE_CORRECTION } from "@woosh/meep-engine/src/engine/network/correction/Correction.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
binary_registry: binaryRegistry, // includes TransformReplicationAdapter and Health's adapter
});
// Register rendering and correction policies before starting the session.
replicate_transform64(session);
session.replicate_discrete(Health, HOST_VALUE_CORRECTION);
// 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, myTransport);
// Engine's normal loop drives networking through EntityManager.update(dt).
Register component serialization adapters in the BinarySerializationRegistry passed as binary_registry; replicate_transform64 uses TransformReplicationAdapter from engine/network/adapters/TransformReplicationAdapter.js for its wire format. Replication registration order must be identical on every peer because it determines component positions in snapshots and AUTH_STATE packets. Registration, defineAction and defineInputSampler must finish before start().
Use replicate_interpolated(ComponentClass, interpoland, correction) for a component with a continuous blend and a copy(other) method, and replicate_discrete(ComponentClass, correction) otherwise. replicate_transform64(session) supplies POSE_INTERPOLAND and POSE_CORRECTION. HOST_VALUE_CORRECTION replaces the value with the host’s; a component predicted from local inputs needs a correction that carries its difference into the present. The older replicate() API is deprecated.
NetworkSystem opens and closes a network frame around each engine fixed step. Incoming packets are queued until the next step opens, after interpolation restores simulation state. The engine’s normal loop drives this; session.tick() is deprecated and does nothing.
| 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 | engine fixed-step rate | when supplied, sets entityManager.fixedUpdateStepSize = 1 / tick_rate_hz |
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 |
reconcile | 'correct' | client compares predicted and authoritative state; 'replay' opts into action-log rewind and replay |
late_input | 'apply' | host applies late input in its current frame; 'rollback' opts into action-log rewind and replay |
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; buffers client input and applies late input in the current frame by default |
'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.”
With NetworkSession’s default late_input: 'apply', input for an already simulated frame is applied in the current frame. Input buffering and client time dilation reduce how often this occurs. Choose late_input: 'rollback' only when the simulation can be reconstructed from actions: the orchestrator rewinds to before the oldest pending action and replays the action log in stable sender order. That replay does not rerun the engine’s physics or other ECS systems. The standalone ServerAuthoritativeServer defaults to rollback; the session explicitly selects apply.
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.
With reconcile: 'correct', NetworkSession compares the host’s state for frame S with PredictionHistory for S, first as wire bytes and then through each component’s Correction.same. Differences are carried into the current component and later predictions through that correction’s apply and carry methods. POSE_CORRECTION carries position, rotation and scale differences and lets interpolation smooth the visual adjustment; VELOCITY_CORRECTION handles rigid-body velocity differences. Choose reconcile: 'replay' for action-only simulations that should rewind and replay unconfirmed inputs instead.
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.
Every replicated change goes through the host’s action executor. An action carries its own change; a direct write to a replicated component - physics moving a body, a controller, your own system - reaches peers only when the writer reports it, by firing a "net_mutate_component" event on the entity after the write:
dataset.sendEvent(entityId, "net_mutate_component", {
component_type: Transform64,
new_state: updatedTransform, // optional; if omitted the live component is the new state
});
The session turns the event into an internal ReplaceComponentAction. Without new_state, the host records the live component as it stands, at full precision - the wire’s quantised image is never written back over it - and a client executes it, so prediction and rewind keep their prior state. With new_state, the prior state is captured and the new one applied through the component’s replication adapter. The component must be registered with replicate_interpolated or replicate_discrete first; an event for one that is not throws.
Ownership is set when a NetworkIdentity attaches, or by the join - never by writing owner_peer_id or network_id afterwards. Each peer’s owned entities are indexed when ownership is set, and the per-owned-entity work of a tick (AUTH_STATE to the owner, a client’s record of what it predicted) walks that index.
Scope filtering
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. The host defaults to OwnerAwareScope.
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 session installs InterpolationSystem if needed and gives networked entities an Interpolated marker. The host and client-owned entities use the local fixed-step timeline; other entities on a client use the session’s delayed network timeline (session.interpolation_log). InterpolationSystem records local state, blends before pose readers such as the camera, and restores the exact state displaced by the render blend before simulation resumes.
Game code writing simulation state between steps must first call session.normalize_if_dirty() (which delegates to InterpolationSystem.restore()), or mark a deliberate teleport with Interpolated.snap; PhysicsSystem.setPose sets that flag. Writes made over a render blend without either are discarded when simulation state is restored. Client time dilation adjusts entityManager.fixedStepTimeScale, preserving the fixed-step duration supplied to systems.
A client must not also simulate the dynamic physics of an entity it plays out from the host. Give remote entities kinematic or static rigid bodies, or no rigid body. The session does not enforce that choice.
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 |
max_datagram_bytes | number | the largest datagram send is handed, header included; the connection packs to it. 1200 by default, in [64, 16383] |
reliable | boolean | false by default. true (a WebSocket) tells the connection that nothing is lost, so it never declares loss and never probes - on TCP a re-send only queues behind the original |
ordered | boolean | false by default; carried, and read by nothing above the transport |
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 } - declares 1150-byte datagrams, so a full one fits one SCTP packet |
WebTransportTransport | adapters/WebTransportTransport.js | { wt, url, options } - HTTP/3 datagrams, sized from the session’s datagrams.maxDatagramSize |
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.
The connection
Transports assume UDP semantics: unreliable, unordered, no flow control. One Connection per peer (transport/connection/Connection.js) sits between NetworkPeer and the transport and carries every message the engine sends, and yours, in the manner of QUIC (RFC 9000 and 9002).
Packets. Each datagram starts with a flags byte - ACK present, a 2- or 4-byte packet number, a 4-bit protocol version - and a packet number that is never reused: anything re-sent goes in a new packet. Acknowledgements are ranges, so one ACK covers any burst; a side keeps at most max_ack_ranges (32, at most 64). A packet is declared lost once one sent three after it has been acknowledged, or once 9/8 of a round trip has passed since it was sent and a later one has been acknowledged; a probe timeout that doubles on each expiry re-sends what a silent tail left outstanding. Lost means not confirmed: a packet that arrived but whose acknowledgement never got back is reported lost, which is the contract the action stream needs - nothing is credited that was not confirmed.
Messages. An unreliable message fits one datagram - 1192 bytes at the default size - and its sender hears exactly once whether it was acknowledged or lost; a larger one throws. A reliable message goes on one of four lanes, each an ordered stream of messages of up to max_message_bytes, re-sent until acknowledged. In the order a flush serves them:
| Lane | Carries | Paced |
|---|---|---|
CONTROL | resume messages, DISCONNECT, recovery requests - one datagram each | no |
| (unreliable) | action-stream slices, AUTH_STATE, TIME_DILATION, FRAME_MARKER | no |
FRAMES | action-stream frames too large for a datagram | no - the stream waits on them |
USER | reliable commands | yes |
BULK | join pages, state bursts | yes |
When things leave. NetworkPeer’s sends only queue. The orchestrators flush once at the end of each tick, so a peer’s whole tick - the action stream, AUTH_STATE, TIME_DILATION, join pages - leaves in as few datagrams as fit, one in the steady state, with the ACK in the first. A token bucket paces USER and BULK at send_rate_bytes_per_second; unpaced traffic is counted against it, and a paced lane still gets at least one datagram a flush, so a peer that owns many entities cannot starve a join. Every timer - loss, probe, pacing - runs on the session’s clock; nothing in the stack reads wall time.
NetworkPeer takes the connection’s limits, and NetworkSession uses its defaults:
| Option | Default | Meaning |
|---|---|---|
send_rate_bytes_per_second | 262144 | the token bucket’s rate. Fixed: no congestion control adapts it to the link |
max_message_bytes | 1 MiB | the largest reliable message. Must match on both ends |
receive_window_bytes | 256 KiB | how far past its acknowledged prefix a lane may send, and so what a receiver holds out of order. Must match on both ends |
max_queued_bytes | 4 MiB | what one lane may hold unacknowledged; a reliable send past it throws |
Reliable commands are the application’s lane. session.peer.send_reliable_command(peer_id, payload, length) queues a message on USER - chat, lobby and room state, level transitions - that arrives once, whole and in order with that peer’s other commands, unless the connection ends first. The receiver’s session.peer.onReliableCommand fires (peer_id, bytes, offset, length) over a scratch buffer, so copy what you keep. The call returns a delivery mark: session.peer.connection_for(peer_id).is_delivered(Lane.USER, mark) says whether it has arrived (Lane is a named export of Connection.js).
A connection that breaks. A connection does not authenticate datagrams; it relies on its transport to deliver only the peer’s (DTLS, TLS, and NodeUDPTransport by source address). A peer past its limits, or an acknowledgement of a packet this side never sent - the mark of a datagram that was not the peer’s - fires onProtocolError, and the session handles it as a dropped transport and closes the transport: the host puts the peer in its grace window, and the client resumes on a new connection.
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 slices (default 8, an orchestrator option forwarded to NetworkPeer), each an unreliable message of at most one datagram 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 when its datagram is acknowledged; a lost one leaves its frames owed, so the next tick’s pack picks them up again. A frame too large for a datagram goes once on the FRAMES lane, reliable and unpaced; the pack skips it while it is in flight, and the receiver holds what follows until it lands. A frame larger than max_message_bytes can never be delivered: it is given up, reported through NetworkPeer.onFrameTooLarge(peer_id, frame_start, frame_end, bytes), and the stream goes on behind it. 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 during a join - on the first page and again on the final one it seeks to the newer of the page’s frame and the host’s latest frame (every TIME_DILATION names it), plus one, plus target_buffer_depth - and session.seek_to_frame(frame) is the manual door for a caller computing its own lead. The same seek recovers a client that stalled: a client more than the host’s input window (frame_capacity) behind - a background tab, a debugger, a laptop asleep - re-takes the host’s time on its first steps back, drops what it predicted on the old numbering and counts it in session.stall_count; inside the window, time dilation closes the gap. session.remote_entity_count is the number of entities this session treats as somebody else’s; zero on a connected client that has finished its join means nothing will be interpolated and the client is about to send the host its own state back.
Joining a world of any size
A join sends the world as pages of whole entities (JOIN_PAGE_BYTES, 16 KiB) on the paced BULK lane, each built only as the lane’s window frees, so host memory per joining peer is the window plus 12 bytes per entity. Meanwhile the host holds that peer’s action stream and AUTH_STATE. A round that goes out within one tick is built at one frame and is the last: a world that fits JOIN_FINAL_ROUND_BYTES (256 KiB) goes in its first tick. A larger one goes over as many ticks as the window takes, and once it has all arrived, every entity changed since its page - by anything the host’s executor ran or recorded - or created since is sent again as the next round. A final page follows the last round and releases the action stream from the next frame. A join that has not converged in JOIN_MAX_ROUNDS (8) rounds is dropped (join_did_not_converge): that is a world changing faster than a peer can be sent it.
The client holds the inbound action stream and AUTH_STATE from connect() until its world is whole. The first page issues the session token and is the connection’s answer - a later accept or reject on that connection is ignored, and the reconnect ladder stops - and each page is applied as it lands. On the final page the client forgets what it predicted on the partial world, applies what it held, releases its own input and fires session.onSynced. A peer that drops before its join finished is never resumed: it holds part of a world, so its claim is refused with StaleFrame and it is joined afresh.
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 // issued by a prior join's first page
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 join never reached it 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 starts a join 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 - or the peer’s join never finished |
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 join it afresh. Turned off, a reject ends the session with onConnectionPermanentlyLost("resume_rejected:<code>").
A join 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 first page 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 join 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 and reports throughput over a sliding window. It owns no timer; call sample(now_ms) whenever you want a data point. It sums bytes_in, bytes_out, packets_in and packets_out, which is what a Transport reports. A Connection’s getStats() names its counters differently - bytes_sent, bytes_received, packets_sent, packets_received, packets_lost, packets_dropped - so registering session.peer.connection_for(peerId) turns every total into NaN; meter the transport, and read the connection’s stats directly for loss.
import { BandwidthMeter } from "@woosh/meep-engine/src/engine/network/diagnostics/BandwidthMeter.js";
const meter = new BandwidthMeter({ window_seconds: 5 });
meter.add_source("server", serverTransport);
// per tick
meter.sample(performance.now());
console.log(meter.rate_bytes_out(), meter.rate_packets_out(), meter.per_source());
console.log(session.peer.connection_for(serverPeerId).getStats().packets_lost);
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, src/engine/network/MESSAGE_TRANSPORT.md (the connection’s design record) and src/engine/network/CONGESTION_CONTROL.md alongside the code.
Relationship to determinism
Deterministic physics helps matching predictions stay in agreement. Networking also transmits authoritative component state, including the direct physics changes the game reports, so bandwidth depends on replicated state as well as player inputs. The default correction mode supports state produced by ECS systems; action-log replay is appropriate when the actions alone reconstruct the simulation.