Skip to content

Drive client networking from a real-time tick#74

Open
fireb0rngg wants to merge 2 commits into
Extremelyd1:mainfrom
fireb0rngg:feat/network-tick
Open

Drive client networking from a real-time tick#74
fireb0rngg wants to merge 2 commits into
Extremelyd1:mainfrom
fireb0rngg:feat/network-tick

Conversation

@fireb0rngg
Copy link
Copy Markdown
Contributor

Player position and 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 when other player clients didn't already know their position, and remote players froze on screen. This led to silly situations where a player opening their map or their inventory could render them invisible to players just entering the scene. Moved 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. Given the duration of scene transitions, this should be adequate.

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.
@Liparakis
Copy link
Copy Markdown
Contributor

Pause-State Improvements

This is a really good solution to the pause-state problem. Moving away from HeroController.Update (which depends on Time.timeScale) to an unscaled tick is definitely the correct direction if we want interpolation and player discovery to continue working during menus, inventory, maps, etc.

A few ideas that could make the implementation even cleaner and more efficient:

1. Use Unity PlayerLoop Instead of a MonoBehaviour

Instead of creating a persistent GameObject with DontDestroyOnLoad you could hook directly into Unity’s PlayerLoop.

Benefits:

  • Keeps the hierarchy clean.
  • Avoids unnecessary MonoBehaviour overhead.
  • Gives more predictable execution ordering.
  • Feels more engine-level instead of scene-level.

2. Reduce Unnecessary Heartbeat Traffic

Sending position packets every 500ms while a player is paused or AFK can generate unnecessary traffic especially on bigger servers.

Since the server already caches the latest player position for late joiners, the heartbeat system could probably be optimized.

State-Based Sync

Send one reliable position update when the player:

  • pauses
  • opens inventory
  • opens the map

Since the player cannot move while paused, one sync is enough.

Exponential Backoff

If the player stays idle:

500ms -> 2s -> 5s -> 10s

As soon as movement resumes:

  • instantly return to the fast update rate

This keeps late-join discovery working while reducing idle network traffic.

Use Time.unscaledDeltaTime Instead of Stopwatch

Using Stopwatch works but it keeps running even if the game is minimized or suspended.

That can cause delayed or burst updates after resuming.

Instead, just accumulate:

timer += Time.unscaledDeltaTime;

Benefits:

  • stays aligned with Unity timing
  • naturally pauses with the app
  • simpler overall

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.
@fireb0rngg
Copy link
Copy Markdown
Contributor Author

fireb0rngg commented May 28, 2026

Appreciate the suggestions. Here's some thoughts:

(1) PlayerLoop has a major downside in that if another mod sets it, it breaks the code we've set.

(2a) I like the idea, but I believe we would run into the issue again if timescale is changed in a state we didn't hook into; notably by other mods.

(2b) Consider this: if a local player enters a pause state, and a remote player enters the scene 8.0s later, that local player will be invisible on the remote player's screen for potentially up to 9.5s. That's the worst case scenario, but even after 2.5s paused, players could totally miss each other during the subsequent 2500ms gap. Keep in mind that this is a highly disruptive issue for player vs. player games, so I don't really want to compromise for small bandwidth wins.

(3) Good point. Made another commit to address this.

@Liparakis
Copy link
Copy Markdown
Contributor

On (1) PlayerLoop: Fully agree, that was a bad suggestion for a modded environment.

On (2a) Time.unscaledDeltaTime risk from other mods: I believe this concern doesn't actually apply here. Time.unscaledDeltaTime is always the real wall-clock delta between frames . It is specifically the value that is immune to Time.timeScale changes. Other mods can set Time.timeScale = 0 all they like and Time.unscaledDeltaTime will still advance with real time. So the float accumulator approach is safe.

Souce Citation: https://docs.unity3d.com/ScriptReference/Time-unscaledDeltaTime.html

On (2b) Back-off heartbeat visibility gap: You're completely right the math confirms it, tbh i am never thinking these things regarding pvp so good catch.

@fireb0rngg
Copy link
Copy Markdown
Contributor Author

Ah, right -- you're correct about 2a, that's my mistake. I can look into state-based syncing sometime soon.

@Extremelyd1
Copy link
Copy Markdown
Owner

In general these changes are welcome, but we should also consider getting rid of pausing for local players altogether. This is also done in HKMP and is much preferred, because being in a multiplayer lobby and being able to pause your own game doesn't make a whole lot of sense.

Most of the code in OnHeroControllerUpdate was originally written with the intention of checking for updates that were driven from the local player (HeroController). That still holds true for the most part, except for the call to update the interpolations of other players.

Perhaps it is best if we simply move that call (_playerManager.UpdateInterpolations(...)) to the PlayerManager and implement the "network tick" changes there instead. And then we should look into disabling pausing the game when connected to a multiplayer game, which I think is a much better solution.

@fireb0rngg
Copy link
Copy Markdown
Contributor Author

Perhaps it is best if we simply move that call (_playerManager.UpdateInterpolations(...)) to the PlayerManager and implement the "network tick" changes there instead. And then we should look into disabling pausing the game when connected to a multiplayer game, which I think is a much better solution.

Agreed. I'll look into that when I have time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants