Skip to content

feat: Inputless RollbackSynchronizer #448

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ and netfox.
* [Multiplayer FPS](examples/multiplayer-fps)
* [Rollback Debugger](examples/rollback-debugger)
* [Property Configuration](examples/property-configuration)
* [Input prediction](examples/input-prediction)
* [NPCs with rollback](examples/rollback-npc)

#### Example game

Expand Down
2 changes: 1 addition & 1 deletion addons/netfox.extras/plugin.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
name="netfox.extras"
description="Game-specific utilities for Netfox"
author="Tamas Galffy and contributors"
version="1.25.6"
version="1.26.0"
script="netfox-extras.gd"
2 changes: 1 addition & 1 deletion addons/netfox.internals/plugin.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
name="netfox.internals"
description="Shared internals for netfox addons"
author="Tamas Galffy and contributors"
version="1.25.6"
version="1.26.0"
script="plugin.gd"
2 changes: 1 addition & 1 deletion addons/netfox.noray/plugin.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
name="netfox.noray"
description="Bulletproof your connectivity with noray integration for netfox"
author="Tamas Galffy and contributors"
version="1.25.6"
version="1.26.0"
script="netfox-noray.gd"
2 changes: 1 addition & 1 deletion addons/netfox/plugin.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
name="netfox"
description="Shared internals for netfox addons"
author="Tamas Galffy and contributors"
version="1.25.6"
version="1.26.0"
script="netfox.gd"
35 changes: 29 additions & 6 deletions addons/netfox/rollback/rollback-synchronizer.gd
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ var _states := _PropertyHistoryBuffer.new()
var _inputs := _PropertyHistoryBuffer.new()
var _latest_state_tick: int
var _earliest_input_tick: int
var _last_simulated_tick: int

# Maps peers (int) to acknowledged ticks (int)
var _ackd_state: Dictionary = {}
Expand Down Expand Up @@ -112,8 +113,11 @@ func process_settings() -> void:
_states.clear()
_inputs.clear()
_ackd_state.clear()

_latest_state_tick = NetworkTime.tick - 1
_earliest_input_tick = NetworkTime.tick
_last_simulated_tick = NetworkTime.tick - 1

_next_full_state_tick = NetworkTime.tick
_next_diff_ack_tick = NetworkTime.tick

Expand Down Expand Up @@ -357,7 +361,6 @@ func _prepare_tick(tick: int) -> void:
# Save data for input prediction
_has_input = retrieved_tick != -1
_input_tick = retrieved_tick
_is_predicted_tick = not _inputs.has(tick)

# Reset the set of simulated and ignored nodes
_simset.clear()
Expand All @@ -369,21 +372,36 @@ func _prepare_tick(tick: int) -> void:
NetworkRollback.notify_simulated(node)

func _can_simulate(node: Node, tick: int) -> bool:
if not enable_prediction and not _inputs.has(tick):
# Don't simulate if prediction is not allowed and input is unknown
if not enable_prediction and _is_predicted_tick_for(node, tick):
# Don't simulate if prediction is not allowed and tick is predicted
return false
if NetworkRollback.is_mutated(node, tick):
# Mutated nodes are always resimulated
return true
if input_properties.is_empty():
# If we're running inputless and own the node, simulate it if we haven't
if node.is_multiplayer_authority():
return tick > _last_simulated_tick
# If we're running inputless and don't own the node, only run as prediction
return enable_prediction
if node.is_multiplayer_authority():
# Simulate from earliest input
# Don't simulate frames we don't have input for
return tick >= _earliest_input_tick
return tick >= _earliest_input_tick and _inputs.has(tick)
else:
# Simulate ONLY if we have state from server
# Simulate from latest authorative state - anything the server confirmed we don't rerun
# Don't simulate frames we don't have input for
return tick >= _latest_state_tick
return tick >= _latest_state_tick and _inputs.has(tick)

func _is_predicted_tick_for(node: Node, tick: int) -> bool:
if input_properties.is_empty():
# We're running without inputs
# It's only predicted if we don't own the node
return not node.is_multiplayer_authority()
else:
# We have input properties, it's only predicted if we don't have the input for the tick
return not _inputs.has(tick)

func _process_tick(tick: int) -> void:
# Simulate rollback tick
Expand All @@ -397,6 +415,7 @@ func _process_tick(tick: int) -> void:
continue

var is_fresh := _freshness_store.is_fresh(node, tick)
_is_predicted_tick = _is_predicted_tick_for(node, tick)
NetworkRollback.process_rollback(node, NetworkTime.ticktime, tick, is_fresh)

if _skipset.has(node):
Expand All @@ -407,12 +426,13 @@ func _process_tick(tick: int) -> void:

