From c65928e832e94fc93acabfe5e814a6891c56f9e2 Mon Sep 17 00:00:00 2001 From: fireb0rngg Date: Mon, 25 May 2026 21:57:09 -0500 Subject: [PATCH 1/2] Drive client networking from a real-time tick Player position/scale broadcast and remote-player interpolation previously ran on the HeroController.Update hook with Time.deltaTime, so they stalled whenever the game was paused (pause menu, inventory, map). Paused players became invisible to peers who didn't already know their position, and remote players froze on screen. Move the tick to a dedicated DontDestroyOnLoad MonoBehaviour using Time.unscaledDeltaTime, and add a 500 ms position heartbeat so stationary players remain discoverable to late joiners. --- SSMP/Game/Client/ClientManager.cs | 44 ++++++++++++++++++++----------- SSMP/Util/NetworkTickBehaviour.cs | 18 +++++++++++++ 2 files changed, 46 insertions(+), 16 deletions(-) create mode 100644 SSMP/Util/NetworkTickBehaviour.cs diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index 6941af05..e31b1472 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -168,6 +168,11 @@ internal class ClientManager : IClientManager { /// private bool _sceneHostDetermined; + /// + /// GameObject hosting the that drives per-frame networking. + /// + private GameObject? _networkTickObject; + #endregion #region IClientManager properties @@ -320,7 +325,10 @@ private void RegisterHooks() { SceneManager.activeSceneChanged += OnSceneChange; CustomHooks.HeroControllerStartAction += OnHeroControllerStart; - EventHooks.HeroControllerUpdate += OnHeroControllerUpdate; + // Drive networking from a dedicated MonoBehaviour so it ticks regardless of Time.timeScale. + _networkTickObject = new GameObject("SSMP_NetworkTick"); + Object.DontDestroyOnLoad(_networkTickObject); + _networkTickObject.AddComponent().OnTick = OnNetworkTick; CustomHooks.AfterEnterSceneHeroTransformed += OnEnterScene; @@ -348,7 +356,11 @@ private void DeregisterHooks() { SceneManager.activeSceneChanged -= OnSceneChange; CustomHooks.HeroControllerStartAction -= OnHeroControllerStart; - EventHooks.HeroControllerUpdate -= OnHeroControllerUpdate; + // Tear down the network tick driver. + if (_networkTickObject != null) { + Object.Destroy(_networkTickObject); + _networkTickObject = null; + } CustomHooks.AfterEnterSceneHeroTransformed -= OnEnterScene; @@ -1185,24 +1197,24 @@ private void OnSceneChange(Scene oldScene, Scene newScene) { } /// - /// Callback method on the HeroController#Update method. + /// Per-frame network tick. Runs every Unity frame regardless of pause state. /// - /// The HeroController instance. - private void OnHeroControllerUpdate(HeroController self) { - // Ignore player position updates on non-gameplay scenes - var currentSceneName = SceneUtil.GetCurrentSceneName(); - if (SceneUtil.IsNonGameplayScene(currentSceneName)) { - return; - } - + private void OnNetworkTick() { // If we are not connected, there is nothing to send to if (!_netClient.IsConnected) { return; } // Update all remote player interpolations in one centralized loop. + // Uses unscaled delta time so interpolation keeps progressing while the game is paused. // We also pass the latest measured RTT so the interpolator can adapt to ping. - _playerManager.UpdateInterpolations(Time.deltaTime, _netClient.UpdateManager.AverageRtt); + _playerManager.UpdateInterpolations(Time.unscaledDeltaTime, _netClient.UpdateManager.AverageRtt); + + // The remaining work samples the local hero, so skip it when the hero isn't present + // (non-gameplay scenes, or before the hero has spawned). + if (HeroController.instance == null || SceneUtil.IsNonGameplayScene(SceneUtil.GetCurrentSceneName())) { + return; + } var heroTransform = HeroController.instance.transform; @@ -1215,14 +1227,14 @@ private void OnHeroControllerUpdate(HeroController self) { // Update rate of 60 Hz const int updateRate = 1000 / 60; + // Heartbeat (ms) so a stationary player is re-broadcast for late-joining peers. + const int heartbeat = 500; if (_lastPositionStopwatch.ElapsedMilliseconds > updateRate) { var newPosition = heroTransform.position; - // If the position changed since last check - if (newPosition != _lastPosition) { - // Update the last position, since it changed + // Send if the position changed, or if the heartbeat interval has elapsed + if (newPosition != _lastPosition || _lastPositionStopwatch.ElapsedMilliseconds > heartbeat) { _lastPosition = newPosition; - // Restart the stopwatch _lastPositionStopwatch.Restart(); _netClient.UpdateManager.UpdatePlayerPosition(new Vector2(newPosition.x, newPosition.y)); diff --git a/SSMP/Util/NetworkTickBehaviour.cs b/SSMP/Util/NetworkTickBehaviour.cs new file mode 100644 index 00000000..dcd3a2fc --- /dev/null +++ b/SSMP/Util/NetworkTickBehaviour.cs @@ -0,0 +1,18 @@ +using System; +using UnityEngine; + +namespace SSMP.Util; + +/// +/// MonoBehaviour that fires every Unity frame, independent of . +/// +internal class NetworkTickBehaviour : MonoBehaviour { + /// + /// Callback invoked once per frame. + /// + public Action? OnTick; + + private void Update() { + OnTick?.Invoke(); + } +} From c29a4df771239ff9df549cfa7ee911ef170fbe59 Mon Sep 17 00:00:00 2001 From: fireb0rngg Date: Wed, 27 May 2026 21:44:57 -0500 Subject: [PATCH 2/2] Accumulate unscaled delta time instead of using Stopwatch Stopwatch is wall-clock and keeps running while the app is suspended or backgrounded, producing a delayed burst update on resume. Time.unscaledDeltaTime advances only with Unity frames, so the heartbeat throttle pauses cleanly with the app and stays consistent with the unscaled time already used for interpolation in this method. --- SSMP/Game/Client/ClientManager.cs | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index e31b1472..893a7caa 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using GlobalEnums; using Steamworks; using SSMP.Animation; @@ -143,10 +142,11 @@ internal class ClientManager : IClientManager { private Vector3 _lastPosition; /// - /// Stopwatch to keep track of the last time that the position of the local player object was updated. - /// Used to throttle position updates to 60 Hz maximum to avoid having to update the packet every frame. + /// Seconds accumulated since the last local player position update, driven by + /// . Used to throttle position updates to 60 Hz maximum to avoid + /// having to update the packet every frame. /// - private readonly Stopwatch _lastPositionStopwatch; + private float _timeSinceLastPosition; /// /// Keeps track of the last updated scale of the local player object. @@ -250,8 +250,6 @@ ModSettings modSettings var clientApi = new ClientApi(this, _commandManager, uiManager, netClient, eventAggregator); _addonManager = new ClientAddonManager(clientApi, _modSettings); - - _lastPositionStopwatch = new Stopwatch(); } #region Internal client-manager methods @@ -1218,24 +1216,21 @@ private void OnNetworkTick() { var heroTransform = HeroController.instance.transform; - // For position updating, we use a stopwatch to check whether the latest update wasn't too soon ago. - // Because each update of the player position in a packet needs to acquire the packet lock, which without - // this rate-limit might happen every frame - if (!_lastPositionStopwatch.IsRunning) { - _lastPositionStopwatch.Start(); - } + // Accumulate unscaled real time so throttling pauses cleanly when the app is suspended or + // backgrounded, instead of bursting an update on resume like a wall-clock stopwatch would. + _timeSinceLastPosition += Time.unscaledDeltaTime; // Update rate of 60 Hz - const int updateRate = 1000 / 60; - // Heartbeat (ms) so a stationary player is re-broadcast for late-joining peers. - const int heartbeat = 500; - if (_lastPositionStopwatch.ElapsedMilliseconds > updateRate) { + const float updateRate = 1f / 60f; + // Heartbeat (seconds) so a stationary player is re-broadcast for late-joining peers. + const float heartbeat = 0.5f; + if (_timeSinceLastPosition > updateRate) { var newPosition = heroTransform.position; // Send if the position changed, or if the heartbeat interval has elapsed - if (newPosition != _lastPosition || _lastPositionStopwatch.ElapsedMilliseconds > heartbeat) { + if (newPosition != _lastPosition || _timeSinceLastPosition > heartbeat) { _lastPosition = newPosition; - _lastPositionStopwatch.Restart(); + _timeSinceLastPosition = 0f; _netClient.UpdateManager.UpdatePlayerPosition(new Vector2(newPosition.x, newPosition.y)); }