app.hypergames.hypersdk 1.8.1 → 1.8.3

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.
Files changed (28) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/HyperLoader.unity +2 -2
  3. package/Runtime/Internal/Data/URLData.cs +14 -18
  4. package/Runtime/Internal/Managers/BackendManager.cs +300 -6
  5. package/Runtime/Internal/Managers/DataManager.cs +21 -0
  6. package/Runtime/Internal/Managers/EventManager.cs +2 -0
  7. package/Runtime/Internal/Managers/GameManager.cs +5 -1
  8. package/Runtime/Internal/UI/Components/ConnectionStatusIcon.cs +261 -16
  9. package/Runtime/Internal/UI/Modals/ScoreSubmissionFailedModal.cs +1 -1
  10. package/Runtime/Plugins/WebGL/HyperTutorialCachePlugin.jslib +4 -2
  11. package/Runtime/Plugins/WebGL/HyperWebSocketPlugin.jslib +5 -0
  12. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Group 36946.png +0 -0
  13. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Group 36946.png.meta +169 -0
  14. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Group-1.png +0 -0
  15. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Group-1.png.meta +169 -0
  16. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Group-2.png +0 -0
  17. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Group-2.png.meta +169 -0
  18. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Group.png +0 -0
  19. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Group.png.meta +169 -0
  20. package/Runtime/Resources/Sprites/Control Bar/{Icon_YesWifi.png.meta → Internet Connection/Icon_YesWifi.png.meta } +1 -1
  21. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Vector.png +0 -0
  22. package/Runtime/Resources/Sprites/Control Bar/Internet Connection/Vector.png.meta +169 -0
  23. package/Runtime/Resources/Sprites/Control Bar/Internet Connection.meta +8 -0
  24. package/Runtime/Resources/UIPrefabs/ControlBar.prefab +736 -69
  25. package/package.json +1 -1
  26. /package/Runtime/Resources/Sprites/Control Bar/{Icon_NoWifi.png → Internet Connection/Icon_NoWifi.png} +0 -0
  27. /package/Runtime/Resources/Sprites/Control Bar/{Icon_NoWifi.png.meta → Internet Connection/Icon_NoWifi.png.meta} +0 -0
  28. /package/Runtime/Resources/Sprites/Control Bar/{Icon_YesWifi.png → Internet Connection/Icon_YesWifi.png} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [1.8.3](https://github.com/mvmhyper/hyper-sdk/compare/v1.8.2...v1.8.3) (2026-04-10)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * WebGL reliability, scores, connection UI, and session-end audio ([adda0d9](https://github.com/mvmhyper/hyper-sdk/commit/adda0d9a688e786872c82a6429ce31b160e6b5e3))
7
+
8
+ ## [1.8.2](https://github.com/mvmhyper/hyper-sdk/compare/v1.8.1...v1.8.2) (2026-04-07)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **ui:** adjust canvas scaler match value for landscape to 0.63 ([c8e98ba](https://github.com/mvmhyper/hyper-sdk/commit/c8e98ba2fd112f5501959a3e7dc078fe4dc4cba3))
14
+
1
15
  ## [1.8.1](https://github.com/mvmhyper/hyper-sdk/compare/v1.8.0...v1.8.1) (2026-04-02)
2
16
 
3
17
 
package/HyperLoader.unity CHANGED
@@ -305,7 +305,7 @@ RectTransform:
305
305
  m_AnchorMin: {x: 0, y: 1}
306
306
  m_AnchorMax: {x: 0, y: 1}
307
307
  m_AnchoredPosition: {x: 489.45853, y: -280.1698}
308
- m_SizeDelta: {x: 1044.9, y: 239.347}
308
+ m_SizeDelta: {x: 0, y: 239.347}
309
309
  m_Pivot: {x: 0.5, y: 0.5}
310
310
  --- !u!114 &751153944
311
311
  MonoBehaviour:
@@ -840,7 +840,7 @@ GameObject:
840
840
  m_Component:
841
841
  - component: {fileID: 1064166296}
842
842
  m_Layer: 0
843
- m_Name: __HyperSDK_BuildMarker_639104165398939431
843
+ m_Name: __HyperSDK_BuildMarker_639114595732632449
844
844
  m_TagString: Untagged
845
845
  m_Icon: {fileID: 0}
846
846
  m_NavMeshLayer: 0
@@ -57,57 +57,53 @@ namespace Hyper.Internal
57
57
 
58
58
  public string WorkspaceId
59
59
  {
60
- get => string.IsNullOrEmpty(_workspaceId) ? (_workspaceId = Guid.NewGuid().ToString()) : _workspaceId;
61
- set => _workspaceId = value;
60
+ get => _workspaceId;
61
+ set => _workspaceId = string.Copy(value);
62
62
  }
63
63
 
64
64
  public string TournamentId
65
65
  {
66
66
  get
67
67
  {
68
- if (string.IsNullOrEmpty(OldTournamentId) && string.IsNullOrEmpty(_tournamentId))
69
- {
70
- _tournamentId = Guid.NewGuid().ToString();
71
- }
72
68
  return string.IsNullOrEmpty(OldTournamentId) ? _tournamentId : OldTournamentId;
73
69
  }
74
- set => _tournamentId = value;
70
+ set => _tournamentId = string.Copy(value);
75
71
  }
76
72
 
77
73
  public string SessionId
78
74
  {
79
- get => string.IsNullOrEmpty(_sessionId) ? (_sessionId = Guid.NewGuid().ToString()) : _sessionId;
75
+ get => _sessionId;
80
76
  set => _sessionId = string.Copy(value);
81
77
  }
82
78
 
83
79
  public string GameId
84
80
  {
85
- get => string.IsNullOrEmpty(_gameId) ? (_gameId = Guid.NewGuid().ToString()) : _gameId;
86
- set => _gameId = value;
81
+ get => _gameId;
82
+ set => _gameId = string.Copy(value);
87
83
  }
88
84
 
89
85
  public string GameSlug
90
86
  {
91
87
  get => _gameSlug;
92
- set => _gameSlug = value;
88
+ set => _gameSlug = string.Copy(value);
93
89
  }
94
90
 
95
91
  public string EntryId
96
92
  {
97
- get => string.IsNullOrEmpty(_entryId) ? (_entryId = Guid.NewGuid().ToString()) : _entryId;
98
- set => _entryId = value;
93
+ get => _entryId;
94
+ set => _entryId = string.Copy(value);
99
95
  }
100
96
 
101
97
  public string UserId
102
98
  {
103
- get => string.IsNullOrEmpty(_userId) ? (_userId = Guid.NewGuid().ToString()) : _userId;
104
- set => _userId = value;
99
+ get => _userId;
100
+ set => _userId = string.Copy(value);
105
101
  }
106
102
 
107
103
  public string EmbedUrl
108
104
  {
109
- get => string.IsNullOrEmpty(_embedUrl) ? (_embedUrl = Guid.NewGuid().ToString()) : _embedUrl;
110
- set => _embedUrl = value;
105
+ get => _embedUrl;
106
+ set => _embedUrl = string.Copy(value);
111
107
  }
112
108
 
113
109
  #endregion
@@ -116,7 +112,7 @@ namespace Hyper.Internal
116
112
  {
117
113
  GameMode = GameMode.Battle;
118
114
  }
119
-
115
+
120
116
  public void SetModeAsPractice()
121
117
  {
122
118
  GameMode = GameMode.Practice;
@@ -1,4 +1,5 @@
1
1
  using Newtonsoft.Json;
2
+ using Newtonsoft.Json.Linq;
2
3
  using System;
3
4
  using System.Collections;
4
5
  using System.Collections.Generic;
@@ -64,6 +65,11 @@ namespace Hyper.Internal.Managers
64
65
 
65
66
  public SubmissionErrorType LastSubmissionError { get; private set; } = SubmissionErrorType.None;
66
67
 
68
+ /// <summary>
69
+ /// Smoothed round-trip latency (ms) from sdkPing/sdkPong, or -1 when unknown or disconnected.
70
+ /// </summary>
71
+ public int SdkSmoothedLatencyMs { get; private set; } = -1;
72
+
67
73
  #endregion
68
74
 
69
75
  #region Private Configuration
@@ -75,7 +81,7 @@ namespace Hyper.Internal.Managers
75
81
  /// Only uses production URL for release builds made via Build Assistant (HYPER_RELEASE_BUILD define).
76
82
  /// All other builds (development, editor, etc.) use the dev server URL.
77
83
  /// </summary>
78
- private string GetServerUrl()
84
+ public string GetServerUrl()
79
85
  {
80
86
  #if HYPER_RELEASE_BUILD
81
87
  // Only release builds from Build Assistant use production URL
@@ -100,7 +106,7 @@ namespace Hyper.Internal.Managers
100
106
  private static extern string EncryptScore(string score);
101
107
 
102
108
  [DllImport("__Internal")]
103
- private static extern void FetchAndCacheTutorials(string gameSlug, string gameObject);
109
+ private static extern void FetchAndCacheTutorials(string serverBaseUrl, string gameSlug, string gameObject);
104
110
 
105
111
  [DllImport("__Internal")]
106
112
  private static extern void InitWebSocket(string serverUrl);
@@ -116,7 +122,7 @@ namespace Hyper.Internal.Managers
116
122
  #else
117
123
  private string EncryptScore(string score) => score;
118
124
 
119
- private void FetchAndCacheTutorials(string gameSlug, string gameObject)
125
+ private void FetchAndCacheTutorials(string serverBaseUrl, string gameSlug, string gameObject)
120
126
  {
121
127
  _isFetchingTutorial = false;
122
128
  _onTutorialSuccess?.Invoke();
@@ -239,6 +245,7 @@ namespace Hyper.Internal.Managers
239
245
  _gameManager.Config.RandomSeed = randomSeed;
240
246
  _gameManager.Config.SessionId = id;
241
247
  _cachedSessionId = string.Copy(id);
248
+ StartSdkPingLoop();
242
249
 
243
250
  return true;
244
251
  }
@@ -290,6 +297,14 @@ namespace Hyper.Internal.Managers
290
297
  private bool _isManuallyClosed;
291
298
  private bool _isSocketErrorCalled;
292
299
 
300
+ private Coroutine _sdkPingCoroutine;
301
+ private long _nextSdkPingId;
302
+ private long? _pendingSdkPingId;
303
+ private float _pendingSdkPingRealtime;
304
+ private float _sdkPingLatencyEma = -1f;
305
+ private const float SdkPingLatencyEmaAlpha = 0.25f;
306
+ private const float SdkPingIntervalSeconds = 3f;
307
+
293
308
  public void InitializeWebSocket()
294
309
  {
295
310
  if (_dataManager == null) _dataManager = HyperRuntime.DataManager;
@@ -313,9 +328,11 @@ namespace Hyper.Internal.Managers
313
328
 
314
329
  public void StopWebSocket()
315
330
  {
331
+ StopSdkPingLoop();
316
332
  _isManuallyClosed = true;
317
333
  isSocketConnected = false;
318
334
  CloseWebSocket();
335
+ _eventManager?.InvokeBackendSocketManuallyClosedEvent();
319
336
  }
320
337
 
321
338
  private IEnumerator StartWebSocketInitialization()
@@ -476,6 +493,73 @@ namespace Hyper.Internal.Managers
476
493
  _sendGameStartedCoroutine = null;
477
494
  }
478
495
 
496
+ private void StopSdkPingLoop()
497
+ {
498
+ if (_sdkPingCoroutine != null)
499
+ {
500
+ StopCoroutine(_sdkPingCoroutine);
501
+ _sdkPingCoroutine = null;
502
+ }
503
+
504
+ _pendingSdkPingId = null;
505
+ _sdkPingLatencyEma = -1f;
506
+ SdkSmoothedLatencyMs = -1;
507
+ }
508
+
509
+ private void StartSdkPingLoop()
510
+ {
511
+ #if UNITY_WEBGL && !UNITY_EDITOR
512
+ if (_sdkPingCoroutine != null)
513
+ return;
514
+
515
+ _sdkPingCoroutine = StartCoroutine(SdkPingLoopCoroutine());
516
+ #endif
517
+ }
518
+
519
+ #if UNITY_WEBGL && !UNITY_EDITOR
520
+ private IEnumerator SdkPingLoopCoroutine()
521
+ {
522
+ while (true)
523
+ {
524
+ yield return new WaitForSecondsRealtime(SdkPingIntervalSeconds);
525
+ TrySendSdkPing();
526
+ }
527
+ }
528
+
529
+ private void TrySendSdkPing()
530
+ {
531
+ if (!isSocketConnected || string.IsNullOrEmpty(_cachedSessionId))
532
+ return;
533
+
534
+ if (_pendingSdkPingId.HasValue)
535
+ return;
536
+
537
+ long pingId = ++_nextSdkPingId;
538
+ _pendingSdkPingId = pingId;
539
+ _pendingSdkPingRealtime = Time.realtimeSinceStartup;
540
+
541
+ try
542
+ {
543
+ var message = new
544
+ {
545
+ @event = "sdkPing",
546
+ data = new
547
+ {
548
+ sessionId = _cachedSessionId,
549
+ pingId
550
+ }
551
+ };
552
+
553
+ string json = JsonConvert.SerializeObject(message);
554
+ SendWebSocketMessage(json);
555
+ }
556
+ catch (Exception ex)
557
+ {
558
+ _pendingSdkPingId = null;
559
+ HyperDebug.LogError($"[sdkPing] Send failed: {ex.Message}");
560
+ }
561
+ }
562
+ #endif
479
563
 
480
564
  #endregion
481
565
 
@@ -508,10 +592,16 @@ namespace Hyper.Internal.Managers
508
592
  {
509
593
  _eventManager?.InvokeConnectionFoundEvent();
510
594
  }
595
+
596
+ if (!string.IsNullOrEmpty(_cachedSessionId))
597
+ {
598
+ StartSdkPingLoop();
599
+ }
511
600
  }
512
601
 
513
602
  public void OnSocketDisconnected()
514
603
  {
604
+ StopSdkPingLoop();
515
605
  isSocketConnected = false;
516
606
 
517
607
  if (!_isManuallyClosed)
@@ -537,6 +627,7 @@ namespace Hyper.Internal.Managers
537
627
  }
538
628
 
539
629
  isSocketConnected = false;
630
+ StopSdkPingLoop();
540
631
  }
541
632
 
542
633
  public void OnSocketGameStarted(string message)
@@ -567,6 +658,38 @@ namespace Hyper.Internal.Managers
567
658
  // Reserved for future use
568
659
  }
569
660
 
661
+ public void OnSocketSdkPong(string message)
662
+ {
663
+ if (string.IsNullOrEmpty(message) || !_pendingSdkPingId.HasValue)
664
+ return;
665
+
666
+ try
667
+ {
668
+ JObject jo = JObject.Parse(message);
669
+ JToken token = jo["pingId"];
670
+ if (token == null || token.Type == JTokenType.Null)
671
+ return;
672
+
673
+ long pongId = token.Value<long>();
674
+ if (pongId != _pendingSdkPingId.Value)
675
+ return;
676
+
677
+ float rttMs = (Time.realtimeSinceStartup - _pendingSdkPingRealtime) * 1000f;
678
+ _pendingSdkPingId = null;
679
+
680
+ if (_sdkPingLatencyEma < 0f)
681
+ _sdkPingLatencyEma = rttMs;
682
+ else
683
+ _sdkPingLatencyEma = Mathf.Lerp(_sdkPingLatencyEma, rttMs, SdkPingLatencyEmaAlpha);
684
+
685
+ SdkSmoothedLatencyMs = Mathf.RoundToInt(_sdkPingLatencyEma);
686
+ }
687
+ catch (Exception ex)
688
+ {
689
+ HyperDebug.LogWarning($"[sdkPong] Failed to parse or apply: {ex.Message}");
690
+ }
691
+ }
692
+
570
693
  public void OnSocketScoreUpdated(string message)