func _record_tick(tick: int) -> void:
# Broadcast state we own
if not _auth_state_property_entries.is_empty() and not _is_predicted_tick:
if not _auth_state_property_entries.is_empty():
var full_state := _PropertySnapshot.new()

for property in _auth_state_property_entries:
if _can_simulate(property.node, tick - 1) \
and not _skipset.has(property.node) \
and not _is_predicted_tick_for(property.node, tick - 1) \
or NetworkRollback.is_mutated(property.node, tick - 1):
# Only broadcast if we've simulated the node
# NOTE: _can_simulate checks mutations, but to override _skipset
Expand Down Expand Up @@ -492,6 +512,9 @@ func _record_tick(tick: int) -> void:

_states.set_snapshot(tick, merge_state.merge(record_state))

# Ack simulation for tick
_last_simulated_tick = maxi(_last_simulated_tick, tick - 1)

# Push metrics
NetworkPerformance.push_rollback_nodes_simulated(_simset.size())

Expand Down
11 changes: 11 additions & 0 deletions examples/rollback-npc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Rollback NPC example

A demo game, demonstrating how to implement server-controlled NPCs that
participate in rollback.

With `RollbackSynchronizer` supporting no input, NPCs ( and other nodes without
input ) can be built the same way as e.g. player nodes.

To edit and/or run, open the Godot project in the repository root, and open the
scene in this directory.

37 changes: 37 additions & 0 deletions examples/rollback-npc/rollback-npc.tscn
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[gd_scene load_steps=7 format=3 uid="uid://cg0vfsftxxwv0"]

[ext_resource type="PackedScene" uid="uid://cngy6hs8ohodj" path="res://examples/shared/scenes/map-square.tscn" id="1_os5l0"]
[ext_resource type="PackedScene" uid="uid://cncdbq72u50j3" path="res://examples/shared/scenes/environment.tscn" id="2_and7e"]
[ext_resource type="Script" path="res://examples/shared/scripts/player-spawner.gd" id="3_1ga2x"]
[ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="3_1tn81"]
[ext_resource type="PackedScene" uid="uid://bl30av5bcvd4d" path="res://examples/rollback-npc/scenes/player.tscn" id="4_pr3ur"]
[ext_resource type="PackedScene" uid="uid://n5t5ahafwaln" path="res://examples/rollback-npc/scenes/npc.tscn" id="6_3tc5x"]

[node name="rollback-npc" type="Node3D"]

[node name="Square Map" parent="." instance=ExtResource("1_os5l0")]

[node name="Environment" parent="." instance=ExtResource("2_and7e")]

[node name="Player Spawner" type="Node" parent="." node_paths=PackedStringArray("spawn_root")]
script = ExtResource("3_1ga2x")
player_scene = ExtResource("4_pr3ur")
spawn_root = NodePath(".")

[node name="UI" type="Control" parent="."]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2

[node name="Network Popup" parent="UI" instance=ExtResource("3_1tn81")]
layout_mode = 1
offset_left = -180.0
offset_top = -120.0
offset_right = 180.0
offset_bottom = 120.0

[node name="NPC" parent="." instance=ExtResource("6_3tc5x")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 1, -6)
51 changes: 51 additions & 0 deletions examples/rollback-npc/scenes/npc.tscn
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
[gd_scene load_steps=7 format=3 uid="uid://n5t5ahafwaln"]

[ext_resource type="Script" path="res://examples/rollback-npc/scripts/npc.gd" id="1_6wds5"]
[ext_resource type="Script" path="res://addons/netfox/rollback/rollback-synchronizer.gd" id="2_a8m5g"]
[ext_resource type="Script" path="res://addons/netfox/tick-interpolator.gd" id="3_ql8cj"]

[sub_resource type="SphereMesh" id="SphereMesh_3ov8j"]

[sub_resource type="CylinderMesh" id="CylinderMesh_tsqs5"]
top_radius = 0.0
height = 0.5

