@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
1,216 lines (1,090 loc) • 77.4 kB
JavaScript
import { assert } from "../../core/assert.js";
import { BinaryBuffer } from "../../core/binary/BinaryBuffer.js";
import Signal from "../../core/events/signal/Signal.js";
import { EntityObserver } from "../ecs/EntityObserver.js";
import { UUID } from "../ecs/guid/UUID.js";
import { BinarySerializationRegistry, } from "../ecs/storage/binary/BinarySerializationRegistry.js";
import { NetworkIdentity } from "./ecs/components/NetworkIdentity.js";
import { NetworkSystem } from "./ecs/NetworkSystem.js";
import { NetworkIdentitySerializationAdapter, } from "./ecs/serialization/NetworkIdentitySerializationAdapter.js";
import { NetworkPeer, ResumeRejectReason, } from "./orchestrator/NetworkPeer.js";
import { ServerAuthoritativeClient } from "./orchestrator/ServerAuthoritativeClient.js";
import { ServerAuthoritativeServer } from "./orchestrator/ServerAuthoritativeServer.js";
import { OwnerAwareScope } from "./replication/ScopeFilter.js";
import { BinaryInterpolationAdapter, InterpolationKind, } from "./sim/BinaryInterpolationAdapter.js";
import { InterpolationLog } from "./sim/InterpolationLog.js";
import { SimAction } from "./sim/SimAction.js";
import { snapshotter_emit } from "./sim/Snapshotter.js";
import { AdaptiveRenderDelay } from "./time/AdaptiveRenderDelay.js";
import { TimeDilation } from "./time/TimeDilation.js";
/**
* Roles a session can occupy. The orchestrator it wires up underneath is
* uniquely determined by the role.
*
* - `'client'` → {@link ServerAuthoritativeClient}: predicts locally
* when an input sampler is registered; reconciles to
* server-authoritative state arriving via AUTH_STATE.
* With no input sampler, a client is a pure spectator
* — it receives the action stream, smooths via
* interpolation, and never predicts or rewinds. This
* is the default role and covers both "playing" and
* "spectating" use cases.
* - `'host'` → {@link ServerAuthoritativeServer}: simulation
* authority for one or more clients; runs the optional
* server-side input buffer (`simulation_delay_ticks`).
*
* @readonly @enum {string}
*/
export const NetworkSessionRole = Object.freeze({
Client: 'client',
Host: 'host',
});
const DEFAULT_TICK_RATE_HZ = 60;
const DEFAULT_SIMULATION_DELAY_TICKS = 4;
const DEFAULT_INITIAL_RENDER_DELAY_FRAMES = 6;
const PULL_PER_MS = 0.0005; // wall-clock low-pass on render frame state machine
const MAX_CLIENT_DILATED_TICKS_PER_STEP = 3;
const MAX_FIXED_STEPS_PER_TICK = 8;
const DEFAULT_SERVER_RESUME_GRACE_MS = 30_000;
const DEFAULT_RECONNECT_POLICY = Object.freeze({
enabled: true,
max_attempts: 8,
base_delay_ms: 200,
max_delay_ms: 5_000,
exponential_factor: 2.0,
total_timeout_ms: 60_000,
accept_state_resync: true,
});
/**
* Client-side reconnect state machine values.
* @readonly @enum {string}
*/
const ReconnectState = Object.freeze({
Connected: 'connected',
Reconnecting: 'reconnecting',
Resyncing: 'resyncing',
Lost: 'lost',
});
/**
* Scratch buffer size used for AUTH_STATE assembly and net_mutate_component
* event payloads. Sized for the common cases (Transform ≈ 36 B); components
* with richer payloads need this raised or a different scratch lifecycle.
*/
const SCRATCH_BUFFER_BYTES = 1024;
/**
* High-level networking facade. Wraps the orchestrator + state machinery
* into one config + lifecycle surface. The lower-level orchestrators
* remain reachable through `session.server` / `session.client` /
* `session.peer` for callers that need to override defaults post-hoc.
*
* Replication is component-driven: attaching a {@link NetworkIdentity}
* to an entity registers it with the session via an `EntityObserver`.
* Mutation is event-driven: callers fire
* `dataset.sendEvent(entity, "net_mutate_component", payload)`.
*
* Per-tick driving is a single `session.tick(dt_seconds)`. The session
* runs fixed-timestep stepping, client time dilation, server-side input
* buffer, AUTH_STATE dispatch, time-dilation feedback, and interpolated
* rendering for remote-owned entities.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class NetworkSession {
// ---- Pre-start registration (set via replicate / defineAction / defineInputSampler) ----
/**
* User-registered replicated component classes, in `replicate()` call
* order. Finalized at `start()` into {@link #replicated} with
* `NetworkIdentity` prepended.
* @private @type {Array<{klass: Function, interp: BinaryInterpolationAdapter|null}>}
*/
#user_replicated = [];
/**
* Finalized at `start()`. Insertion order = wire-format order;
* `NetworkIdentity` is the first entry (its slot allocation must
* fire before subsequent components attach during initial-sync apply).
* Each value: `{ interp, interp_type_id }`.
* @private @type {Map<Function, {interp: BinaryInterpolationAdapter|null, interp_type_id: number}>}
*/
#replicated = new Map();
/**
* Type_id → ComponentClass for the synthetic ReplaceComponentAction.
* @private @type {Function[]}
*/
#interp_type_id_to_class = [];
/** @private @type {Array<Function>} */
#user_action_classes = [];
/** @private @type {((frame: number) => Array<SimAction>|null) | null} */
#input_sampler = null;
// ---- Set in start() ----
/** @private @type {ServerAuthoritativeServer|null} */
#server = null;
/** @private @type {ServerAuthoritativeClient|null} */
#client = null;
/** @private @type {NetworkPeer|null} */
#peer = null;
/** @private @type {EntityObserver|null} */
#identity_observer = null;
/** @private @type {InterpolationLog|null} */
#interp_log = null;
/** @private @type {Function|null} */
#ReplaceComponentAction = null;
// ---- Per-entity tracking ----
/**
* `entity_id → "net_mutate_component" listener` so detach can clean up.
* Also doubles as the iterable of all networked entities.
* @private @type {Map<number, Function>}
*/
#event_listeners = new Map();
/**
* Entities the local peer does NOT own — driven by the render
* interpolation pass.
* @private @type {Set<number>}
*/
#remote_entities = new Set();
// ---- Render-frame interpolation state ----
/** @private @type {number} */ #latest_received_frame = -1;
/** @private @type {number} */ #smooth_render_frame = -1;
/** @private @type {number} */ #smooth_last_wall_ms = -1;
/**
* Set after `#render_interpolated_entities` writes interp values into
* live components; cleared by `normalize_if_dirty`. Drives the
* canonical-form invariant: live is canonical when this is false,
* polluted (smooth) when true.
* @private @type {boolean}
*/
render_dirty = false;
// ---- Per-tick driver state ----
/** @private @type {number} */ #local_frame = 0;
/** @private @type {number} */ #local_frame_accum = 0;
/** @private @type {number} */ #sim_accum_ms = 0;
/** @private @type {Set<number>} */
#connected_peers = new Set();
/**
* Host-only: peer_id → remaining-resends. Drained in
* `#on_host_tick_complete` once `current_sim_frame >= 0`. Sent twice
* for loss tolerance; production code should re-send on receiver
* RECOVERY request.
* @private @type {Map<number, number>}
*/
#pending_initial_sync = new Map();
/**
* Sampled-action ledger for predict-reconcile replay. The sampler
* reads live input, so re-calling it during reconcile would give
* wrong results; replay reads from here instead.
* @private @type {Map<number, Array<{type_id: number, bytes: Uint8Array}>>}
*/
#sampled_actions_per_frame = new Map();
/** @private @type {boolean} */
#started = false;
// ---- Host-side: per-peer session token + grace state ----
/**
* Tokens issued at first INITIAL_SYNC, kept while the peer is
* connected or in the grace window. Used to authenticate
* RESUME_HELLO.
* @private @type {Map<number, UUID>}
*/
#peer_session_tokens = new Map();
/**
* Peers currently in the post-disconnect grace window. Value carries
* the deadline (wall-clock ms) and the cached `last_acked_frame` so
* a successful resume restores the action-stream baseline.
* @private @type {Map<number, { deadline_ms: number, last_acked: number }>}
*/
#peer_grace_state = new Map();
/**
* Per-peer transport onDisconnect listener bookkeeping so we can
* unsubscribe when the peer is fully dropped.
* @private @type {Map<number, { transport: object, listener: Function }>}
*/
#peer_transport_listeners = new Map();
// ---- Client-side: session token + reconnect state machine ----
/**
* Server-issued session token (raw UUID v1 bytes). Set on first
* INITIAL_SYNC arrival; refreshed on every Tier-3 resync. Sent in
* `RESUME_HELLO` to claim continuity with a prior session record.
* Observable to game code for diagnostics; not auth-grade.
* @type {UUID|null}
*/
session_token = null;
/** @private @type {string} */
#reconnect_state = ReconnectState.Connected;
/** @private @type {number} */
#reconnect_attempt = 0;
/** @private @type {number} */
#reconnect_elapsed_ms = 0;
/** @private @type {number} */
#reconnect_next_delay_ms = 0;
/** @private @type {number} */
#reconnect_timer_remaining_ms = 0;
/** @private @type {object|null} */
#current_transport = null;
/** @private @type {Function|null} */
#current_transport_listener = null;
/** @private @type {number} */
#remote_peer_id = -1;
// ---- Signals (initialized inline because they take no input) ----
/**
* Host: a connected peer's transport reported a disconnect (the peer
* has entered the grace window). Args: `(peer_id, reason)`.
* @type {Signal}
*/
onPeerLost = new Signal();
/**
* Host: a peer has been permanently freed — either via
* `drop_peer()`, a grace-window timeout, or a peer-initiated
* DISCONNECT. Args: `(peer_id, reason)`.
* @type {Signal}
*/
onPeerPermanentlyDropped = new Signal();
/**
* Client: the transport has reported a disconnect, and the
* reconnect ladder has started. Args: `(reason)`.
* @type {Signal}
*/
onConnectionLost = new Signal();
/**
* Client: about to rebuild the transport and dial the host. Args:
* `(attempt_number)`.
* @type {Signal}
*/
onReconnectAttempt = new Signal();
/**
* Client: reconnect succeeded. Args: `({ state_resynced: boolean })` —
* `state_resynced` is `true` if we fell through to Tier-3 (server
* had purged our state and re-sent INITIAL_SYNC), `false` for the
* happy Tier-2 path where the action stream just resumed.
* @type {Signal}
*/
onReconnected = new Signal();
/**
* Client: the reconnect ladder is exhausted, or the host explicitly
* kicked us with a DISCONNECT packet, or `reconnect.enabled` is
* false and the link dropped. Args: `(reason)`.
* @type {Signal}
*/
onConnectionPermanentlyLost = new Signal();
// ---- Set in constructor (depend on input or imperative setup) ----
/** @private */ #transport = null;
/** @private */ #scope_filter = null;
/** @private @type {TimeDilation} */ #time_dilation;
/** @private @type {AdaptiveRenderDelay} */ #adaptive_render_delay;
/** @private @type {BinaryBuffer} */ #scratch_send_buf;
/** @private @type {BinaryBuffer} */ #scratch_interp_buf;
/** @private @type {() => void} */ #bound_normalize_if_dirty;
/** @private @type {number} */ #server_resume_grace_ms;
/** @private @type {object} */ #reconnect_policy;
/** @private @type {(() => object) | null} */ #transport_factory;
/**
* @param {{
* entity_manager: EntityManager,
* transport?: object,
* transport_factory?: (() => object),
* role?: 'client'|'host',
* local_peer_id?: number,
* simulation_delay_ticks?: number,
* tick_rate_hz?: number,
* binary_registry?: BinarySerializationRegistry,
* scope_filter?: object,
* time_dilation?: TimeDilation,
* adaptive_render_delay?: AdaptiveRenderDelay,
* server_resume_grace_ms?: number,
* reconnect?: {
* enabled?: boolean,
* max_attempts?: number,
* base_delay_ms?: number,
* max_delay_ms?: number,
* exponential_factor?: number,
* total_timeout_ms?: number,
* accept_state_resync?: boolean,
* },
* }} options
*
* - `transport` is not connected at construction — call {@link connect}
* with an explicit remote peer id after {@link start}.
* - `transport_factory` (client role): zero-arg function that returns
* a fresh `Transport` on each reconnect attempt. Required for
* automatic reconnect; without it, transport-level disconnects fall
* straight through to `onConnectionPermanentlyLost`.
* - `role` defaults to `'client'`. See {@link NetworkSessionRole}.
* - `local_peer_id` defaults to 0 for host, 1 for client. Must be
* unique across peers.
* - `simulation_delay_ticks` is honored only under `role: 'host'`.
* Default 4. See {@link ServerAuthoritativeServer}.
* - `tick_rate_hz` defaults to 60.
* - `scope_filter` defaults to {@link OwnerAwareScope} on the host.
* - `server_resume_grace_ms` (host role): how long peer state is
* retained after a transport drop before being freed. Default 30s.
* - `reconnect` (client role): policy for the reconnect ladder. See
* {@link DEFAULT_RECONNECT_POLICY}. Set `enabled: false` to opt
* out entirely.
*/
constructor({
entity_manager,
transport = null,
transport_factory = null,
role = NetworkSessionRole.Client,
local_peer_id = -1,
simulation_delay_ticks = DEFAULT_SIMULATION_DELAY_TICKS,
tick_rate_hz = DEFAULT_TICK_RATE_HZ,
binary_registry = null,
scope_filter = null,
time_dilation = null,
adaptive_render_delay = null,
server_resume_grace_ms = DEFAULT_SERVER_RESUME_GRACE_MS,
reconnect = null,
} = {}) {
assert.ok(entity_manager !== undefined && entity_manager !== null,
'NetworkSession: entity_manager is required');
if (role !== NetworkSessionRole.Client && role !== NetworkSessionRole.Host) {
throw new Error(`NetworkSession: invalid role '${role}'`);
}
assert.isNumber(tick_rate_hz, 'tick_rate_hz');
assert.ok(tick_rate_hz > 0, 'tick_rate_hz must be positive');
assert.isNonNegativeInteger(simulation_delay_ticks, 'simulation_delay_ticks');
/**
* @readonly
* @type {EntityManager}
*/
this.entity_manager = entity_manager;
/**
* @readonly
* @type {EntityComponentDataset}
*/
this.world = entity_manager.dataset;
/**
* @readonly
* @type {NetworkSessionRole|string}
*/
this.role = role;
/**
* @readonly
* @type {number}
*/
this.tick_period_ms = 1000 / tick_rate_hz;
/**
* @readonly
* @type {number}
*/
this.simulation_delay_ticks = role === NetworkSessionRole.Host
? simulation_delay_ticks
: 0;
/**
* @readonly
* @type {number}
*/
this.local_peer_id = local_peer_id >= 0
? local_peer_id
: (role === NetworkSessionRole.Host ? 0 : 1);
/**
* @readonly
* @type {BinarySerializationRegistry}
*/
this.binary_registry = binary_registry || new BinarySerializationRegistry();
this.#transport = transport;
this.#scope_filter = scope_filter;
this.#time_dilation = time_dilation || new TimeDilation({
target_buffer_depth: this.simulation_delay_ticks > 0 ? this.simulation_delay_ticks : 2,
max_dilation: 0.05,
gain: 0.05,
});
this.#adaptive_render_delay = adaptive_render_delay || new AdaptiveRenderDelay({
tick_period_ms: this.tick_period_ms,
min_delay_frames: 2,
max_delay_frames: 30,
initial_delay_frames: DEFAULT_INITIAL_RENDER_DELAY_FRAMES,
history_size: 60,
safety_multiplier: 2.0,
decay_per_sample_ms: 1.0,
});
this.#scratch_send_buf = new BinaryBuffer();
this.#scratch_send_buf.setCapacity(SCRATCH_BUFFER_BYTES);
this.#scratch_interp_buf = new BinaryBuffer();
this.#scratch_interp_buf.setCapacity(SCRATCH_BUFFER_BYTES);
/**
* @type {() => void}
*/
this.#bound_normalize_if_dirty = () => this.normalize_if_dirty();
this.#server_resume_grace_ms = server_resume_grace_ms;
this.#reconnect_policy = Object.freeze({ ...DEFAULT_RECONNECT_POLICY, ...(reconnect || {}) });
this.#transport_factory = transport_factory;
}
// ----------------------------------------------------------------
// Configuration (must run before start())
// ----------------------------------------------------------------
/**
* Register a component class for network replication.
*
* The component must have a {@link BinaryClassSerializationAdapter}
* registered for its `typeName` in the session's `binary_registry`
* before {@link start} is called — that adapter handles the wire
* format and rewind capture.
*
* `interpolator` is optional. When omitted, the component's state
* snaps on each received update (no sub-tick smoothing). When
* provided, it must extend {@link BinaryInterpolationAdapter} with
* `kind = InterpolationKind.Linear` — Discrete and Cubic are
* reserved for future support and currently throw.
*
* The order of `replicate()` calls must match across all peers in
* the session: AUTH_STATE payload layout iterates components in
* insertion order.
*
* @param {Function} ComponentClass
* @param {BinaryInterpolationAdapter} [interpolator]
*/
replicate(ComponentClass, interpolator = null) {
this.#assert_not_started('replicate');
assert.isFunction(ComponentClass, 'ComponentClass');
if (ComponentClass === NetworkIdentity) {
throw new Error("NetworkSession.replicate: NetworkIdentity is auto-replicated; do not call replicate() for it");
}
for (const entry of this.#user_replicated) {
if (entry.klass === ComponentClass) {
throw new Error(`NetworkSession.replicate: ${ComponentClass.typeName ?? ComponentClass.name} already registered`);
}
}
if (interpolator !== null) {
if (!(interpolator instanceof BinaryInterpolationAdapter)) {
throw new Error("NetworkSession.replicate: interpolator must extend BinaryInterpolationAdapter");
}
if (interpolator.kind !== InterpolationKind.Linear) {
throw new Error(
`NetworkSession.replicate: only Linear interpolation is currently supported; ` +
`got kind=${interpolator.kind} for ${ComponentClass.typeName ?? ComponentClass.name}. ` +
`Omit the interpolator argument for snap (Discrete) behavior; Cubic is reserved.`,
);
}
}
this.#user_replicated.push({ klass: ComponentClass, interp: interpolator });
}
/**
* Register a user-defined {@link SimAction} subclass with the
* session. The action's `type_id` is assigned by the underlying
* registry when {@link start} runs. Calling code constructs
* instances directly (`new MyAction(...)`) and dispatches via
* {@link send} or returns them from the input sampler.
*
* @param {Function} SimActionClass class extending SimAction
*/
defineAction(SimActionClass) {
this.#assert_not_started('defineAction');
assert.isFunction(SimActionClass, 'SimActionClass');
this.#user_action_classes.push(SimActionClass);
}
/**
* Install a per-tick input sampler (client role only). The sampler
* is called once per local sim tick at predict time and must return
* an array of {@link SimAction} instances representing the local
* peer's inputs for that frame. The session executes them locally
* (predict), records their serialized bytes for replay, and forwards
* them over the action stream.
*
* The sampler is NOT called during reconciliation replay — the
* recorded bytes are deserialized and re-executed instead, so the
* action set faithfully reproduces what the user originally
* sampled at that frame.
*
* Return `[]` (or `null`) for ticks with no inputs.
*
* @param {(frame: number) => Array<SimAction>|null} sampler_fn
*/
defineInputSampler(sampler_fn) {
this.#assert_not_started('defineInputSampler');
assert.isFunction(sampler_fn, 'sampler_fn');
if (this.role !== NetworkSessionRole.Client) {
throw new Error(`NetworkSession.defineInputSampler: only valid for role='${NetworkSessionRole.Client}', got '${this.role}'`);
}
this.#input_sampler = sampler_fn;
}
// ----------------------------------------------------------------
// Lifecycle
// ----------------------------------------------------------------
/**
* Wire the session to the engine. After this, the EntityObserver
* is active, the right orchestrator is constructed, and a
* {@link NetworkSystem} is attached. Subsequent {@link replicate}
* / {@link defineAction} / {@link defineInputSampler} calls throw.
*
* Pre-condition: every component class passed to {@link replicate}
* must already have a `BinaryClassSerializationAdapter` registered
* in the session's binary_registry.
*/
async start() {
if (this.#started) throw new Error("NetworkSession.start: already started");
// Auto-register the NetworkIdentity adapter so initial-sync can
// ship it across the wire. Allow caller-supplied registrations
// to win (game might have a custom one).
if (this.binary_registry.getAdapter(NetworkIdentity.typeName) === undefined) {
this.binary_registry.registerAdapter(new NetworkIdentitySerializationAdapter(), NetworkIdentity.typeName);
}
// Finalize the replicated-components ordering: NetworkIdentity
// FIRST (so it gets type_id=0 and arrives as the first per-entity
// record in snapshots — receiver attaches NetworkIdentity first,
// which triggers slot-table allocation before any other component
// payload needs to look up `entity_for(network_id)`).
let next_id = 0;
const network_identity_scratch = new BinaryBuffer();
network_identity_scratch.setCapacity(SCRATCH_BUFFER_BYTES);
this.#replicated.set(NetworkIdentity, {
interp: null,
interp_type_id: next_id++,
});
this.#interp_type_id_to_class[0] = NetworkIdentity;
for (const { klass, interp } of this.#user_replicated) {
const interp_type_id = next_id++;
this.#replicated.set(klass, { interp, interp_type_id });
this.#interp_type_id_to_class[interp_type_id] = klass;
}
// Verify each replicated component has a serialization adapter.
for (const ComponentClass of this.#replicated.keys()) {
const name = ComponentClass.typeName;
if (typeof name !== 'string') {
throw new Error(`NetworkSession.start: ${ComponentClass.name} has no static .typeName; cannot look up its adapter`);
}
if (this.binary_registry.getAdapter(name) === undefined) {
throw new Error(`NetworkSession.start: no BinaryClassSerializationAdapter registered for ${name}; register one in the binary_registry before calling start()`);
}
}
// Build the synthetic ReplaceComponentAction now so it's in the
// action_classes list when the orchestrator is constructed.
this.#ReplaceComponentAction = this.#make_replace_component_action_class();
const replicated_components = Array.from(this.#replicated.keys());
const action_classes = [...this.#user_action_classes, this.#ReplaceComponentAction];
const orchestrator_opts = {
world: this.world,
binary_registry: this.binary_registry,
replicated_components,
action_classes,
};
if (this.role === NetworkSessionRole.Host) {
this.#server = new ServerAuthoritativeServer({
...orchestrator_opts,
simulation_delay_ticks: this.simulation_delay_ticks,
});
this.#peer = this.#server.peer;
this.#peer.replicator.scope_filter = this.#scope_filter || new OwnerAwareScope({
world: this.world,
slot_table: this.#server.slot_table,
identity_class: NetworkIdentity,
});
this.#server.onTickComplete.add((sim_frame) => this.#on_host_tick_complete(sim_frame));
} else {
// Client role: covers both "playing" (input sampler registered)
// and "spectator" (no sampler — sampler-driven onPredict
// returns no actions, so the predict-reconcile machinery
// exists but stays idle).
this.#client = new ServerAuthoritativeClient({
...orchestrator_opts,
time_dilation: this.#time_dilation,
});
this.#peer = this.#client.peer;
if (this.#scope_filter) {
this.#peer.replicator.scope_filter = this.#scope_filter;
}
this.#wire_client_predict_reconcile();
}
// Install the canonical-form normalize hook on the executor.
// Anything that calls executor.execute — predict apply, action-
// stream apply, reconcile replay — first normalizes the world.
// Idempotent: cheap when `render_dirty` is false (one bool
// check), expensive (one adapter.deserialize per remote-owned
// component) only on the first execute after a render pass.
this.#peer.executor.before_execute = this.#bound_normalize_if_dirty;
// NetworkSystem handles slot_table allocation on NetworkIdentity attach.
// addSystem returns a promise that resolves once the system's own
// startup completes (it auto-starts when added to an already-Running
// EM). Await it so the session's identity observer below doesn't
// race with the NetworkSystem's observer over slot_table allocation.
await this.entity_manager.addSystem(new NetworkSystem(this.#peer));
// Sub-tick interpolation log: records per-frame replicated component
// state, sampled at render time for remotely-owned entities.
this.#interp_log = new InterpolationLog({
buffer_capacity_bytes: 65536,
records_capacity: 4096,
});
// Per-frame applied: snapshot all replicated components for each
// remote-owned entity into the interpolation log.
this.#peer.replicator.onFrameApplied.add((peer_id, frame_number) => {
this.#on_frame_applied(peer_id, frame_number);
});
// Initial-sync arrival (client only): store the session token,
// populate the world from the host's snapshot, resume normal
// action-stream processing, and finalize any in-flight Tier-3
// resync state.
if (this.role === NetworkSessionRole.Client) {
this.#peer.onInitialSync.add((_peer_id, session_token, _frame_number, buf, payload_end) => {
const token = new UUID();
token.data = session_token;
this.session_token = token;
this.#apply_initial_sync(buf, payload_end);
if (this.#reconnect_state === ReconnectState.Resyncing) {
this.#enter_connected();
this.onReconnected.send1({ state_resynced: true });
}
});
// Host's response to our RESUME_HELLO during reconnect.
this.#peer.onResumeAccept.add(() => {
if (this.#reconnect_state === ReconnectState.Reconnecting
|| this.#reconnect_state === ReconnectState.Resyncing) {
this.#enter_connected();
this.onReconnected.send1({ state_resynced: false });
}
});
this.#peer.onResumeReject.add((_peer_id, reason_code) => {
if (this.#reconnect_policy.accept_state_resync) {
// Tier-3: drop unconfirmed predictions and wait for
// the host to deliver a fresh INITIAL_SYNC.
this.#sampled_actions_per_frame.clear();
this.session_token = null;
this.#reconnect_state = ReconnectState.Resyncing;
// Restart the elapsed-ms budget so the Resyncing
// watchdog (see #tick_reconnect) has a full
// total_timeout_ms before falling to Lost.
this.#reconnect_elapsed_ms = 0;
} else {
this.#permanently_lose_connection(`resume_rejected:${reason_code}`);
}
});
// Host kicked us — final, no reconnect.
this.#peer.onDisconnectPacket.add((_peer_id, reason_label) => {
this.#permanently_lose_connection(reason_label || 'disconnected_by_host');
});
} else {
// Host: RESUME_HELLO validation + peer-initiated disconnects.
this.#peer.onResumeHello.add((peer_id, local_peer_id, last_acked_frame, session_token) => {
this.#on_resume_hello(peer_id, local_peer_id, last_acked_frame, session_token);
});
this.#peer.onDisconnectPacket.add((peer_id, reason_label) => {
// Client-initiated disconnect — skip the grace window
// and free state immediately.
this.drop_peer(peer_id, reason_label || 'client_disconnect');
});
}
// EntityObserver for NetworkIdentity: patches owner/network_id on
// attach, wires the per-entity mutation listener.
this.#identity_observer = new EntityObserver(
[NetworkIdentity],
(identity, entity_id) => this.#on_identity_attached(identity, entity_id),
(identity, entity_id) => this.#on_identity_detached(identity, entity_id),
this,
);
this.#identity_observer.connect(this.world);
this.#started = true;
}
/**
* Connect to a remote peer. Host can connect many peers (one per
* client); client typically connects one (the server). Called both
* for first-time connects and reconnect attempts — the session
* detects which based on whether a `session_token` is cached
* (client) or whether the peer is in the grace window (host).
*
* @param {number} remote_peer_id
* @param {object} transport
*/
connect(remote_peer_id, transport) {
if (!this.#started) throw new Error("NetworkSession.connect: call start() first");
if (this.role === NetworkSessionRole.Host) {
const in_grace = this.#peer_grace_state.has(remote_peer_id);
this.#server.connect_peer(remote_peer_id, transport);
if (in_grace) {
// Resume candidate. Wait for RESUME_HELLO; restore
// baseline + add to connected_peers on validation.
// Don't queue INITIAL_SYNC — it'll only fire if we
// RESUME_REJECT.
} else {
// Fresh peer. Queue initial-sync for the next host tick.
this.#pending_initial_sync.set(remote_peer_id, 2);
this.#connected_peers.add(remote_peer_id);
}
this.#wire_peer_transport_disconnect(remote_peer_id, transport);
} else {
// Client
this.#client.connect_to_server(remote_peer_id, transport);
this.#remote_peer_id = remote_peer_id;
this.#current_transport = transport;
this.#wire_client_transport_disconnect(transport);
this.#connected_peers.add(remote_peer_id);
// If we have a session token cached, treat this as a
// reconnect attempt and send RESUME_HELLO immediately. A
// fresh session has no token yet (it arrives in the first
// INITIAL_SYNC), so first-time connects don't trigger this.
if (this.session_token !== null) {
const last_acked = Math.max(0, this.#latest_received_frame);
this.#peer.send_resume_hello(
remote_peer_id, this.local_peer_id, last_acked,
this.session_token.data,
);
}
}
}
/**
* Host-only: forcibly free a peer's state, regardless of whether
* they are actively connected, in the grace window, or already
* unknown. Fires `onPeerPermanentlyDropped(peer_id, reason)`.
* Sends a DISCONNECT packet if the peer is still actively
* connected so the remote can short-circuit its reconnect attempts.
*
* Policy for *when* to drop (high server load, anti-cheat, admin
* kick, etc.) is the caller's; the session provides the mechanism.
*
* @param {number} peer_id
* @param {string} [reason]
*/
drop_peer(peer_id, reason = '') {
if (this.role !== NetworkSessionRole.Host) {
throw new Error("NetworkSession.drop_peer: host-only");
}
const was_connected = this.#connected_peers.has(peer_id);
const was_in_grace = this.#peer_grace_state.has(peer_id);
if (!was_connected && !was_in_grace) return;
if (was_connected) {
// Best-effort notification — peer may have already dropped.
try {
this.#peer.send_disconnect(peer_id, reason);
} catch (_) { /* swallow */
}
this.#server.disconnect_peer(peer_id);
}
this.#unwire_peer_transport_disconnect(peer_id);
this.#connected_peers.delete(peer_id);
this.#peer_grace_state.delete(peer_id);
this.#peer_session_tokens.delete(peer_id);
this.#pending_initial_sync.delete(peer_id);
this.onPeerPermanentlyDropped.send2(peer_id, reason);
}
/**
* Client-only: voluntarily disconnect from the server. Sends a
* DISCONNECT packet so the host can free state immediately and
* skip the grace window. Disables reconnect for this session.
*
* @param {string} [reason]
*/
disconnect(reason = '') {
if (this.role !== NetworkSessionRole.Client) {
throw new Error("NetworkSession.disconnect: client-only — host uses drop_peer instead");
}
if (this.#remote_peer_id < 0) return;
const peer_id = this.#remote_peer_id;
try {
this.#peer.send_disconnect(peer_id, reason);
} catch (_) { /* swallow */
}
// Tear down the channel binding so a subsequent connect() with a
// fresh transport doesn't trip the underlying NetworkPeer's
// "peer already exists" guard. Mirrors the cleanup that
// #on_client_transport_disconnected does on an involuntary drop.
try {
this.#peer.disconnect_peer(peer_id);
} catch (_) { /* swallow */
}
if (this.#current_transport_listener !== null && this.#current_transport !== null) {
try {
this.#current_transport.onDisconnect.remove(this.#current_transport_listener);
} catch (_) { /* swallow */
}
}
this.#current_transport = null;
this.#current_transport_listener = null;
this.#remote_peer_id = -1;
this.#permanently_lose_connection(reason || 'client_disconnect');
}
/**
* Tear down. Idempotent — safe to call from cleanup paths.
*/
stop() {
if (!this.#started) return;
if (this.#identity_observer !== null) {
this.#identity_observer.disconnect();
this.#identity_observer = null;
}
for (const [entity_id, listener] of this.#event_listeners) {
this.world.removeEntityEventListener(entity_id, "net_mutate_component", listener);
}
this.#event_listeners.clear();
this.#remote_entities.clear();
this.#sampled_actions_per_frame.clear();
this.#started = false;
}
// ----------------------------------------------------------------
// Per-tick driver
// ----------------------------------------------------------------
/**
* Fixed-timestep accumulator-driven step. Catch-up bounded by
* `MAX_FIXED_STEPS_PER_TICK`. Under `role: 'client'` the local frame
* advances at `1 / dilation_factor` per fixed step.
*
* @param {number} dt_seconds elapsed wall seconds since the previous call
*/
tick(dt_seconds) {
if (!this.#started) return;
// Restore canonical form before sim work. The executor's
// `before_execute` hook also covers per-action paths (e.g.
// packets delivered between session.tick calls).
this.normalize_if_dirty();
const dt_ms = dt_seconds * 1000;
// Host: expire grace-window peers whose deadline has passed.
if (this.role === NetworkSessionRole.Host) {
this.#expire_grace_peers(performance.now());
} else {
// Client: drive the reconnect timer.
this.#tick_reconnect(dt_ms);
}
this.#sim_accum_ms += dt_ms;
let steps = 0;
while (this.#sim_accum_ms >= this.tick_period_ms && steps < MAX_FIXED_STEPS_PER_TICK) {
this.#sim_accum_ms -= this.tick_period_ms;
this.#simulate_one_step();
steps++;
}
if (steps === MAX_FIXED_STEPS_PER_TICK) this.#sim_accum_ms = 0;
// Per-render-frame: smooth interpolated rendering of remote
// entities. Marks the live ECD as polluted so the next sim-tick
// or reconcile starts with a normalize.
this.#render_interpolated_entities();
}
/**
* Undo render-time interpolation on remote-owned components by
* restoring each from its latest InterpolationLog entry. Idempotent
* via the `render_dirty` flag — cheap when false.
*
* Called from `tick()` (top), `executor.before_execute` (per-action
* applies, including transport-delivered action stream), and
* `client.onBeforeReconcile` (before the rewind walk).
*
* @private
*/
normalize_if_dirty() {
if (!this.render_dirty) return;
this.render_dirty = false;
if (this.#latest_received_frame < 0) return;
if (this.#remote_entities.size === 0) return;
const scratch = this.#scratch_interp_buf;
const tick = this.#latest_received_frame;
for (const entity_id of this.#remote_entities) {
const identity = this.world.getComponent(entity_id, NetworkIdentity);
if (identity === undefined || identity.network_id < 0) continue;
for (const [ComponentClass, desc] of this.#replicated) {
if (desc.interp === null) continue;
const live = this.world.getComponent(entity_id, ComponentClass);
if (live === undefined) continue;
scratch.position = 0;
// tick_a === tick_b, t = 0 — degenerate "snap to single
// snapshot" lerp. Output equals the stored snapshot.
const ok = this.#interp_log.interpolate(
scratch, identity.network_id, desc.interp_type_id,
tick, tick, 0, desc.interp,
);
if (!ok) continue;
scratch.position = 0;
const adapter = this.binary_registry.getAdapter(ComponentClass.typeName);
adapter.deserialize(scratch, live);
}
}
}
/**
* Dispatch one user-defined action. Equivalent to
* `peer.executor.execute(action, this.local_peer_id)` but exposed
* at the session level so callers don't need a reference to the
* executor or to know the local peer id.
*
* @param {SimAction} action_instance
*/
send(action_instance) {
if (!this.#started) throw new Error("NetworkSession.send: call start() first");
if (!action_instance || action_instance.isSimAction !== true) {
throw new Error("NetworkSession.send: argument must be a SimAction instance");
}
this.#peer.executor.execute(action_instance, this.local_peer_id);
}
// ----------------------------------------------------------------
// Accessors
// ----------------------------------------------------------------
/**
* @returns {ServerAuthoritativeServer|null}
*/
get server() {
return this.#server;
}
/**
* @returns {ServerAuthoritativeClient|null}
*/
get client() {
return this.#client;
}
/**
* @returns {NetworkPeer|null}
*/
get peer() {
return this.#peer;
}
/**
* @returns {InterpolationLog|null}
*/
get interpolation_log() {
return this.#interp_log;
}
/**
* @returns {AdaptiveRenderDelay}
*/
get adaptive_render_delay() {
return this.#adaptive_render_delay;
}
/**
* @returns {TimeDilation}
*/
get time_dilation() {
return this.#time_dilation;
}
/**
* Most recent locally-completed sim frame.
*/
get current_frame() {
if (this.role === NetworkSessionRole.Host) return this.#server.current_sim_frame;
return this.#local_frame - 1;
}
// ----------------------------------------------------------------
// Internal: identity observer (component-driven spawn/despawn)
// ----------------------------------------------------------------
/**
* Patch `NetworkIdentity` on attach: `owner_peer_id` defaults to
* this session's `local_peer_id` if unset; `network_id` is assigned
* by {@link NetworkSystem.link}, which fires on the same attach.
*
* Callers must not mutate either field afterwards — the slot table
* and replicator cache them.
*
*/
#on_identity_attached(identity, entity_id) {
if (identity.owner_peer_id < 0) {
identity.owner_peer_id = this.local_peer_id;
}
if (identity.owner_peer_id !== this.local_peer_id) {
this.#remote_entities.add(entity_id);
}
// Per-entity event listener for net_mutate_component.
const listener = (payload) => this.#on_net_mutate_component(entity_id, payload);
this.world.addEntityEventListener(entity_id, "net_mutate_component", listener);
this.#event_listeners.set(entity_id, listener);
}
#on_identity_detached(_identity, entity_id) {
const listener = this.#event_listeners.get(entity_id);
if (listener !== undefined) {
this.world.removeEntityEventListener(entity_id, "net_mutate_component", listener);
this.#event_listeners.delete(entity_id);
}
this.#remote_entities.delete(entity_id);
}
/**
* Handler for `"net_mutate_component"` events. Translates the payload
* into a `ReplaceComponentAction` and dispatches via the executor.
*
* Payload:
* - `{ component_type, new_state }` — recommended; engine captures
* current bytes as prior, then applies `new_state` via the
* replication adapter.
* - `{ component_type }` — caller has already mutated live; prior
* bytes equal post bytes (rewind for this mutation is a no-op).
*
*/
#on_net_mutate_component(entity_id, payload) {
const { component_type, new_state } = payload || {};
if (!component_type) {
throw new Error("net_mutate_component: payload must include `component_type`");
}
const desc = this.#replicated.get(component_type);
if (desc === undefined) {
throw new Error(`net_mutate_component: ${component_type.typeName ?? component_type.name} is not replicated; call session.replicate() for it before mutating`);
}
const network_id = this.#peer.slot_table.network_for(entity_id);
if (network_id < 0) return; // not networked yet
const live = this.world.getComponent(entity_id, component_type);
if (live === undefined) return;
const adapter = this.binary_registry.getAdapter(component_type.typeName);
// Build the action with the new-state bytes pre-baked. The
// executor will then capture the current `live` as prior bytes
// before action.apply deserializes new bytes into `live`.
const action = this.#peer.action_registry.acquire(this.#ReplaceComponentAction);
action.network_id = network_id;
action.component_type_id = desc.interp_type_id;
action.payload_buf.position = 0;
if (new_state !== undefined && new_state !== null) {
adapter.serialize(action.payload_buf, new_state);
} else {
// No new_state: live IS the new state.
adapter.serialize(action.payload_buf, live);
}
action.payload_length = action.payload_buf.position;
try {
this.#peer.executor.execute(action, this.local_peer_id);
} finally {
this.#peer.action_registry.release(action);
}
}
/**
* Build the synthetic ReplaceComponentAction class.
* The factory captures `interp_type_id_to_class` and `binary_registry` by
* reference so the inner class methods can resolve type ids and
* adapters without reaching into the outer class's private fields
* (which `#`-private syntax would forbid from a different class).
*/
#make_replace_component_action_class() {
const interp_type_id_to_class = this.#interp_type_id_to_class;
const binary_registry = this.binary_registry;
return class ReplaceComponentAction extends SimAction {
constructor(network_id = 0, component_type_id = 0) {
super();
this.network_id = network_id;
this.component_type_id = component_type_id;
this.payload_buf = new BinaryBuffer();
this.payload_buf.setCapacity(SCRATCH_BUFFER_BYTES);
this.payload_length = 0;
}
apply(world, executor) {
const local = executor.slot_table.entity_for(this.network_id);
if (local < 0) return;
const ComponentClass = interp_type_id_to_class[this.component_type_id];
if (ComponentClass === undefined) return;
const live = world.getComponent(local, ComponentClass);
if (live === undefined) return;
const adapter = binary_registry.getAdapter(ComponentClass.typeName);
if (adapter === undefined) return;
this.payload_buf.position = 0;
adapter.deserialize(this.payload_buf, live);
}
affected_components(callback, executor) {
const local = executor.slot_table.entity_for(this.network_id);
if (local < 0) return;
const ComponentClass = interp_type_id_to_class[this.component_type_id];
if (ComponentClass === undefined) return;
callback(local, ComponentClass);
}
serialize(buffer) {
buffer.writeUintVar(this.network_id);
buffer.writeUint8(this.component_type_id);
buffer.writeUint32(this.payload_length);
if (this.payload_length > 0) {
buffer.writeBytes(this.payload_buf.raw_bytes, 0, this.payload_length);
}
}
deserialize(buffer) {
this.network_id = buffer.readUintVar();
this.component_type_id = buffer.readUint8();
this.payload_length = buffer.readUint32();
if (this.payload_buf.raw_bytes.length < this.payload_length) {
this.payload_buf.setCapacity(this.payload_length);
}
if (this.payload_length > 0) {
buffer.readBytes(this.payload_buf.raw_bytes, 0, this.payload_length);
}
this.payload_buf.position = 0;
}
reset() {
this.network_id = 0;
this.component_type_id = 0;
this.payload_length = 0;
this.payload_buf.position = 0;
}
};
}
// -----------