Skip to content

feat: latest input tick#624

Open
aromancev wants to merge 2 commits into
foxssake:mainfrom
aromancev:feat/latest-input-tick
Open

feat: latest input tick#624
aromancev wants to merge 2 commits into
foxssake:mainfrom
aromancev:feat/latest-input-tick

Conversation

@aromancev

@aromancev aromancev commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

NetworkSynchronizationServer now tracks latest tick for which the server received all inputs. Effectively, it means this tick cannot change due to new inputs coming from the past anymore.

Problem

With real world latency server receives inputs from peers from the past. When server records new ticks, it doesn't yet know all inputs that may've affected the state but it still sends potentially incorrect state to all peers (which is ok). Later, server receives late inputs, re-records corrected state, and sends new snapshots to all peers.

From the perspective of a peer under latency that predicted a state change (such as reduced HP):

  1. Locally predicted HP reduced.
  2. Received a correction from server that HP was not reduced.
  3. Received another correction from the server that HP was reduced indeed.

So if HP is displayed as per the latest server state, it will jitter. Moreover, simply ignoring local prediction is not enough either. The same situation can happen with the server itself if it renders state for a tick while some inputs for that tick have not arrived yet. For example, host makes a melee swing at another player -> that player made a perfect block -> server receives late input and has to revert the damage it done.

Solution

Track latest tick for which all inputs have been collected and broadcast it to all peers so they can adjust their local display states.

This also adds a small primitive for displaying states and avoid jitter (at the cost of latency). Use like this:

var health_display := RollbackDisplayReference.new(self, "health")

. . . 

# in after tick handler or something 
ui.health_bar.value = health_display.get_value()

A second primitive to display local effects based on rollbackable state coming later. This will avoid having to undo the effect when it makes it worse. Such as displaying a wrong damage number before it is "confirmed".

Note: my initial take was to add one byte to tick snapshots to mark them as input complete but that would mean we'd have to send "empty" snapshots and in general complicate the general snapshot logic. Seems like a separate channel for this information is less complex

Another note: this solution is technically not bullet proof and could still result in desync because the input complete confirmation may arrive earlier than the snapshot itself. Though it makes this situation unlikely enough to still make it useful for display effects and maybe some gameplay logic. I was not able to simulate such a condition but it is theoretically possible.

@aromancev

Copy link
Copy Markdown
Contributor Author

I see CI failures but have to run now :) Will fix later.

@elementbound

Copy link
Copy Markdown
Contributor

On first read, this PR looks similar to #518, with the idea being to track ticks that are not expected to change anymore, and use that for things that would be jarring to undo ( HP display, score counters, kill mechanics, etc. ).

The difference is that this PR also syncs this tick across peers, so it works without input broadcast + actually uses the ground truth, which is what the server says is confirmed. Plus the new primitive.

I think it could be nice to try and unify both features, so we get better behavior without increasing API surface by much.


In general I try to avoid is_server() checks in netfox, and instead think in terms of ownership. Not sure how feasible that would be with this PR, will think about it.


As for CI, you can run sh/lint.sh --fix to fix the linting errors. For UIDs, you can import the project in Godot 4.4 or above, which will create the .uid files, and commit those. But these are not really crucial till we're happy with the PR in general.

@aromancev
aromancev force-pushed the feat/latest-input-tick branch 2 times, most recently from e220750 to b8f7c0b Compare July 1, 2026 19:02
@aromancev

aromancev commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Could you clarify a bit more how you would like to unify the contract? In #518 the main surface is get_last_known_input and get_latest_input_tick both of which are node based. This change is node agnostic and acts more like a global state.

The reasoning is that knowing latest inputs for a particular node is not enough to confirm the state won't change. Given the example above, we cannot render HP of a player unless we know inputs of other players.

The only unification I can see is if the surface already exposed in 518 starts using the global state exposed in this PR under the hood to ensure correctness. I don't see how we can reuse that surface for a global state though since it's already node-based and this has to be global.


This is a good point! Refactored the change so that each peer that owns state calculates its own latest complete tick and shares with everyone. Then each peer calculates the combined latest input complete tick. It's a bit more complex now though but will work even if state authority is split between peers.


Fixed!

@aromancev
aromancev force-pushed the feat/latest-input-tick branch from b8f7c0b to 0e00527 Compare July 1, 2026 19:33
@albertok

albertok commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

With 518 it's all about flexibility.

You don't need input broadcast. If it's off the server still has that information and can delay till a confirm via state properties.

The reason it's node based is so you can infer relevance. For example you might only care about players within a certain distance and can act early without waiting for all inputs to arrive.

I also didn't want to impose extra data transfer.

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