[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_2yxbx"]
height = 1.5

[node name="NPC" type="CharacterBody3D"]
collision_layer = 2
collision_mask = 3
script = ExtResource("1_6wds5")

[node name="Mesh" type="Node3D" parent="."]

[node name="Body" type="MeshInstance3D" parent="Mesh"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
mesh = SubResource("SphereMesh_3ov8j")
skeleton = NodePath("../..")

[node name="Hat" type="MeshInstance3D" parent="Mesh"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
mesh = SubResource("CylinderMesh_tsqs5")
skeleton = NodePath("../..")

[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape3D_2yxbx")

[node name="RollbackSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")]
script = ExtResource("2_a8m5g")
root = NodePath("..")
enable_prediction = true
state_properties = Array[String]([":position", ":velocity"])

[node name="TickInterpolator" type="Node" parent="." node_paths=PackedStringArray("root")]
script = ExtResource("3_ql8cj")
root = NodePath("..")
properties = Array[String]([":position"])

[node name="Label3D" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
billboard = 1
text = "NPC"
font_size = 96
37 changes: 37 additions & 0 deletions examples/rollback-npc/scenes/player.tscn
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[gd_scene load_steps=8 format=3 uid="uid://bl30av5bcvd4d"]

[ext_resource type="Script" path="res://examples/rollback-npc/scripts/player.gd" id="1_d0kje"]
[ext_resource type="Script" path="res://examples/rollback-npc/scripts/player-input.gd" id="2_us2sq"]
[ext_resource type="Script" path="res://addons/netfox/rollback/rollback-synchronizer.gd" id="3_bf3yl"]
[ext_resource type="Script" path="res://addons/netfox/tick-interpolator.gd" id="4_4lenk"]

[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_bmc6p"]

[sub_resource type="CapsuleMesh" id="CapsuleMesh_t0alo"]
material = SubResource("StandardMaterial3D_bmc6p")

[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_1dm6n"]

[node name="Player" type="CharacterBody3D" groups=["Players"]]
collision_mask = 3
script = ExtResource("1_d0kje")

[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
mesh = SubResource("CapsuleMesh_t0alo")

[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape3D_1dm6n")

[node name="Input" type="Node" parent="."]
script = ExtResource("2_us2sq")

[node name="RollbackSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")]
script = ExtResource("3_bf3yl")
root = NodePath("..")
state_properties = Array[String]([":transform", ":velocity"])
input_properties = Array[String](["Input:movement"])

[node name="TickInterpolator" type="Node" parent="." node_paths=PackedStringArray("root")]
script = ExtResource("4_4lenk")
root = NodePath("..")
properties = Array[String]([":transform"])
76 changes: 76 additions & 0 deletions examples/rollback-npc/scripts/npc.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
@tool
extends CharacterBody3D

@export var speed: float = 2.0
@export var sensor_radius: float = 4.0
@export var min_radius: float = 1.5

@onready var label := $Label3D as Label3D
@onready var rbs := $RollbackSynchronizer as RollbackSynchronizer

# Get the gravity from the project settings to be synced with RigidBody nodes.
var gravity = ProjectSettings.get_setting(&"physics/3d/default_gravity")

func _get_rollback_state_properties() -> Array:
return [
"position",
"velocity"
]

func _get_interpolated_properties() -> Array:
return [
"position"
]

func _rollback_tick(dt, _tick, _is_fresh: bool):
label.text = "pre" if rbs.is_predicting() else "sim"

# Add gravity
_force_update_is_on_floor()
if not is_on_floor():
velocity.y -= gravity * dt

var target_motion := Vector3.ZERO
var nearby_player := _find_nearby_player()
if nearby_player:
target_motion = nearby_player.global_position - global_position
target_motion.y = 0.

target_motion = target_motion.normalized() * speed

velocity.x = move_toward(velocity.x, target_motion.x, speed / 0.35 * dt)
velocity.z = move_toward(velocity.z, target_motion.z, speed / 0.35 * dt)

# move_and_slide assumes physics delta
# multiplying velocity by NetworkTime.physics_factor compensates for it
velocity *= NetworkTime.physics_factor
move_and_slide()
velocity /= NetworkTime.physics_factor

func _find_nearby_player() -> Node3D:
var players := get_tree().get_nodes_in_group(&"Players")
if players.is_empty():
return null

var sensor_radius_squared := pow(sensor_radius, 2.0)
var min_radius_squared := pow(min_radius, 2.0)

var closest_player: Node3D = null
var closest_distance := INF
for player in players:
var distance := global_position.distance_squared_to(player.global_position)

if distance >= sensor_radius_squared or distance <= min_radius_squared:
continue

if distance < closest_distance:
closest_distance = distance
closest_player = player

return closest_player

func _force_update_is_on_floor():
var old_velocity = velocity
velocity = Vector3.ZERO
move_and_slide()
velocity = old_velocity
1 change: 1 addition & 0 deletions examples/rollback-npc/scripts/npc.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://7ru5vkblxide
16 changes: 16 additions & 0 deletions examples/rollback-npc/scripts/player-input.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@tool
extends BaseNetInput

var movement: Vector3

func _get_rollback_input_properties() -> Array:
return [
"movement"
]

func _gather():
movement = Vector3(
Input.get_axis("move_west", "move_east"),
0.0,
Input.get_axis("move_north", "move_south")
)
1 change: 1 addition & 0 deletions examples/rollback-npc/scripts/player-input.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://brtfiqnk5alw6
Loading
Loading