@usions/sdk 2.20.1 → 2.21.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/README.md CHANGED
@@ -139,10 +139,11 @@ follow four rules so both screens always agree:
139
139
  actions automatically. Use `onPlayerConnection` for the opponent's status;
140
140
  don't end the match on `'reconnecting'` (a ~15s grace window applies),
141
141
  treat `'gone'` as the opponent having left.
142
- 4. **Authority checkpoints with `setState`** — the room authority
143
- (`player_ids[0]`) should checkpoint at meaningful transitions so a
144
- rejoining client restores instantly (delivered as `game_state` in the
145
- join result and `onSync`; max 64 KB):
142
+ 4. **Checkpoint with `setState`** — any participant (not just the host)
143
+ should checkpoint at meaningful transitions so a rejoining client restores
144
+ instantly (delivered as `game_state` in the join result and `onSync`; max
145
+ 64 KB). Letting whoever made the move persist state keeps the snapshot
146
+ fresh even while the host is backgrounded:
146
147
  ```javascript
147
148
  await Usion.game.setState(fullGameState)
148
149
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usions/sdk",
3
- "version": "2.20.1",
3
+ "version": "2.21.0",
4
4
  "description": "Usion Mini App SDK for iframe games and services",
5
5
  "type": "module",
6
6
  "main": "src/modules/index.js",
package/src/browser.js CHANGED
@@ -69,7 +69,7 @@ var Usion = (function () {
69
69
  * Core Usion object with init, _post, _request
70
70
  */
71
71
  const core = {
72
- version: '2.20.1', // injected from package.json at build
72
+ version: '2.21.0', // injected from package.json at build
73
73
  config: {},
74
74
  _initialized: false,
75
75
  _initCallback: null,
@@ -1564,7 +1564,20 @@ var Usion = (function () {
1564
1564
  // backend channel — robust to listener registration order.
1565
1565
  if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
1566
1566
 
1567
+ // Socket.IO v4 emits 'reconnect' on the Manager, not the Socket — a
1568
+ // Socket-level 'reconnect' listener never fires. Track attempts on the
1569
+ // Manager and treat any connect after the first (per socket) as the
1570
+ // reconnect signal, dispatched below AFTER rejoin gating is in place.
1571
+ var hadConnect = false;
1572
+ if (self.socket.io && typeof self.socket.io.on === 'function') {
1573
+ self.socket.io.on('reconnect_attempt', function(n) {
1574
+ self._reconnAttempts = n;
1575
+ });
1576
+ }
1577
+
1567
1578
  self.socket.on('connect', function() {
1579
+ var isReconnect = hadConnect;
1580
+ hadConnect = true;
1568
1581
  self.connected = true;
1569
1582
  self._connecting = false;
1570
1583
  Usion.log('Game socket connected');
@@ -1595,12 +1608,21 @@ var Usion = (function () {
1595
1608
  .then(function() {
1596
1609
  self._rejoinPromise = null;
1597
1610
  // Send any moves queued while offline, now that membership
1598
- // and sync are restored. (Socket.IO v4 emits 'reconnect' on
1599
- // the Manager, not the Socket, so this is the reliable hook.)
1611
+ // and sync are restored. (This connect handler is the reliable
1612
+ // hook see the Manager-vs-Socket note above.)
1600
1613
  if (self._flushOfflineQueue) self._flushOfflineQueue();
1601
1614
  });
1602
1615
  }
1603
1616
 
1617
+ // Dispatched after _rejoinPromise is set, so anything reacting to
1618
+ // 'reconnect' (offline-queue flush, connection-state machine) sees
1619
+ // sends gated behind the rejoin+resync.
1620
+ if (isReconnect) {
1621
+ Usion.log('Game socket reconnected');
1622
+ self._dispatch('reconnect', self._reconnAttempts || 1);
1623
+ self._reconnAttempts = 0;
1624
+ }
1625
+
1604
1626
  resolve();
1605
1627
  });
1606
1628
 
@@ -1623,11 +1645,6 @@ var Usion = (function () {
1623
1645
  self._dispatch('disconnect', reason);
1624
1646
  });
1625
1647
 
1626
- self.socket.on('reconnect', function(attemptNumber) {
1627
- Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
1628
- self._dispatch('reconnect', attemptNumber);
1629
- });
1630
-
1631
1648
  // Game event handlers
1632
1649
  self.socket.on('game:joined', function(data) {
1633
1650
  if (data.sequence !== undefined) {
@@ -2043,7 +2060,15 @@ var Usion = (function () {
2043
2060
  self._joined = true;
2044
2061
  if (response.sequence !== undefined) {
2045
2062
  self._lastSequence = response.sequence;
2063
+ // The joined state reflects everything up to this sequence —
2064
+ // actions at or below it must not be re-delivered (same
2065
+ // baseline the proxy transport sets from GAME_JOINED).
2066
+ self._lastActionApplied = Math.max(self._lastActionApplied, response.sequence);
2046
2067
  }
2068
+ // Transport parity: proxy and direct modes dispatch 'joined';
2069
+ // the socket transport now does too, so onJoined / recovery
2070
+ // logic behaves the same everywhere.
2071
+ self._dispatch('joined', response);
2047
2072
  resolve(response);
2048
2073
  }
2049
2074
  });
@@ -2220,7 +2245,10 @@ var Usion = (function () {
2220
2245
  * in the join ack and in game:sync — recovery becomes "load checkpoint,
2221
2246
  * replay the tail" instead of replaying every action from zero.
2222
2247
  *
2223
- * Only the room authority (player_ids[0] / host) may call this. The
2248
+ * Any participant in the room may call this (not just the host) the
2249
+ * server only requires that you're in player_ids. This keeps the snapshot
2250
+ * fresh even while the host is backgrounded; the host-authority pattern
2251
+ * (only the host calls setState) still works for games that prefer it. The
2224
2252
  * serialized state is capped at 64 KB.
2225
2253
  *
2226
2254
  * @param {*} state - JSON-serializable authoritative game state
@@ -4612,6 +4640,285 @@ var Usion = (function () {
4612
4640
  game.getRtt = function () { return this._pingMeter ? this._pingMeter.rtt : null; };
4613
4641
  }
4614
4642
 
4643
+ /**
4644
+ * Usion SDK Game Reliability — the reconnection-lifecycle surface promised in
4645
+ * v2.16: sequence getters, the unified connection-state machine, and
4646
+ * syncedState (reconnect-safe authoritative shared state).
4647
+ *
4648
+ * Everything here rides the events the game module already dispatches
4649
+ * ('disconnect' / 'reconnect' / 'joined' / 'sync' / 'action'), so it behaves
4650
+ * identically across the socket, embedded-proxy, and direct transports.
4651
+ */
4652
+
4653
+ function applyGameReliability(game, Usion) {
4654
+ // ── Sequence getters ──────────────────────────────────────────────────────
4655
+
4656
+ /** Highest action sequence seen (from joins, syncs, and live actions). */
4657
+ game.getLastSequence = function () { return this._lastSequence || 0; };
4658
+
4659
+ /** Highest action sequence applied locally; trails while catching up. */
4660
+ game.getLastAppliedSequence = function () { return this._lastActionApplied || 0; };
4661
+
4662
+ // ── Connection-state machine ──────────────────────────────────────────────
4663
+ // connected → disconnected → rejoining → reconnected → connected.
4664
+ // 'reconnected' is a transient notification state: observers see it, then
4665
+ // the machine settles back to 'connected' in the same tick.
4666
+
4667
+ game._connState = 'disconnected';
4668
+ game._awaitResync = false;
4669
+
4670
+ game._setConnState = function (state) {
4671
+ if (this._connState === state) return;
4672
+ this._connState = state;
4673
+ this._dispatch('connectionState', state);
4674
+ };
4675
+
4676
+ /** Current connection state, synchronously. */
4677
+ game.getConnectionState = function () { return this._connState; };
4678
+
4679
+ /**
4680
+ * Observe connection-state transitions — one hook to drive a
4681
+ * "Reconnecting…" overlay and gate input, identical on every transport.
4682
+ * Single handler (last wins); returns an unsubscribe fn. For multiple
4683
+ * listeners use game.on('connectionState', cb).
4684
+ */
4685
+ game.onConnectionState = function (callback) { return this._setHandler('connectionState', callback); };
4686
+
4687
+ /**
4688
+ * Fires once per reconnect, after the resync completes, with
4689
+ * { state, lastSequence, viaSync }. Restore local state here — it does NOT
4690
+ * fire for a manually-requested sync.
4691
+ */
4692
+ game.onReconnected = function (callback) { return this._setHandler('reconnected', callback); };
4693
+
4694
+ game.on('disconnect', function () {
4695
+ game._awaitResync = false;
4696
+ game._setConnState('disconnected');
4697
+ });
4698
+
4699
+ game.on('reconnect', function () {
4700
+ if (game.roomId) {
4701
+ game._awaitResync = true;
4702
+ game._setConnState('rejoining');
4703
+ } else {
4704
+ game._setConnState('connected');
4705
+ }
4706
+ });
4707
+
4708
+ game.on('sync', function (data) {
4709
+ if (!game._awaitResync) return;
4710
+ game._awaitResync = false;
4711
+ game._dispatch('reconnected', {
4712
+ state: (data && data.game_state) || null,
4713
+ lastSequence: game._lastSequence || 0,
4714
+ viaSync: true,
4715
+ });
4716
+ game._setConnState('reconnected');
4717
+ game._setConnState('connected');
4718
+ });
4719
+
4720
+ // Transport-level connects (initial connect in every mode) resolve through
4721
+ // connect()/connectDirect() — reflect them in the state machine.
4722
+ function wrapConnect(name) {
4723
+ var orig = game[name];
4724
+ if (typeof orig !== 'function') return;
4725
+ game[name] = function () {
4726
+ var self = this;
4727
+ return orig.apply(self, arguments).then(function (result) {
4728
+ if (!self._awaitResync) self._setConnState('connected');
4729
+ return result;
4730
+ });
4731
+ };
4732
+ }
4733
+ wrapConnect('connect');
4734
+ wrapConnect('connectDirect');
4735
+
4736
+ // ── Host tracking ─────────────────────────────────────────────────────────
4737
+ // Kept on the game module (registered at module creation, so it never
4738
+ // misses an event) — a syncedState created AFTER the join still knows who
4739
+ // the authority is.
4740
+
4741
+ game._hostId = null;
4742
+ function trackHost(d) {
4743
+ if (!d) return;
4744
+ if (d.host_id) game._hostId = d.host_id;
4745
+ else if (d.player_ids && d.player_ids.length) game._hostId = d.player_ids[0];
4746
+ }
4747
+ game.on('joined', trackHost);
4748
+ game.on('playerJoined', trackHost);
4749
+
4750
+ // ── syncedState ───────────────────────────────────────────────────────────
4751
+
4752
+ /**
4753
+ * Reconnect-safe authoritative shared state. Commits are sequenced actions
4754
+ * applied through `reduce` on every client in the same order (deduped by
4755
+ * sequence); the authority (player_ids[0] by default) auto-checkpoints via
4756
+ * setState, and (re)joining clients recover automatically (checkpoint +
4757
+ * un-checkpointed tail). Degrades to local-apply in direct mode.
4758
+ * Full contract: SyncedStateOptions / SyncedState in types/index.d.ts.
4759
+ */
4760
+ game.syncedState = function (initial, opts) {
4761
+ opts = opts || {};
4762
+ var self = this;
4763
+ var reduce = typeof opts.reduce === 'function'
4764
+ ? opts.reduce
4765
+ : function (state, action) { return Object.assign({}, state, action.data); };
4766
+ var checkpointEvery = opts.checkpointEvery != null ? opts.checkpointEvery : 1;
4767
+ var authorityMode = opts.authority === 'all' ? 'all' : 'host';
4768
+ var defaultType = opts.type || 'update';
4769
+
4770
+ var state = initial === undefined ? {} : initial;
4771
+ var appliedSeq = 0;
4772
+ var sinceCheckpoint = 0;
4773
+ var lastGapRequest = -1;
4774
+ var changeCbs = [];
4775
+ var offs = [];
4776
+
4777
+ function notify(reason) {
4778
+ var copy = changeCbs.slice();
4779
+ for (var i = 0; i < copy.length; i++) {
4780
+ try { copy[i](state, reason); } catch (e) { Usion.log('syncedState onChange error: ' + e.message); }
4781
+ }
4782
+ }
4783
+
4784
+ function isAuthority() {
4785
+ if (authorityMode === 'all') return true;
4786
+ var me = self.playerId || (Usion.user && Usion.user.getId && Usion.user.getId());
4787
+ return !!(me && self._hostId && me === self._hostId);
4788
+ }
4789
+
4790
+ function doCheckpoint() {
4791
+ if (self.directMode) {
4792
+ return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode' });
4793
+ }
4794
+ // The wrapper carries the sequence the checkpoint reflects, so recovery
4795
+ // knows exactly where the un-checkpointed tail starts.
4796
+ return self.setState({ __usionSyncedState: 1, seq: appliedSeq, state: state });
4797
+ }
4798
+
4799
+ function maybeAutoCheckpoint() {
4800
+ if (!(checkpointEvery > 0) || !isAuthority()) return;
4801
+ sinceCheckpoint += 1;
4802
+ if (sinceCheckpoint >= checkpointEvery) {
4803
+ sinceCheckpoint = 0;
4804
+ doCheckpoint();
4805
+ }
4806
+ }
4807
+
4808
+ function applyOne(playerId, type, data, sequence) {
4809
+ if (sequence !== undefined && sequence <= appliedSeq) return false; // dedupe
4810
+ var next;
4811
+ try {
4812
+ next = reduce(state, { playerId: playerId, type: type, data: data, sequence: sequence });
4813
+ } catch (e) {
4814
+ Usion.log('syncedState reduce error: ' + e.message);
4815
+ return false;
4816
+ }
4817
+ if (next !== undefined) state = next;
4818
+ if (sequence !== undefined) appliedSeq = sequence;
4819
+ return true;
4820
+ }
4821
+
4822
+ function onAction(d) {
4823
+ if (!d) return;
4824
+ if (applyOne(d.player_id, d.action_type, d.action_data, d.sequence)) {
4825
+ notify('action');
4826
+ maybeAutoCheckpoint();
4827
+ }
4828
+ }
4829
+
4830
+ // Recover from a join ack or sync payload: load the checkpoint (if it is
4831
+ // ours and newer), replay the action tail in order, and if the server's
4832
+ // sequence is still ahead of us, ask once for the missing range.
4833
+ function recover(d) {
4834
+ if (!d) return;
4835
+ var changed = false;
4836
+
4837
+ var gs = d.game_state;
4838
+ if (gs && gs.__usionSyncedState && typeof gs.seq === 'number'
4839
+ && gs.seq >= appliedSeq && gs.state !== undefined) {
4840
+ state = gs.state;
4841
+ appliedSeq = gs.seq;
4842
+ changed = true;
4843
+ }
4844
+
4845
+ var actions = d.actions;
4846
+ if (actions && actions.length) {
4847
+ var sorted = actions.slice().sort(function (a, b) {
4848
+ return (a.sequence || 0) - (b.sequence || 0);
4849
+ });
4850
+ for (var i = 0; i < sorted.length; i++) {
4851
+ var item = sorted[i];
4852
+ if (item && applyOne(item.player_id, item.action_type, item.action_data, item.sequence)) {
4853
+ changed = true;
4854
+ }
4855
+ }
4856
+ }
4857
+
4858
+ if (changed) notify('recover');
4859
+
4860
+ // Gap fill: the checkpoint may lag the room sequence (checkpointEvery>1).
4861
+ // Request the tail from OUR applied sequence — once per position, so a
4862
+ // trimmed history can't loop us.
4863
+ if (d.sequence !== undefined && d.sequence > appliedSeq && lastGapRequest !== appliedSeq) {
4864
+ lastGapRequest = appliedSeq;
4865
+ try { self.requestSync(appliedSeq); } catch (e) { /* non-fatal */ }
4866
+ }
4867
+ }
4868
+
4869
+ offs.push(self.on('action', onAction));
4870
+ offs.push(self.on('sync', recover));
4871
+ offs.push(self.on('joined', recover));
4872
+
4873
+ return {
4874
+ /** Current state value. */
4875
+ get: function () { return state; },
4876
+
4877
+ /**
4878
+ * Commit a change: commit(data) with the default action type, or
4879
+ * commit(type, data, opts) to name it (opts forwarded to game.action,
4880
+ * e.g. nextTurn / queueOffline). Applied exactly once via the echo.
4881
+ */
4882
+ commit: function (typeOrData, data, actionOpts) {
4883
+ var t, payload, o;
4884
+ if (typeof typeOrData === 'string') { t = typeOrData; payload = data; o = actionOpts; }
4885
+ else { t = defaultType; payload = typeOrData; o = data; }
4886
+ if (self.directMode) {
4887
+ // No platform echo in direct mode — apply locally, then send.
4888
+ if (applyOne(self.playerId, t, payload, undefined)) notify('action');
4889
+ return self.action(t, payload, o);
4890
+ }
4891
+ return self.action(t, payload, o);
4892
+ },
4893
+
4894
+ /** Subscribe to changes: cb(state, reason). Returns an unsubscribe fn. */
4895
+ onChange: function (cb) {
4896
+ changeCbs.push(cb);
4897
+ return function () {
4898
+ var i = changeCbs.indexOf(cb);
4899
+ if (i >= 0) changeCbs.splice(i, 1);
4900
+ };
4901
+ },
4902
+
4903
+ /** Whether this client is the checkpointing authority. */
4904
+ isAuthority: isAuthority,
4905
+
4906
+ /** Force a server checkpoint now (no-op result in direct mode). */
4907
+ checkpoint: function () { return doCheckpoint(); },
4908
+
4909
+ /** Sequence of the last action applied into this state. */
4910
+ getSequence: function () { return appliedSeq; },
4911
+
4912
+ /** Detach all listeners; the instance stops receiving updates. */
4913
+ destroy: function () {
4914
+ for (var i = 0; i < offs.length; i++) { try { offs[i](); } catch (e) { /* noop */ } }
4915
+ offs.length = 0;
4916
+ changeCbs.length = 0;
4917
+ },
4918
+ };
4919
+ };
4920
+ }
4921
+
4615
4922
  /**
4616
4923
  * Usion SDK Game Core — game module base, connect routing, event registrations
4617
4924
  */
@@ -4637,6 +4944,8 @@ var Usion = (function () {
4637
4944
  reconnect: 'reconnect',
4638
4945
  connection_error: 'connectionError',
4639
4946
  room_assigned: 'roomAssigned',
4947
+ connection_state: 'connectionState',
4948
+ reconnected: 'reconnected',
4640
4949
  };
4641
4950
 
4642
4951
  function _normalizeEventName(event) {
@@ -4850,6 +5159,8 @@ var Usion = (function () {
4850
5159
  applyGameProxy(game, Usion);
4851
5160
  applyGameMethods(game, Usion);
4852
5161
  applyGameNetcode(game, Usion);
5162
+ // Applied last: wraps connect/connectDirect and rides dispatched events.
5163
+ applyGameReliability(game, Usion);
4853
5164
 
4854
5165
  // Foreground catch-up safety net (generic across every transport).
4855
5166
  //
@@ -7,6 +7,7 @@ import { applyGameSocket } from './game-socket.js';
7
7
  import { applyGameProxy } from './game-proxy.js';
8
8
  import { applyGameMethods } from './game-methods.js';
9
9
  import { applyGameNetcode } from './game-netcode.js';
10
+ import { applyGameReliability } from './game-reliability.js';
10
11
 
11
12
  // Map any reasonable spelling of a game event onto the internal handler
12
13
  // name: 'game:player_joined' / 'player_joined' / 'playerJoined' → 'playerJoined'.
@@ -28,6 +29,8 @@ const _EVENT_ALIASES = {
28
29
  reconnect: 'reconnect',
29
30
  connection_error: 'connectionError',
30
31
  room_assigned: 'roomAssigned',
32
+ connection_state: 'connectionState',
33
+ reconnected: 'reconnected',
31
34
  };
32
35
 
33
36
  function _normalizeEventName(event) {
@@ -241,6 +244,8 @@ export function createGameModule(Usion) {
241
244
  applyGameProxy(game, Usion);
242
245
  applyGameMethods(game, Usion);
243
246
  applyGameNetcode(game, Usion);
247
+ // Applied last: wraps connect/connectDirect and rides dispatched events.
248
+ applyGameReliability(game, Usion);
244
249
 
245
250
  // Foreground catch-up safety net (generic across every transport).
246
251
  //
@@ -71,7 +71,15 @@ export function applyGameMethods(game, Usion) {
71
71
  self._joined = true;
72
72
  if (response.sequence !== undefined) {
73
73
  self._lastSequence = response.sequence;
74
+ // The joined state reflects everything up to this sequence —
75
+ // actions at or below it must not be re-delivered (same
76
+ // baseline the proxy transport sets from GAME_JOINED).
77
+ self._lastActionApplied = Math.max(self._lastActionApplied, response.sequence);
74
78
  }
79
+ // Transport parity: proxy and direct modes dispatch 'joined';
80
+ // the socket transport now does too, so onJoined / recovery
81
+ // logic behaves the same everywhere.
82
+ self._dispatch('joined', response);
75
83
  resolve(response);
76
84
  }
77
85
  });
@@ -248,7 +256,10 @@ export function applyGameMethods(game, Usion) {
248
256
  * in the join ack and in game:sync — recovery becomes "load checkpoint,
249
257
  * replay the tail" instead of replaying every action from zero.
250
258
  *
251
- * Only the room authority (player_ids[0] / host) may call this. The
259
+ * Any participant in the room may call this (not just the host) the
260
+ * server only requires that you're in player_ids. This keeps the snapshot
261
+ * fresh even while the host is backgrounded; the host-authority pattern
262
+ * (only the host calls setState) still works for games that prefer it. The
252
263
  * serialized state is capped at 64 KB.
253
264
  *
254
265
  * @param {*} state - JSON-serializable authoritative game state
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Usion SDK Game Reliability — the reconnection-lifecycle surface promised in
3
+ * v2.16: sequence getters, the unified connection-state machine, and
4
+ * syncedState (reconnect-safe authoritative shared state).
5
+ *
6
+ * Everything here rides the events the game module already dispatches
7
+ * ('disconnect' / 'reconnect' / 'joined' / 'sync' / 'action'), so it behaves
8
+ * identically across the socket, embedded-proxy, and direct transports.
9
+ */
10
+
11
+ export function applyGameReliability(game, Usion) {
12
+ // ── Sequence getters ──────────────────────────────────────────────────────
13
+
14
+ /** Highest action sequence seen (from joins, syncs, and live actions). */
15
+ game.getLastSequence = function () { return this._lastSequence || 0; };
16
+
17
+ /** Highest action sequence applied locally; trails while catching up. */
18
+ game.getLastAppliedSequence = function () { return this._lastActionApplied || 0; };
19
+
20
+ // ── Connection-state machine ──────────────────────────────────────────────
21
+ // connected → disconnected → rejoining → reconnected → connected.
22
+ // 'reconnected' is a transient notification state: observers see it, then
23
+ // the machine settles back to 'connected' in the same tick.
24
+
25
+ game._connState = 'disconnected';
26
+ game._awaitResync = false;
27
+
28
+ game._setConnState = function (state) {
29
+ if (this._connState === state) return;
30
+ this._connState = state;
31
+ this._dispatch('connectionState', state);
32
+ };
33
+
34
+ /** Current connection state, synchronously. */
35
+ game.getConnectionState = function () { return this._connState; };
36
+
37
+ /**
38
+ * Observe connection-state transitions — one hook to drive a
39
+ * "Reconnecting…" overlay and gate input, identical on every transport.
40
+ * Single handler (last wins); returns an unsubscribe fn. For multiple
41
+ * listeners use game.on('connectionState', cb).
42
+ */
43
+ game.onConnectionState = function (callback) { return this._setHandler('connectionState', callback); };
44
+
45
+ /**
46
+ * Fires once per reconnect, after the resync completes, with
47
+ * { state, lastSequence, viaSync }. Restore local state here — it does NOT
48
+ * fire for a manually-requested sync.
49
+ */
50
+ game.onReconnected = function (callback) { return this._setHandler('reconnected', callback); };
51
+
52
+ game.on('disconnect', function () {
53
+ game._awaitResync = false;
54
+ game._setConnState('disconnected');
55
+ });
56
+
57
+ game.on('reconnect', function () {
58
+ if (game.roomId) {
59
+ game._awaitResync = true;
60
+ game._setConnState('rejoining');
61
+ } else {
62
+ game._setConnState('connected');
63
+ }
64
+ });
65
+
66
+ game.on('sync', function (data) {
67
+ if (!game._awaitResync) return;
68
+ game._awaitResync = false;
69
+ game._dispatch('reconnected', {
70
+ state: (data && data.game_state) || null,
71
+ lastSequence: game._lastSequence || 0,
72
+ viaSync: true,
73
+ });
74
+ game._setConnState('reconnected');
75
+ game._setConnState('connected');
76
+ });
77
+
78
+ // Transport-level connects (initial connect in every mode) resolve through
79
+ // connect()/connectDirect() — reflect them in the state machine.
80
+ function wrapConnect(name) {
81
+ var orig = game[name];
82
+ if (typeof orig !== 'function') return;
83
+ game[name] = function () {
84
+ var self = this;
85
+ return orig.apply(self, arguments).then(function (result) {
86
+ if (!self._awaitResync) self._setConnState('connected');
87
+ return result;
88
+ });
89
+ };
90
+ }
91
+ wrapConnect('connect');
92
+ wrapConnect('connectDirect');
93
+
94
+ // ── Host tracking ─────────────────────────────────────────────────────────
95
+ // Kept on the game module (registered at module creation, so it never
96
+ // misses an event) — a syncedState created AFTER the join still knows who
97
+ // the authority is.
98
+
99
+ game._hostId = null;
100
+ function trackHost(d) {
101
+ if (!d) return;
102
+ if (d.host_id) game._hostId = d.host_id;
103
+ else if (d.player_ids && d.player_ids.length) game._hostId = d.player_ids[0];
104
+ }
105
+ game.on('joined', trackHost);
106
+ game.on('playerJoined', trackHost);
107
+
108
+ // ── syncedState ───────────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Reconnect-safe authoritative shared state. Commits are sequenced actions
112
+ * applied through `reduce` on every client in the same order (deduped by
113
+ * sequence); the authority (player_ids[0] by default) auto-checkpoints via
114
+ * setState, and (re)joining clients recover automatically (checkpoint +
115
+ * un-checkpointed tail). Degrades to local-apply in direct mode.
116
+ * Full contract: SyncedStateOptions / SyncedState in types/index.d.ts.
117
+ */
118
+ game.syncedState = function (initial, opts) {
119
+ opts = opts || {};
120
+ var self = this;
121
+ var reduce = typeof opts.reduce === 'function'
122
+ ? opts.reduce
123
+ : function (state, action) { return Object.assign({}, state, action.data); };
124
+ var checkpointEvery = opts.checkpointEvery != null ? opts.checkpointEvery : 1;
125
+ var authorityMode = opts.authority === 'all' ? 'all' : 'host';
126
+ var defaultType = opts.type || 'update';
127
+
128
+ var state = initial === undefined ? {} : initial;
129
+ var appliedSeq = 0;
130
+ var sinceCheckpoint = 0;
131
+ var lastGapRequest = -1;
132
+ var changeCbs = [];
133
+ var offs = [];
134
+
135
+ function notify(reason) {
136
+ var copy = changeCbs.slice();
137
+ for (var i = 0; i < copy.length; i++) {
138
+ try { copy[i](state, reason); } catch (e) { Usion.log('syncedState onChange error: ' + e.message); }
139
+ }
140
+ }
141
+
142
+ function isAuthority() {
143
+ if (authorityMode === 'all') return true;
144
+ var me = self.playerId || (Usion.user && Usion.user.getId && Usion.user.getId());
145
+ return !!(me && self._hostId && me === self._hostId);
146
+ }
147
+
148
+ function doCheckpoint() {
149
+ if (self.directMode) {
150
+ return Promise.resolve({ success: false, error: 'not_supported_in_direct_mode' });
151
+ }
152
+ // The wrapper carries the sequence the checkpoint reflects, so recovery
153
+ // knows exactly where the un-checkpointed tail starts.
154
+ return self.setState({ __usionSyncedState: 1, seq: appliedSeq, state: state });
155
+ }
156
+
157
+ function maybeAutoCheckpoint() {
158
+ if (!(checkpointEvery > 0) || !isAuthority()) return;
159
+ sinceCheckpoint += 1;
160
+ if (sinceCheckpoint >= checkpointEvery) {
161
+ sinceCheckpoint = 0;
162
+ doCheckpoint();
163
+ }
164
+ }
165
+
166
+ function applyOne(playerId, type, data, sequence) {
167
+ if (sequence !== undefined && sequence <= appliedSeq) return false; // dedupe
168
+ var next;
169
+ try {
170
+ next = reduce(state, { playerId: playerId, type: type, data: data, sequence: sequence });
171
+ } catch (e) {
172
+ Usion.log('syncedState reduce error: ' + e.message);
173
+ return false;
174
+ }
175
+ if (next !== undefined) state = next;
176
+ if (sequence !== undefined) appliedSeq = sequence;
177
+ return true;
178
+ }
179
+
180
+ function onAction(d) {
181
+ if (!d) return;
182
+ if (applyOne(d.player_id, d.action_type, d.action_data, d.sequence)) {
183
+ notify('action');
184
+ maybeAutoCheckpoint();
185
+ }
186
+ }
187
+
188
+ // Recover from a join ack or sync payload: load the checkpoint (if it is
189
+ // ours and newer), replay the action tail in order, and if the server's
190
+ // sequence is still ahead of us, ask once for the missing range.
191
+ function recover(d) {
192
+ if (!d) return;
193
+ var changed = false;
194
+
195
+ var gs = d.game_state;
196
+ if (gs && gs.__usionSyncedState && typeof gs.seq === 'number'
197
+ && gs.seq >= appliedSeq && gs.state !== undefined) {
198
+ state = gs.state;
199
+ appliedSeq = gs.seq;
200
+ changed = true;
201
+ }
202
+
203
+ var actions = d.actions;
204
+ if (actions && actions.length) {
205
+ var sorted = actions.slice().sort(function (a, b) {
206
+ return (a.sequence || 0) - (b.sequence || 0);
207
+ });
208
+ for (var i = 0; i < sorted.length; i++) {
209
+ var item = sorted[i];
210
+ if (item && applyOne(item.player_id, item.action_type, item.action_data, item.sequence)) {
211
+ changed = true;
212
+ }
213
+ }
214
+ }
215
+
216
+ if (changed) notify('recover');
217
+
218
+ // Gap fill: the checkpoint may lag the room sequence (checkpointEvery>1).
219
+ // Request the tail from OUR applied sequence — once per position, so a
220
+ // trimmed history can't loop us.
221
+ if (d.sequence !== undefined && d.sequence > appliedSeq && lastGapRequest !== appliedSeq) {
222
+ lastGapRequest = appliedSeq;
223
+ try { self.requestSync(appliedSeq); } catch (e) { /* non-fatal */ }
224
+ }
225
+ }
226
+
227
+ offs.push(self.on('action', onAction));
228
+ offs.push(self.on('sync', recover));
229
+ offs.push(self.on('joined', recover));
230
+
231
+ return {
232
+ /** Current state value. */
233
+ get: function () { return state; },
234
+
235
+ /**
236
+ * Commit a change: commit(data) with the default action type, or
237
+ * commit(type, data, opts) to name it (opts forwarded to game.action,
238
+ * e.g. nextTurn / queueOffline). Applied exactly once via the echo.
239
+ */
240
+ commit: function (typeOrData, data, actionOpts) {
241
+ var t, payload, o;
242
+ if (typeof typeOrData === 'string') { t = typeOrData; payload = data; o = actionOpts; }
243
+ else { t = defaultType; payload = typeOrData; o = data; }
244
+ if (self.directMode) {
245
+ // No platform echo in direct mode — apply locally, then send.
246
+ if (applyOne(self.playerId, t, payload, undefined)) notify('action');
247
+ return self.action(t, payload, o);
248
+ }
249
+ return self.action(t, payload, o);
250
+ },
251
+
252
+ /** Subscribe to changes: cb(state, reason). Returns an unsubscribe fn. */
253
+ onChange: function (cb) {
254
+ changeCbs.push(cb);
255
+ return function () {
256
+ var i = changeCbs.indexOf(cb);
257
+ if (i >= 0) changeCbs.splice(i, 1);
258
+ };
259
+ },
260
+
261
+ /** Whether this client is the checkpointing authority. */
262
+ isAuthority: isAuthority,
263
+
264
+ /** Force a server checkpoint now (no-op result in direct mode). */
265
+ checkpoint: function () { return doCheckpoint(); },
266
+
267
+ /** Sequence of the last action applied into this state. */
268
+ getSequence: function () { return appliedSeq; },
269
+
270
+ /** Detach all listeners; the instance stops receiving updates. */
271
+ destroy: function () {
272
+ for (var i = 0; i < offs.length; i++) { try { offs[i](); } catch (e) { /* noop */ } }
273
+ offs.length = 0;
274
+ changeCbs.length = 0;
275
+ },
276
+ };
277
+ };
278
+ }
@@ -44,7 +44,20 @@ export function applyGameSocket(game, Usion) {
44
44
  // backend channel — robust to listener registration order.
45
45
  if (Usion._bindBackendSocket) Usion._bindBackendSocket(self.socket);
46
46
 
47
+ // Socket.IO v4 emits 'reconnect' on the Manager, not the Socket — a
48
+ // Socket-level 'reconnect' listener never fires. Track attempts on the
49
+ // Manager and treat any connect after the first (per socket) as the
50
+ // reconnect signal, dispatched below AFTER rejoin gating is in place.
51
+ var hadConnect = false;
52
+ if (self.socket.io && typeof self.socket.io.on === 'function') {
53
+ self.socket.io.on('reconnect_attempt', function(n) {
54
+ self._reconnAttempts = n;
55
+ });
56
+ }
57
+
47
58
  self.socket.on('connect', function() {
59
+ var isReconnect = hadConnect;
60
+ hadConnect = true;
48
61
  self.connected = true;
49
62
  self._connecting = false;
50
63
  Usion.log('Game socket connected');
@@ -75,12 +88,21 @@ export function applyGameSocket(game, Usion) {
75
88
  .then(function() {
76
89
  self._rejoinPromise = null;
77
90
  // Send any moves queued while offline, now that membership
78
- // and sync are restored. (Socket.IO v4 emits 'reconnect' on
79
- // the Manager, not the Socket, so this is the reliable hook.)
91
+ // and sync are restored. (This connect handler is the reliable
92
+ // hook see the Manager-vs-Socket note above.)
80
93
  if (self._flushOfflineQueue) self._flushOfflineQueue();
81
94
  });
82
95
  }
83
96
 
97
+ // Dispatched after _rejoinPromise is set, so anything reacting to
98
+ // 'reconnect' (offline-queue flush, connection-state machine) sees
99
+ // sends gated behind the rejoin+resync.
100
+ if (isReconnect) {
101
+ Usion.log('Game socket reconnected');
102
+ self._dispatch('reconnect', self._reconnAttempts || 1);
103
+ self._reconnAttempts = 0;
104
+ }
105
+
84
106
  resolve();
85
107
  });
86
108
 
@@ -103,11 +125,6 @@ export function applyGameSocket(game, Usion) {
103
125
  self._dispatch('disconnect', reason);
104
126
  });
105
127
 
106
- self.socket.on('reconnect', function(attemptNumber) {
107
- Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
108
- self._dispatch('reconnect', attemptNumber);
109
- });
110
-
111
128
  // Game event handlers
112
129
  self.socket.on('game:joined', function(data) {
113
130
  if (data.sequence !== undefined) {
package/types/index.d.ts CHANGED
@@ -274,6 +274,60 @@ export interface GameInviteResult {
274
274
  invited: string[];
275
275
  }
276
276
 
277
+ /** Unified connection-state machine, identical across all transports. */
278
+ export type GameConnectionState = 'connected' | 'disconnected' | 'rejoining' | 'reconnected';
279
+
280
+ /** Payload of game.onReconnected — fires once after a reconnect's resync. */
281
+ export interface ReconnectedInfo {
282
+ /** The server checkpoint from the resync (may be empty). */
283
+ state: Record<string, any> | null;
284
+ /** Highest action sequence after the resync. */
285
+ lastSequence: number;
286
+ /** True when recovery came through a game:sync payload. */
287
+ viaSync: boolean;
288
+ }
289
+
290
+ /** An action as seen by a syncedState reducer. */
291
+ export interface SyncedStateAction {
292
+ playerId: string;
293
+ type: string;
294
+ data: any;
295
+ /** Authoritative sequence; undefined only for direct-mode local applies. */
296
+ sequence: number | undefined;
297
+ }
298
+
299
+ /** Options for game.syncedState(). */
300
+ export interface SyncedStateOptions<S = Record<string, any>> {
301
+ /** Pure reducer applied to every action; default shallow-merges action.data. */
302
+ reduce?: (state: S, action: SyncedStateAction) => S;
303
+ /** Authority checkpoint cadence in actions (1 = every action; 0 disables). */
304
+ checkpointEvery?: number;
305
+ /** Who checkpoints: 'host' (player_ids[0], default) or 'all' participants. */
306
+ authority?: 'host' | 'all';
307
+ /** Default action type used by commit(data) (default 'update'). */
308
+ type?: string;
309
+ }
310
+
311
+ /** Reconnect-safe authoritative shared state (see game.syncedState). */
312
+ export interface SyncedState<S = Record<string, any>> {
313
+ /** Current state value. */
314
+ get(): S;
315
+ /** Commit with the default action type. Applied exactly once via the echo. */
316
+ commit(data: Record<string, any>): Promise<ActionResult>;
317
+ /** Commit a named action; opts forwarded to game.action (nextTurn, …). */
318
+ commit(type: string, data?: Record<string, any>, opts?: GameActionOptions): Promise<ActionResult>;
319
+ /** Subscribe to changes: cb(state, reason). Returns an unsubscribe fn. */
320
+ onChange(callback: (state: S, reason: 'action' | 'recover') => void): UnsubscribeFn;
321
+ /** Whether this client is the checkpointing authority. */
322
+ isAuthority(): boolean;
323
+ /** Force a server checkpoint now (no-op result in direct mode). */
324
+ checkpoint(): Promise<{ success: boolean; error?: string; code?: UsionErrorCode }>;
325
+ /** Sequence of the last action applied into this state. */
326
+ getSequence(): number;
327
+ /** Detach all listeners; the instance stops receiving updates. */
328
+ destroy(): void;
329
+ }
330
+
277
331
  export interface GameModule {
278
332
  // Connection
279
333
  connect(socketUrl?: string, token?: string): Promise<void>;
@@ -300,8 +354,9 @@ export interface GameModule {
300
354
  // pause input and show a "reconnecting" indicator.
301
355
  // - For turn-based games, pass { nextTurn } on each move and trust
302
356
  // current_turn from join/sync instead of deriving the turn locally.
303
- // - The room authority (player_ids[0]) should checkpoint via setState()
304
- // at meaningful transitions so rejoining clients can restore instantly.
357
+ // - Any participant should checkpoint via setState() at meaningful
358
+ // transitions so rejoining clients can restore instantly (the host
359
+ // need not be the one to call it).
305
360
  join(roomId?: string): Promise<GameJoinResult>;
306
361
  leave(): void;
307
362
  action(actionType: string, actionData?: Record<string, any>, opts?: GameActionOptions): Promise<ActionResult>;
@@ -322,9 +377,11 @@ export interface GameModule {
322
377
  invite(opts?: GameInviteOptions): Promise<GameInviteResult>;
323
378
 
324
379
  /**
325
- * Checkpoint authoritative game state on the server (authority only
326
- * player_ids[0]/host). (Re)joining clients receive the latest checkpoint
327
- * as game_state in the join ack and in game:sync. Max 64 KB serialized.
380
+ * Checkpoint authoritative game state on the server. Any participant in the
381
+ * room may call this (not just player_ids[0]/host), so the snapshot stays
382
+ * fresh even while the host is backgrounded. (Re)joining clients receive the
383
+ * latest checkpoint as game_state in the join ack and in game:sync. Max 64 KB
384
+ * serialized.
328
385
  */
329
386
  setState(state: Record<string, any>): Promise<{ success: boolean; error?: string; code?: UsionErrorCode }>;
330
387
 
@@ -365,6 +422,36 @@ export interface GameModule {
365
422
  */
366
423
  onRoomAssigned(callback: (data: { roomId: string }) => void): UnsubscribeFn;
367
424
 
425
+ // ─── Reconnection lifecycle (SDK ≥ 2.21; documented since 2.16) ──────────
426
+ /** Highest action sequence seen (joins, syncs, live actions). */
427
+ getLastSequence(): number;
428
+ /** Highest action sequence applied locally; trails while catching up. */
429
+ getLastAppliedSequence(): number;
430
+ /** Current connection state, synchronously. */
431
+ getConnectionState(): GameConnectionState;
432
+ /**
433
+ * Observe connection-state transitions ('connected' → 'disconnected' →
434
+ * 'rejoining' → 'reconnected' → 'connected') — identical across the socket,
435
+ * embedded-proxy, and direct transports. One hook to drive a
436
+ * "Reconnecting…" overlay and gate input.
437
+ */
438
+ onConnectionState(callback: (state: GameConnectionState) => void): UnsubscribeFn;
439
+ /**
440
+ * Fires once per reconnect, after the resync completes. Restore local
441
+ * state here — it does NOT fire for a manually-requested sync.
442
+ */
443
+ onReconnected(callback: (info: ReconnectedInfo) => void): UnsubscribeFn;
444
+ /**
445
+ * Reconnect-safe authoritative shared state. Commits are sequenced actions
446
+ * applied through `reduce` on every client in the same order (deduped by
447
+ * sequence); the authority (player_ids[0] by default) auto-checkpoints via
448
+ * setState, and (re)joining clients recover automatically (load checkpoint,
449
+ * replay the un-checkpointed tail). Use for state that must survive a
450
+ * disconnect — unlike replicate() (realtime, lossy). Degrades to
451
+ * local-apply in direct mode (no platform checkpoint there).
452
+ */
453
+ syncedState<S = Record<string, any>>(initial: S, opts?: SyncedStateOptions<S>): SyncedState<S>;
454
+
368
455
  /**
369
456
  * Register an ADDITIONAL event listener. Unlike the onX methods this
370
457
  * supports multiple listeners per event, can be called before connect(),