@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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Usion SDK — unified backend channel.
3
+ *
4
+ * One switchboard for backend request/response + server push that works in
5
+ * every mode, so features (lobby, matchmaking, presence, …) never reach for a
6
+ * specific socket:
7
+ *
8
+ * - standalone / platform : uses the SDK's own Socket.IO socket (game.socket)
9
+ * - embedded (iframe/WebView): relays through the parent app via postMessage
10
+ * (BACKEND_EMIT request → host emits on its authenticated socket; the host
11
+ * forwards allow-listed server pushes back as BACKEND_EVENT)
12
+ *
13
+ * Security: in embedded mode the host MUST restrict which events it relays to a
14
+ * safe, namespaced allow-list (e.g. lobby:* / mm:*) so a mini-app can't abuse
15
+ * the user's authenticated connection. The backend re-validates every call.
16
+ */
17
+ export function applyBackendChannel(Usion) {
18
+ Usion._backendHandlers = {};
19
+ Usion._boundSockets = typeof WeakSet !== 'undefined' ? new WeakSet() : null;
20
+
21
+ /**
22
+ * Bind a Socket.IO socket so any allow-listed server event is routed to the
23
+ * registered _backendOn handlers (via onAny — robust to registration order).
24
+ */
25
+ Usion._bindBackendSocket = function (socket) {
26
+ if (!socket || typeof socket.onAny !== 'function') return;
27
+ if (this._boundSockets) {
28
+ if (this._boundSockets.has(socket)) return;
29
+ this._boundSockets.add(socket);
30
+ }
31
+ const self = this;
32
+ socket.onAny(function (event, payload) {
33
+ const h = self._backendHandlers[event];
34
+ if (h) h(payload);
35
+ });
36
+ };
37
+
38
+ /** Subscribe to a backend server-push event (works in all modes). */
39
+ Usion._backendOn = function (event, handler) {
40
+ this._backendHandlers[event] = handler;
41
+ const s = this.game && this.game.socket;
42
+ if (s) {
43
+ if (typeof s.onAny === 'function') this._bindBackendSocket(s);
44
+ else if (typeof s.on === 'function') s.on(event, handler); // fallback (tests / minimal sockets)
45
+ }
46
+ };
47
+
48
+ /**
49
+ * Emit a backend request and await its ack. Routes to the SDK socket when
50
+ * standalone, or through the parent host when embedded.
51
+ * @returns {Promise<any>}
52
+ */
53
+ Usion._backendEmit = function (event, data, timeout) {
54
+ const self = this;
55
+ timeout = timeout || 8000;
56
+ const s = self.game && self.game.socket;
57
+ if (s && s.connected) {
58
+ return new Promise(function (resolve, reject) {
59
+ let done = false;
60
+ const timer = setTimeout(function () { if (done) return; done = true; reject(new Error('Backend request timeout')); }, timeout);
61
+ try {
62
+ s.emit(event, data || {}, function (resp) {
63
+ if (done) return; done = true; clearTimeout(timer);
64
+ if (resp && resp.error) reject(new Error(resp.message || resp.error));
65
+ else resolve(resp);
66
+ });
67
+ } catch (e) { clearTimeout(timer); reject(e); }
68
+ });
69
+ }
70
+ if (self._isEmbedded) {
71
+ // Host relays this onto its authenticated socket and replies with the ack.
72
+ return self._request('BACKEND_EMIT', { event: event, data: data || {} }, timeout);
73
+ }
74
+ return Promise.reject(new Error('No backend connection — call Usion.game.connect() first'));
75
+ };
76
+ }
@@ -181,6 +181,12 @@ export const core = {
181
181
  if (data.type === 'BOT_MESSAGE' && self.bot && self.bot._messageHandler) {
182
182
  self.bot._messageHandler(data.message);
183
183
  }
184
+
185
+ // Handle backend server-push events relayed by the host (embedded mode).
186
+ if (data.type === 'BACKEND_EVENT' && data.event && self._backendHandlers) {
187
+ const h = self._backendHandlers[data.event];
188
+ if (h) h(data.data);
189
+ }
184
190
  });
185
191
 
186
192
  // Signal ready to parent
@@ -6,6 +6,7 @@ import { applyGameDirect } from './game-direct.js';
6
6
  import { applyGameSocket } from './game-socket.js';
7
7
  import { applyGameProxy } from './game-proxy.js';
8
8
  import { applyGameMethods } from './game-methods.js';
9
+ import { applyGameNetcode } from './game-netcode.js';
9
10
 
10
11
  /**
11
12
  * Create the game module with all sub-modules applied
@@ -30,6 +31,8 @@ export function createGameModule(Usion) {
30
31
  _useProxy: false,
31
32
  _proxyListenerSetup: false,
32
33
  _heartbeatInterval: null,
34
+ _pingMeter: null,
35
+ _pongWaiters: [],
33
36
 
34
37
  /**
35
38
  * Connect to the game socket server
@@ -145,6 +148,7 @@ export function createGameModule(Usion) {
145
148
  applyGameSocket(game, Usion);
146
149
  applyGameProxy(game, Usion);
147
150
  applyGameMethods(game, Usion);
151
+ applyGameNetcode(game, Usion);
148
152
 
149
153
  return game;
150
154
  }
@@ -26,6 +26,9 @@ export function applyGameDirect(game, Usion) {
26
26
 
27
27
  self._connecting = true;
28
28
  self.directMode = true;
29
+ self._autoReconnect = config.autoReconnect !== undefined
30
+ ? config.autoReconnect
31
+ : !(Usion.config && Usion.config.autoReconnect === false);
29
32
  self._connectPromise = self._fetchDirectAccess(config)
30
33
  .then(function(access) {
31
34
  self.directConfig = access;
@@ -154,6 +157,10 @@ export function applyGameDirect(game, Usion) {
154
157
  if (self._eventHandlers.disconnect) {
155
158
  self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
156
159
  }
160
+ // Seamless resume: if the drop wasn't an intentional disconnect()
161
+ // (which clears directMode), transparently re-establish + re-join +
162
+ // resync from the last sequence.
163
+ if (self.directMode && self._autoReconnect !== false) self._scheduleDirectReconnect();
157
164
  };
158
165
 
159
166
  ws.onmessage = function(evt) {
@@ -162,6 +169,39 @@ export function applyGameDirect(game, Usion) {
162
169
  });
163
170
  };
164
171
 
172
+ /**
173
+ * Reconnect a dropped direct-mode socket with capped exponential backoff,
174
+ * then re-join the room and request a resync (Dota-style "fetch latest
175
+ * snapshot = instant rejoin"). Keeps retrying while still in directMode.
176
+ * @private
177
+ */
178
+ game._scheduleDirectReconnect = function() {
179
+ var self = this;
180
+ if (self._reconnecting) return;
181
+ self._reconnecting = true;
182
+ var attempt = self._reconnectAttempt || 0;
183
+ var go = function() {
184
+ if (!self.directMode) { self._reconnecting = false; return; } // disconnected meanwhile
185
+ attempt += 1;
186
+ self._reconnectAttempt = attempt;
187
+ self._fetchDirectAccess({})
188
+ .then(function(access) { self.directConfig = access; return self._initDirectSocket(access); })
189
+ .then(function() {
190
+ self.connected = true;
191
+ self._reconnecting = false;
192
+ self._reconnectAttempt = 0;
193
+ if (self._eventHandlers.reconnect) self._eventHandlers.reconnect(attempt);
194
+ if (self.roomId) self.requestSync(self._lastSequence || 0); // resync / resume
195
+ })
196
+ .catch(function() {
197
+ if (!self.directMode) { self._reconnecting = false; return; }
198
+ var delay = Math.min(1000 * Math.pow(2, attempt - 1), 15000);
199
+ setTimeout(go, delay);
200
+ });
201
+ };
202
+ setTimeout(go, 500);
203
+ };
204
+
165
205
  game._sendDirect = function(type, payload) {
166
206
  if (!this.directSocket || this.directSocket.readyState !== WebSocket.OPEN) return;
167
207
  this._directSeq = this._directSeq + 1;
@@ -205,6 +245,11 @@ export function applyGameDirect(game, Usion) {
205
245
  return;
206
246
  }
207
247
  if (data.type === 'pong') {
248
+ // Resolve a pending game.ping() RTT probe, if any.
249
+ if (this._pongWaiters && this._pongWaiters.length) {
250
+ const waiter = this._pongWaiters.shift();
251
+ if (waiter) waiter();
252
+ }
208
253
  if (this._eventHandlers.sync) this._eventHandlers.sync(payload);
209
254
  return;
210
255
  }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Usion SDK Game Netcode — convenience wiring of the netcode toolkit onto the
3
+ * game module. These helpers ride on the existing realtime/action channels, so
4
+ * they work in every connection mode with no host/backend change (the one
5
+ * exception is RTT measurement — see game.ping below).
6
+ */
7
+ import { SnapshotInterpolation } from './netcode/interpolation.js';
8
+ import { Predictor } from './netcode/prediction.js';
9
+ import { Coalescer } from './netcode/sender.js';
10
+ import { PingMeter } from './netcode/ping.js';
11
+ import { MeshConnection } from './netcode/mesh.js';
12
+ import { MeshNetwork } from './netcode/mesh-network.js';
13
+ import { WebTransportConnection } from './netcode/webtransport.js';
14
+ import { NetworkSim } from './netcode/netsim.js';
15
+ import { Lockstep } from './netcode/lockstep.js';
16
+ import { LagCompensator } from './netcode/lagcomp.js';
17
+ import { diff, patch, quantize } from './netcode/delta.js';
18
+ import { encode, decode } from './netcode/binary.js';
19
+
20
+ export function applyGameNetcode(game, Usion) {
21
+ game.diff = diff;
22
+ game.patch = patch;
23
+ game.quantize = quantize;
24
+ game.encode = encode;
25
+ game.decode = decode;
26
+
27
+ game.createInterpolation = function (opts) { return new SnapshotInterpolation(opts || {}); };
28
+ game.createPredictor = function (opts) { return new Predictor(opts || {}); };
29
+ game.createLagCompensator = function (opts) { return new LagCompensator(opts || {}); };
30
+ game.createLockstep = function (opts) { return new Lockstep(opts || {}); };
31
+
32
+ // ── Realtime channel router ──────────────────────────────────────────────
33
+ // Multiplexes realtime by action_type so replicate / mesh / mesh-network and
34
+ // the game's own onRealtime can coexist. Also the inject point for the
35
+ // network simulator's inbound side.
36
+ game._channels = game._channels || {};
37
+ game._userRealtime = game._userRealtime || null;
38
+ game._inboundDispatch = null;
39
+ function dispatchRealtime(data) {
40
+ const at = data && data.action_type;
41
+ if (at && game._channels[at]) { game._channels[at](data && (data.action_data !== undefined ? data.action_data : data), data); return; }
42
+ if (game._userRealtime) game._userRealtime(data);
43
+ }
44
+ game._dispatchRealtime = dispatchRealtime;
45
+ game._installRealtimeRouter = function () {
46
+ if (game._routerInstalled) return;
47
+ if (game._eventHandlers.realtime && game._eventHandlers.realtime !== game._router) game._userRealtime = game._eventHandlers.realtime;
48
+ game._router = function (data) { (game._inboundDispatch || game._dispatchRealtime)(data); };
49
+ game._eventHandlers.realtime = game._router;
50
+ game._routerInstalled = true;
51
+ };
52
+ /** Register a handler for one realtime action_type. Returns an unsubscribe fn. */
53
+ game._onChannel = function (type, handler) { game._channels[type] = handler; game._installRealtimeRouter(); return function () { delete game._channels[type]; }; };
54
+ // Route onRealtime through the router so channels keep working.
55
+ game.onRealtime = function (cb) { game._userRealtime = cb; game._installRealtimeRouter(); };
56
+
57
+ /** Fixed-rate outbound sender (defaults to game.realtime). */
58
+ game.createSender = function (opts) {
59
+ opts = opts || {};
60
+ const self = this;
61
+ const send = opts.send || function (type, data) { self.realtime(type, data); };
62
+ return new Coalescer({
63
+ hz: opts.hz || 20,
64
+ autoStart: opts.autoStart !== false,
65
+ onFlush: function (entries) { for (let i = 0; i < entries.length; i++) send(entries[i].type, entries[i].data); },
66
+ });
67
+ };
68
+
69
+ /**
70
+ * Coalesced, sequence-guarded, delta-compressed snapshot sender.
71
+ *
72
+ * Deltas are computed against the **last keyframe** (not the previous frame),
73
+ * so a single lost delta on an unreliable channel never desyncs — the next
74
+ * delta still applies to the keyframe the receiver holds. Each message
75
+ * carries a monotonic seq (`s`) and, for deltas, the keyframe seq it's based
76
+ * on (`b`); the receiver uses these to drop stale/out-of-order frames and to
77
+ * hold until it has the right keyframe. Optional float `precision`
78
+ * (quantization) and binary `encode` shrink the wire further.
79
+ *
80
+ * Pass `source` (a getter) to auto-read the state each tick — the basis for
81
+ * game.replicate(). Pair with game.createSnapshotReceiver().
82
+ */
83
+ game.createSnapshotSender = function (opts) {
84
+ opts = opts || {};
85
+ const self = this;
86
+ const channel = opts.channel || 'state';
87
+ const useDelta = opts.delta !== false;
88
+ const keyframeEvery = opts.keyframeEvery != null ? opts.keyframeEvery : 30;
89
+ const precision = opts.precision;
90
+ const enc = opts.encode === true ? encode : (typeof opts.encode === 'function' ? opts.encode : null);
91
+ const send = opts.send || function (type, data) { self.realtime(type, data); };
92
+ const source = typeof opts.source === 'function' ? opts.source : null;
93
+ let keyframeState = null; // an immutable clone (deltas diff against this)
94
+ let keyframeSeq = 0;
95
+ let seq = 0;
96
+ let sinceKey = 0;
97
+
98
+ function snapshotClone(v) { return v == null ? v : JSON.parse(JSON.stringify(v)); }
99
+
100
+ function emit(raw) {
101
+ if (raw === undefined || raw === null) return;
102
+ const state = precision != null ? quantize(raw, precision) : raw;
103
+ let payload;
104
+ const wantKey = !useDelta || keyframeState === null || sinceKey >= keyframeEvery;
105
+ if (wantKey) {
106
+ seq += 1; keyframeState = snapshotClone(state); keyframeSeq = seq; sinceKey = 0;
107
+ payload = { s: seq, f: state };
108
+ } else {
109
+ const d = diff(keyframeState, state);
110
+ if (d === undefined) return; // nothing changed — don't burn a seq
111
+ seq += 1; sinceKey += 1;
112
+ payload = { s: seq, b: keyframeSeq, d: d };
113
+ }
114
+ send(channel, enc ? enc(payload) : payload);
115
+ }
116
+
117
+ // Two drive modes: a `source` getter (read every tick) or queued send().
118
+ const hz = opts.hz || 20;
119
+ let queued;
120
+ const co = source ? null : new Coalescer({
121
+ hz: hz, autoStart: opts.autoStart !== false,
122
+ onFlush: function (entries) { for (let i = 0; i < entries.length; i++) if (entries[i].type === '__snap') queued = entries[i].data; emit(queued); },
123
+ });
124
+ let timer = null;
125
+ function start() {
126
+ if (co) { co.start(); return; }
127
+ if (timer || typeof setInterval === 'undefined') return;
128
+ timer = setInterval(function () { emit(source()); }, Math.max(1, Math.round(1000 / hz)));
129
+ }
130
+ function stop() { if (co) co.stop(); if (timer) { clearInterval(timer); timer = null; } }
131
+ if (source && opts.autoStart !== false) start();
132
+
133
+ return {
134
+ send: function (state) { if (co) co.queue('__snap', state); else emit(state); },
135
+ flush: function () { if (co) co.flush(); else if (source) emit(source()); },
136
+ start: start,
137
+ stop: stop,
138
+ reset: function () { keyframeState = null; keyframeSeq = 0; seq = 0; sinceKey = 0; },
139
+ };
140
+ };
141
+
142
+ /**
143
+ * Receiver for createSnapshotSender. Holds the keyframe baseline + last
144
+ * applied seq, so it safely drops stale/out-of-order frames and ignores
145
+ * deltas whose keyframe it missed (until the next keyframe arrives).
146
+ * @returns {{ receive:(msg:any)=>any, state:any, stats:object, reset:()=>void }}
147
+ */
148
+ game.createSnapshotReceiver = function (opts) {
149
+ opts = opts || {};
150
+ const dec = opts.decode === true ? decode : (typeof opts.decode === 'function' ? opts.decode : null);
151
+ let base = null, baseSeq = -1, current = null, appliedSeq = -1, dropped = 0;
152
+ return {
153
+ receive: function (msg) {
154
+ if (dec && (msg instanceof ArrayBuffer || ArrayBuffer.isView(msg))) msg = dec(msg);
155
+ if (!msg) return current;
156
+ if (msg.f !== undefined) {
157
+ if (msg.s !== undefined && msg.s <= appliedSeq) { dropped += 1; return current; }
158
+ base = msg.f; baseSeq = msg.s !== undefined ? msg.s : 0; current = msg.f; appliedSeq = baseSeq;
159
+ return current;
160
+ }
161
+ if (msg.d !== undefined) {
162
+ if (msg.b !== undefined && msg.b !== baseSeq) { dropped += 1; return current; } // missed keyframe
163
+ if (msg.s !== undefined && msg.s <= appliedSeq) { dropped += 1; return current; } // stale/dup
164
+ current = patch(base, msg.d);
165
+ if (msg.s !== undefined) appliedSeq = msg.s;
166
+ return current;
167
+ }
168
+ return current;
169
+ },
170
+ get state() { return current; },
171
+ get stats() { return { appliedSeq: appliedSeq, baseSeq: baseSeq, dropped: dropped }; },
172
+ reset: function () { base = null; baseSeq = -1; current = null; appliedSeq = -1; dropped = 0; },
173
+ };
174
+ };
175
+
176
+ /**
177
+ * Declarative state replication (host side). Mutate the plain object you pass
178
+ * in; the SDK auto-diffs and broadcasts it every tick (sequence-guarded,
179
+ * delta-compressed, optional quantize/binary). Clients read it with
180
+ * game.replica(). This is the ~40-lines-into-2 ergonomic.
181
+ * @returns {{ state:any, flush:()=>void, start:()=>void, stop:()=>void }}
182
+ */
183
+ game.replicate = function (obj, opts) {
184
+ opts = opts || {};
185
+ let state = obj != null ? obj : {};
186
+ const sender = this.createSnapshotSender(Object.assign({}, opts, { source: function () { return state; } }));
187
+ return {
188
+ get state() { return state; },
189
+ set state(v) { state = v; },
190
+ flush: function () { sender.flush(); },
191
+ start: function () { sender.start(); },
192
+ stop: function () { sender.stop(); },
193
+ };
194
+ };
195
+
196
+ /**
197
+ * Declarative state replication (client side). Receives what a host
198
+ * replicate()s on the same channel, drops stale/out-of-order frames, and
199
+ * (optionally) interpolates. Read `.state` for the latest authoritative state,
200
+ * or `.view()` for smoothed entities when `interpolate` keys are given.
201
+ * @returns {{ state:any, view:()=>any, onChange:(cb)=>void, stop:()=>void }}
202
+ */
203
+ game.replica = function (opts) {
204
+ opts = opts || {};
205
+ const channel = opts.channel || 'state';
206
+ const rx = this.createSnapshotReceiver({ decode: opts.decode });
207
+ const interp = opts.interpolate ? this.createInterpolation(typeof opts.interpolate === 'object' ? opts.interpolate : {}) : null;
208
+ const keys = typeof opts.interpolate === 'string' ? opts.interpolate : (opts.keys || (opts.interpolate && opts.interpolate.keys));
209
+ const group = opts.group || (opts.interpolate && opts.interpolate.group);
210
+ let onChange = null;
211
+ const off = this._onChannel(channel, function (data) {
212
+ const next = rx.receive(data);
213
+ if (interp && next != null) interp.add(next);
214
+ if (onChange) onChange(next);
215
+ });
216
+ return {
217
+ get state() { return rx.state; },
218
+ view: function () { return interp ? interp.calc(keys || 'x y', group) : rx.state; },
219
+ onChange: function (cb) { onChange = cb; },
220
+ stop: function () { off(); },
221
+ };
222
+ };
223
+
224
+ /**
225
+ * One-line WebRTC peer-to-peer setup (2 peers). Signaling rides the realtime
226
+ * channel via the router; gameplay then flows directly peer-to-peer over UDP.
227
+ */
228
+ game.createMesh = function (opts) {
229
+ opts = opts || {};
230
+ const self = this;
231
+ const channel = opts.signalChannel || 'signal';
232
+ const mesh = new MeshConnection(Object.assign({}, opts, {
233
+ sendSignal: function (payload) { self.realtime(channel, payload); },
234
+ }));
235
+ this._onChannel(channel, function (payload) { if (payload && payload.type) mesh.handleSignal(payload); });
236
+ return mesh;
237
+ };
238
+
239
+ /**
240
+ * N-peer full mesh. Signaling routed per-peer over the realtime channel (each
241
+ * message carries {to, from}); messages addressed to us are dispatched to the
242
+ * right peer connection. selfId defaults to the current user id.
243
+ * @returns {MeshNetwork}
244
+ */
245
+ game.createMeshNetwork = function (opts) {
246
+ opts = opts || {};
247
+ const self = this;
248
+ const channel = opts.signalChannel || 'mesh';
249
+ const selfId = opts.selfId || (Usion.user && Usion.user.getId && Usion.user.getId());
250
+ const net = new MeshNetwork(Object.assign({}, opts, {
251
+ selfId: selfId,
252
+ sendSignal: function (toPeerId, payload) { self.realtime(channel, { to: toPeerId, from: selfId, payload: payload }); },
253
+ }));
254
+ this._onChannel(channel, function (ad) { if (ad && ad.to === selfId && ad.from) net.handleSignal(ad.from, ad.payload); });
255
+ return net;
256
+ };
257
+
258
+ /**
259
+ * Inject artificial latency / jitter / packet loss locally to test how the
260
+ * game feels on a bad network (every studio has this). Affects both outbound
261
+ * realtime sends and inbound realtime dispatch. Call with null/false to turn
262
+ * it off. No-op safety: realtime must exist on the game module.
263
+ * @param {{latencyMs?:number,jitterMs?:number,lossPct?:number,dupPct?:number}|null} opts
264
+ */
265
+ game.simulateNetwork = function (opts) {
266
+ const self = this;
267
+ self._installRealtimeRouter();
268
+ if (!self._realtimeRaw && typeof self.realtime === 'function') self._realtimeRaw = self.realtime.bind(self);
269
+ if (!opts) {
270
+ if (self._realtimeRaw) self.realtime = self._realtimeRaw;
271
+ self._inboundDispatch = null;
272
+ if (self._sim) self._sim.flush();
273
+ self._sim = null;
274
+ return;
275
+ }
276
+ if (!self._sim) self._sim = new NetworkSim(opts);
277
+ else self._sim.set(opts);
278
+ if (self._realtimeRaw) self.realtime = self._sim.wrap(self._realtimeRaw);
279
+ self._inboundDispatch = self._sim.wrap(self._dispatchRealtime);
280
+ return self._sim;
281
+ };
282
+
283
+ /**
284
+ * Create a WebTransport (HTTP/3) connection — the lowest-latency client-server
285
+ * path (UDP-like datagrams, no TCP head-of-line blocking). `url` defaults to
286
+ * Usion.config.webTransportUrl. Call `connect()`, then `send()` (datagram) /
287
+ * `sendReliable()`; feed `onMessage` into a snapshot receiver. Direct/standalone
288
+ * games only (the iframe/WebView proxy relay can't carry datagrams).
289
+ * @returns {WebTransportConnection}
290
+ */
291
+ game.createWebTransport = function (opts) {
292
+ opts = opts || {};
293
+ const url = opts.url || (Usion.config && (Usion.config.webTransportUrl || Usion.config.wtUrl));
294
+ return new WebTransportConnection(Object.assign({}, opts, { url: url }));
295
+ };
296
+
297
+ /**
298
+ * Measure round-trip time once (single outstanding probe), updating the
299
+ * rolling estimate (game.getRtt). Direct mode uses the protocol ping/pong;
300
+ * platform mode uses a lightweight server ack. Resolves to ms, or null if
301
+ * unavailable (proxy mode, or not connected).
302
+ * @returns {Promise<number|null>}
303
+ */
304
+ game.ping = function () {
305
+ const self = this;
306
+ if (!self._pingMeter) self._pingMeter = new PingMeter();
307
+ if (self._pingInFlight) return self._pingInFlight; // single outstanding probe
308
+
309
+ let promise;
310
+ if (self.directMode) {
311
+ if (!self.directSocket || self.directSocket.readyState !== 1) return Promise.resolve(null);
312
+ promise = new Promise(function (resolve) {
313
+ const id = self._pingMeter.begin();
314
+ let done = false;
315
+ const finish = function () { if (done) return; done = true; resolve(self._pingMeter.end(id)); };
316
+ self._pongWaiters.push(finish);
317
+ self._sendDirect('ping', { t: Date.now() });
318
+ setTimeout(function () {
319
+ if (done) return; done = true;
320
+ const idx = self._pongWaiters.indexOf(finish);
321
+ if (idx >= 0) self._pongWaiters.splice(idx, 1);
322
+ delete self._pingMeter._outstanding[id];
323
+ resolve(null);
324
+ }, 3000);
325
+ });
326
+ } else if (self.socket && self.connected && !self._useProxy) {
327
+ promise = new Promise(function (resolve) {
328
+ const start = Date.now();
329
+ let done = false;
330
+ try {
331
+ self.socket.emit('game:ping', { t: start }, function () { if (done) return; done = true; resolve(self._pingMeter.sample(Date.now() - start)); });
332
+ } catch (e) { resolve(null); return; }
333
+ setTimeout(function () { if (done) return; done = true; resolve(null); }, 3000);
334
+ });
335
+ } else {
336
+ return Promise.resolve(null); // proxy mode has no point-to-point ack
337
+ }
338
+
339
+ self._pingInFlight = promise;
340
+ promise.then(function () { self._pingInFlight = null; }, function () { self._pingInFlight = null; });
341
+ return promise;
342
+ };
343
+
344
+ /** Latest smoothed round-trip time in ms (null until first ping). */
345
+ game.getRtt = function () { return this._pingMeter ? this._pingMeter.rtt : null; };
346
+ }
@@ -40,6 +40,10 @@ export function applyGameSocket(game, Usion) {
40
40
  reconnectionDelayMax: 10000
41
41
  });