571
694
  {
572
695
  if (_lastSentBatch != null && _lastSentBatch.Count > 0)
@@ -585,6 +708,12 @@ namespace Hyper.Internal.Managers
585
708
  }
586
709
 
587
710
  TryUpdateCSRFToken(message, "scoreUpdated");
711
+ if (!string.IsNullOrEmpty(_csrfToken))
712
+ {
713
+ _isWaitingForTokenLogEmitted = false;
714
+ int pendingCount = _dataManager?.PendingScores?.Count ?? 0;
715
+ HyperDebug.Log($"[CSRF] Fresh token received from scoreUpdated. Score sending can continue. PendingCount={pendingCount}");
716
+ }
588
717
  }
589
718
 
590
719
  private void TryUpdateCSRFToken(string json, string context = null)
@@ -849,6 +978,40 @@ namespace Hyper.Internal.Managers
849
978
  yield return new WaitForSecondsRealtime(1f);
850
979
  }
851
980
 
981
+ // Token-gated flow invalidates token after each send.
982
+ // Refresh token again before sending the final score payload.
983
+ if (string.IsNullOrEmpty(_csrfToken))
984
+ {
985
+ _gameResumed = false;
986
+ HyperDebug.Log("[CSRF] Token unavailable after backlog flush. Refreshing token before final score submit.");
987
+
988
+ if (_sendGameResumedCoroutine == null)
989
+ {
990
+ _sendGameResumedCoroutine = StartCoroutine(SendGameResumedCoroutine());
991
+ yield return _sendGameResumedCoroutine;
992
+ _sendGameResumedCoroutine = null;
993
+ }
994
+ else
995
+ {
996
+ yield return _sendGameResumedCoroutine;
997
+ }
998
+
999
+ if (string.IsNullOrEmpty(_csrfToken))
1000
+ {
1001
+ LastSubmissionError = SubmissionErrorType.CSRFTokenError;
1002
+ HyperDebug.LogWarning("Final score submit blocked: CSRF token still unavailable after refresh attempt. Retrying... [CSRF_TOKEN_ERROR]");
1003
+ _currentRetryCount++;
1004
+ _gameManager?.GameOverModalComponent?.UpdateAttemptCount(_currentRetryCount, MAX_RETRY_COUNT);
1005
+
1006
+ if (_currentRetryCount < MAX_RETRY_COUNT)
1007
+ {
1008
+ yield return new WaitForSecondsRealtime(1f);
1009
+ }
1010
+
1011
+ continue;
1012
+ }
1013
+ }
1014
+
852
1015
  // Sending final score via WebSocket
853
1016
  _finalScoreSubmitted = false;
854
1017
  _isWaitingForFinalScoreResponse = true;
@@ -975,6 +1138,12 @@ namespace Hyper.Internal.Managers
975
1138
  private readonly WaitForSeconds _scoreSendInterval = new WaitForSeconds(1f);
976
1139
  private bool _isWaitingForFinalScoreResponse;
977
1140
  private bool _finalScoreSubmitted;
1141
+ private bool _isWaitingForTokenLogEmitted;
1142
+ /// <summary>
1143
+ /// True while a score batch is being encrypted/sent. Prevents overlapping sends and
1144
+ /// lets <see cref="SendScoresInBatch"/> skip a tick instead of draining pending scores mid-flight.
1145
+ /// </summary>
1146
+ private bool _scoreBatchSendInProgress;
978
1147
 
979
1148
  public void StartSendingScores()
980
1149
  {
@@ -1006,6 +1175,28 @@ namespace Hyper.Internal.Managers
1006
1175
  {
1007
1176
  int pendingCount = _dataManager?.PendingScores?.Count ?? 0;
1008
1177
  HyperDebug.Log($"[Score] Socket disconnected - accumulating scores for offline batch. PendingCount={pendingCount}");
1178
+ _isWaitingForTokenLogEmitted = false;
1179
+ yield return _scoreSendInterval;
1180
+ continue;
1181
+ }
1182
+
1183
+ if (string.IsNullOrEmpty(_csrfToken))
1184
+ {
1185
+ int pendingCount = _dataManager?.PendingScores?.Count ?? 0;
1186
+ if (!_isWaitingForTokenLogEmitted)
1187
+ {
1188
+ HyperDebug.LogWarning($"[Score] CSRF token unavailable - pausing score sends and batching until token refresh. PendingCount={pendingCount}");
1189
+ _isWaitingForTokenLogEmitted = true;
1190
+ }
1191
+
1192
+ yield return _scoreSendInterval;
1193
+ continue;
1194
+ }
1195
+
1196
+ if (_scoreBatchSendInProgress)
1197
+ {
1198
+ int pendingCount = _dataManager?.PendingScores?.Count ?? 0;
1199
+ HyperDebug.LogWarning($"[Score] Batch send in progress; skipping this tick to avoid overlapping submits. PendingCount={pendingCount}");
1009
1200
  yield return _scoreSendInterval;
1010
1201
  continue;
1011
1202
  }
@@ -1028,6 +1219,7 @@ namespace Hyper.Internal.Managers
1028
1219
  }
1029
1220
  else
1030
1221
  {
1222
+ _isWaitingForTokenLogEmitted = false;
1031
1223
  HyperDebug.Log("[Score] No pending scores to send in this tick.");
1032
1224
  }
1033
1225
 
@@ -1035,10 +1227,75 @@ namespace Hyper.Internal.Managers
1035
1227
  }
1036
1228
  }
1037
1229
 
1230
+ /// <summary>
1231
+ /// Sorts a batch in chronological order (oldest to newest) by score time.
1232
+ /// Null entries and entries without snapshots are pushed to the end.
1233
+ /// </summary>
1234
+ private static void SortScoresByScoreTimeAscending(List<ScoreData> scores)
1235
+ {
1236
+ if (scores == null || scores.Count <= 1) return;
1237
+ scores.Sort(CompareScoresByScoreTimeAscending);
1238
+ }
1239
+
1240
+ private static int CompareScoresByScoreTimeAscending(ScoreData left, ScoreData right)
1241
+ {
1242
+ if (ReferenceEquals(left, right)) return 0;
1243
+ if (left == null) return 1;
1244
+ if (right == null) return -1;
1245
+
1246
+ bool leftHasSnapshot = left.scoreSnapshot != null;
1247
+ bool rightHasSnapshot = right.scoreSnapshot != null;
1248
+
1249
+ if (!leftHasSnapshot && !rightHasSnapshot)
1250
+ {
1251
+ return left.cumulativeScore.CompareTo(right.cumulativeScore);
1252
+ }
1253
+ if (!leftHasSnapshot) return 1;
1254
+ if (!rightHasSnapshot) return -1;
1255
+
1256
+ int byTime = left.scoreSnapshot.scoreTime.CompareTo(right.scoreSnapshot.scoreTime);
1257
+ if (byTime != 0) return byTime;
1258
+
1259
+ return left.cumulativeScore.CompareTo(right.cumulativeScore);
1260
+ }
1261
+
1038
1262
  private void SendScoresOverWebSocket(List<ScoreData> scores)
1039
1263
  {
1040
1264
  if (scores == null || scores.Count == 0) return;
1041
1265
 
1266
+ SortScoresByScoreTimeAscending(scores);
1267
+
1268
+ ScoreData firstSortedScore = null;
1269
+ ScoreData lastSortedScore = null;
1270
+ for (int i = 0; i < scores.Count; i++)
1271
+ {
1272
+ if (scores[i] != null)
1273
+ {
1274
+ firstSortedScore = scores[i];
1275
+ break;
1276
+ }
1277
+ }
1278
+ for (int i = scores.Count - 1; i >= 0; i--)
1279
+ {
1280
+ if (scores[i] != null)
1281
+ {
1282
+ lastSortedScore = scores[i];
1283
+ break;
1284
+ }
1285
+ }
1286
+
1287
+ if (firstSortedScore != null && lastSortedScore != null)
1288
+ {
1289
+ float firstTime = firstSortedScore.scoreSnapshot?.scoreTime ?? -1f;
1290
+ float lastTime = lastSortedScore.scoreSnapshot?.scoreTime ?? -1f;
1291
+ HyperDebug.Log(
1292
+ $"[Score] Sorted score batch by time ascending. " +
1293
+ $"FirstTime={firstTime}, LastTime={lastTime}, " +
1294
+ $"FirstCumulativeScore={firstSortedScore.cumulativeScore}, LastCumulativeScore={lastSortedScore.cumulativeScore}, " +
1295
+ $"BatchSize={scores.Count}"
1296
+ );
1297
+ }
1298
+
1042
1299
  // Don't send if socket is not connected - prevents sending with expired tokens
1043
1300
  // and ensures we wait for reconnection to get fresh token via SendGameResumed()
1044
1301
  if (!isSocketConnected)
@@ -1048,6 +1305,27 @@ namespace Hyper.Internal.Managers
1048
1305
  return;
1049
1306
  }
1050
1307
 
1308
+ if (string.IsNullOrEmpty(_csrfToken))
1309
+ {
1310
+ int lastScore = scores[scores.Count - 1].cumulativeScore;
1311
+ HyperDebug.LogWarning($"[Score] Cannot send scores: CSRF token unavailable. LastBatchCumulativeScore={lastScore} [ErrorType=CSRFTokenError]");
1312
+ return;
1313
+ }
1314
+
1315
+ if (_scoreBatchSendInProgress)
1316
+ {
1317
+ HyperDebug.LogWarning("[Score] Overlapping SendScoresOverWebSocket — re-queueing batch at front of pending.");
1318
+ if (_dataManager == null) _dataManager = HyperRuntime.DataManager;
1319
+ if (_dataManager?.PendingScores != null && scores.Count > 0)
1320
+ {
1321
+ _dataManager.PendingScores.InsertRange(0, scores);
1322
+ }
1323
+
1324
+ scores.Clear();
1325
+ return;
1326
+ }
1327
+
1328
+ _scoreBatchSendInProgress = true;
1051
1329
  try
1052
1330
  {
1053
1331
  // Clone the batch into standalone ScoreData instances
@@ -1075,12 +1353,27 @@ namespace Hyper.Internal.Managers
1075
1353
  return;
1076
1354
  }
1077
1355
 
1078
- var message = new WebSocketScoreMessage(_cachedSessionId, _csrfToken, encryptedData);
1356
+ string tokenUsedForSend = _csrfToken;
1357
+ var message = new WebSocketScoreMessage(_cachedSessionId, tokenUsedForSend, encryptedData);
1079
1358
  string json = JsonConvert.SerializeObject(message);
1080
- int csrfLength = string.IsNullOrEmpty(_csrfToken) ? 0 : _csrfToken.Length;
1359
+ int csrfLength = string.IsNullOrEmpty(tokenUsedForSend) ? 0 : tokenUsedForSend.Length;
1081
1360
  int lastCumulativeScore = scores[scores.Count - 1].cumulativeScore;
1082
1361
  HyperDebug.Log($"[Score] Sending score batch. LastCumulativeScore={lastCumulativeScore}, BatchSize={scores.Count}, SessionId={_cachedSessionId}, CsrfLength={csrfLength}");
1083
1362
  SendWebSocketMessage(json);
1363
+
1364
+ // Strict token-gated flow: invalidate only the token we sent. If scoreUpdated ran
1365
+ // re-entrantly during SendWebSocketMessage (WebGL) and refreshed _csrfToken, do not wipe it.
1366
+ if (string.Equals(_csrfToken, tokenUsedForSend, StringComparison.Ordinal))
1367
+ {
1368
+ _csrfToken = null;
1369
+ _isWaitingForTokenLogEmitted = false;
1370
+ HyperDebug.Log("[CSRF] Token invalidated after score batch send. Awaiting fresh token from scoreUpdated.");
1371
+ }
1372
+ else
1373
+ {
1374
+ _isWaitingForTokenLogEmitted = false;
1375
+ HyperDebug.Log("[CSRF] Token refreshed during batch send; preserving new token (re-entrancy-safe).");
1376
+ }
1084
1377
  }
1085
1378
  catch (Exception ex)
1086
1379
  {
@@ -1089,6 +1382,7 @@ namespace Hyper.Internal.Managers
1089
1382
  }
1090
1383
  finally
