@usions/sdk 2.2.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/browser.js CHANGED
@@ -184,6 +184,12 @@ var Usion = (function () {
184
184
  if (data.type === 'BOT_MESSAGE' && self.bot && self.bot._messageHandler) {
185
185
  self.bot._messageHandler(data.message);
186
186
  }
187
+
188
+ // Handle backend server-push events relayed by the host (embedded mode).
189
+ if (data.type === 'BACKEND_EVENT' && data.event && self._backendHandlers) {
190
+ const h = self._backendHandlers[data.event];
191
+ if (h) h(data.data);
192
+ }
187
193
  });
188
194
 
189
195
  // Signal ready to parent
@@ -1037,6 +1043,9 @@ var Usion = (function () {
1037
1043
 
1038
1044
  self._connecting = true;
1039
1045
  self.directMode = true;
1046
+ self._autoReconnect = config.autoReconnect !== undefined
1047
+ ? config.autoReconnect
1048
+ : !(Usion.config && Usion.config.autoReconnect === false);
1040
1049
  self._connectPromise = self._fetchDirectAccess(config)
1041
1050
  .then(function(access) {
1042
1051
  self.directConfig = access;
@@ -1165,6 +1174,10 @@ var Usion = (function () {
1165
1174
  if (self._eventHandlers.disconnect) {
1166
1175
  self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
1167
1176
  }
1177
+ // Seamless resume: if the drop wasn't an intentional disconnect()
1178
+ // (which clears directMode), transparently re-establish + re-join +
1179
+ // resync from the last sequence.
1180
+ if (self.directMode && self._autoReconnect !== false) self._scheduleDirectReconnect();
1168
1181
  };
1169
1182
 
1170
1183
  ws.onmessage = function(evt) {
@@ -1173,6 +1186,39 @@ var Usion = (function () {
1173
1186
  });
1174
1187
  };
1175
1188
 
1189
+ /**
1190
+ * Reconnect a dropped direct-mode socket with capped exponential backoff,
1191
+ * then re-join the room and request a resync (Dota-style "fetch latest
1192
+ * snapshot = instant rejoin"). Keeps retrying while still in directMode.
1193
+ * @private
1194
+ */
1195
+ game._scheduleDirectReconnect = function() {
1196
+ var self = this;
1197
+ if (self._reconnecting) return;
1198
+ self._reconnecting = true;
1199
+ var attempt = self._reconnectAttempt || 0;
1200
+ var go = function() {
1201
+ if (!self.directMode) { self._reconnecting = false; return; } // disconnected meanwhile
1202
+ attempt += 1;
1203
+ self._reconnectAttempt = attempt;
1204
+ self._fetchDirectAccess({})
1205
+ .then(function(access) { self.directConfig = access; return self._initDirectSocket(access); })
1206
+ .then(function() {
1207
+ self.connected = true;
1208
+ self._reconnecting = false;
1209
+ self._reconnectAttempt = 0;
1210
+ if (self._eventHandlers.reconnect) self._eventHandlers.reconnect(attempt);
1211
+ if (self.roomId) self.requestSync(self._lastSequence || 0); // resync / resume
1212
+ })
1213
+ .catch(function() {
1214
+ if (!self.directMode) { self._reconnecting = false; return; }
1215
+ var delay = Math.min(1000 * Math.pow(2, attempt - 1), 15000);
1216
+ setTimeout(go, delay);
1217
+ });
1218
+ };
1219
+ setTimeout(go, 500);
1220
+ };
1221
+
1176
1222
  game._sendDirect = function(type, payload) {
1177
1223
  if (!this.directSocket || this.directSocket.readyState !== WebSocket.OPEN) return;
1178
1224
  this._directSeq = this._directSeq + 1;
@@ -1216,6 +1262,11 @@ var Usion = (function () {
1216
1262
  return;
1217
1263
  }
1218
1264
  if (data.type === 'pong') {
1265
+ // Resolve a pending game.ping() RTT probe, if any.
1266
+ if (this._pongWaiters && this._pongWaiters.length) {
1267
+ const waiter = this._pongWaiters.shift();
1268
+ if (waiter) waiter();
1269
+ }
1219
1270
  if (this._eventHandlers.sync) this._eventHandlers.sync(payload);
1220
1271
  return;
1221
1272
  }
@@ -1271,6 +1322,10 @@ var Usion = (function () {
1271
1322
  reconnectionDelayMax: 10000
1272
1323
  });
1273
1324
 
1325
+ // Route allow-listed backend server events (lobby, etc.) to the unified
1326
+ // backend channel — robust to listener registration order.
1327
+ if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
1328
+
1274
1329
  self.socket.on('connect', function() {
1275
1330
  self.connected = true;
1276
1331
  self._connecting = false;
@@ -2020,152 +2075,2452 @@ var Usion = (function () {
2020
2075
  }
2021
2076
 
2022
2077
  /**
2023
- * Usion SDK Game Core game module base, connect routing, event registrations
2078
+ * Usion SDK NetcodeSnapshot interpolation.
2079
+ *
2080
+ * Render every entity slightly in the past and interpolate between the two
2081
+ * snapshots straddling "renderTime", so motion stays smooth and late /
2082
+ * duplicate / out-of-order / dropped packets are absorbed for free. This is
2083
+ * the single biggest perceived-lag fix for real-time games.
2084
+ *
2085
+ * Practices adopted (Valve Source "Interpolation" + geckos.io):
2086
+ * - Interpolation delay (buffer) sized in server-frames, with an **adaptive**
2087
+ * mode that grows the buffer with measured network jitter (cl_interp_ratio).
2088
+ * - **Capped extrapolation** when the buffer underruns (packet loss): project
2089
+ * forward from the last known velocity, bounded (Source caps at 0.25s) so
2090
+ * prediction error stays small.
2091
+ * - Optional **server-time domain**: if snapshots carry a server `time`, render
2092
+ * against an estimated server clock (robust to bursty arrival). Default stays
2093
+ * the client arrival-time domain — zero clock sync required.
2094
+ *
2095
+ * Key-spec syntax: 'x y angle(deg) r(rad)'.
2024
2096
  */
2025
2097
 
2098
+ function shortestAngle(a, b, twoPi) {
2099
+ let d = (b - a) % twoPi;
2100
+ const half = twoPi / 2;
2101
+ if (d > half) d -= twoPi;
2102
+ if (d < -half) d += twoPi;
2103
+ return d;
2104
+ }
2026
2105
 
2027
- /**
2028
- * Create the game module with all sub-modules applied
2029
- * @param {object} Usion - Reference to the main Usion object
2030
- */
2031
- function createGameModule(Usion) {
2032
- const game = {
2033
- socket: null,
2034
- directSocket: null,
2035
- roomId: null,
2036
- playerId: null,
2037
- connected: false,
2038
- directMode: false,
2039
- directConfig: null,
2040
- _directSeq: 0,
2041
- _eventHandlers: {},
2042
- _lastSequence: 0,
2043
- _connecting: false,
2044
- _connectPromise: null,
2045
- _joined: false,
2046
- _joinPromise: null,
2047
- _useProxy: false,
2048
- _proxyListenerSetup: false,
2049
- _heartbeatInterval: null,
2106
+ function parseKeys(spec) {
2107
+ return String(spec || '')
2108
+ .trim()
2109
+ .split(/\s+/)
2110
+ .filter(Boolean)
2111
+ .map((tok) => {
2112
+ const m = tok.match(/^(.+?)\((deg|rad)\)$/);
2113
+ return m ? { key: m[1], type: m[2] } : { key: tok, type: 'linear' };
2114
+ });
2115
+ }
2050
2116
 
2051
- /**
2052
- * Connect to the game socket server
2053
- * @param {string} socketUrl - Socket.IO server URL (optional, uses config)
2054
- * @param {string} token - JWT auth token (optional, uses user.getToken())
2055
- * @returns {Promise} Resolves when connected
2056
- */
2057
- connect: function(socketUrl, token) {
2058
- const self = this;
2059
- var connectionMode = (Usion.config && Usion.config.connectionMode) || 'platform';
2060
- if (connectionMode === 'direct') {
2061
- return self.connectDirect();
2062
- }
2117
+ function lerpField(a, b, t, type) {
2118
+ if (type === 'deg') return a + shortestAngle(a, b, 360) * t;
2119
+ if (type === 'rad') return a + shortestAngle(a, b, Math.PI * 2) * t;
2120
+ return a + (b - a) * t;
2121
+ }
2063
2122
 
2064
- // If already connected (direct or proxy), return immediately
2065
- if (self._useProxy && self.connected) {
2066
- return Promise.resolve();
2067
- }
2068
- if (self.socket && self.connected) {
2069
- return Promise.resolve();
2070
- }
2123
+ /** Time-ordered ring buffer of snapshots. Newest last. */
2124
+ class Vault {
2125
+ constructor(maxSize = 120) {
2126
+ this._items = [];
2127
+ this._maxSize = maxSize;
2128
+ }
2129
+ add(snapshot) {
2130
+ this._items.push(snapshot);
2131
+ if (this._items.length > this._maxSize) this._items.shift();
2132
+ }
2133
+ get size() { return this._items.length; }
2134
+ setMaxSize(n) { this._maxSize = Math.max(2, n | 0); }
2135
+ latest() { return this._items.length ? this._items[this._items.length - 1] : null; }
2136
+ prevLatest() { return this._items.length >= 2 ? this._items[this._items.length - 2] : null; }
2137
+ clear() { this._items = []; }
2138
+
2139
+ /** [older, newer] snapshots straddling `time`, clamping at the ends. */
2140
+ straddle(time) {
2141
+ const items = this._items;
2142
+ if (items.length === 0) return [null, null];
2143
+ if (items.length === 1) return [items[0], items[0]];
2144
+ if (time <= items[0].time) return [items[0], items[0]];
2145
+ const last = items[items.length - 1];
2146
+ if (time >= last.time) return [last, last];
2147
+ for (let i = items.length - 1; i > 0; i--) {
2148
+ if (items[i - 1].time <= time && time <= items[i].time) return [items[i - 1], items[i]];
2149
+ }
2150
+ return [last, last];
2151
+ }
2152
+ }
2071
2153
 
2072
- // If currently connecting, return the existing promise
2073
- if (self._connecting && self._connectPromise) {
2074
- return self._connectPromise;
2075
- }
2154
+ function asEntityArray(state, group) {
2155
+ if (group) return Array.isArray(state[group]) ? state[group] : [];
2156
+ return Array.isArray(state) ? state : [];
2157
+ }
2076
2158
 
2077
- // When running inside an iframe or WebView, use parent as socket proxy
2078
- // (checked BEFORE token validation — iframe games don't need a token)
2079
- var isInFrame = !!window.__USION_PROXY__
2080
- || window.parent !== window
2081
- || !!window.ReactNativeWebView
2082
- || !!Usion._isEmbedded;
2159
+ class SnapshotInterpolation {
2160
+ /**
2161
+ * @param {object} [opts]
2162
+ * @param {number} [opts.serverFps=20] Expected snapshot rate (sets default buffer).
2163
+ * @param {number} [opts.bufferMs] Fixed interpolation delay (default ≈ 3 frames).
2164
+ * @param {boolean} [opts.adaptive=false] Grow the buffer with measured jitter.
2165
+ * @param {number} [opts.minBufferMs] Lower clamp for adaptive buffer.
2166
+ * @param {number} [opts.maxBufferMs] Upper clamp for adaptive buffer.
2167
+ * @param {number} [opts.extrapolationMs=0] Max forward extrapolation on underrun (0 = off).
2168
+ * @param {boolean} [opts.serverTime=false] Use snapshot.time (server clock) domain.
2169
+ * @param {number} [opts.maxSize=120]
2170
+ * @param {function} [opts.now]
2171
+ */
2172
+ constructor(opts = {}) {
2173
+ const serverFps = opts.serverFps || 20;
2174
+ this._frameMs = 1000 / serverFps;
2175
+ this.vault = new Vault(opts.maxSize || 120);
2176
+ this._fixedBuffer = opts.bufferMs != null ? opts.bufferMs : Math.ceil(this._frameMs * 3);
2177
+ this._bufferMs = this._fixedBuffer;
2178
+ this._adaptive = !!opts.adaptive;
2179
+ this._minBuffer = opts.minBufferMs != null ? opts.minBufferMs : Math.ceil(this._frameMs * 2);
2180
+ this._maxBuffer = opts.maxBufferMs != null ? opts.maxBufferMs : Math.ceil(this._frameMs * 8);
2181
+ this._extrapolationMs = opts.extrapolationMs || 0;
2182
+ this._useServerTime = !!opts.serverTime;
2183
+ this._now = opts.now || (() => Date.now());
2184
+
2185
+ // adaptive/jitter + server-clock estimation state
2186
+ this._lastArrival = null;
2187
+ this._avgInterval = this._frameMs;
2188
+ this._jitter = 0;
2189
+ this._offset = null; // EWMA(arrival - serverTime)
2190
+ }
2083
2191
 
2084
- if (isInFrame) {
2085
- Usion.log('Running in iframe \u2013 using parent app as socket proxy');
2086
- return self._connectViaProxy();
2192
+ getBufferMs() { return this._bufferMs; }
2193
+ setBufferMs(ms) { this._fixedBuffer = Math.max(0, ms | 0); this._bufferMs = this._fixedBuffer; }
2194
+ getJitter() { return this._jitter; }
2195
+
2196
+ /**
2197
+ * Add a received snapshot. `state` is an entity array (each needs a stable
2198
+ * `id`) or a map of named groups → entity arrays. Pass `{ state, time }` to
2199
+ * supply a server timestamp for the server-time domain.
2200
+ */
2201
+ add(snapshot) {
2202
+ if (!snapshot) return;
2203
+ const arrival = this._now();
2204
+ const state = snapshot.state !== undefined ? snapshot.state : snapshot;
2205
+ const serverTime = snapshot.time;
2206
+
2207
+ // Jitter / interval estimate (EWMA), used by the adaptive buffer.
2208
+ if (this._lastArrival != null) {
2209
+ const interval = arrival - this._lastArrival;
2210
+ this._avgInterval += 0.1 * (interval - this._avgInterval);
2211
+ this._jitter += 0.1 * (Math.abs(interval - this._avgInterval) - this._jitter);
2212
+ if (this._adaptive) {
2213
+ const target = this._avgInterval + 2 * this._jitter;
2214
+ this._bufferMs = Math.max(this._minBuffer, Math.min(this._maxBuffer, target));
2087
2215
  }
2216
+ }
2217
+ this._lastArrival = arrival;
2088
2218
 
2089
- // Use config values as defaults (only for direct socket connections)
2090
- socketUrl = socketUrl || Usion.config.socketUrl;
2091
- token = token || Usion.user.getToken();
2219
+ // Server-clock offset estimate.
2220
+ if (this._useServerTime && typeof serverTime === 'number') {
2221
+ const o = arrival - serverTime;
2222
+ this._offset = this._offset == null ? o : this._offset + 0.05 * (o - this._offset);
2223
+ }
2092
2224
 
2093
- if (!socketUrl) {
2094
- return Promise.reject(new Error('No socket URL provided'));
2225
+ // Stamp the time used for the interpolation domain.
2226
+ const time = (this._useServerTime && typeof serverTime === 'number') ? serverTime : arrival;
2227
+ this.vault.add({ time, state });
2228
+ }
2229
+
2230
+ _renderTime() {
2231
+ if (this._useServerTime && this._offset != null) return (this._now() - this._offset) - this._bufferMs;
2232
+ return this._now() - this._bufferMs;
2233
+ }
2234
+
2235
+ /**
2236
+ * Interpolated entities for the current render instant.
2237
+ * @param {string} keys e.g. 'x y' or 'x y angle(deg)'
2238
+ * @param {string} [group]
2239
+ * @returns {Array|null}
2240
+ */
2241
+ calc(keys, group) {
2242
+ const renderTime = this._renderTime();
2243
+ const latest = this.vault.latest();
2244
+ if (!latest) return null;
2245
+ const specs = parseKeys(keys);
2246
+
2247
+ // Buffer underrun: render time is ahead of the newest snapshot.
2248
+ if (renderTime > latest.time) {
2249
+ if (this._extrapolationMs > 0) return this._extrapolate(renderTime, specs, group);
2250
+ return asEntityArray(latest.state, group).map((e) => Object.assign({}, e));
2251
+ }
2252
+
2253
+ const [older, newer] = this.vault.straddle(renderTime);
2254
+ if (!older || !newer) return null;
2255
+
2256
+ let t = 0;
2257
+ if (newer.time !== older.time) {
2258
+ t = (renderTime - older.time) / (newer.time - older.time);
2259
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
2260
+ }
2261
+ return this._blend(asEntityArray(older.state, group), asEntityArray(newer.state, group), t, specs);
2262
+ }
2263
+
2264
+ _blend(oldEntities, newEntities, t, specs) {
2265
+ const oldById = {};
2266
+ for (let i = 0; i < oldEntities.length; i++) oldById[oldEntities[i].id] = oldEntities[i];
2267
+ return newEntities.map((nentity) => {
2268
+ const oentity = oldById[nentity.id];
2269
+ const out = Object.assign({}, nentity);
2270
+ if (oentity) {
2271
+ for (let i = 0; i < specs.length; i++) {
2272
+ const { key, type } = specs[i];
2273
+ const a = oentity[key];
2274
+ const b = nentity[key];
2275
+ if (typeof a === 'number' && typeof b === 'number') out[key] = lerpField(a, b, t, type);
2276
+ }
2095
2277
  }
2096
- if (!token) {
2097
- return Promise.reject(new Error('No auth token available'));
2278
+ return out;
2279
+ });
2280
+ }
2281
+
2282
+ _extrapolate(renderTime, specs, group) {
2283
+ const newer = this.vault.latest();
2284
+ const older = this.vault.prevLatest();
2285
+ const newEntities = asEntityArray(newer.state, group);
2286
+ if (!older || newer.time <= older.time) return newEntities.map((e) => Object.assign({}, e));
2287
+
2288
+ const dt = Math.min(renderTime - newer.time, this._extrapolationMs); // cap
2289
+ const span = newer.time - older.time;
2290
+ const oldById = {};
2291
+ const oldEntities = asEntityArray(older.state, group);
2292
+ for (let i = 0; i < oldEntities.length; i++) oldById[oldEntities[i].id] = oldEntities[i];
2293
+
2294
+ return newEntities.map((nentity) => {
2295
+ const oentity = oldById[nentity.id];
2296
+ const out = Object.assign({}, nentity);
2297
+ if (oentity) {
2298
+ for (let i = 0; i < specs.length; i++) {
2299
+ const { key } = specs[i]; // linear extrapolation (angles handled as linear here)
2300
+ const a = oentity[key];
2301
+ const b = nentity[key];
2302
+ if (typeof a === 'number' && typeof b === 'number') {
2303
+ const vel = (b - a) / span;
2304
+ out[key] = b + vel * dt;
2305
+ }
2306
+ }
2098
2307
  }
2308
+ return out;
2309
+ });
2310
+ }
2311
+ }
2099
2312
 
2100
- self._connecting = true;
2101
- self._connectPromise = new Promise(function(resolve, reject) {
2102
- // Check if socket.io-client is available
2103
- if (typeof io === 'undefined') {
2104
- // Load socket.io client
2105
- var script = document.createElement('script');
2106
- script.src = '/socket.io.min.js';
2107
- script.onload = function() {
2108
- self._initSocket(socketUrl, token, resolve, reject);
2109
- };
2110
- script.onerror = function() {
2111
- // Local file not available, try CDN as fallback
2112
- var cdnScript = document.createElement('script');
2113
- cdnScript.src = 'https://cdn.socket.io/4.7.2/socket.io.min.js';
2114
- cdnScript.onload = function() {
2115
- self._initSocket(socketUrl, token, resolve, reject);
2116
- };
2117
- cdnScript.onerror = function() {
2118
- self._connecting = false;
2119
- reject(new Error('Failed to load Socket.IO client'));
2120
- };
2121
- document.head.appendChild(cdnScript);
2122
- };
2123
- document.head.appendChild(script);
2124
- } else {
2125
- self._initSocket(socketUrl, token, resolve, reject);
2313
+ /**
2314
+ * Usion SDK Netcode Client-side prediction + server reconciliation.
2315
+ *
2316
+ * Apply local input instantly (tagged with a sequence number); when the
2317
+ * authoritative state arrives carrying the last input the server processed,
2318
+ * snap to it and replay every still-unacknowledged input on top. The local
2319
+ * player sees zero input delay while staying consistent with the server.
2320
+ *
2321
+ * Error smoothing (Overwatch / Gabriel Gambetta): hard-snapping the render
2322
+ * position on every correction looks jittery. Instead we keep the visible
2323
+ * position as `corrected + error`, where `error` is the gap a correction
2324
+ * introduced, and decay that error to zero over a few frames — so corrections
2325
+ * are smooth and, in the common case, invisible. Enable via `opts.smooth`.
2326
+ *
2327
+ * The game supplies a pure `apply(state, input) -> newState` (must not mutate).
2328
+ */
2329
+ function parseSmoothKeys(smooth) {
2330
+ if (!smooth) return null;
2331
+ let keys = smooth.keys != null ? smooth.keys : smooth;
2332
+ if (typeof keys === 'string') keys = keys.trim().split(/\s+/).filter(Boolean);
2333
+ return Array.isArray(keys) && keys.length ? keys : null;
2334
+ }
2335
+
2336
+ class Predictor {
2337
+ /**
2338
+ * @param {object} opts
2339
+ * @param {(state:any, input:any)=>any} opts.apply Pure state transition.
2340
+ * @param {any} [opts.initialState]
2341
+ * @param {{keys?:string|string[], rate?:number, snapTo?:number}|string} [opts.smooth]
2342
+ * Error-smoothing config. `keys` are numeric fields to blend; `rate` is the
2343
+ * per-frame decay (0..1, default 0.2); `snapTo` is the residual below which
2344
+ * the error is zeroed (default 0.001).
2345
+ */
2346
+ constructor(opts = {}) {
2347
+ if (typeof opts.apply !== 'function') throw new Error('Predictor requires an apply(state, input) function');
2348
+ this._apply = opts.apply;
2349
+ this._state = opts.initialState !== undefined ? opts.initialState : null;
2350
+ this._seq = 0;
2351
+ this._pending = [];
2352
+ this._smoothKeys = parseSmoothKeys(opts.smooth);
2353
+ this._smoothRate = (opts.smooth && opts.smooth.rate != null) ? opts.smooth.rate : 0.2;
2354
+ this._snapTo = (opts.smooth && opts.smooth.snapTo != null) ? opts.smooth.snapTo : 0.001;
2355
+ this._error = {};
2356
+ }
2357
+
2358
+ get state() { return this._state; }
2359
+ get pending() { return this._pending.slice(); }
2360
+ get lastSeq() { return this._seq; }
2361
+
2362
+ /** Apply an input locally and record it. Attach the returned `seq` when sending. */
2363
+ predict(input) {
2364
+ this._seq += 1;
2365
+ const seq = this._seq;
2366
+ this._pending.push({ seq, input });
2367
+ this._state = this._apply(this._state, input);
2368
+ return { seq, state: this._state };
2369
+ }
2370
+
2371
+ /**
2372
+ * Reconcile against authoritative server state.
2373
+ * @param {any} serverState Authoritative state.
2374
+ * @param {number} ackedSeq Highest input sequence the server has applied.
2375
+ * @returns {any} the corrected predicted state (authoritative + replayed inputs)
2376
+ */
2377
+ reconcile(serverState, ackedSeq) {
2378
+ const before = this._state;
2379
+ const ack = ackedSeq == null ? -1 : ackedSeq;
2380
+ this._pending = this._pending.filter((p) => p.seq > ack);
2381
+ let s = serverState;
2382
+ for (let i = 0; i < this._pending.length; i++) s = this._apply(s, this._pending[i].input);
2383
+
2384
+ // Accumulate the correction gap into the error offset for smoothing.
2385
+ if (this._smoothKeys && before && s) {
2386
+ for (let i = 0; i < this._smoothKeys.length; i++) {
2387
+ const k = this._smoothKeys[i];
2388
+ if (typeof before[k] === 'number' && typeof s[k] === 'number') {
2389
+ this._error[k] = (this._error[k] || 0) + (before[k] - s[k]);
2126
2390
  }
2127
- });
2391
+ }
2392
+ }
2393
+ this._state = s;
2394
+ return this._state;
2395
+ }
2128
2396
 
2129
- return self._connectPromise;
2130
- },
2397
+ /**
2398
+ * The state to render: corrected state plus the decaying error offset.
2399
+ * Call once per rendered frame; it decays the error toward zero.
2400
+ * @param {number} [rate] Override the per-frame decay factor.
2401
+ * @returns {any}
2402
+ */
2403
+ view(rate) {
2404
+ if (!this._smoothKeys || !this._state || typeof this._state !== 'object') return this._state;
2405
+ const r = rate != null ? rate : this._smoothRate;
2406
+ const out = Array.isArray(this._state) ? this._state.slice() : Object.assign({}, this._state);
2407
+ for (let i = 0; i < this._smoothKeys.length; i++) {
2408
+ const k = this._smoothKeys[i];
2409
+ let e = this._error[k] || 0;
2410
+ if (typeof out[k] === 'number' && e !== 0) out[k] = out[k] + e;
2411
+ e *= (1 - r);
2412
+ if (Math.abs(e) < this._snapTo) e = 0;
2413
+ this._error[k] = e;
2414
+ }
2415
+ return out;
2416
+ }
2131
2417
 
2132
- // Event handler registrations
2133
- onJoined: function(callback) { this._eventHandlers.joined = callback; },
2134
- onPlayerJoined: function(callback) { this._eventHandlers.playerJoined = callback; },
2135
- onPlayerLeft: function(callback) { this._eventHandlers.playerLeft = callback; },
2136
- onStateUpdate: function(callback) { this._eventHandlers.stateUpdate = callback; },
2137
- onSync: function(callback) { this._eventHandlers.sync = callback; },
2138
- onAction: function(callback) { this._eventHandlers.action = callback; },
2139
- onRealtime: function(callback) { this._eventHandlers.realtime = callback; },
2140
- onGameFinished: function(callback) { this._eventHandlers.finished = callback; },
2141
- onGameRestarted: function(callback) { this._eventHandlers.restarted = callback; },
2142
- onError: function(callback) { this._eventHandlers.error = callback; },
2143
- onRematchRequest: function(callback) { this._eventHandlers.rematchRequest = callback; },
2144
- onDisconnect: function(callback) { this._eventHandlers.disconnect = callback; },
2145
- onReconnect: function(callback) { this._eventHandlers.reconnect = callback; },
2146
- onConnectionError: function(callback) { this._eventHandlers.connectionError = callback; },
2418
+ /** Hard reset (e.g. on rematch). */
2419
+ reset(state) {
2420
+ this._state = state !== undefined ? state : null;
2421
+ this._seq = 0;
2422
+ this._pending = [];
2423
+ this._error = {};
2424
+ }
2425
+ }
2147
2426
 
2148
- /**
2149
- * Register a generic event handler
2150
- * @param {string} event - Event name
2151
- * @param {function} callback - Handler function
2152
- */
2153
- on: function(event, callback) {
2154
- if (this.socket) {
2155
- this.socket.on(event, callback);
2427
+ /**
2428
+ * Usion SDK Netcode — Outbound send coalescing at a fixed tick rate.
2429
+ *
2430
+ * Games tend to emit on every input event or every animation frame (often
2431
+ * 60+/s). Each emit is a separate Socket.IO message and, in platform mode,
2432
+ * a separate server-side broadcast (and historically a Redis write). That
2433
+ * floods the wire and the server far beyond what gameplay needs.
2434
+ *
2435
+ * The Coalescer buffers outbound messages and flushes them on a fixed tick
2436
+ * (e.g. 20 Hz). Two modes per channel:
2437
+ * - queue(type, data): latest-wins — only the newest value per type is sent
2438
+ * each tick. Ideal for state snapshots, cursor/position updates.
2439
+ * - append(type, data): buffered — every value is kept and sent as a batch.
2440
+ * Use for discrete inputs you can't afford to drop.
2441
+ *
2442
+ * `onFlush(entries)` receives `[{ type, data }]` in insertion order. The
2443
+ * Coalescer is transport-agnostic; the caller decides how to send each entry
2444
+ * (game.realtime, game.action, a WebRTC datachannel, etc.).
2445
+ */
2446
+ class Coalescer {
2447
+ /**
2448
+ * @param {object} opts
2449
+ * @param {number} [opts.hz=20] Flush frequency.
2450
+ * @param {(entries:Array<{type:string,data:any}>)=>void} opts.onFlush
2451
+ * @param {boolean} [opts.autoStart=true]
2452
+ * @param {Function} [opts.setInterval] Injectable for tests.
2453
+ * @param {Function} [opts.clearInterval]
2454
+ */
2455
+ constructor(opts = {}) {
2456
+ if (typeof opts.onFlush !== 'function') {
2457
+ throw new Error('Coalescer requires an onFlush(entries) callback');
2458
+ }
2459
+ this._onFlush = opts.onFlush;
2460
+ this._hz = opts.hz || 20;
2461
+ this._setInterval = opts.setInterval || (typeof setInterval !== 'undefined' ? setInterval : null);
2462
+ this._clearInterval = opts.clearInterval || (typeof clearInterval !== 'undefined' ? clearInterval : null);
2463
+ this._order = []; // slot keys in first-seen order
2464
+ this._slots = {}; // key -> { mode:'latest'|'list', value, list }
2465
+ this._timer = null;
2466
+ if (opts.autoStart !== false) this.start();
2467
+ }
2468
+
2469
+ get running() { return this._timer !== null; }
2470
+
2471
+ _ensure(type, mode) {
2472
+ let slot = this._slots[type];
2473
+ if (!slot) {
2474
+ slot = { mode, value: undefined, list: [] };
2475
+ this._slots[type] = slot;
2476
+ this._order.push(type);
2477
+ }
2478
+ return slot;
2479
+ }
2480
+
2481
+ /** Latest-wins: only the newest data for `type` is sent on the next flush. */
2482
+ queue(type, data) {
2483
+ const slot = this._ensure(type, 'latest');
2484
+ slot.mode = 'latest';
2485
+ slot.value = data;
2486
+ }
2487
+
2488
+ /** Buffered: every value for `type` is kept and sent on the next flush. */
2489
+ append(type, data) {
2490
+ const slot = this._ensure(type, 'list');
2491
+ slot.mode = 'list';
2492
+ slot.list.push(data);
2493
+ }
2494
+
2495
+ /** Build pending entries (insertion order) and clear the buffer. */
2496
+ drain() {
2497
+ const entries = [];
2498
+ for (let i = 0; i < this._order.length; i++) {
2499
+ const type = this._order[i];
2500
+ const slot = this._slots[type];
2501
+ if (slot.mode === 'latest') {
2502
+ if (slot.value !== undefined) entries.push({ type, data: slot.value });
2503
+ } else {
2504
+ for (let j = 0; j < slot.list.length; j++) entries.push({ type, data: slot.list[j] });
2156
2505
  }
2157
2506
  }
2158
- };
2507
+ this._order = [];
2508
+ this._slots = {};
2509
+ return entries;
2510
+ }
2159
2511
 
2160
- // Apply sub-modules
2161
- applyGameDirect(game, Usion);
2162
- applyGameSocket(game, Usion);
2163
- applyGameProxy(game, Usion);
2164
- applyGameMethods(game, Usion);
2512
+ /** Flush now (also called automatically on each tick). */
2513
+ flush() {
2514
+ if (this._order.length === 0) return;
2515
+ const entries = this.drain();
2516
+ if (entries.length) this._onFlush(entries);
2517
+ }
2165
2518
 
2166
- return game;
2519
+ start() {
2520
+ if (this._timer || !this._setInterval) return;
2521
+ this._timer = this._setInterval(() => this.flush(), Math.max(1, Math.round(1000 / this._hz)));
2522
+ }
2523
+
2524
+ stop() {
2525
+ if (this._timer && this._clearInterval) this._clearInterval(this._timer);
2526
+ this._timer = null;
2527
+ }
2167
2528
  }
2168
2529
 
2530
+ /**
2531
+ * Usion SDK Netcode — Round-trip-time meter.
2532
+ *
2533
+ * Tracks RTT with an exponentially-weighted moving average plus a jitter
2534
+ * estimate, so games (and the debug overlay) can surface real latency and so
2535
+ * the interpolation buffer can be tuned to network conditions. Transport
2536
+ * agnostic: feed it raw samples, or use begin()/end() to time individual
2537
+ * probes.
2538
+ */
2539
+ class PingMeter {
2540
+ /**
2541
+ * @param {object} [opts]
2542
+ * @param {number} [opts.alpha=0.2] EWMA smoothing factor (0..1).
2543
+ * @param {Function} [opts.now] Clock source (default Date.now).
2544
+ */
2545
+ constructor(opts = {}) {
2546
+ this._alpha = opts.alpha != null ? opts.alpha : 0.2;
2547
+ this._now = opts.now || (() => Date.now());
2548
+ this._rtt = null;
2549
+ this._jitter = 0;
2550
+ this._last = null;
2551
+ this._id = 0;
2552
+ this._outstanding = {};
2553
+ }
2554
+
2555
+ /** Smoothed round-trip time in ms (null until the first sample). */
2556
+ get rtt() { return this._rtt; }
2557
+ /** One-way latency estimate (≈ rtt / 2). */
2558
+ get latency() { return this._rtt == null ? null : this._rtt / 2; }
2559
+ /** Smoothed absolute variation between samples (ms). */
2560
+ get jitter() { return this._jitter; }
2561
+ /** Most recent raw RTT sample (ms). */
2562
+ get last() { return this._last; }
2563
+
2564
+ /** Start timing a probe; pass the returned id to end(). */
2565
+ begin() {
2566
+ const id = ++this._id;
2567
+ this._outstanding[id] = this._now();
2568
+ return id;
2569
+ }
2570
+
2571
+ /** Complete a probe started with begin(); returns the RTT sample or null. */
2572
+ end(id) {
2573
+ const sent = this._outstanding[id];
2574
+ if (sent == null) return null;
2575
+ delete this._outstanding[id];
2576
+ return this.sample(this._now() - sent);
2577
+ }
2578
+
2579
+ /** Feed a raw RTT sample (ms). Returns the same value. */
2580
+ sample(rttMs) {
2581
+ if (!(rttMs >= 0)) return null;
2582
+ if (this._rtt == null) {
2583
+ this._rtt = rttMs;
2584
+ } else {
2585
+ this._jitter += this._alpha * (Math.abs(rttMs - this._rtt) - this._jitter);
2586
+ this._rtt += this._alpha * (rttMs - this._rtt);
2587
+ }
2588
+ this._last = rttMs;
2589
+ return rttMs;
2590
+ }
2591
+
2592
+ reset() {
2593
+ this._rtt = null;
2594
+ this._jitter = 0;
2595
+ this._last = null;
2596
+ this._outstanding = {};
2597
+ }
2598
+ }
2599
+
2600
+ /**
2601
+ * Usion SDK Netcode — WebRTC peer-to-peer data channels.
2602
+ *
2603
+ * WebSocket runs over TCP (ordered+reliable) → one lost packet stalls
2604
+ * everything behind it (head-of-line blocking). WebRTC data channels run over
2605
+ * UDP/SCTP and can be unreliable+unordered, so the newest state always gets
2606
+ * through. Gameplay flows peer-to-peer (no backend hop, no HOL blocking).
2607
+ *
2608
+ * Practices adopted (WebRTC game-networking guidance):
2609
+ * - **Two channels**: 'unreliable' (ordered:false, maxRetransmits:0) for
2610
+ * gameplay, 'reliable' (ordered:true) for must-arrive events.
2611
+ * - **Sequence numbers** on the unreliable channel → drop stale/out-of-order
2612
+ * frames (the receiver only ever advances).
2613
+ * - **TURN-ready**: ~15–20% of sessions need a relay; pass iceServers (use the
2614
+ * `MeshConnection.iceServers()` helper). Trickle ICE is used by default.
2615
+ * - **Reconnect**: monitor (ice)connectionState and recover via ICE restart.
2616
+ *
2617
+ * Signaling I/O is injected, so the same class works in every connection mode
2618
+ * and is testable with a fake RTCPeerConnection.
2619
+ */
2620
+ const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
2621
+
2622
+ function isBinary$1(d) {
2623
+ return d instanceof ArrayBuffer || ArrayBuffer.isView(d);
2624
+ }
2625
+
2626
+ class MeshConnection {
2627
+ /**
2628
+ * @param {object} opts
2629
+ * @param {'host'|'guest'} opts.role
2630
+ * @param {(payload:object)=>void} opts.sendSignal
2631
+ * @param {Array} [opts.iceServers]
2632
+ * @param {boolean} [opts.sequenced=true] Drop stale frames on the unreliable channel.
2633
+ * @param {boolean} [opts.autoReconnect=true] Recover via ICE restart (host).
2634
+ * @param {number} [opts.maxRestarts=5]
2635
+ * @param {Function} [opts.RTCPeerConnection] Injectable for tests.
2636
+ * @param {Function} [opts.setTimeout] Injectable for tests.
2637
+ */
2638
+ constructor(opts = {}) {
2639
+ if (opts.role !== 'host' && opts.role !== 'guest') throw new Error("MeshConnection requires role 'host' or 'guest'");
2640
+ if (typeof opts.sendSignal !== 'function') throw new Error('MeshConnection requires a sendSignal(payload) function');
2641
+ this.role = opts.role;
2642
+ this._sendSignal = opts.sendSignal;
2643
+ this._iceServers = opts.iceServers || DEFAULT_ICE;
2644
+ this._sequenced = opts.sequenced !== false;
2645
+ this._autoReconnect = opts.autoReconnect !== false;
2646
+ this._maxRestarts = opts.maxRestarts != null ? opts.maxRestarts : 5;
2647
+ this._RTCPeerConnection = opts.RTCPeerConnection || (typeof RTCPeerConnection !== 'undefined' ? RTCPeerConnection : null);
2648
+ this._setTimeout = opts.setTimeout || (typeof setTimeout !== 'undefined' ? setTimeout : null);
2649
+
2650
+ this.connected = false;
2651
+ this._pc = null;
2652
+ this._unreliable = null;
2653
+ this._reliable = null;
2654
+ this._sendSeq = 0;
2655
+ this._recvSeq = 0;
2656
+ this._restarts = 0;
2657
+
2658
+ this.onOpen = null; // () => void
2659
+ this.onMessage = null; // (data, channel) => void
2660
+ this.onClose = null; // () => void
2661
+ this.onError = null; // (err) => void
2662
+ this.onStateChange = null; // (state:string) => void
2663
+ }
2664
+
2665
+ /** Build an iceServers array with optional TURN relay. */
2666
+ static iceServers(cfg = {}) {
2667
+ const list = [{ urls: cfg.stun || 'stun:stun.l.google.com:19302' }];
2668
+ if (cfg.turn) {
2669
+ const entry = { urls: cfg.turn };
2670
+ if (cfg.turnUsername) entry.username = cfg.turnUsername;
2671
+ if (cfg.turnCredential) entry.credential = cfg.turnCredential;
2672
+ list.push(entry);
2673
+ }
2674
+ return list;
2675
+ }
2676
+
2677
+ async start() {
2678
+ if (!this._RTCPeerConnection) throw new Error('RTCPeerConnection unavailable');
2679
+ const pc = new this._RTCPeerConnection({ iceServers: this._iceServers });
2680
+ this._pc = pc;
2681
+
2682
+ pc.onicecandidate = (e) => { if (e && e.candidate) this._sendSignal({ type: 'ice', candidate: e.candidate }); };
2683
+ const onState = () => this._handleStateChange(pc.connectionState || pc.iceConnectionState);
2684
+ pc.onconnectionstatechange = onState;
2685
+ pc.oniceconnectionstatechange = onState;
2686
+
2687
+ if (this.role === 'host') {
2688
+ this._bindChannel((this._unreliable = pc.createDataChannel('unreliable', { ordered: false, maxRetransmits: 0 })), 'unreliable');
2689
+ this._bindChannel((this._reliable = pc.createDataChannel('reliable', { ordered: true })), 'reliable');
2690
+ await this._makeOffer(false);
2691
+ } else {
2692
+ pc.ondatachannel = (e) => {
2693
+ const ch = e.channel;
2694
+ if (ch.label === 'unreliable') this._bindChannel((this._unreliable = ch), 'unreliable');
2695
+ else if (ch.label === 'reliable') this._bindChannel((this._reliable = ch), 'reliable');
2696
+ };
2697
+ }
2698
+ }
2699
+
2700
+ async _makeOffer(iceRestart) {
2701
+ const pc = this._pc;
2702
+ const offer = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined);
2703
+ await pc.setLocalDescription(offer);
2704
+ this._sendSignal({ type: 'offer', sdp: pc.localDescription, restart: !!iceRestart });
2705
+ }
2706
+
2707
+ _handleStateChange(state) {
2708
+ if (this.onStateChange) this.onStateChange(state);
2709
+ if (state === 'connected') { this._restarts = 0; return; }
2710
+ if (state === 'failed' || state === 'disconnected' || state === 'closed') {
2711
+ if (this.connected) { this.connected = false; if (this.onClose) this.onClose(); }
2712
+ if (this._autoReconnect && this.role === 'host' && state !== 'closed' && this._restarts < this._maxRestarts) {
2713
+ this._restarts += 1;
2714
+ const delay = Math.min(1000 * this._restarts, 5000);
2715
+ if (this._setTimeout) this._setTimeout(() => { this._makeOffer(true).catch((err) => { if (this.onError) this.onError(err); }); }, delay);
2716
+ }
2717
+ }
2718
+ }
2719
+
2720
+ async handleSignal(payload) {
2721
+ const pc = this._pc;
2722
+ if (!pc || !payload || !payload.type) return;
2723
+ try {
2724
+ if (payload.type === 'offer') {
2725
+ await pc.setRemoteDescription(payload.sdp);
2726
+ const answer = await pc.createAnswer();
2727
+ await pc.setLocalDescription(answer);
2728
+ this._sendSignal({ type: 'answer', sdp: pc.localDescription });
2729
+ } else if (payload.type === 'answer') {
2730
+ await pc.setRemoteDescription(payload.sdp);
2731
+ } else if (payload.type === 'ice' && payload.candidate) {
2732
+ await pc.addIceCandidate(payload.candidate);
2733
+ }
2734
+ } catch (err) {
2735
+ if (this.onError) this.onError(err);
2736
+ }
2737
+ }
2738
+
2739
+ _bindChannel(ch, label) {
2740
+ ch.binaryType = 'arraybuffer';
2741
+ ch.onopen = () => {
2742
+ if (label === 'unreliable' && !this.connected) { this.connected = true; if (this.onOpen) this.onOpen(); }
2743
+ };
2744
+ ch.onmessage = (e) => {
2745
+ if (!this.onMessage) return;
2746
+ let data = e.data;
2747
+ if (isBinary$1(data)) { this.onMessage(data, label); return; }
2748
+ try { data = typeof data === 'string' ? JSON.parse(data) : data; } catch (_) { this.onMessage(e.data, label); return; }
2749
+ if (label === 'unreliable' && this._sequenced && data && typeof data.__s === 'number') {
2750
+ if (data.__s <= this._recvSeq) return; // stale / out-of-order — drop
2751
+ this._recvSeq = data.__s;
2752
+ this.onMessage(data.m, label);
2753
+ return;
2754
+ }
2755
+ this.onMessage(data, label);
2756
+ };
2757
+ ch.onclose = () => { if (this.connected) { this.connected = false; if (this.onClose) this.onClose(); } };
2758
+ ch.onerror = (e) => { if (this.onError) this.onError(e); };
2759
+ }
2760
+
2761
+ /** Send over the unreliable/unordered channel (sequenced unless binary). */
2762
+ send(data) {
2763
+ const ch = this._unreliable;
2764
+ if (!ch || ch.readyState !== 'open') return false;
2765
+ if (isBinary$1(data)) { ch.send(data); return true; }
2766
+ if (this._sequenced) { this._sendSeq += 1; ch.send(JSON.stringify({ __s: this._sendSeq, m: data })); }
2767
+ else ch.send(typeof data === 'string' ? data : JSON.stringify(data));
2768
+ return true;
2769
+ }
2770
+
2771
+ /** Send over the reliable/ordered channel. */
2772
+ sendReliable(data) {
2773
+ const ch = this._reliable;
2774
+ if (!ch || ch.readyState !== 'open') return false;
2775
+ if (isBinary$1(data)) ch.send(data);
2776
+ else ch.send(typeof data === 'string' ? data : JSON.stringify(data));
2777
+ return true;
2778
+ }
2779
+
2780
+ close() {
2781
+ try { if (this._unreliable) this._unreliable.close(); } catch (_) {}
2782
+ try { if (this._reliable) this._reliable.close(); } catch (_) {}
2783
+ try { if (this._pc) this._pc.close(); } catch (_) {}
2784
+ this._unreliable = this._reliable = this._pc = null;
2785
+ this.connected = false;
2786
+ }
2787
+ }
2788
+
2789
+ /**
2790
+ * Usion SDK Netcode — N-peer full mesh.
2791
+ *
2792
+ * Manages one MeshConnection per remote peer so >2 players can talk directly
2793
+ * peer-to-peer. For each pair, the peer with the lexicographically smaller id
2794
+ * is the host (deterministic role assignment avoids offer/answer "glare").
2795
+ * Signaling is routed per-peer (`sendSignal(toPeerId, payload)`); feed inbound
2796
+ * signaling with `handleSignal(fromPeerId, payload)`.
2797
+ */
2798
+
2799
+ class MeshNetwork {
2800
+ /**
2801
+ * @param {object} opts
2802
+ * @param {string} opts.selfId
2803
+ * @param {(toPeerId:string, payload:object)=>void} opts.sendSignal
2804
+ * @param {Array} [opts.iceServers]
2805
+ * @param {Function} [opts.RTCPeerConnection]
2806
+ * @param {Function} [opts.setTimeout]
2807
+ * @param {boolean} [opts.sequenced]
2808
+ * @param {boolean} [opts.autoReconnect]
2809
+ */
2810
+ constructor(opts = {}) {
2811
+ if (!opts.selfId) throw new Error('MeshNetwork requires a selfId');
2812
+ if (typeof opts.sendSignal !== 'function') throw new Error('MeshNetwork requires sendSignal(toPeerId, payload)');
2813
+ this.selfId = opts.selfId;
2814
+ this._sendSignal = opts.sendSignal;
2815
+ this._opts = opts;
2816
+ this._peers = {}; // peerId -> MeshConnection
2817
+
2818
+ this.onPeerOpen = null; // (peerId) => void
2819
+ this.onPeerClose = null; // (peerId) => void
2820
+ this.onMessage = null; // (peerId, data, channel) => void
2821
+ this.onError = null; // (peerId, err) => void
2822
+ }
2823
+
2824
+ get peerIds() { return Object.keys(this._peers); }
2825
+ get connectedCount() { let n = 0; for (const id in this._peers) if (this._peers[id].connected) n += 1; return n; }
2826
+ peer(peerId) { return this._peers[peerId] || null; }
2827
+
2828
+ _roleFor(peerId) { return this.selfId < peerId ? 'host' : 'guest'; }
2829
+
2830
+ _ensurePeer(peerId) {
2831
+ if (this._peers[peerId]) return this._peers[peerId];
2832
+ const self = this;
2833
+ const conn = new MeshConnection({
2834
+ role: this._roleFor(peerId),
2835
+ iceServers: this._opts.iceServers,
2836
+ RTCPeerConnection: this._opts.RTCPeerConnection,
2837
+ setTimeout: this._opts.setTimeout,
2838
+ sequenced: this._opts.sequenced,
2839
+ autoReconnect: this._opts.autoReconnect,
2840
+ sendSignal: (payload) => self._sendSignal(peerId, payload),
2841
+ });
2842
+ conn.onOpen = () => { if (self.onPeerOpen) self.onPeerOpen(peerId); };
2843
+ conn.onClose = () => { if (self.onPeerClose) self.onPeerClose(peerId); };
2844
+ conn.onMessage = (data, channel) => { if (self.onMessage) self.onMessage(peerId, data, channel); };
2845
+ conn.onError = (err) => { if (self.onError) self.onError(peerId, err); };
2846
+ this._peers[peerId] = conn;
2847
+ return conn;
2848
+ }
2849
+
2850
+ /** Connect to a peer (creates the connection and starts negotiation). */
2851
+ async addPeer(peerId) {
2852
+ if (peerId === this.selfId) return null;
2853
+ const conn = this._ensurePeer(peerId);
2854
+ await conn.start();
2855
+ return conn;
2856
+ }
2857
+
2858
+ /** Sync the mesh to a roster of peer ids: connect to new ones, drop missing. */
2859
+ async setRoster(peerIds) {
2860
+ const want = {};
2861
+ for (let i = 0; i < peerIds.length; i++) if (peerIds[i] !== this.selfId) want[peerIds[i]] = true;
2862
+ for (const id in want) if (!this._peers[id]) await this.addPeer(id);
2863
+ for (const id in this._peers) if (!want[id]) this.removePeer(id);
2864
+ }
2865
+
2866
+ removePeer(peerId) {
2867
+ const conn = this._peers[peerId];
2868
+ if (!conn) return;
2869
+ try { conn.close(); } catch (_) {}
2870
+ delete this._peers[peerId];
2871
+ }
2872
+
2873
+ /** Route an inbound signaling message from a peer. */
2874
+ async handleSignal(fromPeerId, payload) {
2875
+ if (!fromPeerId || fromPeerId === this.selfId) return;
2876
+ const conn = this._ensurePeer(fromPeerId);
2877
+ if (!conn._pc) await conn.start(); // lazily start guest side on first offer
2878
+ await conn.handleSignal(payload);
2879
+ }
2880
+
2881
+ /** Send to one peer over the unreliable channel. */
2882
+ send(peerId, data) { const c = this._peers[peerId]; return c ? c.send(data) : false; }
2883
+ sendReliable(peerId, data) { const c = this._peers[peerId]; return c ? c.sendReliable(data) : false; }
2884
+
2885
+ /** Broadcast to all connected peers (unreliable). */
2886
+ broadcast(data) { for (const id in this._peers) this._peers[id].send(data); }
2887
+ broadcastReliable(data) { for (const id in this._peers) this._peers[id].sendReliable(data); }
2888
+
2889
+ close() { for (const id in this._peers) { try { this._peers[id].close(); } catch (_) {} } this._peers = {}; }
2890
+ }
2891
+
2892
+ /**
2893
+ * Usion SDK Netcode — WebTransport (HTTP/3 / QUIC) client transport.
2894
+ *
2895
+ * The lowest-latency *client-server* path for browser games (Baseline across
2896
+ * major browsers since 2026). Unlike WebSocket (TCP → head-of-line blocking)
2897
+ * it offers UDP-like unreliable **datagrams** — the newest snapshot always
2898
+ * gets through — plus reliable **streams** for must-arrive events, over a
2899
+ * single QUIC connection, without WebRTC's ICE/STUN/TURN/SDP complexity.
2900
+ *
2901
+ * Uses the native `WebTransport` API (zero dependency). On the server, pair
2902
+ * with an HTTP/3 server such as the open-source `@fails-components/webtransport`
2903
+ * (Node). Same interface shape as MeshConnection so it drops into the same
2904
+ * snapshot sender/receiver + interpolation pipeline.
2905
+ *
2906
+ * Framing:
2907
+ * - datagram = [seq:uint32 BE][type:uint8][payload] (one datagram = one msg)
2908
+ * - stream = [len:uint32 BE][type:uint8][payload] (len = 1 + payload bytes)
2909
+ * - type 0 = binary (Uint8Array), 1 = JSON (utf8). Sequenced datagrams drop
2910
+ * stale/out-of-order frames (receiver only advances).
2911
+ */
2912
+ const _enc$1 = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
2913
+ const _dec$1 = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
2914
+
2915
+ function toBytes(s) { return _enc$1 ? _enc$1.encode(s) : Uint8Array.from(Buffer.from(s, 'utf8')); }
2916
+ function fromBytes(b) { return _dec$1 ? _dec$1.decode(b) : Buffer.from(b).toString('utf8'); }
2917
+ function isBinary(d) { return d instanceof ArrayBuffer || ArrayBuffer.isView(d); }
2918
+
2919
+ /** Encode a value to { type, bytes }: binary passthrough or JSON utf8. */
2920
+ function encodePayload(data) {
2921
+ if (isBinary(data)) return { type: 0, bytes: data instanceof Uint8Array ? data : new Uint8Array(data.buffer || data) };
2922
+ return { type: 1, bytes: toBytes(JSON.stringify(data)) };
2923
+ }
2924
+ function decodePayload(type, bytes) {
2925
+ if (type === 1) { try { return JSON.parse(fromBytes(bytes)); } catch (_) { return null; } }
2926
+ return bytes;
2927
+ }
2928
+
2929
+ /** datagram frame: [seq:4][type:1][payload] */
2930
+ function encodeDatagram(seq, data) {
2931
+ const { type, bytes } = encodePayload(data);
2932
+ const out = new Uint8Array(5 + bytes.length);
2933
+ new DataView(out.buffer).setUint32(0, seq >>> 0, false);
2934
+ out[4] = type;
2935
+ out.set(bytes, 5);
2936
+ return out;
2937
+ }
2938
+ function decodeDatagram(frame) {
2939
+ const b = frame instanceof Uint8Array ? frame : new Uint8Array(frame);
2940
+ if (b.length < 5) return null;
2941
+ const seq = new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(0, false);
2942
+ return { seq: seq, value: decodePayload(b[4], b.subarray(5)) };
2943
+ }
2944
+
2945
+ /** stream frame: [len:4][type:1][payload], len = 1 + payload length */
2946
+ function encodeStreamFrame(data) {
2947
+ const { type, bytes } = encodePayload(data);
2948
+ const out = new Uint8Array(4 + 1 + bytes.length);
2949
+ new DataView(out.buffer).setUint32(0, 1 + bytes.length, false);
2950
+ out[4] = type;
2951
+ out.set(bytes, 5);
2952
+ return out;
2953
+ }
2954
+
2955
+ /** Stateful deframer for the reliable byte stream. push(bytes) → [values]. */
2956
+ class StreamDeframer {
2957
+ constructor() { this._buf = new Uint8Array(0); }
2958
+ push(chunk) {
2959
+ const c = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
2960
+ const merged = new Uint8Array(this._buf.length + c.length);
2961
+ merged.set(this._buf, 0); merged.set(c, this._buf.length);
2962
+ this._buf = merged;
2963
+ const out = [];
2964
+ while (this._buf.length >= 4) {
2965
+ const len = new DataView(this._buf.buffer, this._buf.byteOffset, 4).getUint32(0, false);
2966
+ if (this._buf.length < 4 + len) break;
2967
+ const type = this._buf[4];
2968
+ const payload = this._buf.subarray(5, 4 + len);
2969
+ out.push(decodePayload(type, payload.slice()));
2970
+ this._buf = this._buf.subarray(4 + len);
2971
+ }
2972
+ return out;
2973
+ }
2974
+ }
2975
+
2976
+ class WebTransportConnection {
2977
+ /**
2978
+ * @param {object} opts
2979
+ * @param {string} opts.url https:// HTTP/3 endpoint
2980
+ * @param {Array} [opts.serverCertificateHashes] for self-signed dev certs
2981
+ * @param {boolean}[opts.sequenced=true] drop stale datagrams
2982
+ * @param {Function}[opts.WebTransport] injectable for tests
2983
+ */
2984
+ constructor(opts = {}) {
2985
+ if (!opts.url) throw new Error('WebTransportConnection requires a url');
2986
+ this._url = opts.url;
2987
+ this._opts = opts;
2988
+ this._sequenced = opts.sequenced !== false;
2989
+ this._WT = opts.WebTransport || (typeof WebTransport !== 'undefined' ? WebTransport : null);
2990
+
2991
+ this.connected = false;
2992
+ this._t = null;
2993
+ this._dgWriter = null;
2994
+ this._streamWriter = null;
2995
+ this._deframer = new StreamDeframer();
2996
+ this._sendSeq = 0;
2997
+ this._recvSeq = 0;
2998
+
2999
+ this.onOpen = null; // () => void
3000
+ this.onMessage = null; // (data, channel:'datagram'|'reliable') => void
3001
+ this.onClose = null; // () => void
3002
+ this.onError = null; // (err) => void
3003
+ }
3004
+
3005
+ async connect() {
3006
+ if (!this._WT) throw new Error('WebTransport unavailable in this environment');
3007
+ const init = {};
3008
+ if (this._opts.serverCertificateHashes) init.serverCertificateHashes = this._opts.serverCertificateHashes;
3009
+ const t = new this._WT(this._url, init);
3010
+ this._t = t;
3011
+ if (t.ready && typeof t.ready.then === 'function') await t.ready;
3012
+
3013
+ if (t.datagrams && t.datagrams.writable) this._dgWriter = t.datagrams.writable.getWriter();
3014
+ if (typeof t.createBidirectionalStream === 'function') {
3015
+ try {
3016
+ const s = await t.createBidirectionalStream();
3017
+ this._streamWriter = s.writable.getWriter();
3018
+ this._pumpReadable(s.readable, 'reliable');
3019
+ } catch (e) { /* reliable stream optional */ }
3020
+ }
3021
+ if (t.datagrams && t.datagrams.readable) this._pumpDatagrams(t.datagrams.readable);
3022
+ if (t.closed && typeof t.closed.then === 'function') t.closed.then(() => this._onClosed(), () => this._onClosed());
3023
+
3024
+ this.connected = true;
3025
+ if (this.onOpen) this.onOpen();
3026
+ }
3027
+
3028
+ async _pumpDatagrams(readable) {
3029
+ try {
3030
+ const reader = readable.getReader();
3031
+ for (;;) {
3032
+ const { value, done } = await reader.read();
3033
+ if (done) break;
3034
+ if (value) this._onDatagramBytes(value);
3035
+ }
3036
+ } catch (e) { if (this.onError) this.onError(e); }
3037
+ }
3038
+
3039
+ async _pumpReadable(readable, channel) {
3040
+ try {
3041
+ const reader = readable.getReader();
3042
+ for (;;) {
3043
+ const { value, done } = await reader.read();
3044
+ if (done) break;
3045
+ if (value) { const msgs = this._deframer.push(value); for (let i = 0; i < msgs.length; i++) if (this.onMessage) this.onMessage(msgs[i], channel); }
3046
+ }
3047
+ } catch (e) { if (this.onError) this.onError(e); }
3048
+ }
3049
+
3050
+ /** Handle one inbound datagram frame (also the test entry point). */
3051
+ _onDatagramBytes(frame) {
3052
+ const parsed = decodeDatagram(frame);
3053
+ if (!parsed) return;
3054
+ if (this._sequenced) {
3055
+ if (parsed.seq <= this._recvSeq) return; // stale / out-of-order
3056
+ this._recvSeq = parsed.seq;
3057
+ }
3058
+ if (this.onMessage) this.onMessage(parsed.value, 'datagram');
3059
+ }
3060
+
3061
+ /** Send over the unreliable datagram channel (sequenced). */
3062
+ send(data) {
3063
+ if (!this._dgWriter) return false;
3064
+ this._sendSeq += 1;
3065
+ try { this._dgWriter.write(encodeDatagram(this._sendSeq, data)); return true; }
3066
+ catch (e) { return false; }
3067
+ }
3068
+
3069
+ /** Send over the reliable ordered stream. */
3070
+ sendReliable(data) {
3071
+ if (!this._streamWriter) return false;
3072
+ try { this._streamWriter.write(encodeStreamFrame(data)); return true; }
3073
+ catch (e) { return false; }
3074
+ }
3075
+
3076
+ _onClosed() {
3077
+ if (this.connected) { this.connected = false; if (this.onClose) this.onClose(); }
3078
+ }
3079
+
3080
+ close() {
3081
+ try { if (this._dgWriter) this._dgWriter.releaseLock && this._dgWriter.releaseLock(); } catch (_) {}
3082
+ try { if (this._t) this._t.close(); } catch (_) {}
3083
+ this._t = this._dgWriter = this._streamWriter = null;
3084
+ this.connected = false;
3085
+ }
3086
+ }
3087
+
3088
+ /**
3089
+ * Usion SDK Netcode — network condition simulator.
3090
+ *
3091
+ * Every serious game studio tests under degraded networks. NetworkSim wraps a
3092
+ * send (or receive) function and injects artificial **latency**, **jitter**,
3093
+ * **packet loss**, and **duplication** so creators can feel and tune their game
3094
+ * on a bad connection — locally, before shipping. Pure and deterministic when
3095
+ * you inject `random` / `setTimeout` (used in tests).
3096
+ */
3097
+ class NetworkSim {
3098
+ /**
3099
+ * @param {object} [opts]
3100
+ * @param {number} [opts.latencyMs=0] Base one-way delay added to each message.
3101
+ * @param {number} [opts.jitterMs=0] Random +/- variation around the latency.
3102
+ * @param {number} [opts.lossPct=0] Probability (0–100) a message is dropped.
3103
+ * @param {number} [opts.dupPct=0] Probability (0–100) a message is duplicated.
3104
+ * @param {Function} [opts.setTimeout] Injectable scheduler (tests).
3105
+ * @param {Function} [opts.clearTimeout]
3106
+ * @param {Function} [opts.random] Injectable RNG (tests).
3107
+ */
3108
+ constructor(opts = {}) {
3109
+ this._set = opts.setTimeout || (typeof setTimeout !== 'undefined' ? setTimeout : null);
3110
+ this._clear = opts.clearTimeout || (typeof clearTimeout !== 'undefined' ? clearTimeout : null);
3111
+ this._rand = opts.random || Math.random;
3112
+ this._timers = new Set();
3113
+ this.set(opts);
3114
+ }
3115
+
3116
+ /** Update conditions live. */
3117
+ set(opts = {}) {
3118
+ if (opts.latencyMs != null) this.latencyMs = Math.max(0, opts.latencyMs);
3119
+ else if (this.latencyMs == null) this.latencyMs = 0;
3120
+ if (opts.jitterMs != null) this.jitterMs = Math.max(0, opts.jitterMs);
3121
+ else if (this.jitterMs == null) this.jitterMs = 0;
3122
+ if (opts.lossPct != null) this.lossPct = Math.max(0, Math.min(100, opts.lossPct));
3123
+ else if (this.lossPct == null) this.lossPct = 0;
3124
+ if (opts.dupPct != null) this.dupPct = Math.max(0, Math.min(100, opts.dupPct));
3125
+ else if (this.dupPct == null) this.dupPct = 0;
3126
+ return this;
3127
+ }
3128
+
3129
+ /** Delay for one message: latency ± jitter (never negative). */
3130
+ _delay() {
3131
+ const j = this.jitterMs ? (this._rand() * 2 - 1) * this.jitterMs : 0;
3132
+ const d = this.latencyMs + j;
3133
+ return d > 0 ? d : 0;
3134
+ }
3135
+
3136
+ _schedule(fn, delay) {
3137
+ if (!this._set) { fn(); return; }
3138
+ const id = this._set(() => { this._timers.delete(id); fn(); }, delay);
3139
+ this._timers.add(id);
3140
+ }
3141
+
3142
+ /**
3143
+ * Wrap a delivery function so calls are delayed/dropped/duplicated per the
3144
+ * current conditions. The returned function has the same signature.
3145
+ */
3146
+ wrap(fn) {
3147
+ const self = this;
3148
+ return function (...args) {
3149
+ // Fast path: no degradation configured.
3150
+ if (!self.latencyMs && !self.jitterMs && !self.lossPct && !self.dupPct) return fn.apply(this, args);
3151
+ if (self.lossPct && self._rand() * 100 < self.lossPct) return undefined; // dropped
3152
+ const ctx = this;
3153
+ self._schedule(() => fn.apply(ctx, args), self._delay());
3154
+ if (self.dupPct && self._rand() * 100 < self.dupPct) self._schedule(() => fn.apply(ctx, args), self._delay());
3155
+ return undefined;
3156
+ };
3157
+ }
3158
+
3159
+ /** Cancel all in-flight scheduled deliveries. */
3160
+ flush() {
3161
+ if (this._clear) for (const id of this._timers) this._clear(id);
3162
+ this._timers.clear();
3163
+ }
3164
+ }
3165
+
3166
+ /**
3167
+ * Usion SDK Netcode — deterministic lockstep (the League-of-Legends / RTS model).
3168
+ *
3169
+ * Instead of streaming world state, every client runs the **same deterministic
3170
+ * simulation** and exchanges only **inputs**. Bandwidth is tiny even with
3171
+ * hundreds of units, and you get **replays for free** (re-run the input log).
3172
+ *
3173
+ * A frame advances only once every player's input for that frame is known.
3174
+ * Local inputs are scheduled `inputDelay` frames ahead so they have time to
3175
+ * reach peers before that frame is simulated (this hides latency). The game
3176
+ * owns its simulation; this class only orders inputs and tells you when to step.
3177
+ *
3178
+ * Determinism is the contract: your `step(frame, inputsByPlayer)` must be
3179
+ * fully deterministic (no Date.now/Math.random without a seeded source, no
3180
+ * floating-point divergence) or clients will desync.
3181
+ */
3182
+ class Lockstep {
3183
+ /**
3184
+ * @param {object} opts
3185
+ * @param {string} opts.playerId
3186
+ * @param {string[]} opts.players All player ids in the match.
3187
+ * @param {(frame:number, inputs:Object)=>void} opts.step Deterministic sim step.
3188
+ * @param {(msg:{frame:number,playerId:string,input:any})=>void} [opts.send]
3189
+ * @param {number} [opts.inputDelay=2] Frames of input delay (latency hiding).
3190
+ * @param {any} [opts.idleInput=null] Input used for the seeded startup frames.
3191
+ */
3192
+ constructor(opts = {}) {
3193
+ if (!opts.playerId) throw new Error('Lockstep requires playerId');
3194
+ if (!Array.isArray(opts.players) || opts.players.length === 0) throw new Error('Lockstep requires players[]');
3195
+ if (typeof opts.step !== 'function') throw new Error('Lockstep requires a step(frame, inputs) function');
3196
+ this.playerId = opts.playerId;
3197
+ this._players = opts.players.slice();
3198
+ this._step = opts.step;
3199
+ this._send = opts.send || function () {};
3200
+ this._inputDelay = opts.inputDelay != null ? opts.inputDelay : 2;
3201
+ this._idle = opts.idleInput != null ? opts.idleInput : null;
3202
+
3203
+ this._inputs = {}; // frame -> { playerId: input }
3204
+ this._frame = 0; // next frame to simulate
3205
+ this._submitFrame = this._inputDelay; // next frame the local player will fill
3206
+ this._replay = []; // [{ frame, inputs }]
3207
+
3208
+ // Seed the first `inputDelay` frames as idle so the sim can start.
3209
+ for (let f = 0; f < this._inputDelay; f++) {
3210
+ this._inputs[f] = {};
3211
+ for (let i = 0; i < this._players.length; i++) this._inputs[f][this._players[i]] = this._idle;
3212
+ }
3213
+ }
3214
+
3215
+ get frame() { return this._frame; }
3216
+ get players() { return this._players.slice(); }
3217
+
3218
+ /** Submit the local player's input for the next schedulable frame. */
3219
+ submit(input) {
3220
+ const frame = this._submitFrame++;
3221
+ if (!this._inputs[frame]) this._inputs[frame] = {};
3222
+ this._inputs[frame][this.playerId] = input;
3223
+ this._send({ frame: frame, playerId: this.playerId, input: input });
3224
+ return frame;
3225
+ }
3226
+
3227
+ /** Record a remote player's input. */
3228
+ receive(msg) {
3229
+ if (!msg || msg.frame == null || !msg.playerId) return;
3230
+ if (!this._inputs[msg.frame]) this._inputs[msg.frame] = {};
3231
+ this._inputs[msg.frame][msg.playerId] = msg.input;
3232
+ }
3233
+
3234
+ _ready(frame) {
3235
+ const fr = this._inputs[frame];
3236
+ if (!fr) return false;
3237
+ for (let i = 0; i < this._players.length; i++) if (!(this._players[i] in fr)) return false;
3238
+ return true;
3239
+ }
3240
+
3241
+ /**
3242
+ * Advance the simulation by every frame whose inputs are fully known.
3243
+ * @returns {number} frames advanced this call.
3244
+ */
3245
+ tick() {
3246
+ let advanced = 0;
3247
+ while (this._ready(this._frame)) {
3248
+ const inputs = this._inputs[this._frame];
3249
+ this._step(this._frame, inputs);
3250
+ this._replay.push({ frame: this._frame, inputs: inputs });
3251
+ delete this._inputs[this._frame];
3252
+ this._frame += 1;
3253
+ advanced += 1;
3254
+ }
3255
+ return advanced;
3256
+ }
3257
+
3258
+ /** True if the sim is blocked waiting on a missing input (stall detection). */
3259
+ isStalled() { return !this._ready(this._frame); }
3260
+
3261
+ /** The ordered input log — persist it to enable replays. */
3262
+ getReplay() { return this._replay.slice(); }
3263
+
3264
+ /** Re-simulate a recorded match by replaying its input log. */
3265
+ static replay(log, step) {
3266
+ for (let i = 0; i < log.length; i++) step(log[i].frame, log[i].inputs);
3267
+ }
3268
+ }
3269
+
3270
+ /**
3271
+ * Usion SDK Netcode — server-side lag compensation ("server rewind").
3272
+ *
3273
+ * The fairness technique behind CS:GO/CS2 and Valorant. The authoritative
3274
+ * server records a short history of world snapshots; when a client claims an
3275
+ * action (e.g. a shot) the server **rewinds** the world to what that client
3276
+ * actually saw — accounting for their latency and interpolation delay — and
3277
+ * resolves the hit against that past state. Without this, high-ping players
3278
+ * must "lead" their targets and hit registration feels broken.
3279
+ *
3280
+ * Entities are matched by `id` and interpolated between the two recorded
3281
+ * snapshots straddling the rewind time (same math as client interpolation).
3282
+ * Pure and testable; use it inside your game server (direct mode).
3283
+ */
3284
+ function lerp(a, b, t) { return a + (b - a) * t; }
3285
+
3286
+ class LagCompensator {
3287
+ /**
3288
+ * @param {object} [opts]
3289
+ * @param {number} [opts.historyMs=1000] How far back to retain snapshots.
3290
+ * @param {number} [opts.maxSize=256] Hard cap on retained snapshots.
3291
+ * @param {function} [opts.now] Clock source (default Date.now).
3292
+ */
3293
+ constructor(opts = {}) {
3294
+ this._historyMs = opts.historyMs || 1000;
3295
+ this._maxSize = opts.maxSize || 256;
3296
+ this._now = opts.now || (() => Date.now());
3297
+ this._history = []; // [{ time, entities:[{id,...}] }] oldest→newest
3298
+ }
3299
+
3300
+ /**
3301
+ * Record the authoritative world state for this tick.
3302
+ * @param {Array} entities Array of entities (each with a stable `id`).
3303
+ * @param {number} [time] Server time (default now()).
3304
+ */
3305
+ record(entities, time) {
3306
+ const t = time != null ? time : this._now();
3307
+ this._history.push({ time: t, entities: entities.map((e) => Object.assign({}, e)) });
3308
+ const cutoff = t - this._historyMs;
3309
+ while (this._history.length > this._maxSize || (this._history.length > 2 && this._history[0].time < cutoff)) {
3310
+ this._history.shift();
3311
+ }
3312
+ }
3313
+
3314
+ get size() { return this._history.length; }
3315
+ clear() { this._history = []; }
3316
+
3317
+ /**
3318
+ * Reconstruct the world as it was at `time` (interpolated), keyed by id.
3319
+ * @param {number} time Absolute server time to rewind to.
3320
+ * @param {string[]} [keys] Numeric fields to interpolate (default: all numbers).
3321
+ * @returns {Object<string, object>} entities by id, or {} if no history.
3322
+ */
3323
+ rewind(time, keys) {
3324
+ const h = this._history;
3325
+ if (h.length === 0) return {};
3326
+ if (time <= h[0].time) return byId(h[0].entities);
3327
+ if (time >= h[h.length - 1].time) return byId(h[h.length - 1].entities);
3328
+
3329
+ let older = h[0], newer = h[h.length - 1];
3330
+ for (let i = h.length - 1; i > 0; i--) {
3331
+ if (h[i - 1].time <= time && time <= h[i].time) { older = h[i - 1]; newer = h[i]; break; }
3332
+ }
3333
+ const span = newer.time - older.time;
3334
+ const t = span > 0 ? (time - older.time) / span : 0;
3335
+ const oldById = byId(older.entities);
3336
+ const out = {};
3337
+ const newById = byId(newer.entities);
3338
+ for (const id in newById) {
3339
+ const b = newById[id];
3340
+ const a = oldById[id];
3341
+ const e = Object.assign({}, b);
3342
+ if (a) {
3343
+ const fields = keys || Object.keys(b);
3344
+ for (let k = 0; k < fields.length; k++) {
3345
+ const key = fields[k];
3346
+ if (typeof a[key] === 'number' && typeof b[key] === 'number') e[key] = lerp(a[key], b[key], t);
3347
+ }
3348
+ }
3349
+ out[id] = e;
3350
+ }
3351
+ return out;
3352
+ }
3353
+
3354
+ /**
3355
+ * Convenience: rewind to what a client saw, given their measured round-trip
3356
+ * time and interpolation buffer. rewindMs is clamped to `maxRewindMs` (cap
3357
+ * it — Valorant caps ~35ms; CS allows more).
3358
+ * @returns {Object<string, object>} entities by id
3359
+ */
3360
+ rewindForClient(rttMs, interpBufferMs, opts) {
3361
+ opts = opts || {};
3362
+ const maxRewind = opts.maxRewindMs != null ? opts.maxRewindMs : 250;
3363
+ let rewindMs = (rttMs || 0) / 2 + (interpBufferMs || 0);
3364
+ if (rewindMs > maxRewind) rewindMs = maxRewind;
3365
+ return this.rewind(this._now() - rewindMs, opts.keys);
3366
+ }
3367
+ }
3368
+
3369
+ function byId(entities) {
3370
+ const m = {};
3371
+ for (let i = 0; i < entities.length; i++) m[entities[i].id] = entities[i];
3372
+ return m;
3373
+ }
3374
+
3375
+ /**
3376
+ * Usion SDK Netcode — Delta compression for JSON game state.
3377
+ *
3378
+ * Real-time games typically re-send their entire world every tick. Most of
3379
+ * that payload is unchanged. `diff` produces a minimal patch describing only
3380
+ * what changed since the previous state; `patch` reconstructs the new state
3381
+ * from a base + patch. Both are plain JSON, so the patch rides on the existing
3382
+ * realtime/action channels unchanged.
3383
+ *
3384
+ * Wire format:
3385
+ * - Objects → `{ k: <patch> }` for changed keys, `_d: [keys]` for deletions.
3386
+ * - **Entity arrays** (every element a plain object with a stable `id`) →
3387
+ * keyed diff `{ _ka:{id:patch}, _add:[entities], _del:[ids], _ord:[ids] }`.
3388
+ * This is O(changes), not O(index shift): inserting/removing an element no
3389
+ * longer rewrites every following index. (Gaffer-on-Games "state sync".)
3390
+ * - Other arrays → index diff `{ _a:{i:patch}, _n:length }`.
3391
+ * - Primitives / type changes → `{ _s: value }`.
3392
+ * - No change → `undefined`.
3393
+ *
3394
+ * `quantize` rounds numeric fields to a fixed resolution before diffing, so
3395
+ * sub-resolution jitter doesn't produce spurious deltas and payloads shrink
3396
+ * (the single biggest, simplest snapshot-compression win — Gaffer on Games).
3397
+ */
3398
+
3399
+ const SET = '_s'; // wholesale set
3400
+ const DEL = '_d'; // deleted object keys
3401
+ const ARR$1 = '_a'; // index array patches
3402
+ const LEN = '_n'; // index array length
3403
+ const KA = '_ka'; // keyed-array: per-id patches
3404
+ const ADD = '_add'; // keyed-array: added entities (full)
3405
+ const KDEL = '_del';// keyed-array: removed ids
3406
+ const ORD = '_ord'; // keyed-array: explicit id order (only when it changes)
3407
+
3408
+ function isPlainObject(v) {
3409
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
3410
+ }
3411
+
3412
+ function isEntityArray(v) {
3413
+ if (!Array.isArray(v) || v.length === 0) return false;
3414
+ for (let i = 0; i < v.length; i++) {
3415
+ const e = v[i];
3416
+ if (!isPlainObject(e) || (typeof e.id !== 'string' && typeof e.id !== 'number')) return false;
3417
+ }
3418
+ return true;
3419
+ }
3420
+
3421
+ function sameOrder(a, b) {
3422
+ if (a.length !== b.length) return false;
3423
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
3424
+ return true;
3425
+ }
3426
+
3427
+ /**
3428
+ * Compute a minimal patch turning `prev` into `next`.
3429
+ * @returns the patch, or `undefined` when nothing changed.
3430
+ */
3431
+ function diff(prev, next) {
3432
+ if (prev === next) return undefined;
3433
+
3434
+ const prevArr = Array.isArray(prev);
3435
+ const nextArr = Array.isArray(next);
3436
+
3437
+ // Type changed (or one side primitive) — send the whole value.
3438
+ if (prevArr !== nextArr || isPlainObject(prev) !== isPlainObject(next)) {
3439
+ return { [SET]: next };
3440
+ }
3441
+
3442
+ if (nextArr) {
3443
+ // Keyed (entity) arrays: diff by id so index shifts are free.
3444
+ if (isEntityArray(prev) && isEntityArray(next)) return diffKeyedArray(prev, next);
3445
+ return diffIndexArray(prev, next);
3446
+ }
3447
+
3448
+ if (isPlainObject(next)) return diffObject(prev, next);
3449
+
3450
+ // Two differing primitives.
3451
+ return { [SET]: next };
3452
+ }
3453
+
3454
+ function diffObject(prev, next) {
3455
+ const out = {};
3456
+ let changed = false;
3457
+ for (const k in next) {
3458
+ if (!Object.prototype.hasOwnProperty.call(next, k)) continue;
3459
+ const d = diff(prev[k], next[k]);
3460
+ if (d !== undefined) { out[k] = d; changed = true; }
3461
+ }
3462
+ const deleted = [];
3463
+ for (const k in prev) {
3464
+ if (!Object.prototype.hasOwnProperty.call(prev, k)) continue;
3465
+ if (!Object.prototype.hasOwnProperty.call(next, k)) deleted.push(k);
3466
+ }
3467
+ if (deleted.length) { out[DEL] = deleted; changed = true; }
3468
+ return changed ? out : undefined;
3469
+ }
3470
+
3471
+ function diffIndexArray(prev, next) {
3472
+ const out = { [ARR$1]: {} };
3473
+ let changed = false;
3474
+ for (let i = 0; i < next.length; i++) {
3475
+ const d = i < prev.length ? diff(prev[i], next[i]) : { [SET]: next[i] };
3476
+ if (d !== undefined) { out[ARR$1][i] = d; changed = true; }
3477
+ }
3478
+ if (next.length !== prev.length) { out[LEN] = next.length; changed = true; }
3479
+ return changed ? out : undefined;
3480
+ }
3481
+
3482
+ function diffKeyedArray(prev, next) {
3483
+ const prevById = {};
3484
+ const prevIds = [];
3485
+ for (let i = 0; i < prev.length; i++) { prevById[prev[i].id] = prev[i]; prevIds.push(prev[i].id); }
3486
+ const nextIds = [];
3487
+ const out = {};
3488
+ let changed = false;
3489
+
3490
+ for (let i = 0; i < next.length; i++) {
3491
+ const e = next[i];
3492
+ nextIds.push(e.id);
3493
+ if (Object.prototype.hasOwnProperty.call(prevById, e.id)) {
3494
+ const d = diff(prevById[e.id], e);
3495
+ if (d !== undefined) {
3496
+ if (!out[KA]) out[KA] = {};
3497
+ out[KA][e.id] = d;
3498
+ changed = true;
3499
+ }
3500
+ } else {
3501
+ if (!out[ADD]) out[ADD] = [];
3502
+ out[ADD].push(e);
3503
+ changed = true;
3504
+ }
3505
+ }
3506
+
3507
+ const nextIdSet = {};
3508
+ for (let i = 0; i < nextIds.length; i++) nextIdSet[nextIds[i]] = true;
3509
+ for (let i = 0; i < prevIds.length; i++) {
3510
+ if (!nextIdSet[prevIds[i]]) {
3511
+ if (!out[KDEL]) out[KDEL] = [];
3512
+ out[KDEL].push(prevIds[i]);
3513
+ changed = true;
3514
+ }
3515
+ }
3516
+
3517
+ if (!sameOrder(prevIds, nextIds)) { out[ORD] = nextIds; changed = true; }
3518
+ return changed ? out : undefined;
3519
+ }
3520
+
3521
+ /**
3522
+ * Apply a patch produced by `diff` to `base`, returning the new value.
3523
+ * Does not mutate `base`. A nullish patch returns `base` unchanged.
3524
+ */
3525
+ function patch(base, p) {
3526
+ if (p === undefined || p === null) return base;
3527
+ if (Object.prototype.hasOwnProperty.call(p, SET)) return p[SET];
3528
+
3529
+ // Keyed (entity) array.
3530
+ if (p[KA] || p[ADD] || p[KDEL] || p[ORD]) return patchKeyedArray(base, p);
3531
+
3532
+ // Index array.
3533
+ if (Object.prototype.hasOwnProperty.call(p, ARR$1) || Object.prototype.hasOwnProperty.call(p, LEN)) {
3534
+ const src = Array.isArray(base) ? base : [];
3535
+ const len = Object.prototype.hasOwnProperty.call(p, LEN) ? p[LEN] : src.length;
3536
+ const out = src.slice(0, len);
3537
+ const patches = p[ARR$1] || {};
3538
+ for (const i in patches) {
3539
+ if (!Object.prototype.hasOwnProperty.call(patches, i)) continue;
3540
+ out[i] = patch(out[i], patches[i]);
3541
+ }
3542
+ return out;
3543
+ }
3544
+
3545
+ // Object patch.
3546
+ const out = isPlainObject(base) ? Object.assign({}, base) : {};
3547
+ const deleted = p[DEL];
3548
+ for (const k in p) {
3549
+ if (k === DEL) continue;
3550
+ if (!Object.prototype.hasOwnProperty.call(p, k)) continue;
3551
+ out[k] = patch(out[k], p[k]);
3552
+ }
3553
+ if (Array.isArray(deleted)) for (let i = 0; i < deleted.length; i++) delete out[deleted[i]];
3554
+ return out;
3555
+ }
3556
+
3557
+ function patchKeyedArray(base, p) {
3558
+ const baseArr = Array.isArray(base) ? base : [];
3559
+ const byId = {};
3560
+ const baseOrder = [];
3561
+ for (let i = 0; i < baseArr.length; i++) {
3562
+ const e = baseArr[i];
3563
+ if (e && (typeof e.id === 'string' || typeof e.id === 'number')) { byId[e.id] = e; baseOrder.push(e.id); }
3564
+ }
3565
+ // Apply per-id field patches.
3566
+ if (p[KA]) for (const id in p[KA]) {
3567
+ if (Object.prototype.hasOwnProperty.call(p[KA], id) && byId[id] !== undefined) byId[id] = patch(byId[id], p[KA][id]);
3568
+ }
3569
+ // Additions.
3570
+ if (p[ADD]) for (let i = 0; i < p[ADD].length; i++) { const e = p[ADD][i]; byId[e.id] = e; }
3571
+ // Removals.
3572
+ const removed = {};
3573
+ if (p[KDEL]) for (let i = 0; i < p[KDEL].length; i++) removed[p[KDEL][i]] = true;
3574
+
3575
+ if (p[ORD]) {
3576
+ const out = [];
3577
+ for (let i = 0; i < p[ORD].length; i++) {
3578
+ const id = p[ORD][i];
3579
+ if (byId[id] !== undefined) out.push(byId[id]);
3580
+ }
3581
+ return out;
3582
+ }
3583
+ // No order change: keep base order minus removals, patched in place.
3584
+ const out = [];
3585
+ for (let i = 0; i < baseOrder.length; i++) {
3586
+ const id = baseOrder[i];
3587
+ if (!removed[id]) out.push(byId[id]);
3588
+ }
3589
+ return out;
3590
+ }
3591
+
3592
+ /**
3593
+ * Round every numeric field to `precision` decimal places (default 2),
3594
+ * returning a new value. Apply before diffing so sub-resolution jitter and
3595
+ * float noise don't generate spurious deltas, and payloads stay small.
3596
+ */
3597
+ function quantize(value, precision = 2) {
3598
+ const f = Math.pow(10, precision);
3599
+ function q(v) {
3600
+ if (typeof v === 'number') return Number.isFinite(v) ? Math.round(v * f) / f : v;
3601
+ if (Array.isArray(v)) return v.map(q);
3602
+ if (isPlainObject(v)) {
3603
+ const o = {};
3604
+ for (const k in v) if (Object.prototype.hasOwnProperty.call(v, k)) o[k] = q(v[k]);
3605
+ return o;
3606
+ }
3607
+ return v;
3608
+ }
3609
+ return q(value);
3610
+ }
3611
+
3612
+ /**
3613
+ * Usion SDK Netcode — compact binary codec (zero-dependency).
3614
+ *
3615
+ * A tiny MessagePack-style serializer for JSON-like snapshot payloads. Pair it
3616
+ * with the snapshot sender/receiver (`encode`/`decode` options) to send game
3617
+ * state as binary over the WebRTC data channel or a binary-capable WebSocket —
3618
+ * meaningfully smaller than JSON, especially after quantization (small ints
3619
+ * pack into 1–2 bytes). WebRTC/Socket.IO transmit ArrayBuffers natively.
3620
+ *
3621
+ * NOT for the iframe/WebView proxy path (that bridge relays JSON) — use binary
3622
+ * on direct/mesh transports.
3623
+ *
3624
+ * Wire tags: 0xc0 null · 0xc2 false · 0xc3 true · 0xc4 int(zigzag varint) ·
3625
+ * 0xc5 float64 · 0xc6 string · 0xc7 array · 0xc8 object
3626
+ */
3627
+ const NULL = 0xc0, FALSE = 0xc2, TRUE = 0xc3, INT = 0xc4, F64 = 0xc5, STR = 0xc6, ARR = 0xc7, OBJ = 0xc8;
3628
+
3629
+ const _enc = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
3630
+ const _dec = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
3631
+
3632
+ function pushVarint(out, n) {
3633
+ // unsigned LEB128
3634
+ n = n >>> 0;
3635
+ while (n > 0x7f) { out.push((n & 0x7f) | 0x80); n >>>= 7; }
3636
+ out.push(n);
3637
+ }
3638
+ function zigzag(n) { return ((n << 1) ^ (n >> 31)) >>> 0; }
3639
+ function unzigzag(u) { return (u >>> 1) ^ -(u & 1); }
3640
+
3641
+ function encodeValue(out, v) {
3642
+ if (v === null || v === undefined) { out.push(NULL); return; }
3643
+ const t = typeof v;
3644
+ if (t === 'boolean') { out.push(v ? TRUE : FALSE); return; }
3645
+ if (t === 'number') {
3646
+ if (Number.isInteger(v) && v >= -2147483648 && v <= 2147483647) {
3647
+ out.push(INT); pushVarint(out, zigzag(v));
3648
+ } else {
3649
+ out.push(F64);
3650
+ const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v, true);
3651
+ for (let i = 0; i < 8; i++) out.push(b[i]);
3652
+ }
3653
+ return;
3654
+ }
3655
+ if (t === 'string') {
3656
+ out.push(STR);
3657
+ const bytes = _enc ? _enc.encode(v) : Buffer.from(v, 'utf8');
3658
+ pushVarint(out, bytes.length);
3659
+ for (let i = 0; i < bytes.length; i++) out.push(bytes[i]);
3660
+ return;
3661
+ }
3662
+ if (Array.isArray(v)) {
3663
+ out.push(ARR); pushVarint(out, v.length);
3664
+ for (let i = 0; i < v.length; i++) encodeValue(out, v[i]);
3665
+ return;
3666
+ }
3667
+ if (t === 'object') {
3668
+ const keys = Object.keys(v);
3669
+ out.push(OBJ); pushVarint(out, keys.length);
3670
+ for (let i = 0; i < keys.length; i++) {
3671
+ const k = keys[i];
3672
+ const kb = _enc ? _enc.encode(k) : Buffer.from(k, 'utf8');
3673
+ pushVarint(out, kb.length);
3674
+ for (let j = 0; j < kb.length; j++) out.push(kb[j]);
3675
+ encodeValue(out, v[k]);
3676
+ }
3677
+ return;
3678
+ }
3679
+ out.push(NULL);
3680
+ }
3681
+
3682
+ /** Encode a JSON-like value to a Uint8Array. */
3683
+ function encode(value) {
3684
+ const out = [];
3685
+ encodeValue(out, value);
3686
+ return Uint8Array.from(out);
3687
+ }
3688
+
3689
+ function reader(bytes) {
3690
+ let off = 0;
3691
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3692
+ function varint() {
3693
+ let shift = 0, result = 0, b;
3694
+ do { b = bytes[off++]; result |= (b & 0x7f) << shift; shift += 7; } while (b & 0x80);
3695
+ return result >>> 0;
3696
+ }
3697
+ function str() {
3698
+ const len = varint();
3699
+ const slice = bytes.subarray(off, off + len);
3700
+ off += len;
3701
+ return _dec ? _dec.decode(slice) : Buffer.from(slice).toString('utf8');
3702
+ }
3703
+ function value() {
3704
+ const tag = bytes[off++];
3705
+ switch (tag) {
3706
+ case NULL: return null;
3707
+ case TRUE: return true;
3708
+ case FALSE: return false;
3709
+ case INT: return unzigzag(varint());
3710
+ case F64: { const v = dv.getFloat64(off, true); off += 8; return v; }
3711
+ case STR: return str();
3712
+ case ARR: { const n = varint(); const a = new Array(n); for (let i = 0; i < n; i++) a[i] = value(); return a; }
3713
+ case OBJ: { const n = varint(); const o = {}; for (let i = 0; i < n; i++) { const k = str(); o[k] = value(); } return o; }
3714
+ default: return null;
3715
+ }
3716
+ }
3717
+ return value;
3718
+ }
3719
+
3720
+ /** Decode a Uint8Array / ArrayBuffer produced by `encode`. */
3721
+ function decode(buf) {
3722
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
3723
+ return reader(bytes)();
3724
+ }
3725
+
3726
+ /**
3727
+ * Usion SDK Game Netcode — convenience wiring of the netcode toolkit onto the
3728
+ * game module. These helpers ride on the existing realtime/action channels, so
3729
+ * they work in every connection mode with no host/backend change (the one
3730
+ * exception is RTT measurement — see game.ping below).
3731
+ */
3732
+
3733
+ function applyGameNetcode(game, Usion) {
3734
+ game.diff = diff;
3735
+ game.patch = patch;
3736
+ game.quantize = quantize;
3737
+ game.encode = encode;
3738
+ game.decode = decode;
3739
+
3740
+ game.createInterpolation = function (opts) { return new SnapshotInterpolation(opts || {}); };
3741
+ game.createPredictor = function (opts) { return new Predictor(opts || {}); };
3742
+ game.createLagCompensator = function (opts) { return new LagCompensator(opts || {}); };
3743
+ game.createLockstep = function (opts) { return new Lockstep(opts || {}); };
3744
+
3745
+ // ── Realtime channel router ──────────────────────────────────────────────
3746
+ // Multiplexes realtime by action_type so replicate / mesh / mesh-network and
3747
+ // the game's own onRealtime can coexist. Also the inject point for the
3748
+ // network simulator's inbound side.
3749
+ game._channels = game._channels || {};
3750
+ game._userRealtime = game._userRealtime || null;
3751
+ game._inboundDispatch = null;
3752
+ function dispatchRealtime(data) {
3753
+ const at = data && data.action_type;
3754
+ if (at && game._channels[at]) { game._channels[at](data && (data.action_data !== undefined ? data.action_data : data), data); return; }
3755
+ if (game._userRealtime) game._userRealtime(data);
3756
+ }
3757
+ game._dispatchRealtime = dispatchRealtime;
3758
+ game._installRealtimeRouter = function () {
3759
+ if (game._routerInstalled) return;
3760
+ if (game._eventHandlers.realtime && game._eventHandlers.realtime !== game._router) game._userRealtime = game._eventHandlers.realtime;
3761
+ game._router = function (data) { (game._inboundDispatch || game._dispatchRealtime)(data); };
3762
+ game._eventHandlers.realtime = game._router;
3763
+ game._routerInstalled = true;
3764
+ };
3765
+ /** Register a handler for one realtime action_type. Returns an unsubscribe fn. */
3766
+ game._onChannel = function (type, handler) { game._channels[type] = handler; game._installRealtimeRouter(); return function () { delete game._channels[type]; }; };
3767
+ // Route onRealtime through the router so channels keep working.
3768
+ game.onRealtime = function (cb) { game._userRealtime = cb; game._installRealtimeRouter(); };
3769
+
3770
+ /** Fixed-rate outbound sender (defaults to game.realtime). */
3771
+ game.createSender = function (opts) {
3772
+ opts = opts || {};
3773
+ const self = this;
3774
+ const send = opts.send || function (type, data) { self.realtime(type, data); };
3775
+ return new Coalescer({
3776
+ hz: opts.hz || 20,
3777
+ autoStart: opts.autoStart !== false,
3778
+ onFlush: function (entries) { for (let i = 0; i < entries.length; i++) send(entries[i].type, entries[i].data); },
3779
+ });
3780
+ };
3781
+
3782
+ /**
3783
+ * Coalesced, sequence-guarded, delta-compressed snapshot sender.
3784
+ *
3785
+ * Deltas are computed against the **last keyframe** (not the previous frame),
3786
+ * so a single lost delta on an unreliable channel never desyncs — the next
3787
+ * delta still applies to the keyframe the receiver holds. Each message
3788
+ * carries a monotonic seq (`s`) and, for deltas, the keyframe seq it's based
3789
+ * on (`b`); the receiver uses these to drop stale/out-of-order frames and to
3790
+ * hold until it has the right keyframe. Optional float `precision`
3791
+ * (quantization) and binary `encode` shrink the wire further.
3792
+ *
3793
+ * Pass `source` (a getter) to auto-read the state each tick — the basis for
3794
+ * game.replicate(). Pair with game.createSnapshotReceiver().
3795
+ */
3796
+ game.createSnapshotSender = function (opts) {
3797
+ opts = opts || {};
3798
+ const self = this;
3799
+ const channel = opts.channel || 'state';
3800
+ const useDelta = opts.delta !== false;
3801
+ const keyframeEvery = opts.keyframeEvery != null ? opts.keyframeEvery : 30;
3802
+ const precision = opts.precision;
3803
+ const enc = opts.encode === true ? encode : (typeof opts.encode === 'function' ? opts.encode : null);
3804
+ const send = opts.send || function (type, data) { self.realtime(type, data); };
3805
+ const source = typeof opts.source === 'function' ? opts.source : null;
3806
+ let keyframeState = null; // an immutable clone (deltas diff against this)
3807
+ let keyframeSeq = 0;
3808
+ let seq = 0;
3809
+ let sinceKey = 0;
3810
+
3811
+ function snapshotClone(v) { return v == null ? v : JSON.parse(JSON.stringify(v)); }
3812
+
3813
+ function emit(raw) {
3814
+ if (raw === undefined || raw === null) return;
3815
+ const state = precision != null ? quantize(raw, precision) : raw;
3816
+ let payload;
3817
+ const wantKey = !useDelta || keyframeState === null || sinceKey >= keyframeEvery;
3818
+ if (wantKey) {
3819
+ seq += 1; keyframeState = snapshotClone(state); keyframeSeq = seq; sinceKey = 0;
3820
+ payload = { s: seq, f: state };
3821
+ } else {
3822
+ const d = diff(keyframeState, state);
3823
+ if (d === undefined) return; // nothing changed — don't burn a seq
3824
+ seq += 1; sinceKey += 1;
3825
+ payload = { s: seq, b: keyframeSeq, d: d };
3826
+ }
3827
+ send(channel, enc ? enc(payload) : payload);
3828
+ }
3829
+
3830
+ // Two drive modes: a `source` getter (read every tick) or queued send().
3831
+ const hz = opts.hz || 20;
3832
+ let queued;
3833
+ const co = source ? null : new Coalescer({
3834
+ hz: hz, autoStart: opts.autoStart !== false,
3835
+ onFlush: function (entries) { for (let i = 0; i < entries.length; i++) if (entries[i].type === '__snap') queued = entries[i].data; emit(queued); },
3836
+ });
3837
+ let timer = null;
3838
+ function start() {
3839
+ if (co) { co.start(); return; }
3840
+ if (timer || typeof setInterval === 'undefined') return;
3841
+ timer = setInterval(function () { emit(source()); }, Math.max(1, Math.round(1000 / hz)));
3842
+ }
3843
+ function stop() { if (co) co.stop(); if (timer) { clearInterval(timer); timer = null; } }
3844
+ if (source && opts.autoStart !== false) start();
3845
+
3846
+ return {
3847
+ send: function (state) { if (co) co.queue('__snap', state); else emit(state); },
3848
+ flush: function () { if (co) co.flush(); else if (source) emit(source()); },
3849
+ start: start,
3850
+ stop: stop,
3851
+ reset: function () { keyframeState = null; keyframeSeq = 0; seq = 0; sinceKey = 0; },
3852
+ };
3853
+ };
3854
+
3855
+ /**
3856
+ * Receiver for createSnapshotSender. Holds the keyframe baseline + last
3857
+ * applied seq, so it safely drops stale/out-of-order frames and ignores
3858
+ * deltas whose keyframe it missed (until the next keyframe arrives).
3859
+ * @returns {{ receive:(msg:any)=>any, state:any, stats:object, reset:()=>void }}
3860
+ */
3861
+ game.createSnapshotReceiver = function (opts) {
3862
+ opts = opts || {};
3863
+ const dec = opts.decode === true ? decode : (typeof opts.decode === 'function' ? opts.decode : null);
3864
+ let base = null, baseSeq = -1, current = null, appliedSeq = -1, dropped = 0;
3865
+ return {
3866
+ receive: function (msg) {
3867
+ if (dec && (msg instanceof ArrayBuffer || ArrayBuffer.isView(msg))) msg = dec(msg);
3868
+ if (!msg) return current;
3869
+ if (msg.f !== undefined) {
3870
+ if (msg.s !== undefined && msg.s <= appliedSeq) { dropped += 1; return current; }
3871
+ base = msg.f; baseSeq = msg.s !== undefined ? msg.s : 0; current = msg.f; appliedSeq = baseSeq;
3872
+ return current;
3873
+ }
3874
+ if (msg.d !== undefined) {
3875
+ if (msg.b !== undefined && msg.b !== baseSeq) { dropped += 1; return current; } // missed keyframe
3876
+ if (msg.s !== undefined && msg.s <= appliedSeq) { dropped += 1; return current; } // stale/dup
3877
+ current = patch(base, msg.d);
3878
+ if (msg.s !== undefined) appliedSeq = msg.s;
3879
+ return current;
3880
+ }
3881
+ return current;
3882
+ },
3883
+ get state() { return current; },
3884
+ get stats() { return { appliedSeq: appliedSeq, baseSeq: baseSeq, dropped: dropped }; },
3885
+ reset: function () { base = null; baseSeq = -1; current = null; appliedSeq = -1; dropped = 0; },
3886
+ };
3887
+ };
3888
+
3889
+ /**
3890
+ * Declarative state replication (host side). Mutate the plain object you pass
3891
+ * in; the SDK auto-diffs and broadcasts it every tick (sequence-guarded,
3892
+ * delta-compressed, optional quantize/binary). Clients read it with
3893
+ * game.replica(). This is the ~40-lines-into-2 ergonomic.
3894
+ * @returns {{ state:any, flush:()=>void, start:()=>void, stop:()=>void }}
3895
+ */
3896
+ game.replicate = function (obj, opts) {
3897
+ opts = opts || {};
3898
+ let state = obj != null ? obj : {};
3899
+ const sender = this.createSnapshotSender(Object.assign({}, opts, { source: function () { return state; } }));
3900
+ return {
3901
+ get state() { return state; },
3902
+ set state(v) { state = v; },
3903
+ flush: function () { sender.flush(); },
3904
+ start: function () { sender.start(); },
3905
+ stop: function () { sender.stop(); },
3906
+ };
3907
+ };
3908
+
3909
+ /**
3910
+ * Declarative state replication (client side). Receives what a host
3911
+ * replicate()s on the same channel, drops stale/out-of-order frames, and
3912
+ * (optionally) interpolates. Read `.state` for the latest authoritative state,
3913
+ * or `.view()` for smoothed entities when `interpolate` keys are given.
3914
+ * @returns {{ state:any, view:()=>any, onChange:(cb)=>void, stop:()=>void }}
3915
+ */
3916
+ game.replica = function (opts) {
3917
+ opts = opts || {};
3918
+ const channel = opts.channel || 'state';
3919
+ const rx = this.createSnapshotReceiver({ decode: opts.decode });
3920
+ const interp = opts.interpolate ? this.createInterpolation(typeof opts.interpolate === 'object' ? opts.interpolate : {}) : null;
3921
+ const keys = typeof opts.interpolate === 'string' ? opts.interpolate : (opts.keys || (opts.interpolate && opts.interpolate.keys));
3922
+ const group = opts.group || (opts.interpolate && opts.interpolate.group);
3923
+ let onChange = null;
3924
+ const off = this._onChannel(channel, function (data) {
3925
+ const next = rx.receive(data);
3926
+ if (interp && next != null) interp.add(next);
3927
+ if (onChange) onChange(next);
3928
+ });
3929
+ return {
3930
+ get state() { return rx.state; },
3931
+ view: function () { return interp ? interp.calc(keys || 'x y', group) : rx.state; },
3932
+ onChange: function (cb) { onChange = cb; },
3933
+ stop: function () { off(); },
3934
+ };
3935
+ };
3936
+
3937
+ /**
3938
+ * One-line WebRTC peer-to-peer setup (2 peers). Signaling rides the realtime
3939
+ * channel via the router; gameplay then flows directly peer-to-peer over UDP.
3940
+ */
3941
+ game.createMesh = function (opts) {
3942
+ opts = opts || {};
3943
+ const self = this;
3944
+ const channel = opts.signalChannel || 'signal';
3945
+ const mesh = new MeshConnection(Object.assign({}, opts, {
3946
+ sendSignal: function (payload) { self.realtime(channel, payload); },
3947
+ }));
3948
+ this._onChannel(channel, function (payload) { if (payload && payload.type) mesh.handleSignal(payload); });
3949
+ return mesh;
3950
+ };
3951
+
3952
+ /**
3953
+ * N-peer full mesh. Signaling routed per-peer over the realtime channel (each
3954
+ * message carries {to, from}); messages addressed to us are dispatched to the
3955
+ * right peer connection. selfId defaults to the current user id.
3956
+ * @returns {MeshNetwork}
3957
+ */
3958
+ game.createMeshNetwork = function (opts) {
3959
+ opts = opts || {};
3960
+ const self = this;
3961
+ const channel = opts.signalChannel || 'mesh';
3962
+ const selfId = opts.selfId || (Usion.user && Usion.user.getId && Usion.user.getId());
3963
+ const net = new MeshNetwork(Object.assign({}, opts, {
3964
+ selfId: selfId,
3965
+ sendSignal: function (toPeerId, payload) { self.realtime(channel, { to: toPeerId, from: selfId, payload: payload }); },
3966
+ }));
3967
+ this._onChannel(channel, function (ad) { if (ad && ad.to === selfId && ad.from) net.handleSignal(ad.from, ad.payload); });
3968
+ return net;
3969
+ };
3970
+
3971
+ /**
3972
+ * Inject artificial latency / jitter / packet loss locally to test how the
3973
+ * game feels on a bad network (every studio has this). Affects both outbound
3974
+ * realtime sends and inbound realtime dispatch. Call with null/false to turn
3975
+ * it off. No-op safety: realtime must exist on the game module.
3976
+ * @param {{latencyMs?:number,jitterMs?:number,lossPct?:number,dupPct?:number}|null} opts
3977
+ */
3978
+ game.simulateNetwork = function (opts) {
3979
+ const self = this;
3980
+ self._installRealtimeRouter();
3981
+ if (!self._realtimeRaw && typeof self.realtime === 'function') self._realtimeRaw = self.realtime.bind(self);
3982
+ if (!opts) {
3983
+ if (self._realtimeRaw) self.realtime = self._realtimeRaw;
3984
+ self._inboundDispatch = null;
3985
+ if (self._sim) self._sim.flush();
3986
+ self._sim = null;
3987
+ return;
3988
+ }
3989
+ if (!self._sim) self._sim = new NetworkSim(opts);
3990
+ else self._sim.set(opts);
3991
+ if (self._realtimeRaw) self.realtime = self._sim.wrap(self._realtimeRaw);
3992
+ self._inboundDispatch = self._sim.wrap(self._dispatchRealtime);
3993
+ return self._sim;
3994
+ };
3995
+
3996
+ /**
3997
+ * Create a WebTransport (HTTP/3) connection — the lowest-latency client-server
3998
+ * path (UDP-like datagrams, no TCP head-of-line blocking). `url` defaults to
3999
+ * Usion.config.webTransportUrl. Call `connect()`, then `send()` (datagram) /
4000
+ * `sendReliable()`; feed `onMessage` into a snapshot receiver. Direct/standalone
4001
+ * games only (the iframe/WebView proxy relay can't carry datagrams).
4002
+ * @returns {WebTransportConnection}
4003
+ */
4004
+ game.createWebTransport = function (opts) {
4005
+ opts = opts || {};
4006
+ const url = opts.url || (Usion.config && (Usion.config.webTransportUrl || Usion.config.wtUrl));
4007
+ return new WebTransportConnection(Object.assign({}, opts, { url: url }));
4008
+ };
4009
+
4010
+ /**
4011
+ * Measure round-trip time once (single outstanding probe), updating the
4012
+ * rolling estimate (game.getRtt). Direct mode uses the protocol ping/pong;
4013
+ * platform mode uses a lightweight server ack. Resolves to ms, or null if
4014
+ * unavailable (proxy mode, or not connected).
4015
+ * @returns {Promise<number|null>}
4016
+ */
4017
+ game.ping = function () {
4018
+ const self = this;
4019
+ if (!self._pingMeter) self._pingMeter = new PingMeter();
4020
+ if (self._pingInFlight) return self._pingInFlight; // single outstanding probe
4021
+
4022
+ let promise;
4023
+ if (self.directMode) {
4024
+ if (!self.directSocket || self.directSocket.readyState !== 1) return Promise.resolve(null);
4025
+ promise = new Promise(function (resolve) {
4026
+ const id = self._pingMeter.begin();
4027
+ let done = false;
4028
+ const finish = function () { if (done) return; done = true; resolve(self._pingMeter.end(id)); };
4029
+ self._pongWaiters.push(finish);
4030
+ self._sendDirect('ping', { t: Date.now() });
4031
+ setTimeout(function () {
4032
+ if (done) return; done = true;
4033
+ const idx = self._pongWaiters.indexOf(finish);
4034
+ if (idx >= 0) self._pongWaiters.splice(idx, 1);
4035
+ delete self._pingMeter._outstanding[id];
4036
+ resolve(null);
4037
+ }, 3000);
4038
+ });
4039
+ } else if (self.socket && self.connected && !self._useProxy) {
4040
+ promise = new Promise(function (resolve) {
4041
+ const start = Date.now();
4042
+ let done = false;
4043
+ try {
4044
+ self.socket.emit('game:ping', { t: start }, function () { if (done) return; done = true; resolve(self._pingMeter.sample(Date.now() - start)); });
4045
+ } catch (e) { resolve(null); return; }
4046
+ setTimeout(function () { if (done) return; done = true; resolve(null); }, 3000);
4047
+ });
4048
+ } else {
4049
+ return Promise.resolve(null); // proxy mode has no point-to-point ack
4050
+ }
4051
+
4052
+ self._pingInFlight = promise;
4053
+ promise.then(function () { self._pingInFlight = null; }, function () { self._pingInFlight = null; });
4054
+ return promise;
4055
+ };
4056
+
4057
+ /** Latest smoothed round-trip time in ms (null until first ping). */
4058
+ game.getRtt = function () { return this._pingMeter ? this._pingMeter.rtt : null; };
4059
+ }
4060
+
4061
+ /**
4062
+ * Usion SDK Game Core — game module base, connect routing, event registrations
4063
+ */
4064
+
4065
+
4066
+ /**
4067
+ * Create the game module with all sub-modules applied
4068
+ * @param {object} Usion - Reference to the main Usion object
4069
+ */
4070
+ function createGameModule(Usion) {
4071
+ const game = {
4072
+ socket: null,
4073
+ directSocket: null,
4074
+ roomId: null,
4075
+ playerId: null,
4076
+ connected: false,
4077
+ directMode: false,
4078
+ directConfig: null,
4079
+ _directSeq: 0,
4080
+ _eventHandlers: {},
4081
+ _lastSequence: 0,
4082
+ _connecting: false,
4083
+ _connectPromise: null,
4084
+ _joined: false,
4085
+ _joinPromise: null,
4086
+ _useProxy: false,
4087
+ _proxyListenerSetup: false,
4088
+ _heartbeatInterval: null,
4089
+ _pingMeter: null,
4090
+ _pongWaiters: [],
4091
+
4092
+ /**
4093
+ * Connect to the game socket server
4094
+ * @param {string} socketUrl - Socket.IO server URL (optional, uses config)
4095
+ * @param {string} token - JWT auth token (optional, uses user.getToken())
4096
+ * @returns {Promise} Resolves when connected
4097
+ */
4098
+ connect: function(socketUrl, token) {
4099
+ const self = this;
4100
+ var connectionMode = (Usion.config && Usion.config.connectionMode) || 'platform';
4101
+ if (connectionMode === 'direct') {
4102
+ return self.connectDirect();
4103
+ }
4104
+
4105
+ // If already connected (direct or proxy), return immediately
4106
+ if (self._useProxy && self.connected) {
4107
+ return Promise.resolve();
4108
+ }
4109
+ if (self.socket && self.connected) {
4110
+ return Promise.resolve();
4111
+ }
4112
+
4113
+ // If currently connecting, return the existing promise
4114
+ if (self._connecting && self._connectPromise) {
4115
+ return self._connectPromise;
4116
+ }
4117
+
4118
+ // When running inside an iframe or WebView, use parent as socket proxy
4119
+ // (checked BEFORE token validation — iframe games don't need a token)
4120
+ var isInFrame = !!window.__USION_PROXY__
4121
+ || window.parent !== window
4122
+ || !!window.ReactNativeWebView
4123
+ || !!Usion._isEmbedded;
4124
+
4125
+ if (isInFrame) {
4126
+ Usion.log('Running in iframe \u2013 using parent app as socket proxy');
4127
+ return self._connectViaProxy();
4128
+ }
4129
+
4130
+ // Use config values as defaults (only for direct socket connections)
4131
+ socketUrl = socketUrl || Usion.config.socketUrl;
4132
+ token = token || Usion.user.getToken();
4133
+
4134
+ if (!socketUrl) {
4135
+ return Promise.reject(new Error('No socket URL provided'));
4136
+ }
4137
+ if (!token) {
4138
+ return Promise.reject(new Error('No auth token available'));
4139
+ }
4140
+
4141
+ self._connecting = true;
4142
+ self._connectPromise = new Promise(function(resolve, reject) {
4143
+ // Check if socket.io-client is available
4144
+ if (typeof io === 'undefined') {
4145
+ // Load socket.io client
4146
+ var script = document.createElement('script');
4147
+ script.src = '/socket.io.min.js';
4148
+ script.onload = function() {
4149
+ self._initSocket(socketUrl, token, resolve, reject);
4150
+ };
4151
+ script.onerror = function() {
4152
+ // Local file not available, try CDN as fallback
4153
+ var cdnScript = document.createElement('script');
4154
+ cdnScript.src = 'https://cdn.socket.io/4.7.2/socket.io.min.js';
4155
+ cdnScript.onload = function() {
4156
+ self._initSocket(socketUrl, token, resolve, reject);
4157
+ };
4158
+ cdnScript.onerror = function() {
4159
+ self._connecting = false;
4160
+ reject(new Error('Failed to load Socket.IO client'));
4161
+ };
4162
+ document.head.appendChild(cdnScript);
4163
+ };
4164
+ document.head.appendChild(script);
4165
+ } else {
4166
+ self._initSocket(socketUrl, token, resolve, reject);
4167
+ }
4168
+ });
4169
+
4170
+ return self._connectPromise;
4171
+ },
4172
+
4173
+ // Event handler registrations
4174
+ onJoined: function(callback) { this._eventHandlers.joined = callback; },
4175
+ onPlayerJoined: function(callback) { this._eventHandlers.playerJoined = callback; },
4176
+ onPlayerLeft: function(callback) { this._eventHandlers.playerLeft = callback; },
4177
+ onStateUpdate: function(callback) { this._eventHandlers.stateUpdate = callback; },
4178
+ onSync: function(callback) { this._eventHandlers.sync = callback; },
4179
+ onAction: function(callback) { this._eventHandlers.action = callback; },
4180
+ onRealtime: function(callback) { this._eventHandlers.realtime = callback; },
4181
+ onGameFinished: function(callback) { this._eventHandlers.finished = callback; },
4182
+ onGameRestarted: function(callback) { this._eventHandlers.restarted = callback; },
4183
+ onError: function(callback) { this._eventHandlers.error = callback; },
4184
+ onRematchRequest: function(callback) { this._eventHandlers.rematchRequest = callback; },
4185
+ onDisconnect: function(callback) { this._eventHandlers.disconnect = callback; },
4186
+ onReconnect: function(callback) { this._eventHandlers.reconnect = callback; },
4187
+ onConnectionError: function(callback) { this._eventHandlers.connectionError = callback; },
4188
+
4189
+ /**
4190
+ * Register a generic event handler
4191
+ * @param {string} event - Event name
4192
+ * @param {function} callback - Handler function
4193
+ */
4194
+ on: function(event, callback) {
4195
+ if (this.socket) {
4196
+ this.socket.on(event, callback);
4197
+ }
4198
+ }
4199
+ };
4200
+
4201
+ // Apply sub-modules
4202
+ applyGameDirect(game, Usion);
4203
+ applyGameSocket(game, Usion);
4204
+ applyGameProxy(game, Usion);
4205
+ applyGameMethods(game, Usion);
4206
+ applyGameNetcode(game, Usion);
4207
+
4208
+ return game;
4209
+ }
4210
+
4211
+ /**
4212
+ * Usion SDK Lobby — parties, ready-up, and matchmaking.
4213
+ *
4214
+ * The social rendezvous layer that game rooms can't provide: form a party by
4215
+ * code, ready up together, and start as a group — plus a thin wrapper over the
4216
+ * platform's stranger matchmaking. Rides the platform Socket.IO connection
4217
+ * (Usion.game.socket), so connect first with Usion.game.connect().
4218
+ *
4219
+ * const { code } = await Usion.lobby.create({ maxPlayers: 4 });
4220
+ * Usion.lobby.onUpdate(({ members }) => renderLobby(members));
4221
+ * await Usion.lobby.setReady(true);
4222
+ * // host, once everyone's ready: create a room then start the party in it
4223
+ * const room = await Usion.lobby.queue(serviceId); // or your own room API
4224
+ * await Usion.lobby.start(room.id);
4225
+ * Usion.lobby.onStarted(({ room_id }) => Usion.game.join(room_id));
4226
+ *
4227
+ * Works in every mode: it rides the unified backend channel (Usion._backendEmit
4228
+ * / _backendOn), which uses the SDK's own socket when standalone and relays
4229
+ * through the parent app when embedded (iframe/WebView).
4230
+ */
4231
+ function createLobbyModule(Usion) {
4232
+ const state = { code: null, host: null, status: null, members: [] };
4233
+ const handlers = {};
4234
+ let bound = false;
4235
+
4236
+ function bind() {
4237
+ if (bound) return;
4238
+ bound = true;
4239
+ Usion._backendOn('lobby:update', function (d) {
4240
+ state.code = d.code; state.host = d.host; state.status = d.status; state.members = d.members || [];
4241
+ if (handlers.update) handlers.update(d);
4242
+ });
4243
+ Usion._backendOn('lobby:started', function (d) {
4244
+ state.status = 'started';
4245
+ if (handlers.started) handlers.started(d);
4246
+ });
4247
+ }
4248
+
4249
+ function ack(event, data) {
4250
+ bind();
4251
+ return Usion._backendEmit(event, data);
4252
+ }
4253
+
4254
+ return {
4255
+ get state() { return state; },
4256
+
4257
+ /** Register a handler for lobby membership/ready changes. */
4258
+ onUpdate: function (cb) { handlers.update = cb; bind(); },
4259
+ /** Register a handler for when the host starts the party. */
4260
+ onStarted: function (cb) { handlers.started = cb; bind(); },
4261
+
4262
+ /** Create a party. Resolves with { code }. You become the host. */
4263
+ create: async function (opts) {
4264
+ opts = opts || {};
4265
+ const r = await ack('lobby:create', { max_players: opts.maxPlayers || 8, public: !!opts.public });
4266
+ if (r && r.code) state.code = r.code;
4267
+ return r;
4268
+ },
4269
+
4270
+ /** Join a party by code. */
4271
+ join: async function (code) {
4272
+ const r = await ack('lobby:join', { code: String(code || '').toUpperCase() });
4273
+ if (r && r.code) state.code = r.code;
4274
+ return r;
4275
+ },
4276
+
4277
+ /** Leave the current party. */
4278
+ leave: function () { state.code = null; return ack('lobby:leave', {}).catch(function () {}); },
4279
+
4280
+ /** Set your ready state. */
4281
+ setReady: function (ready) { return ack('lobby:ready', { ready: ready !== false }); },
4282
+
4283
+ /** True when every member is ready. */
4284
+ allReady: function () { return state.members.length > 0 && state.members.every(function (m) { return m.ready; }); },
4285
+
4286
+ /** Whether the current user is the party host. */
4287
+ isHost: function () {
4288
+ const id = Usion.user && Usion.user.getId && Usion.user.getId();
4289
+ return !!(state.host && id && state.host === id);
4290
+ },
4291
+
4292
+ /** Host: start the party in an already-created game room. */
4293
+ start: function (roomId) { return ack('lobby:start', { room_id: roomId }); },
4294
+
4295
+ /**
4296
+ * Stranger matchmaking — find or create a game room for a service via the
4297
+ * platform's REST matchmaker. Resolves with the room.
4298
+ */
4299
+ queue: async function (serviceId, opts) {
4300
+ opts = opts || {};
4301
+ const apiUrl = (Usion.config && Usion.config.apiUrl) || '';
4302
+ const token = Usion.user && Usion.user.getToken && Usion.user.getToken();
4303
+ if (!apiUrl) throw new Error('No apiUrl configured');
4304
+ const res = await fetch(apiUrl.replace(/\/$/, '') + '/games/matchmake', {
4305
+ method: 'POST',
4306
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
4307
+ body: JSON.stringify({ service_id: serviceId, conversation_id: opts.conversationId || ('standalone_' + serviceId) }),
4308
+ });
4309
+ if (!res.ok) throw new Error('Matchmake failed: HTTP ' + res.status);
4310
+ return res.json();
4311
+ },
4312
+ };
4313
+ }
4314
+
4315
+ /**
4316
+ * Usion SDK Leaderboard — scores for mini-games.
4317
+ *
4318
+ * Opt-in per game (the service must have `leaderboard.enabled`). Rides the
4319
+ * unified backend channel, so it works in standalone AND embedded games.
4320
+ *
4321
+ * await Usion.leaderboard.submit(1500); // your score
4322
+ * const friends = await Usion.leaderboard.friends(); // people you've messaged + you
4323
+ * const top = await Usion.leaderboard.top(); // global top N
4324
+ * const me = await Usion.leaderboard.me(); // { score, rank, total }
4325
+ *
4326
+ * The **friends** board is scoped to users you've messaged (your conversations)
4327
+ * plus yourself — that's the "see the scores of people I've chatted with" view.
4328
+ * Entries: { user_id, name, avatar, score, rank, is_me, metadata }.
4329
+ */
4330
+ function createLeaderboardModule(Usion) {
4331
+ function serviceId(opts) {
4332
+ return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
4333
+ }
4334
+
4335
+ return {
4336
+ /** Submit a score (best score is kept, per the service's order config). */
4337
+ submit: function (score, metadata, opts) {
4338
+ return Usion._backendEmit('lb:submit', { service_id: serviceId(opts), score: score, metadata: metadata != null ? metadata : null });
4339
+ },
4340
+
4341
+ /** Leaderboard of people you've messaged (plus you). Returns entries[]. */
4342
+ friends: function (opts) {
4343
+ opts = opts || {};
4344
+ return Usion._backendEmit('lb:friends', { service_id: serviceId(opts), limit: opts.limit || 50 })
4345
+ .then(function (r) { return (r && r.entries) || []; });
4346
+ },
4347
+
4348
+ /** Global top N. Returns entries[]. */
4349
+ top: function (opts) {
4350
+ opts = opts || {};
4351
+ return Usion._backendEmit('lb:top', { service_id: serviceId(opts), limit: opts.limit || 20 })
4352
+ .then(function (r) { return (r && r.entries) || []; });
4353
+ },
4354
+
4355
+ /** Your own score + global rank: { score, rank, total }. */
4356
+ me: function (opts) {
4357
+ return Usion._backendEmit('lb:me', { service_id: serviceId(opts) });
4358
+ },
4359
+ };
4360
+ }
4361
+
4362
+ /**
4363
+ * Usion SDK Matchmaking — pair up with online strangers ("quick match").
4364
+ *
4365
+ * Lets a mini-app connect players who don't know each other: join a queue, and
4366
+ * when enough players are waiting the platform creates a room and matches you.
4367
+ * Rides the unified backend channel, so it works standalone AND embedded.
4368
+ *
4369
+ * const m = await Usion.matchmaking.find(); // resolves when matched
4370
+ * await Usion.game.connect(); await Usion.game.join(m.roomId);
4371
+ * // ...or cancel while waiting:
4372
+ * Usion.matchmaking.cancel();
4373
+ * Usion.matchmaking.onMatch(({ roomId, players }) => { ... });
4374
+ */
4375
+ function createMatchmakingModule(Usion) {
4376
+ let pending = null; // { resolve, reject } for an in-flight find()
4377
+ let onMatchCb = null;
4378
+ let bound = false;
4379
+
4380
+ function normalize(d) {
4381
+ return { roomId: d && d.room_id, players: (d && d.player_ids) || [], serviceId: d && d.service_id };
4382
+ }
4383
+
4384
+ function bind() {
4385
+ if (bound) return;
4386
+ bound = true;
4387
+ Usion._backendOn('mm:matched', function (d) {
4388
+ const r = normalize(d);
4389
+ if (onMatchCb) onMatchCb(r);
4390
+ if (pending) { const p = pending; pending = null; p.resolve(r); }
4391
+ });
4392
+ }
4393
+
4394
+ return {
4395
+ /** Register a handler called whenever a match is found. */
4396
+ onMatch: function (cb) { onMatchCb = cb; bind(); },
4397
+
4398
+ /**
4399
+ * Join the queue for `serviceId` (defaults to the current game) and resolve
4400
+ * when matched with { roomId, players, serviceId }. Stays pending until a
4401
+ * match (use cancel() to stop waiting).
4402
+ */
4403
+ find: function (serviceId, opts) {
4404
+ opts = opts || {};
4405
+ const sid = serviceId || (Usion.config && Usion.config.serviceId);
4406
+ bind();
4407
+ return Usion._backendEmit('mm:join', { service_id: sid, size: opts.size || 2 }).then(function () {
4408
+ return new Promise(function (resolve, reject) {
4409
+ if (pending) pending.reject(new Error('superseded'));
4410
+ pending = { resolve: resolve, reject: reject };
4411
+ });
4412
+ });
4413
+ },
4414
+
4415
+ /** Leave the queue / stop waiting. */
4416
+ cancel: function () {
4417
+ if (pending) { pending.reject(new Error('cancelled')); pending = null; }
4418
+ return Usion._backendEmit('mm:cancel', {});
4419
+ },
4420
+ };
4421
+ }
4422
+
4423
+ /**
4424
+ * Usion SDK — unified backend channel.
4425
+ *
4426
+ * One switchboard for backend request/response + server push that works in
4427
+ * every mode, so features (lobby, matchmaking, presence, …) never reach for a
4428
+ * specific socket:
4429
+ *
4430
+ * - standalone / platform : uses the SDK's own Socket.IO socket (game.socket)
4431
+ * - embedded (iframe/WebView): relays through the parent app via postMessage
4432
+ * (BACKEND_EMIT request → host emits on its authenticated socket; the host
4433
+ * forwards allow-listed server pushes back as BACKEND_EVENT)
4434
+ *
4435
+ * Security: in embedded mode the host MUST restrict which events it relays to a
4436
+ * safe, namespaced allow-list (e.g. lobby:* / mm:*) so a mini-app can't abuse
4437
+ * the user's authenticated connection. The backend re-validates every call.
4438
+ */
4439
+ function applyBackendChannel(Usion) {
4440
+ Usion._backendHandlers = {};
4441
+ Usion._boundSockets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
4442
+
4443
+ /**
4444
+ * Bind a Socket.IO socket so any allow-listed server event is routed to the
4445
+ * registered _backendOn handlers (via onAny — robust to registration order).
4446
+ */
4447
+ Usion._bindBackendSocket = function (socket) {
4448
+ if (!socket || typeof socket.onAny !== 'function') return;
4449
+ if (this._boundSockets) {
4450
+ if (this._boundSockets.has(socket)) return;
4451
+ this._boundSockets.add(socket);
4452
+ }
4453
+ const self = this;
4454
+ socket.onAny(function (event, payload) {
4455
+ const h = self._backendHandlers[event];
4456
+ if (h) h(payload);
4457
+ });
4458
+ };
4459
+
4460
+ /** Subscribe to a backend server-push event (works in all modes). */
4461
+ Usion._backendOn = function (event, handler) {
4462
+ this._backendHandlers[event] = handler;
4463
+ const s = this.game && this.game.socket;
4464
+ if (s) {
4465
+ if (typeof s.onAny === 'function') this._bindBackendSocket(s);
4466
+ else if (typeof s.on === 'function') s.on(event, handler); // fallback (tests / minimal sockets)
4467
+ }
4468
+ };
4469
+
4470
+ /**
4471
+ * Emit a backend request and await its ack. Routes to the SDK socket when
4472
+ * standalone, or through the parent host when embedded.
4473
+ * @returns {Promise<any>}
4474
+ */
4475
+ Usion._backendEmit = function (event, data, timeout) {
4476
+ const self = this;
4477
+ timeout = timeout || 8000;
4478
+ const s = self.game && self.game.socket;
4479
+ if (s && s.connected) {
4480
+ return new Promise(function (resolve, reject) {
4481
+ let done = false;
4482
+ const timer = setTimeout(function () { if (done) return; done = true; reject(new Error('Backend request timeout')); }, timeout);
4483
+ try {
4484
+ s.emit(event, data || {}, function (resp) {
4485
+ if (done) return; done = true; clearTimeout(timer);
4486
+ if (resp && resp.error) reject(new Error(resp.message || resp.error));
4487
+ else resolve(resp);
4488
+ });
4489
+ } catch (e) { clearTimeout(timer); reject(e); }
4490
+ });
4491
+ }
4492
+ if (self._isEmbedded) {
4493
+ // Host relays this onto its authenticated socket and replies with the ack.
4494
+ return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout);
4495
+ }
4496
+ return Promise.reject(new Error('No backend connection — call Usion.game.connect() first'));
4497
+ };
4498
+ }
4499
+
4500
+ /**
4501
+ * Usion SDK Netcode — public namespace.
4502
+ *
4503
+ * A small, zero-dependency, transport-agnostic toolkit for smooth low-latency
4504
+ * multiplayer. Works across all Usion connection modes (platform / direct /
4505
+ * proxy) and both platforms, computing on top of the existing realtime/action
4506
+ * plumbing — no host or backend changes required to use it.
4507
+ *
4508
+ * SnapshotInterpolation — smooth rendering; adaptive buffer + capped extrapolation
4509
+ * Predictor — client prediction + reconciliation + error smoothing
4510
+ * Coalescer — fixed-Hz outbound send batching
4511
+ * PingMeter — RTT / jitter telemetry
4512
+ * MeshConnection — WebRTC P2P (sequenced, TURN-ready, auto-reconnect)
4513
+ * MeshNetwork — N-peer full mesh
4514
+ * diff / patch / quantize — JSON delta compression (id-keyed arrays)
4515
+ * encode / decode — compact binary codec
4516
+ */
4517
+
4518
+ const netcode = {
4519
+ SnapshotInterpolation, Vault, Predictor, Coalescer, PingMeter,
4520
+ MeshConnection, MeshNetwork, WebTransportConnection, NetworkSim, Lockstep, LagCompensator,
4521
+ diff, patch, quantize, encode, decode,
4522
+ };
4523
+
2169
4524
  /**
2170
4525
  * Usion Mini App SDK v2.1
2171
4526
  *
@@ -2193,6 +4548,14 @@ var Usion = (function () {
2193
4548
  Usion.bot = createBotModule(Usion);
2194
4549
  Usion.fileStorage = createFileStorageModule(Usion);
2195
4550
  Usion.game = createGameModule(Usion);
4551
+ // Unified backend channel (used by lobby etc.; works standalone + embedded).
4552
+ applyBackendChannel(Usion);
4553
+ Usion.lobby = createLobbyModule(Usion);
4554
+ Usion.leaderboard = createLeaderboardModule(Usion);
4555
+ Usion.matchmaking = createMatchmakingModule(Usion);
4556
+
4557
+ // Netcode toolkit (transport-agnostic, zero-dependency).
4558
+ Usion.netcode = netcode;
2196
4559
 
2197
4560
  // Attach results methods directly on Usion
2198
4561
  Object.assign(Usion, createResultsMethods(Usion));