42
42
 
43
+ // Route allow-listed backend server events (lobby, etc.) to the unified
44
+ // backend channel — robust to listener registration order.
45
+ if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
46
+
43
47
  self.socket.on('connect', function() {
44
48
  self.connected = true;
45
49
  self._connecting = false;
@@ -25,6 +25,11 @@ import { uiMethods } from './ui.js';
25
25
  import { miscMethods } from './misc.js';
26
26
  import { createFileStorageModule } from './file-storage.js';
27
27
  import { createGameModule } from './game-core.js';
28
+ import { createLobbyModule } from './lobby.js';
29
+ import { createLeaderboardModule } from './leaderboard.js';
30
+ import { createMatchmakingModule } from './matchmaking.js';
31
+ import { applyBackendChannel } from './backend-channel.js';
32
+ import { netcode } from './netcode/index.js';
28
33
 
29
34
  // Build the Usion object from core
30
35
  const Usion = Object.assign({}, core);
@@ -38,6 +43,14 @@ Usion.chat = createChatModule(Usion);
38
43
  Usion.bot = createBotModule(Usion);
39
44
  Usion.fileStorage = createFileStorageModule(Usion);
40
45
  Usion.game = createGameModule(Usion);
46
+ // Unified backend channel (used by lobby etc.; works standalone + embedded).
47
+ applyBackendChannel(Usion);
48
+ Usion.lobby = createLobbyModule(Usion);
49
+ Usion.leaderboard = createLeaderboardModule(Usion);
50
+ Usion.matchmaking = createMatchmakingModule(Usion);
51
+
52
+ // Netcode toolkit (transport-agnostic, zero-dependency).
53
+ Usion.netcode = netcode;
41
54
 
42
55
  // Attach results methods directly on Usion
43
56
  Object.assign(Usion, createResultsMethods(Usion));
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Usion SDK Leaderboard — scores for mini-games.
3
+ *
4
+ * Opt-in per game (the service must have `leaderboard.enabled`). Rides the
5
+ * unified backend channel, so it works in standalone AND embedded games.
6
+ *
7
+ * await Usion.leaderboard.submit(1500); // your score
8
+ * const friends = await Usion.leaderboard.friends(); // people you've messaged + you
9
+ * const top = await Usion.leaderboard.top(); // global top N
10
+ * const me = await Usion.leaderboard.me(); // { score, rank, total }
11
+ *
12
+ * The **friends** board is scoped to users you've messaged (your conversations)
13
+ * plus yourself — that's the "see the scores of people I've chatted with" view.
14
+ * Entries: { user_id, name, avatar, score, rank, is_me, metadata }.
15
+ */
16
+ export function createLeaderboardModule(Usion) {
17
+ function serviceId(opts) {
18
+ return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
19
+ }
20
+
21
+ return {
22
+ /** Submit a score (best score is kept, per the service's order config). */
23
+ submit: function (score, metadata, opts) {
24
+ return Usion._backendEmit('lb:submit', { service_id: serviceId(opts), score: score, metadata: metadata != null ? metadata : null });
25
+ },
26
+
27
+ /** Leaderboard of people you've messaged (plus you). Returns entries[]. */
28
+ friends: function (opts) {
29
+ opts = opts || {};
30
+ return Usion._backendEmit('lb:friends', { service_id: serviceId(opts), limit: opts.limit || 50 })
31
+ .then(function (r) { return (r && r.entries) || []; });
32
+ },
33
+
34
+ /** Global top N. Returns entries[]. */
35
+ top: function (opts) {
36
+ opts = opts || {};
37
+ return Usion._backendEmit('lb:top', { service_id: serviceId(opts), limit: opts.limit || 20 })
38
+ .then(function (r) { return (r && r.entries) || []; });
39
+ },
40
+
41
+ /** Your own score + global rank: { score, rank, total }. */
42
+ me: function (opts) {
43
+ return Usion._backendEmit('lb:me', { service_id: serviceId(opts) });
44
+ },
45
+ };
46
+ }