1091
1384
  {
1385
+ _scoreBatchSendInProgress = false;
1092
1386
  if (_dataManager == null) _dataManager = HyperRuntime.DataManager;
1093
1387
  _dataManager?.ReturnScoreDataToPool(scores);
1094
1388
  scores?.Clear();
@@ -1210,7 +1504,7 @@ namespace Hyper.Internal.Managers
1210
1504
  string gameSlug = _gameManager.Config.GameSlug;
1211
1505
 
1212
1506
  #if UNITY_WEBGL && !UNITY_EDITOR
1213
- FetchAndCacheTutorials(gameSlug, gameObject.name);
1507
+ FetchAndCacheTutorials(GetServerUrl(), gameSlug, gameObject.name);
1214
1508
  #else
1215
1509
  StartCoroutine(SimulateTutorialFetch());
1216
1510
  #endif
@@ -134,6 +134,7 @@ namespace Hyper.Internal.Managers
134
134
  internal TutorialData TutorialData { get; private set; }
135
135
  internal bool HasReferencedTutorialResources { get; private set; }
136
136
  private const int SCORE_POOL_CAPACITY = 15;
137
+ private bool _gameSessionEndAudioMuted;
137
138
 
138
139
  internal void ConfigurePlayerSoundSetting()
139
140
  {
@@ -141,8 +142,28 @@ namespace Hyper.Internal.Managers
141
142
  if (_gameManager == null) _gameManager = HyperRuntime.GameManager;
142
143
  }
143
144
 
145
+ /// <summary>
146
+ /// Mutes all game audio when a session ends (global listener). Idempotent until restored.
147
+ /// </summary>
148
+ internal void MuteAudioForGameSessionEnd()
149
+ {
150
+ if (_gameSessionEndAudioMuted) return;
151
+ _gameSessionEndAudioMuted = true;
152
+ AudioListener.volume = 0f;
153
+ }
154
+
155
+ /// <summary>
156
+ /// Restores listener volume from the player's sound preference (0 or 1). Used after custom restart.
157
+ /// </summary>
158
+ internal void RestoreAudioAfterGameSessionEnd()
159
+ {
160
+ if (!_gameSessionEndAudioMuted) return;
161
+ SetSound(GetSound());
162
+ }
163
+
144
164
  internal void SetSound(int sound)
145
165
  {
166
+ _gameSessionEndAudioMuted = false;
146
167
  PlayerPrefs.SetInt(HyperConstants.SOUND_KEY, sound);
147
168
  _eventManager.InvokeSoundChangedEvent(sound == 1);
148
169
  AudioListener.volume = sound;
@@ -40,6 +40,7 @@ namespace Hyper.Internal.Managers
40
40
  internal event Action OnStartDeadlineReached;
41
41
  internal event Action OnInternetConnectionLost;
42
42
  internal event Action OnInternetConnectionFound;
43
+ internal event Action OnBackendSocketManuallyClosed;
43
44
  internal event Action OnTutorialFetchComplete;
44
45
  internal event Action OnTutorialResourcesReferenced;
45
46
  internal event Action OnTutorialLoadingComplete;
@@ -81,6 +82,7 @@ namespace Hyper.Internal.Managers
81
82
  internal void InvokeCustomRestartEvent() => OnCustomRestart?.Invoke();
82
83
  internal void InvokeConnectionLostEvent() => OnInternetConnectionLost?.Invoke();
83
84
  internal void InvokeConnectionFoundEvent() => OnInternetConnectionFound?.Invoke();
85
+ internal void InvokeBackendSocketManuallyClosedEvent() => OnBackendSocketManuallyClosed?.Invoke();
84
86
  internal void InvokeTutorialLoadingCompleteEvent() => OnTutorialLoadingComplete?.Invoke();
85
87
  internal void InvokeTutorialResourcesReferencedEvent() => OnTutorialResourcesReferenced?.Invoke();
86
88
  internal void InvokeTutorialFetchCompleteEvent() => OnTutorialFetchComplete?.Invoke();
@@ -120,6 +120,7 @@ namespace Hyper.Internal.Managers
120
120
  if (IsBattleMode) return;
121
121
 
122
122
  _hasGameEnded = true;
123
+ _dataManager.MuteAudioForGameSessionEnd();
123
124
  HyperRuntime.Timer.StopAllTimers();
124
125
 
125
126
  if (!HyperRuntime.GameContent.IsPracticeMenu)
@@ -845,6 +846,7 @@ namespace Hyper.Internal.Managers
845
846
 
846
847
  HyperRuntime.Timer.Reset();
847
848
  HyperRuntime.DataManager.UpdateScore(0);
849
+ HyperRuntime.DataManager.RestoreAudioAfterGameSessionEnd();
848
850
 
849
851
  ShowControlBarElements();
850
852
  ShowStartingInstructionModal();
@@ -857,6 +859,7 @@ namespace Hyper.Internal.Managers
857
859
  private void EndGameInternal()
858
860
  {
859
861
  _hasGameEnded = true;
862
+ _dataManager.MuteAudioForGameSessionEnd();
860
863
 
861
864
  _eventManager.InvokeGameEndedEvent();
862
865
  HyperRuntime.Timer.StopAllTimers();
@@ -999,7 +1002,7 @@ namespace Hyper.Internal.Managers
999
1002
  _canvasScaler = _canvas.GetComponent<CanvasScaler>();
1000
1003
  _canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
1001
1004
  _canvasScaler.referenceResolution = new Vector2(1080, 1920);
1002
- _canvasScaler.matchWidthOrHeight = IsPortrait() ? 0.43f : 0.73f;
1005
+ _canvasScaler.matchWidthOrHeight = IsPortrait() ? 0.43f : 0.63f;
1003
1006
 
1004
1007
  CreateSafeArea();
1005
1008
  CreateBlockerUI();
@@ -1393,6 +1396,7 @@ namespace Hyper.Internal.Managers
1393
1396
  private void ShowTimeUpModalInternal()
1394
1397
  {
1395
1398
  _hasGameEnded = true;
1399
+ _dataManager.MuteAudioForGameSessionEnd();
1396
1400
 
1397
1401
  if (_timeUpModal == null)
1398
1402
  {