diff --git a/addons/netfox/icons/input-sender.svg b/addons/netfox/icons/input-sender.svg
new file mode 100644
index 000000000..3f3f39358
--- /dev/null
+++ b/addons/netfox/icons/input-sender.svg
@@ -0,0 +1,54 @@
+
+
+
+
diff --git a/addons/netfox/icons/input-sender.svg.import b/addons/netfox/icons/input-sender.svg.import
new file mode 100644
index 000000000..8976192c0
--- /dev/null
+++ b/addons/netfox/icons/input-sender.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dd227x8br84rs"
+path="res://.godot/imported/input-sender.svg-7b3cd669dc50ce8229dd58ca509f298a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/netfox/icons/input-sender.svg"
+dest_files=["res://.godot/imported/input-sender.svg-7b3cd669dc50ce8229dd58ca509f298a.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/netfox/icons/simulator.svg b/addons/netfox/icons/simulator.svg
new file mode 100644
index 000000000..4cf290d18
--- /dev/null
+++ b/addons/netfox/icons/simulator.svg
@@ -0,0 +1,54 @@
+
+
+
+
diff --git a/addons/netfox/icons/simulator.svg.import b/addons/netfox/icons/simulator.svg.import
new file mode 100644
index 000000000..462d8e81c
--- /dev/null
+++ b/addons/netfox/icons/simulator.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dyh2wa862n5kx"
+path="res://.godot/imported/simulator.svg-a031f70114a4770a3b287d54c5d39a10.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/netfox/icons/simulator.svg"
+dest_files=["res://.godot/imported/simulator.svg-a031f70114a4770a3b287d54c5d39a10.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd
new file mode 100644
index 000000000..493ef8a03
--- /dev/null
+++ b/addons/netfox/input-sender.gd
@@ -0,0 +1,370 @@
+@tool
+extends Node
+class_name InputSender
+
+## Stores inputs and sends them to host.
+## [br][br]
+##
+## [InputSender] is a multi purpose node to use on networked games. It sends
+## inputs to the host, which either runs them through a [Simulator], or emits
+## [signal input_applied] to run your own host side logic with them.
+## [br][br]
+## [b]Predicting and merging inputs[/b]: inputs are gathered once per tick,
+## the same way as for rollback. Sometimes netfox needs an input that was never
+## gathered, or has to fit two gathered inputs into one tick:
+## [br]- [b]predict[/b]: an input is needed for a tick, but there is none. This
+## happens when a client's input lead grows and a stamp is skipped, and when
+## the host simulates a player whose input didn't arrive in time.
+## [br]- [b]merge[/b]: two inputs get the same stamp. This happens when a
+## client's input lead shrinks.
+## [br]Both are rare, see [_SimulatorTimeTracker]. By default, predicting
+## copies the last input, and merging keeps the newer input. If the defaults
+## don't fit, implement these methods in the [member root] node's script:
+## [codeblock]
+## # Return the input for a tick that has none. last is the latest input
+## # before it. May be called for multiple ticks in a row with the same last.
+## func _predict_input(last: Dictionary) -> Dictionary:
+## var predicted := last.duplicate()
+## predicted["Input:jump"] = false
+## return predicted
+##
+## # Return a single input made from two inputs that got the same tick.
+## # older was gathered one tick before newer.
+## func _merge_input(older: Dictionary, newer: Dictionary) -> Dictionary:
+## var merged := newer.duplicate()
+## merged["Input:jump"] = older["Input:jump"] or newer["Input:jump"]
+## return merged
+## [/codeblock]
+## Inputs are dictionaries, with the paths in [member input_properties] as keys,
+## and the input values as values. Properties missing from the returned
+## dictionary keep the value from [code]last[/code] or [code]newer[/code].
+## [br][br]
+## For example, inputs that are only true for a single tick, like a jump press,
+## are copied by default when predicting, and the press happens twice. When
+## merging, the default drops the older press.
+
+## Emitted on the host, after every tick, when the inputs of [param tick] are
+## applied to the input properties. Use it to run host side logic with the
+## inputs, e.g. for physics vehicles synchronized with a [StateSynchronizer].
+## Whatever you change here is recorded as the state of [code]tick + 1[/code],
+## the same as with [Simulator] and rollback.
+## [br][br]
+## Emitted for every player, including the host's own. Clients stamp their
+## inputs ahead, so they're usually here in time. If they're not,
+## [param is_predicted] is true, and the inputs are predicted from the latest
+## ones before [param tick], see "Predicting and merging inputs" above. Inputs
+## arriving after their tick was applied are never applied, the host doesn't
+## change the past.
+## [br][br]
+## Not emitted on clients. Only emitted if something is connected to it.
+## [codeblock]
+## func _on_input_sender_input_applied(tick: int, is_predicted: bool) -> void:
+## engine_force = input.movement.y * engine_power
+## # Don't fire on a guessed input
+## if input.fire and not is_predicted:
+## fire(tick)
+## [/codeblock]
+signal input_applied(tick: int, is_predicted: bool)
+
+## The root node for resolving node paths in inputs. Defaults to the parent node.
+@export var root: Node = get_parent()
+
+@export_group("Input")
+## Properties that define the input for the game simulation.
+## [br][br]
+## Input properties drive the simulation, which in turn results in updated state
+## properties. Input is recorded after every network tick.
+@export var input_properties: Array[String]
+
+# Make sure this exists from the get-go, just not in the scene tree
+## Decides which peers will receive updates
+var visibility_filter := PeerVisibilityFilter.new()
+
+var _input_properties := _PropertyPool.new()
+var _properties_dirty: bool = false
+
+# Nodes with a schema registered through this InputSender, see set_schema()
+var _schema_nodes := _Set.new()
+
+var _logger := NetfoxLogger._for_netfox("InputSender")
+
+# We need these to de-couple input-senders working logic from recording.
+# InputSender applies state and emits signals, but this can change saved and synced
+# input properties, to prevent that, input-sender will save its pre-logic-inputs
+# and apply them whenever logic ends.
+#
+# TODO we are moving to server pattern, this might be not neccessary after that.
+var _saved_inputs_snapshot : _PropertySnapshot
+var _property_cache: PropertyCache
+var _property_entries: Array[PropertyEntry] = []
+
+# The input before anything was gathered or applied, keyed like _get_input().
+# Applied for ticks that have no input at all, e.g. right after a player
+# spawned, on every peer the same way. See _SimulatorServer._apply_input_at().
+var _default_input: Dictionary = {}
+
+func _ready() -> void:
+ if Engine.is_editor_hint():
+ return
+
+ # DANGER we dont do deferred call here, because Simulator node depends on
+ # has_authority_over_input to determine its register logic.
+ # Therefore it is needed that input-sender is registered and ready to
+ # return the result of that helper function.
+ process_settings()
+
+ # Reprocess authority on connect
+ if NetworkEvents.enabled:
+ # User might change `multiplayer` - `NetworkEvents` handles that
+ NetworkEvents.on_client_start.connect(func(__): process_settings())
+ else:
+ multiplayer.connected_to_server.connect(process_settings)
+
+func _enter_tree() -> void:
+ if Engine.is_editor_hint():
+ return
+
+ if not visibility_filter:
+ visibility_filter = PeerVisibilityFilter.new()
+
+ if not visibility_filter.get_parent():
+ add_child(visibility_filter)
+
+func _exit_tree():
+ if Engine.is_editor_hint():
+ return
+
+ for node in _input_properties.get_subjects():
+ NetworkSynchronizationServer.deregister(node)
+ NetworkIdentityServer.deregister_node(node)
+ NetworkHistoryServer.deregister(node)
+
+ InputSenderServer._deregister_input_sender(self)
+
+## Process settings.
+## [br][br]
+## Call this after any change to configuration. Updates based on authority too
+## ( calls process_authority ).
+func process_settings() -> void:
+ process_authority()
+
+ _property_cache = PropertyCache.new(root)
+ _property_entries.clear()
+
+ _saved_inputs_snapshot = _PropertySnapshot.new()
+
+ for property in input_properties:
+ var property_entry = _property_cache.get_entry(property)
+ _property_entries.push_back(property_entry)
+
+ # Remember the default input. Only properties seen for the first time, so
+ # reprocessing settings later never takes gathered values as defaults.
+ for property_entry in _property_entries:
+ var path := property_entry.to_string()
+ if not _default_input.has(path):
+ _default_input[path] = property_entry.get_value()
+
+ # Register identifiers
+ for node in _input_properties.get_subjects():
+ NetworkIdentityServer.register_node(node)
+
+ # Register visibility filter
+ for node in _input_properties.get_subjects():
+ NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter)
+
+## Process settings based on authority.
+## [br][br]
+## Call this whenever the authority of input node changes.
+## Make sure to do this at the same time on all peers.
+func process_authority():
+ InputSenderServer._deregister_input_sender(self)
+
+ for node in _input_properties.get_subjects():
+ for property in _input_properties.get_properties_of(node):
+ NetworkHistoryServer.deregister_input_sender(node, property)
+ NetworkSynchronizationServer.deregister_input_sender(node, property)
+
+ # Process authority
+ _input_properties.set_from_paths(root, input_properties)
+
+ # Register new recorded inputs
+ for node in _input_properties.get_subjects():
+ for property in _input_properties.get_properties_of(node):
+ NetworkHistoryServer.register_input_sender(node, property)
+ NetworkSynchronizationServer.register_input_sender(node, property)
+
+ InputSenderServer._register_input_sender(self)
+
+## Add an input property.
+## [br][br]
+## Settings will be automatically updated. The [param node] may be a string or
+## [NodePath] pointing to a node, or an actual [Node] instance. If the given
+## property is already tracked, this method does nothing.
+func add_input(node: Variant, property: String) -> void:
+ var property_path := PropertyEntry.make_path(root, node, property)
+ if not property_path or input_properties.has(property_path):
+ return
+
+ input_properties.push_back(property_path)
+ _properties_dirty = true
+ _reprocess_settings.call_deferred()
+
+## Set the schema for transmitting properties over the network.
+## [br][br]
+## The [param schema] must be a dictionary, with the keys being property path
+## strings, and the values are the associated [NetworkSchemaSerializer] objects.
+## Properties are interpreted relative to the [member root] node. The schema
+## should contain input properties. Properties not specified in the schema will
+## use a generic fallback serializer. By using the right serializer for the
+## right property, bandwidth usage can be lowered.
+## [br][br]
+## The schema must be the same on every peer.
+## [br][br]
+## See [NetworkSchemas] for many common serializers.
+## [br][br]
+## Example:
+## [codeblock]
+## input_sender.set_schema({
+## "Input:movement": NetworkSchemas.vec3f32(),
+## "Input:aim": NetworkSchemas.vec3f32(),
+## "Input:is_firing": NetworkSchemas.bool8()
+## })
+## [/codeblock]
+func set_schema(schema: Dictionary) -> void:
+ # Remove previous schema
+ clear_schema()
+
+ # Register new schema
+ merge_schema(schema)
+
+## Add serializers from [param schema].
+## [br][br]
+## See [method set_schema] for specifying [param schema]. As opposed to [method
+## set_schema], this method updates the schema, instead of overriding it. If
+## a property had a serializer specified previously, this will replace it.
+func merge_schema(schema: Dictionary) -> void:
+ for prop in schema:
+ var prop_entry := PropertyEntry.parse(root, prop)
+ var serializer := schema[prop] as NetworkSchemaSerializer
+ NetworkSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer)
+ _schema_nodes.add(prop_entry.node)
+
+## Clear any serializers specified earlier.
+## [br][br]
+## See [method set_schema].
+func clear_schema() -> void:
+ for node in _schema_nodes:
+ NetworkSynchronizationServer.deregister_schema_for(node)
+ _schema_nodes.clear()
+
+## Helper function to determine if [InputSender] has authority over its input_properties
+## This function iterates over input_properties subjects and checks if they have authority.
+## If none of them has authority or no input_node is configured this will return false,
+## If any of them has authority this will return true instantly.
+## Its developers responsibility to always make sure input_nodes have same configuration.
+## TODO make sure to document this responsibility to developer.
+func has_authority_over_input_nodes() -> bool:
+ for subject in _input_properties.get_subjects():
+
+ # ObjectPool does not guarentee every subject is node.
+ if not subject is Node:
+ continue
+
+ # Found input node, check if it has authority
+ if subject.is_multiplayer_authority():
+ return true
+
+ # Did not find any node, or none of them has authority.
+ return false
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_EDITOR_PRE_SAVE:
+ update_configuration_warnings()
+ elif what == NOTIFICATION_PREDELETE:
+ for node in _input_properties.get_subjects():
+ NetworkSynchronizationServer.deregister(node)
+ NetworkIdentityServer.deregister_node(node)
+ NetworkHistoryServer.deregister(node)
+
+func _get_configuration_warnings() -> PackedStringArray:
+ if not root:
+ root = get_parent()
+
+ # Check if root exists.
+ if not root:
+ return ["No valid root node found!"]
+
+ var result := PackedStringArray()
+
+ result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_input_sender_input_properties",
+ func(node, prop):
+ add_input(node, prop)
+ ))
+
+ if _input_properties.is_empty() and input_properties.is_empty():
+ return ["Input properties are not configured!"]
+
+ return result
+
+func _reprocess_settings() -> void:
+ if not _properties_dirty or Engine.is_editor_hint():
+ return
+
+ _properties_dirty = false
+ process_settings()
+
+# Returns the current input, see "Predicting and merging inputs" in the class
+# description.
+func _get_input() -> Dictionary:
+ return _PropertySnapshot.extract(_property_entries).as_dictionary()
+
+# Applies the given input. Properties missing from it are left unchanged.
+func _set_input(input: Dictionary) -> void:
+ for property_entry in _property_entries:
+ var path := property_entry.to_string()
+ if input.has(path):
+ property_entry.set_value(input[path])
+
+# Returns the predicted input for a tick that has none, using the root node's
+# _predict_input() if it has one, see the class description.
+func _get_predicted_input(last: Dictionary) -> Dictionary:
+ if not root or not root.has_method("_predict_input"):
+ return last.duplicate()
+
+ var predicted: Variant = root.call("_predict_input", last.duplicate())
+ if not predicted is Dictionary:
+ _logger.error("_predict_input() of %s must return a Dictionary, copying the last input instead", [root.name])
+ return last.duplicate()
+
+ # Properties missing from the result keep the last value
+ var result := last.duplicate()
+ result.merge(predicted as Dictionary, true)
+ return result
+
+# Returns a single input made from two inputs, using the root node's
+# _merge_input() if it has one, see the class description.
+func _get_merged_input(older: Dictionary, newer: Dictionary) -> Dictionary:
+ if not root or not root.has_method("_merge_input"):
+ return newer.duplicate()
+
+ var merged: Variant = root.call("_merge_input", older.duplicate(), newer.duplicate())
+ if not merged is Dictionary:
+ _logger.error("_merge_input() of %s must return a Dictionary, keeping the newer input instead", [root.name])
+ return newer.duplicate()
+
+ # Properties missing from the result keep the newer value
+ var result := newer.duplicate()
+ result.merge(merged as Dictionary, true)
+ return result
+
+# Helper function to save current input_properties.
+# Used internally by InputSenderServer/SimulatorServer to record state before
+# overwriting properties and emitting signals.
+func _save_properties() -> void:
+ _saved_inputs_snapshot = _PropertySnapshot.extract(_property_entries)
+
+# Helper function to restore input_properties.
+# Used internally by InputSenderServer/SimulatorServer to restore state after
+# overwriting properties.
+func _restore_properties() -> void:
+ _saved_inputs_snapshot.apply(_property_cache)
diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd
index e7592fba2..970d64c0d 100644
--- a/addons/netfox/netfox.gd
+++ b/addons/netfox/netfox.gd
@@ -169,7 +169,94 @@ var SETTINGS: Array[Dictionary] = [
"name": "netfox/events/enabled",
"value": true,
"type": TYPE_BOOL
- }
+ },
+ # Input Sender
+ {
+ # Each input packet also carries this many previous inputs, in case
+ # some packets are lost
+ "name": "netfox/input_sender/input_redundancy",
+ "value": 3,
+ "type" : TYPE_INT
+ },
+ {
+ # Ticks of past inputs kept. The buffers also get room for inputs
+ # stamped ahead, see netfox/simulator/max_input_lead
+ "name": "netfox/input_sender/history_limit",
+ "value": 64,
+ "type" : TYPE_INT
+ },
+ {
+ # Send inputs to every peer, not only the host
+ "name": "netfox/input_sender/enable_input_broadcast",
+ "value": false,
+ "type" : TYPE_BOOL
+ },
+ # Simulator
+ {
+ # Ticks between full states, diff states are sent in between.
+ # 0 sends a full state every tick
+ "name": "netfox/simulator/full_state_interval",
+ "value": 24,
+ "type": TYPE_INT,
+ "hint": PROPERTY_HINT_RANGE,
+ "hint_string": "0,60,or_greater"
+ },
+ {
+ # Send only what changed since the previous tick between full states
+ "name": "netfox/simulator/enable_diff_states",
+ "value": true,
+ "type": TYPE_BOOL
+ },
+ {
+ # Ticks of past simulator state kept, also how far back projectiles
+ # can be caught up. The buffers also get room for states predicted
+ # ahead, see netfox/simulator/max_input_lead
+ "name": "netfox/simulator/history_limit",
+ "value": 64,
+ "type" : TYPE_INT
+ },
+ {
+ # Most ticks the host rewinds when catching up a client's projectile
+ "name": "netfox/simulator/max_view_delay_ticks",
+ "value": 30,
+ "type" : TYPE_INT
+ },
+ {
+ # Clients stamp their inputs ahead by rtt / 2 + jitter + this margin,
+ # in seconds, so inputs arrive at the host before they're needed
+ "name": "netfox/simulator/input_lead_margin",
+ "value": 0.1,
+ "type": TYPE_FLOAT,
+ "hint": PROPERTY_HINT_RANGE,
+ "hint_string": "0,1,or_greater"
+ },
+ {
+ # Clients never stamp their inputs further ahead than this, in seconds.
+ # The host drops inputs stamped further ahead. InputSender and
+ # Simulator history buffers get this much room for the future, on top
+ # of their history_limit
+ "name": "netfox/simulator/max_input_lead",
+ "value": 1.0,
+ "type": TYPE_FLOAT,
+ "hint": PROPERTY_HINT_RANGE,
+ "hint_string": "0,2,or_greater"
+ },
+ {
+ # Seconds between periodic view delay samples sent by clients
+ "name": "netfox/simulator/view_delay_send_interval",
+ "value": 0.25,
+ "type": TYPE_FLOAT,
+ "hint": PROPERTY_HINT_RANGE,
+ "hint_string": "0,2,or_greater"
+ },
+ {
+ # View delay changes of at least this many seconds are sent immediately
+ "name": "netfox/simulator/view_delay_change_threshold",
+ "value": 0.06,
+ "type": TYPE_FLOAT,
+ "hint": PROPERTY_HINT_RANGE,
+ "hint_string": "0,1,or_greater"
+ },
]
const AUTOLOADS: Array[Dictionary] = [
@@ -220,7 +307,15 @@ const AUTOLOADS: Array[Dictionary] = [
{
"name": "InterpolationServer",
"path": ROOT + "/servers/interpolation-server.gd"
- }
+ },
+ {
+ "name": "InputSenderServer",
+ "path": ROOT + "/servers/input-sender-server.gd"
+ },
+ {
+ "name": "SimulatorServer",
+ "path": ROOT + "/servers/simulator-server.gd"
+ },
]
const TYPES: Array[Dictionary] = [
@@ -254,6 +349,18 @@ const TYPES: Array[Dictionary] = [
"script": ROOT + "/rollback/predictive-synchronizer.gd",
"icon": ROOT + "/icons/predictive-synchronizer.svg"
},
+ {
+ "name": "InputSender",
+ "base": "Node",
+ "script": ROOT + "/input-sender.gd",
+ "icon": ROOT + "/icons/input-sender.svg"
+ },
+ {
+ "name": "Simulator",
+ "base": "Node",
+ "script": ROOT + "/simulator.gd",
+ "icon": ROOT + "/icons/simulator.svg"
+ },
]
func _enter_tree():
diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd
index e53c1bb2b..682ae740f 100644
--- a/addons/netfox/network-time.gd
+++ b/addons/netfox/network-time.gd
@@ -577,6 +577,9 @@ func _loop() -> void:
# Record data for rollback
NetworkRollback._after_tick(tick)
+ # Simulate, record, synchronize.
+ SimulatorServer._after_tick(tick)
+
# Record data for StateSynchronizer
NetworkHistoryServer._record_sync_state(tick + 1)
NetworkSynchronizationServer._synchronize_sync_state(tick + 1)
@@ -613,6 +616,13 @@ func _after_tick_loop() -> void:
# Run rollback loop
NetworkRollback._rollback()
+ # Restore state for Simulator before emitting after_tick_loop so that
+ # listeners can run logic after state is set.
+ # Local authoritative simulators are predicted up to tick + input lead, so
+ # restore up to there. Other subjects only have states received from the
+ # host, which are never newer than tick, so this changes nothing for them.
+ NetworkHistoryServer._restore_simulator(tick + SimulatorServer._get_input_lead())
+
# Emit signal
after_tick_loop.emit()
diff --git a/addons/netfox/servers/data/per-object-history.gd b/addons/netfox/servers/data/per-object-history.gd
index 891e1fc0f..d90700189 100644
--- a/addons/netfox/servers/data/per-object-history.gd
+++ b/addons/netfox/servers/data/per-object-history.gd
@@ -46,6 +46,30 @@ func ensure_snapshot(tick: int, subject: Object, carry_forward: bool) -> _Object
# NOTE: May return null if tick too old
return history.get_at(tick) as _ObjectSnapshot
+# Resets the subject's snapshot at the given tick to its latest auth state
+# The snapshot is replaced with a copy of the latest auth snapshot before the
+# tick, discarding any non-auth (e.g. predicted) data recorded for the tick. If
+# there's no earlier auth snapshot, it's replaced with an empty one.
+# Does nothing if the snapshot at the tick is already auth.
+func reset_to_auth(tick: int, subject: Object) -> void:
+ if not _data.has(subject):
+ _data[subject] = _HistoryBuffer.new(_history_size)
+ var history := _data[subject] as _HistoryBuffer
+
+ var existing := history.get_at(tick) as _ObjectSnapshot
+ if existing and existing.is_auth():
+ return
+
+ # Walk backwards from the previous tick to find the latest auth snapshot
+ var latest_index := mini(tick - 1, history.get_latest_index())
+ for index in range(latest_index, history.get_earliest_index() - 1, -1):
+ var snapshot := history.get_at(index) as _ObjectSnapshot
+ if snapshot and snapshot.is_auth():
+ history.set_at(tick, snapshot.duplicate())
+ return
+
+ history.set_at(tick, _ObjectSnapshot.new(subject))
+
func get_latest_snapshot(tick: int, subject: Object) -> _ObjectSnapshot:
if not _data.has(subject):
return null
diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd
new file mode 100644
index 000000000..696d0b114
--- /dev/null
+++ b/addons/netfox/servers/input-sender-server.gd
@@ -0,0 +1,271 @@
+extends Node
+class_name _InputSenderServer
+
+# @public class
+
+## Handles [InputSender] related operations.
+##
+## InputSenderServer does two jobs, after every tick:
+## [br]1. [b]Send our own inputs[/b], on every peer: the input properties of
+## InputSenders we have authority over are recorded under a stamp and sent to
+## the host. On the host, the stamp is the tick itself. On clients, it's
+## tick + input lead, see [method _record_and_send_inputs].
+## [br]2. [b]Apply inputs[/b], on the host only: for every InputSender, the
+## inputs of the current tick are applied, and
+## [signal InputSender.input_applied] is emitted. If the inputs of the tick
+## didn't arrive, they're predicted, see [InputSender].
+## [br][br]
+## Example, on the host at tick 100, with a client stamping 5 ticks ahead:
+## [codeblock]
+## after_tick 100 | host's own inputs recorded and stamped 100
+## | inputs of 100 applied for every InputSender, the
+## | client's were stamped 100 when it was at tick 95
+## | input_applied(100, false) emitted
+## after that | StateSynchronizers record the result as tick 101
+## [/codeblock]
+## Inputs that arrive after the host applied their tick are never applied, the
+## host already predicted them, and doesn't change the past.
+
+static var _logger := NetfoxLogger._for_netfox("InputSenderServer")
+
+var _history_server : NetworkHistoryServer = null
+var _synchronization_server : NetworkSynchronizationServer = null
+
+# A lead change skips at most this many stamps. A bigger jump means time
+# jumped forward, e.g. after a pause, and the skipped stamps aren't filled.
+const MAX_FILLED_STAMPS := 4
+
+# Latest tick our own inputs were recorded under, -1 if nothing recorded yet.
+# Inputs are stamped with tick + input lead, see _SimulatorTimeTracker.
+var _last_recorded_tick : int = -1
+
+# Inputs held back for a tick because the input lead shrank, see
+# _record_and_send_inputs. Maps InputSender -> input Dictionary.
+var _held_inputs : Dictionary = {}
+
+# Every registered InputSender.
+var _input_senders : Array[InputSender] = []
+
+func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null):
+ _history_server = p_history_server
+ _synchronization_server = p_synchronization_server
+
+func _ready():
+ # Ensure dependencies
+ if not _history_server: _history_server = NetworkHistoryServer
+ if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer
+
+ # Inputs are recorded after every tick, the same way as for rollback, so
+ # users can gather inputs the same way: set the input properties before
+ # the tick, e.g. on NetworkTime.before_tick_loop or before_tick.
+ #
+ # Inputs are applied right after recording them, still on after_tick.
+ # This way the host's own inputs of this tick are already recorded, and
+ # whatever input_applied handlers change is recorded by StateSynchronizers
+ # as the state of tick + 1, which happens after after_tick.
+ NetworkTime.after_tick.connect(_after_tick)
+
+ # Ticks start over when NetworkTime starts, so do the stamps.
+ NetworkTime.after_sync.connect(_reset_stamps)
+
+func _after_tick(_delta: float, tick: int) -> void:
+ _record_and_send_inputs(tick)
+
+ if multiplayer.is_server():
+ _apply_inputs(tick)
+
+# Host side. Applies the inputs of the given tick for every InputSender, and
+# emits input_applied. Inputs are read from each player's own history, so
+# nothing another player sends can affect it. Missing inputs are predicted
+# from the latest ones before the tick, see InputSender.
+#
+# InputSenders with nothing connected to input_applied are skipped, there's
+# nothing to apply the inputs for. This is checked every tick, so connecting
+# at any time works from the next tick on.
+func _apply_inputs(tick: int) -> void:
+ for input_sender in _input_senders:
+ if input_sender.input_applied.get_connections().is_empty():
+ continue
+
+ var input_subjects := input_sender._input_properties.get_subjects()
+ var latest_input_tick := _history_server.get_latest_input_sender_for(input_subjects, tick)
+ var is_predicted := latest_input_tick != tick
+
+ # Save the input properties, so the inputs gathered on this peer are
+ # untouched by what we apply here.
+ input_sender._save_properties()
+
+ # Apply the inputs of this tick, or the latest ones before it. If the
+ # player has no inputs at all yet, e.g. it just spawned, apply its
+ # default input, the same as SimulatorServer does.
+ if latest_input_tick < 0:
+ input_sender._set_input(input_sender._default_input)
+ else:
+ _history_server._restore_input_sender_subjects(input_subjects, tick)
+ if is_predicted:
+ input_sender._set_input(input_sender._get_predicted_input(input_sender._get_input()))
+
+ input_sender.input_applied.emit(tick, is_predicted)
+
+ input_sender._restore_properties()
+
+# Records our own inputs and sends them to the host. Called after every tick.
+#
+# Host: the lead is always 0, so the input gathered on tick T is stamped T.
+# Ticks go up by one, so every tick gets a new stamp, and none of the lead
+# handling below happens. The only exception is a pause, where time jumps.
+#
+# Client: the input gathered on tick T is stamped T + lead, so it reaches the
+# host before the host simulates that tick, see _SimulatorTimeTracker. While
+# the lead stays the same, stamps go up by one every tick, just like ticks:
+#
+# tick | lead | stamp | sent
+# 100 | 5 | 105 | input of 100
+# 101 | 5 | 106 | input of 101
+#
+# Lead grows (5 -> 6): tick 102 is stamped 108, so stamp 107 gets no input.
+# The host needs an input for every tick it simulates, so 107 gets an input
+# predicted from 106, then 108 gets the input of tick 102:
+#
+# tick | lead | stamp | sent
+# 102 | 6 | 108 | predicted input under 107, input of 102 under 108
+#
+# Lead shrinks (6 -> 5): tick 201 was stamped 207, and tick 202 would be
+# stamped 207 too. What to do with the input of tick 202?
+# - Send it under 207: no, 207 was already sent, and the host never lets a
+# client change an input it already has (cheating).
+# - Send it under 208: no, tick 203 is stamped 208, so the clash just moves
+# to the next tick, and the next, forever.
+# - Drop it: no, it may contain e.g. a fire press, which would be lost.
+# A smaller lead means one stamp less, so two gathered inputs have to share
+# one stamp. So we hold the input of tick 202 back, send nothing on this tick,
+# and on the next tick send it merged with the input of tick 203 under 208:
+#
+# tick | lead | stamp | sent
+# 201 | 6 | 207 | input of 201
+# 202 | 5 | 207 | nothing, input of 202 held back
+# 203 | 5 | 208 | input of 202 merged with input of 203
+#
+# The lead changes by at most 1 tick per second, so this is rare. Predicting
+# and merging is done by InputSender, so users can customize it.
+func _record_and_send_inputs(tick: int) -> void:
+ var stamped_tick := tick + SimulatorServer._get_input_lead()
+
+ if _last_recorded_tick < 0:
+ # First input, nothing to continue from
+ _record_inputs(stamped_tick)
+ return
+
+ var next_tick := _last_recorded_tick + 1
+ var skipped := stamped_tick - next_tick
+
+ if skipped == 0:
+ # Lead didn't change
+ _record_inputs(stamped_tick)
+ elif skipped == -1:
+ # Lead shrank, this stamp is taken already. Hold this input back, and
+ # merge it into the next one.
+ _hold_inputs()
+ elif skipped < -1:
+ # Ticks went backwards, which only happens when NetworkTime restarts,
+ # and we reset on that. Don't hold inputs forever, start over.
+ _logger.warning(
+ "Input stamp went back from @%d to @%d, starting over",
+ [_last_recorded_tick, stamped_tick])
+ _held_inputs.clear()
+ _record_inputs(stamped_tick)
+ elif skipped > MAX_FILLED_STAMPS:
+ # Time jumped forward, e.g. after a pause, there's nothing to fill.
+ _logger.debug(
+ "Input stamp jumped from @%d to @%d, not filling",
+ [_last_recorded_tick, stamped_tick])
+ _record_inputs(stamped_tick)
+ else:
+ # Lead grew, fill the skipped stamps with predicted inputs
+ _record_predicted_inputs(next_tick, stamped_tick)
+ _record_inputs(stamped_tick)
+
+# Records and sends the gathered inputs under the given stamp. If any inputs
+# were held back, they're merged into the gathered ones first.
+func _record_inputs(stamped_tick: int) -> void:
+ if _held_inputs.is_empty():
+ _record_and_send(stamped_tick)
+ else:
+ # InputSender -> input Dictionary
+ var merged_inputs : Dictionary = {}
+ for input_sender in _held_inputs:
+ if is_instance_valid(input_sender):
+ merged_inputs[input_sender] = input_sender._get_merged_input(
+ _held_inputs[input_sender], input_sender._get_input())
+ _held_inputs.clear()
+
+ _record_and_send_with(stamped_tick, merged_inputs)
+
+# Records and sends predicted inputs under the stamps from from_tick to
+# to_tick (exclusive). All of them are predicted from the inputs recorded under
+# the stamp before from_tick, same as the host does for missing inputs.
+func _record_predicted_inputs(from_tick: int, to_tick: int) -> void:
+ # InputSender -> input Dictionary
+ var predicted_inputs : Dictionary = {}
+
+ for input_sender in _get_local_input_senders():
+ # Apply the inputs recorded under the previous stamp to read them
+ input_sender._save_properties()
+ _history_server._restore_input_sender_subjects(
+ input_sender._input_properties.get_subjects(), from_tick - 1)
+ var last := input_sender._get_input()
+ input_sender._restore_properties()
+
+ predicted_inputs[input_sender] = input_sender._get_predicted_input(last)
+
+ for stamped_tick in range(from_tick, to_tick):
+ _record_and_send_with(stamped_tick, predicted_inputs)
+
+# Holds back the gathered inputs, to be merged into the next ones.
+func _hold_inputs() -> void:
+ for input_sender in _get_local_input_senders():
+ if _held_inputs.has(input_sender):
+ # Already holding one, shouldn't happen since the lead changes
+ # rarely. Merge, so nothing is lost.
+ _held_inputs[input_sender] = input_sender._get_merged_input(
+ _held_inputs[input_sender], input_sender._get_input())
+ else:
+ _held_inputs[input_sender] = input_sender._get_input()
+
+# Applies the given inputs, records and sends them under the given stamp, then
+# puts back the gathered inputs, so input scripts find them as they left them.
+# inputs maps InputSender -> input Dictionary.
+func _record_and_send_with(stamped_tick: int, inputs: Dictionary) -> void:
+ for input_sender: InputSender in inputs:
+ input_sender._save_properties()
+ input_sender._set_input(inputs[input_sender] as Dictionary)
+
+ _record_and_send(stamped_tick)
+
+ for input_sender: InputSender in inputs:
+ input_sender._restore_properties()
+
+func _record_and_send(stamped_tick: int) -> void:
+ _history_server._record_input_sender(stamped_tick)
+ _synchronization_server._synchronize_input_sender(stamped_tick)
+ _last_recorded_tick = stamped_tick
+
+# InputSenders whose inputs we gather and send.
+func _get_local_input_senders() -> Array[InputSender]:
+ var result : Array[InputSender] = []
+ for input_sender in _input_senders:
+ if input_sender.has_authority_over_input_nodes():
+ result.push_back(input_sender)
+ return result
+
+func _reset_stamps() -> void:
+ _last_recorded_tick = -1
+ _held_inputs.clear()
+
+func _register_input_sender(input_sender : InputSender) -> void:
+ if not _input_senders.has(input_sender):
+ _input_senders.push_back(input_sender)
+
+func _deregister_input_sender(input_sender : InputSender) -> void:
+ _input_senders.erase(input_sender)
+ _held_inputs.erase(input_sender)
diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd
index 318fdddd1..9fc2d362b 100644
--- a/addons/netfox/servers/network-history-server.gd
+++ b/addons/netfox/servers/network-history-server.gd
@@ -5,19 +5,31 @@ class_name _NetworkHistoryServer
## Tracks the history of objects' properties
##
-## Specifically, history is stored for rollback state properties, rollback input
-## properties, and synchronized state properties.
+## History is stored for [br]
+## 1- rollback state and inputs,
+## 2- syncronized states,
+## 3- input_sender inputs,
+## 4- simulator states.
## [br][br]
-## Keeping history lets rollback restore earlier game states for resimulation,
-## and enables [_NetworkSynchronizationServer] to send diff states by comparing
-## against historical data.
+## Keeping history is needed for rewind operations and it also enables comparing,
+## sending diff states.
var _rb_input_properties := _PropertyPool.new()
var _rb_state_properties := _PropertyPool.new()
var _sync_state_properties := _PropertyPool.new()
+var _input_sender_properties := _PropertyPool.new()
+var _simulator_properties := _PropertyPool.new()
var _rb_history_size := NetworkRollback.history_limit
var _sync_history_size := ProjectSettings.get_setting("netfox/state_synchronizer/history_limit", 64) as int
+# InputSender and Simulator histories also hold ticks in the future: clients
+# stamp inputs ahead and predict their own state ahead. So their buffers are
+# history_limit ticks of the past, plus room for the future, see
+# _SimulatorTimeTracker.get_future_history_ticks().
+var _input_sender_history_size := ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) as int \
+ + _SimulatorTimeTracker.get_future_history_ticks()
+var _simulator_history_size := ProjectSettings.get_setting("netfox/simulator/history_limit", 64) as int \
+ + _SimulatorTimeTracker.get_future_history_ticks()
var _ignored_subjects := _Set.new()
@@ -25,11 +37,15 @@ var _ignored_subjects := _Set.new()
var _rb_input_history := _PerObjectHistory.new(_rb_history_size)
var _rb_state_history := _PerObjectHistory.new(_rb_history_size)
var _sync_history := _PerObjectHistory.new(_sync_history_size)
+var _input_sender_history := _PerObjectHistory.new(_input_sender_history_size)
+var _simulator_history := _PerObjectHistory.new(_simulator_history_size)
# Cached snapshots for syncing
var _rb_input_snapshots := _HistoryBuffer.new(_rb_history_size)
var _rb_state_snapshots := _HistoryBuffer.new(_rb_history_size)
var _sync_state_snapshots := _HistoryBuffer.new(_sync_history_size)
+var _input_sender_snapshots := _HistoryBuffer.new(_input_sender_history_size)
+var _simulator_snapshots := _HistoryBuffer.new(_simulator_history_size)
static var _logger := NetfoxLogger._for_netfox("NetworkHistoryServer")
@@ -57,6 +73,22 @@ func register_sync_state(node: Node, property: NodePath) -> void:
func deregister_sync_state(node: Node, property: NodePath) -> void:
_sync_state_properties.erase(node, property)
+## Register a input_sender input property
+func register_input_sender(node: Node, property: NodePath) -> void:
+ _input_sender_properties.add(node, property)
+
+## Deregister a input_sender input propert
+func deregister_input_sender(node: Node, property: NodePath) -> void:
+ _input_sender_properties.erase(node, property)
+
+## Register a simulator property
+func register_simulator(node: Node, property: NodePath) -> void:
+ _simulator_properties.add(node, property)
+
+## Deregister a simulator property
+func deregister_simulator(node: Node, property: NodePath) -> void:
+ _simulator_properties.erase(node, property)
+
## Deregister a node, no longer tracking any property it had registered using
## any of the [code]register_*()[/code] methods
func deregister(node: Node) -> void:
@@ -64,14 +96,20 @@ func deregister(node: Node) -> void:
_rb_state_properties.erase_subject(node)
_rb_input_properties.erase_subject(node)
_sync_state_properties.erase_subject(node)
+ _input_sender_properties.erase_subject(node)
+ _simulator_properties.erase_subject(node)
# Erase from per-object history
_rb_state_history.erase_subject(node)
_rb_input_history.erase_subject(node)
_sync_history.erase_subject(node)
+ _input_sender_history.erase_subject(node)
+ _simulator_history.erase_subject(node)
# Erase from per-tick history
- for history in [_rb_state_snapshots, _rb_input_snapshots, _sync_state_snapshots]:
+ for history in [_rb_state_snapshots, _rb_input_snapshots,\
+ _sync_state_snapshots, _input_sender_snapshots, _simulator_snapshots]:
+
for value in history.values():
var snapshot := value as _Snapshot
snapshot.erase_subject(node)
@@ -126,6 +164,44 @@ func get_state_age_for(subjects: Array, tick: int) -> int:
func get_latest_input_for(subjects: Array, tick: int) -> int:
return _get_latest_for(subjects, tick, _rb_input_history)
+## Get the latest tick where any of the [param subjects] had input_sender data
+## available
+func get_latest_input_sender_for(subjects: Array, tick: int) -> int:
+ return _get_latest_for(subjects, tick, _input_sender_history)
+
+## Get the latest tick where any of the [param subjects] had simulator data
+## available
+func get_latest_simulator_for(subjects: Array, tick: int) -> int:
+ return _get_latest_for(subjects, tick, _simulator_history)
+
+## Get the latest tick at or before [param tick] where every one of the
+## [param subjects] has auth (received) simulator state. Returns -1 if there's
+## none.
+## [br][br]
+## Auth is checked on the per-object history, not on the per-tick snapshot. A
+## diff state only carries the subjects whose properties changed, so a subject
+## that didn't change is missing from that tick's snapshot even though the host
+## sent it. Its auth state is carried forward in the per-object history instead,
+## see [method _merge_simulator].
+func get_latest_simulator_for_snapshot(subjects: Array, tick: int) -> int:
+ # Walk backwards from the newest tick, so the first match is the latest one
+ var latest_index := mini(tick, _simulator_snapshots.get_latest_index())
+
+ for index in range(latest_index, _simulator_snapshots.get_earliest_index() - 1, -1):
+ if not _simulator_snapshots.has_at(index):
+ continue
+
+ var has_all_auth := true
+ for subject in subjects:
+ if not _simulator_history.is_auth(index, subject):
+ has_all_auth = false
+ break
+
+ if has_all_auth:
+ return index
+
+ return -1
+
## Return how old is the latest rollback input data for any of the
## [param subjects], in ticks
func get_input_age_for(subjects: Array, tick: int) -> int:
@@ -176,6 +252,48 @@ func _record_sync_state(tick: int) -> void:
return subject.is_multiplayer_authority()
)
+func _record_input_sender(tick: int) -> void:
+ _record(tick, _input_sender_history, _input_sender_snapshots, _input_sender_properties,\
+ true, func(subject: Node):
+ return subject.is_multiplayer_authority()
+ )
+
+func _record_simulator(tick: int) -> void:
+ _record(tick, _simulator_history, _simulator_snapshots, _simulator_properties, true, func(subject: Node):
+ return subject.is_multiplayer_authority()
+ )
+
+# Records the current state of the given simulator subjects for the given tick,
+# even if we don't have authority over them.
+#
+# Used by SimulatorServer to record simulators one by one, e.g. predicted state
+# of local authoritative simulators (we own the input, but not the state).
+# Properties are the ones registered with [method register_simulator].
+#
+# Existing auth data is never overwritten. Only the per-object history is
+# written, no per-tick snapshot.
+func _record_simulator_subjects(subjects: Array, tick: int) -> void:
+ for subject in subjects:
+ assert(subject is Node, "Only nodes supported for now!")
+
+ # Never overwrite auth data, e.g. truth received from the host
+ if _simulator_history.is_auth(tick, subject):
+ continue
+
+ var properties := _simulator_properties.get_properties_of(subject)
+ assert(not properties.is_empty(), "Simulator subject recorded without registered properties! Please report a bug!")
+
+ var object_snapshot := _simulator_history.ensure_snapshot(tick, subject, true)
+ if object_snapshot == null:
+ _logger.warning("Dropping recorded tick @%d for subject %s as out-of-bounds", [tick, subject])
+ continue
+
+ for property in properties:
+ object_snapshot.record_property(property)
+ object_snapshot.set_auth((subject as Node).is_multiplayer_authority())
+
+ _logger.trace("Recorded simulator state @%d: %s", [tick, object_snapshot])
+
func _restore_rollback_input(tick: int) -> bool:
return _restore_latest(tick, _rb_input_history)
@@ -185,6 +303,40 @@ func _restore_rollback_state(tick: int) -> bool:
func _restore_synchronizer_state(tick: int) -> bool:
return _restore_latest(tick, _sync_history)
+# Applies the latest input_sender inputs at or before the given tick, but only
+# to the given subjects, so a single InputSender can be restored without
+# touching the others. Nothing is written to the history.
+func _restore_input_sender_subjects(subjects: Array, tick: int) -> void:
+ for subject in subjects:
+ var object_snapshot := _input_sender_history.get_latest_snapshot(tick, subject)
+ if object_snapshot:
+ object_snapshot.apply()
+
+# Applies the auth simulator state at the given tick to each of the subjects.
+# Values come from the per-object history, so diffs received for the tick are
+# already merged on top of the previous auth state.
+#
+# NOTE: On a fresh connection, if a diff arrives before the first full state,
+# the auth state only holds the properties in that diff. Only those are
+# applied, the rest are filled in by the next full state.
+func _restore_simulator_subjects(subjects: Array, tick: int) -> void:
+ for subject in subjects:
+ # Shouldn't happen, the tick is picked from received snapshots. It only
+ # can if the state was stored in _simulator_snapshots, but was too old
+ # for _merge_history. Never apply non-auth (e.g. predicted) data as truth.
+ if not _simulator_history.is_auth(tick, subject):
+ _logger.warning("No auth simulator state for %s @%d, skipping restore", [subject, tick])
+ continue
+
+ var object_snapshot := _simulator_history.get_latest_snapshot(tick, subject)
+ object_snapshot.apply()
+
+# Restores non authoritative simulators to known last state.
+func _restore_simulator(tick: int) -> bool:
+ return _restore_latest(tick, _simulator_history, func(subject: Node) -> bool:
+ return not subject.is_multiplayer_authority()
+)
+
func _get_rollback_input_snapshot(tick: int) -> _Snapshot:
return _rb_input_snapshots.get_at(tick)
@@ -194,6 +346,12 @@ func _get_rollback_state_snapshot(tick: int) -> _Snapshot:
func _get_synchronizer_state_snapshot(tick: int) -> _Snapshot:
return _sync_state_snapshots.get_at(tick)
+func _get_input_sender_snapshot(tick: int) -> _Snapshot:
+ return _input_sender_snapshots.get_at(tick)
+
+func _get_simulator_snapshot(tick: int) -> _Snapshot:
+ return _simulator_snapshots.get_at(tick)
+
func _merge_rollback_input(snapshot: _Snapshot) -> bool:
_merge_snapshot(snapshot, _rb_input_snapshots, true)
return _merge_history(snapshot, _rb_input_history, true)
@@ -206,6 +364,32 @@ func _merge_synchronizer_state(snapshot: _Snapshot) -> bool:
_merge_snapshot(snapshot, _sync_state_snapshots, true)
return _merge_history(snapshot, _sync_history)
+func _merge_input_sender(snapshot: _Snapshot) -> bool:
+ _merge_snapshot(snapshot, _input_sender_snapshots, true)
+ return _merge_history(snapshot, _input_sender_history, true)
+
+# Merges a simulator state received from the host, either full or diff.
+#
+# Works the same way as merging rollback state: the per-tick snapshot keeps what
+# was received, and the per-object history is merged with carry-forward. A diff
+# only contains properties that changed since the previous tick, carry-forward
+# fills in the rest from the previous tick.
+#
+# Auth data in the history must only ever hold values received from the host.
+# Local authoritative simulators record their own predictions into the same
+# history (see _record_simulator_subjects), so before merging, each subject's
+# entry at this tick is reset to its latest auth state. This way carry-forward
+# never copies predicted values into auth data.
+func _merge_simulator(snapshot: _Snapshot) -> bool:
+ # Auth subjects include the ones a diff sent without any changed property.
+ # Resetting them carries their latest auth state forward to this tick, so
+ # the tick counts as truth for them too, see get_latest_simulator_for_snapshot.
+ for subject in snapshot.get_auth_subjects():
+ _simulator_history.reset_to_auth(snapshot.tick, subject)
+
+ _merge_snapshot(snapshot, _simulator_snapshots)
+ return _merge_history(snapshot, _simulator_history)
+
func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void:
var snapshot := snapshots.get_at(tick, _Snapshot.new(tick)) as _Snapshot
if not snapshots.has_at(tick):
@@ -252,10 +436,19 @@ func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, p
_rb_state_history:
_logger.trace("Recorded state @%d: %s", [tick, snapshot])
-func _restore_latest(tick: int, history: _PerObjectHistory) -> bool:
+# Restore latest for given history. Optional filter can be used to filter subjects.
+# Return false from filter function to not restore.
+# Return true from filter function to restore.
+# func(subject: Node) -> bool:
+# return not subject.is_multiplayer_authority()
+func _restore_latest(tick: int, history: _PerObjectHistory, filter: Callable = Callable()) -> bool:
var any_applied := false
for subject in history.subjects_raw():
+ # Optional filter. example: to only restore subjects we dont have authority over.
+ if filter.is_valid() and not filter.call(subject):
+ continue
+
# Grab latest snapshot up to tick
var snapshot := history.get_latest_snapshot(tick, subject)
@@ -265,6 +458,7 @@ func _restore_latest(tick: int, history: _PerObjectHistory) -> bool:
any_applied = true
match history:
+ _simulator_history: _logger.trace("Restored simulation state @%d: %s", [tick, snapshot])
_rb_input_history: _logger.trace("Restored input @%d: %s", [tick, snapshot])
_rb_state_history: _logger.trace("Restored state @%d: %s", [tick, snapshot])
diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd
index d78e1b79f..0d7a3e000 100644
--- a/addons/netfox/servers/network-synchronization-server.gd
+++ b/addons/netfox/servers/network-synchronization-server.gd
@@ -5,10 +5,10 @@ class_name _NetworkSynchronizationServer
## Synchronizes properties over the network
##
-## Handles synchronization of rollback and state properties (
-## [RollbackSynchronizer] and [StateSynchronizer] ), while respecting visibility
+## Handles synchronization of states and inputs while respecting visibility
## filters and schemas for serialization.
## [br][br]
+## [RollbackSynchronizer], [StateSynchronizer], [InputSender], [Simulator] uses this class internally.
## Packets are sent per tick, instead of per object. So for every simulated
## rollback tick, a packet is sent with states, and for every recorded input,
## a packet is sent with the inputs.
@@ -29,6 +29,10 @@ var _rb_owned_input_properties := _PropertyPool.new()
var _rb_owned_state_properties := _PropertyPool.new()
var _sync_state_properties := _PropertyPool.new()
var _sync_owned_state_properties := _PropertyPool.new()
+var _input_sender_properties := _PropertyPool.new()
+var _input_sender_owned_properties := _PropertyPool.new()
+var _simulator_properties := _PropertyPool.new()
+var _simulator_owned_properties := _PropertyPool.new()
var _visibility_filters := {} # Node to PeerVisibilityFilter
@@ -36,8 +40,7 @@ var _rb_enable_input_broadcast := ProjectSettings.get_setting("netfox/rollback/e
var _rb_enable_diffs := NetworkRollback.enable_diff_states
var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state_interval", 24) as int
var _rb_full_scheduler := _IntervalScheduler.new(_rb_full_interval)
-
-var _input_redundancy := NetworkRollback.input_redundancy
+var _rb_input_redundancy := NetworkRollback.input_redundancy
var _rb_sent_state_history := {} # peer to _HistoryBuffer of _Snapshot
@@ -50,6 +53,13 @@ var _sync_full_scheduler := _IntervalScheduler.new(_sync_full_interval)
# https://stackoverflow.com/a/35697810
var _max_packet_size := ProjectSettings.get_setting("netfox/general/max_sync_packet_size", 508) as int
+var _input_sender_enable_broadcast := ProjectSettings.get_setting("netfox/input_sender/enable_input_broadcast", false) as bool
+var _input_sender_redundancy := ProjectSettings.get_setting("netfox/input_sender/input_redundancy", 3) as int
+
+var _simulator_enable_diffs := ProjectSettings.get_setting("netfox/simulator/enable_diff_states", true) as bool
+var _simulator_full_interval := ProjectSettings.get_setting("netfox/simulator/full_state_interval", 24) as int
+var _simulator_full_scheduler := _IntervalScheduler.new(_simulator_full_interval)
+
var _schemas := _NetworkSchema.new()
var _dense_serializer: _DenseSnapshotSerializer
@@ -60,6 +70,11 @@ var _cmd_full_state: NetworkCommandServer.Command
var _cmd_diff_state: NetworkCommandServer.Command
var _cmd_input: NetworkCommandServer.Command
+var _cmd_input_sender: NetworkCommandServer.Command
+
+var _cmd_full_simulator: NetworkCommandServer.Command
+var _cmd_diff_simulator: NetworkCommandServer.Command
+
var _cmd_full_sync: NetworkCommandServer.Command
var _cmd_diff_sync: NetworkCommandServer.Command
@@ -67,6 +82,8 @@ static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer")
signal _on_input(snapshot: _Snapshot)
signal _on_state(snapshot: _Snapshot)
+signal _on_input_sender(snapshot : _Snapshot)
+signal _on_simulator(snapshot: _Snapshot)
## Register a [param property] of [param node] to be synchronized
## as rollback state
@@ -107,6 +124,32 @@ func deregister_sync_state(node: Node, property: NodePath) -> void:
_sync_state_properties.erase(node, property)
_sync_owned_state_properties.erase(node, property)
+## Register a [param property] of [param node] to be synchronized
+## as input_sender input
+func register_input_sender(node: Node, property: NodePath) -> void:
+ _input_sender_properties.add(node, property)
+ if node.is_multiplayer_authority():
+ _input_sender_owned_properties.add(node, property)
+
+## Deregister a [param property] of [param node] from being synchronized
+## as input_sender input
+func deregister_input_sender(node: Node, property: NodePath) -> void:
+ _input_sender_properties.erase(node, property)
+ _input_sender_owned_properties.erase(node, property)
+
+## Register a [param property] of [param node] to be syncronized
+## as simulator state.
+func register_simulator(node: Node, property: NodePath) -> void:
+ _simulator_properties.add(node, property)
+ if node.is_multiplayer_authority():
+ _simulator_owned_properties.add(node, property)
+
+## Deregister a [param property] of [param node] from being syncronized
+## as simulator state.
+func deregister_simulator(node: Node, property: NodePath) -> void:
+ _simulator_properties.erase(node, property)
+ _simulator_owned_properties.erase(node, property)
+
## Register a [param serializer] to use when transmitting
## [param property param] of [param node] over the network
func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSerializer) -> void:
@@ -138,6 +181,10 @@ func deregister(node: Node) -> void:
_rb_owned_input_properties.erase_subject(node)
_sync_state_properties.erase_subject(node)
_sync_owned_state_properties.erase_subject(node)
+ _input_sender_properties.erase_subject(node)
+ _input_sender_owned_properties.erase_subject(node)
+ _simulator_properties.erase_subject(node)
+ _simulator_owned_properties.erase_subject(node)
_visibility_filters.erase(node)
_schemas.erase_subject(node)
@@ -240,7 +287,7 @@ func _synchronize_input(tick: int) -> void:
notified_peers.erase(multiplayer.get_unique_id())
# Prepare snapshot package
- for offset in _input_redundancy:
+ for offset in _rb_input_redundancy:
# Grab snapshot from NetworkHistoryServer
var snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick - offset)
if not snapshot:
@@ -350,6 +397,97 @@ func _synchronize_sync_state(tick: int) -> void:
# NOTE: This is a shared instance, theoretically shouldn't screw things up
_last_sync_state_sent = snapshot
+func _synchronize_input_sender(tick: int) -> void:
+ # We don't own inputs, nothing to synchronize
+ if _input_sender_owned_properties.is_empty():
+ return
+
+ var snapshots := [] as Array[_Snapshot]
+ var notified_peers := _Set.new()
+
+ # By default input sender only sends input to host, check if its enabled.
+ if not _input_sender_enable_broadcast:
+ # Input broadcast is off, only send inputs to the host, which is
+ # always peer 1.
+ notified_peers.add(MultiplayerPeer.TARGET_PEER_SERVER)
+ else:
+ # If input broadcast is on, send inputs to everyone
+ for peer in multiplayer.get_peers():
+ notified_peers.add(peer)
+
+ # Make sure to not send input to ourselves
+ # Even if host is also a player, host should be able to reach stored inputs via
+ # recorded snapshots.
+ notified_peers.erase(multiplayer.get_unique_id())
+
+ # Prepare snapshot package
+ for offset in _input_sender_redundancy:
+ # Grab snapshot from NetworkHistoryServer
+ var snapshot := NetworkHistoryServer._get_input_sender_snapshot(tick - offset)
+ if not snapshot:
+ break
+
+ _logger.trace("Submitting input_sender inputs: %s", [snapshot])
+ snapshots.append(snapshot)
+
+ _logger.trace("Submitting input_sender inputs to peers: %s", [notified_peers])
+ for peer in notified_peers:
+ var data := _redundant_serializer.write_for(peer, snapshots, _input_sender_owned_properties)
+ _cmd_input_sender.send(data, peer)
+
+func _synchronize_simulator(tick: int) -> void:
+ # We don't own state, nothing to synchronize
+ if _simulator_owned_properties.is_empty():
+ return
+
+ # Grab snapshot from NetworkHistoryServer
+ var snapshot := NetworkHistoryServer._get_simulator_snapshot(tick)
+ if not snapshot:
+ # No data for tick
+ return
+
+ if snapshot.is_empty():
+ # Nothing to send
+ return
+
+ # Figure out whether to send full- or diff state
+ var is_full := _simulator_full_scheduler.is_now()
+ if not _simulator_enable_diffs:
+ is_full = true
+
+ # Check if we have history to diff to
+ var reference_snapshot := NetworkHistoryServer._get_simulator_snapshot(tick - 1)
+ if not reference_snapshot:
+ is_full = true
+
+ if is_full:
+ # Send full states
+ for peer in multiplayer.get_peers():
+ var filter := func(subject): return _is_node_visible_to(peer, subject)
+
+ var packets := _dense_serializer.write_for(peer, snapshot, _simulator_owned_properties, filter)
+ for packet in packets:
+ _cmd_full_simulator.send(packet, peer)
+
+ NetworkPerformance.push_full_state_props(snapshot.size())
+ NetworkPerformance.push_sent_state_props(snapshot.size())
+ else:
+ var diff := _Snapshot.make_patch(reference_snapshot, snapshot)
+ if diff.is_empty():
+ # Nothing changed, don't send anything
+ return
+
+ # Send diff states
+ for peer in multiplayer.get_peers():
+ var filter := func(subject): return _is_node_visible_to(peer, subject)
+
+ var packets := _sparse_serializer.write_for(peer, diff, _simulator_owned_properties, filter)
+ for packet in packets:
+ _cmd_diff_simulator.send(packet, peer)
+
+ NetworkPerformance.push_full_state_props(snapshot.size())
+ NetworkPerformance.push_sent_state_props(diff.size())
+
func _init(
p_command_server: _NetworkCommandServer = null,
p_history_server: _NetworkHistoryServer = null,
@@ -382,6 +520,13 @@ func _ready():
_cmd_diff_state = _command_server.register_command(_handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
_cmd_input = _command_server.register_command(_handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
+ _cmd_input_sender = _command_server.register_command(_handle_input_sender, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
+
+ # Unreliable, same as rollback state. Every packet carries its own tick, and
+ # diffs lost on the way are corrected by the next full state.
+ _cmd_full_simulator = _command_server.register_command(_handle_full_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
+ _cmd_diff_simulator = _command_server.register_command(_handle_diff_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
+
_cmd_full_sync = _command_server.register_command(_handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED)
_cmd_diff_sync = _command_server.register_command(_handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED)
@@ -441,6 +586,55 @@ func _handle_diff_sync(sender: int, data: PackedByteArray):
NetworkHistoryServer._merge_synchronizer_state(snapshot)
_logger.trace("Ingested sync diff: %s", [snapshot])
+func _handle_input_sender(sender : int, data : PackedByteArray) -> void:
+ var buffer := StreamPeerBuffer.new()
+ buffer.data_array = data
+
+ var snapshots := _redundant_serializer.read_from(sender, _input_sender_properties, buffer, true)
+
+ for snapshot in snapshots:
+ # Inputs stamped too far ahead would push everyone's inputs out of the
+ # history buffers, see _SimulatorTimeTracker. Drop them before they get
+ # there.
+ if not SimulatorServer._is_input_tick_accepted(snapshot.tick):
+ _logger.warning(
+ "Dropping inputs from peer #%d stamped @%d, too far ahead of @%d",
+ [sender, snapshot.tick, NetworkTime.tick])
+ continue
+
+ snapshot.sanitize(sender)
+
+ _logger.trace("Ingesting input_sender inputs: %s", [snapshot])
+ if NetworkHistoryServer._merge_input_sender(snapshot):
+ _on_input_sender.emit(snapshot)
+
+func _handle_full_simulator(sender: int, data: PackedByteArray) -> void:
+ var buffer := StreamPeerBuffer.new()
+ buffer.data_array = data
+
+ var snapshot := _dense_serializer.read_from(sender, _simulator_properties, buffer, true)
+
+ _ingest_simulator(sender, snapshot)
+
+func _handle_diff_simulator(sender: int, data: PackedByteArray) -> void:
+ var buffer := StreamPeerBuffer.new()
+ buffer.data_array = data
+
+ var diff := _sparse_serializer.read_from(sender, _simulator_properties, buffer)
+ _logger.trace("Received simulator diff state for @%d", [diff.tick])
+
+ _ingest_simulator(sender, diff)
+
+func _ingest_simulator(sender: int, snapshot: _Snapshot) -> void:
+ snapshot.sanitize(sender)
+
+ _logger.trace("Ingesting simulator state: %s", [snapshot])
+ NetworkHistoryServer._merge_simulator(snapshot)
+
+ # Emitted for every received state, even if it changed nothing. The view
+ # delay is measured every time a state arrives, see _SimulatorTimeTracker.
+ _on_simulator.emit(snapshot)
+
func _ingest_state(sender: int, snapshot: _Snapshot) -> void:
snapshot.sanitize(sender)
diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd
new file mode 100644
index 000000000..a9794fdc2
--- /dev/null
+++ b/addons/netfox/servers/simulator-server.gd
@@ -0,0 +1,591 @@
+extends Node
+class_name _SimulatorServer
+
+# @public class
+
+## Handles [Simulator] related operations.
+
+## TODO: We should find a better name for Simulator word.
+##
+## [Before reading this server, please read InputSender, InputSenderServer, Simulator.]
+
+## Insight
+##
+## Time model: the host's tick is the truth. The host simulates everyone, its
+## own player and remote players, at its current tick. Clients stamp their
+## inputs ahead of their own tick (input lead), so the inputs arrive before the
+## host needs them. Clients predict their own player up to tick + input lead.
+## See [_SimulatorTimeTracker].
+##
+## Simulating a tick works the same way as rollback: simulating tick T applies
+## the inputs of tick T, calls _simulated_tick(delta, T, is_fresh), and the
+## resulting state is recorded as the state of tick T + 1. Inputs of tick T
+## are recorded on after_tick, before SimulatorServer runs, so they're always
+## there for our own inputs. Example for the host, at tick 100:
+##
+## after_tick | input of tick 100 is recorded
+## SimulatorServer | inputs of 100 applied, _simulated_tick(delta, 100)
+## | state recorded and sent as tick 101
+## after_tick_loop | state of tick 101 is shown
+##
+## This also puts Simulators in the same tick numbers as StateSynchronizers,
+## which record state after a tick as tick + 1 too.
+##
+## Depending on simulator authority there are 4 options we can operate on:
+##
+## 1- We have authority over both simulator input/state = host simulator
+## 2- We have authority over input but not state = local authoritative simulator
+## 3- We have authority over state but not input = host puppet
+## 4- We dont have any authority over input/state = client puppet
+##
+## 1- host simulator:
+## - Simulate the current tick with its inputs.
+## (input-sender inputs are recorded on after tick).
+## - Save the resulting state as tick + 1, as invidiual fashion.
+## - Broadcast it togather with 2.
+##
+## 2- local authoritative simulator:
+## - We have inputs available locally up to tick + input lead.
+## (input-sender inputs are recorded on after tick).
+## - Apply the latest authoritative state received from host. (the truth)
+## - Simulate every tick from the truth up to tick + input lead, which results
+## in the state of tick + input lead + 1.
+##
+## 3- host puppet.
+## - Simulate the current tick with its inputs.
+## Clients stamp inputs ahead, so they are usually here already.
+## - If inputs are missing, predict them, see InputSender.
+## - Save the resulting state as tick + 1.
+## - Broadcast them.
+##
+## 4- client puppet.
+## - Apply the latest authoritative state received from host. (the truth)
+##
+##
+## By restoring to latest state we already handle 4.
+## We dont need to keep history of non-host simulators.
+
+
+var _history_server : _NetworkHistoryServer = null
+var _synchronization_server : _NetworkSynchronizationServer = null
+var _logger := NetfoxLogger._for_netfox("SimulatorServer")
+
+# How many ticks of the past we keep for simulation, e.g. recorded collision
+# shapes, and how far back projectiles can be caught up.
+# The history buffers also have room for the future on top of this, see
+# _SimulatorTimeTracker.get_future_history_ticks().
+var _simulation_history_size : int = ProjectSettings.get_setting("netfox/simulator/history_limit", 64)
+
+# Maximum view delay accepted from clients, in ticks. Limits how far into the
+# past a client can rewind when firing, see register_projectile.
+var _max_view_delay_ticks : int = ProjectSettings.get_setting("netfox/simulator/max_view_delay_ticks", 30)
+
+# Node -> latest tick simulated for it, see _is_tick_fresh_for.
+var _latest_simulated_ticks : Dictionary = {}
+
+# Projectiles that are alive.
+var _living_projectiles : Array[Node] = []
+
+# Helper to record and restore simulation aware collision-shapes/areas.
+var _simulated_collision_recorder : _SimulatedCollisionRecorder = _SimulatedCollisionRecorder.new()
+
+# Helper to track how each client's time relates to the host's: input lead and
+# view delay. See _SimulatorTimeTracker.
+var _time_tracker : _SimulatorTimeTracker = _SimulatorTimeTracker.new()
+
+# Fresh registered projectiles.
+# Mapped by tick (int) -> Array of projectiles (Node) that are estimated to be fired on mapped tick.
+# See register_projectile method.
+var _fresh_registered_projectiles_by_tick : Dictionary = {}
+
+# Grouped simulators depending on their authority modes.
+# Better readability on code / we only check authority on register.
+var _host_simulators : Array[Simulator] = []
+var _local_authoritative_simulators : Array[Simulator] = []
+var _host_puppet_simulators : Array[Simulator] = []
+var _client_puppet_simulators : Array[Simulator] = []
+
+func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null):
+ _history_server = p_history_server
+ _synchronization_server = p_synchronization_server
+
+func _ready():
+ # Ensure dependencies
+ if not _history_server: _history_server = NetworkHistoryServer
+ if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer
+
+ _simulated_collision_recorder.initialize(get_tree())
+ NetworkTime.after_sync.connect(_reset_simulated_ticks)
+ # View delay samples are stamped ahead like inputs, so they get the same
+ # room for the future as the Simulator history buffers.
+ _time_tracker.initialize(self, NetworkCommandServer, _synchronization_server,
+ _simulation_history_size + _SimulatorTimeTracker.get_future_history_ticks())
+
+# Register a simulator node.
+# Will check for authority over inputs and categorize by it.
+# Its Simulator's responsibility to only register if input-sender is configured.
+func register_simulator(simulator : Simulator) -> void:
+ if simulator.is_multiplayer_authority():
+ if simulator.listened_input_sender.has_authority_over_input_nodes():
+ _host_simulators.push_back(simulator)
+ else:
+ _host_puppet_simulators.push_back(simulator)
+ else:
+ if simulator.listened_input_sender.has_authority_over_input_nodes():
+ _local_authoritative_simulators.push_back(simulator)
+ else:
+ _client_puppet_simulators.push_back(simulator)
+
+
+# Deregister a simulator node.
+func deregister_simulator(simulator : Simulator) -> void:
+ _host_simulators.erase(simulator)
+ _host_puppet_simulators.erase(simulator)
+ _local_authoritative_simulators.erase(simulator)
+ _client_puppet_simulators.erase(simulator)
+
+ _latest_simulated_ticks.erase(simulator)
+
+## Register a projectile to be stepped by simulation.
+## This method should only be called on host.
+## [br][br]
+## [param fired_tick] is the tick of the fire input, e.g. the tick passed to
+## [code]_simulated_tick[/code] when the fire input is applied, or the tick
+## given by [signal InputSender.input_applied].
+## [br][br]
+## The projectile is caught up from the tick of the host's simulation that the
+## firing peer was seeing at [param fired_tick], see [_SimulatorTimeTracker]. The
+## rewind is limited by [code]netfox/simulator/max_view_delay_ticks[/code].
+## [br][br]
+## Projectiles are stepped after Simulators, so stepping a projectile for tick
+## T checks it against the world after this tick's movement, the state of
+## T + 1. The same is true while catching up.
+func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : int) -> void:
+
+ var has_step_method := projectile.has_method("step")
+ var has_is_alive_method := projectile.has_method("is_alive")
+
+ if not has_step_method or not has_is_alive_method:
+ _logger.error(
+ "Error registering projectile %s : Projectiles must implement step and is_alive methods.",
+ [projectile.name]
+ )
+ return
+
+ var view_delay := _get_view_delay_for(firing_peer_id, fired_tick)
+
+ # The firing peer saw the state of tick fired_tick - view_delay. Stepping
+ # tick T checks against the state of T + 1, so start one tick earlier: the
+ # first step then checks against exactly what the firing peer saw.
+ # Example: the peer saw the state of 97, so the first step is for tick 96,
+ # and checks against the state of 97.
+ var estimated_firing_tick := fired_tick - view_delay - 1
+
+ # Without a view delay sample, the fire input's tick can be in the future
+ # for a client, see _get_view_delay_for. Never start later than now.
+ estimated_firing_tick = mini(estimated_firing_tick, NetworkTime.tick)
+
+ if estimated_firing_tick < 0:
+ _logger.error(
+ "Error registering projectile %s: Calculated estimated_firing_tick as negative. Not registering.\n" +
+ "view delay: %s, fired tick: %s, estimated firing tick: %s",
+ [projectile.name, view_delay, fired_tick, estimated_firing_tick]
+ )
+ return
+
+ if estimated_firing_tick < NetworkTime.tick - _simulation_history_size:
+ _logger.error(
+ "Error registering projectile %s: Calculated estimated_firing_tick is older than history.\n" +
+ "Returning without registering. view delay: %s, fired tick: %s, estimated firing tick: %s",
+ [projectile.name, view_delay, fired_tick, estimated_firing_tick]
+ )
+ return
+
+ _logger.debug(
+ "Registered projectile %s with stats:\n" +
+ "firing_peer: %s, fired tick: %s, view delay: %s, estimated firing tick: %s",
+ [projectile.name, firing_peer_id, fired_tick, view_delay, estimated_firing_tick]
+ )
+
+ if not _fresh_registered_projectiles_by_tick.has(estimated_firing_tick):
+ _fresh_registered_projectiles_by_tick[estimated_firing_tick] = []
+ var existing_projectile_arr := _fresh_registered_projectiles_by_tick[estimated_firing_tick] as Array
+ existing_projectile_arr.push_back(projectile)
+
+# Returns how many ticks behind the given peer saw the host's simulation at
+# the given tick of the peer.
+func _get_view_delay_for(peer: int, tick: int) -> int:
+ # The host sees everyone at its current tick, nothing to rewind.
+ # This also covers offline play.
+ if peer == multiplayer.get_unique_id():
+ return 0
+
+ var view_delay := _time_tracker.get_view_delay(peer, tick, -1)
+ if view_delay < 0:
+ # No sample for this tick, e.g. the peer just connected. Without a
+ # sample, don't rewind at all.
+ _logger.debug("No view delay from peer #%s @%d, not rewinding", [peer, tick])
+ return 0
+
+ # The view delay is reported by the client, so don't trust it blindly.
+ # It can't rewind further than max_view_delay_ticks.
+ return clampi(view_delay, 0, maxi(0, _max_view_delay_ticks))
+
+## Returns how many ticks ahead this peer stamps its inputs, see
+## [_SimulatorTimeTracker]. Always 0 on the host.
+func get_input_lead_ticks() -> int:
+ return _time_tracker.get_input_lead()
+
+## Returns the maximum input lead in ticks, see
+## [code]netfox/simulator/max_input_lead[/code].
+func get_max_input_lead_ticks() -> int:
+ return _time_tracker.get_max_input_lead_ticks()
+
+## Returns the maximum view delay in ticks, see
+## [code]netfox/simulator/max_view_delay_ticks[/code]. The host never rewinds
+## further than this when registering projectiles, see
+## [method register_projectile].
+func get_max_view_delay_ticks() -> int:
+ return maxi(0, _max_view_delay_ticks)
+
+## Client side: returns the view delay this peer reports to the host, in ticks,
+## or -1 if no simulator state has arrived yet.
+## [br][br]
+## The input lead is included, so this is how far the host rewinds when this
+## peer fires, see [_SimulatorTimeTracker].
+func get_view_delay_ticks() -> int:
+ return _time_tracker.get_reported_view_delay()
+
+## Host side: returns the newest view delay reported by [param peer], in ticks,
+## or -1 if it hasn't reported yet.
+## [br][br]
+## Not clamped to [method get_max_view_delay_ticks].
+func get_view_delay_ticks_for(peer: int) -> int:
+ return _time_tracker.get_latest_view_delay(peer)
+
+# Returns how many ticks ahead we stamp our inputs. Always 0 on the host.
+# Used by InputSenderServer and NetworkTime internally.
+func _get_input_lead() -> int:
+ return _time_tracker.get_input_lead()
+
+# Returns whether an input stamped with the given tick is accepted now. Inputs
+# stamped too far ahead are dropped, see _SimulatorTimeTracker.
+# Used by NetworkSynchronizationServer internally.
+func _is_input_tick_accepted(stamped_tick: int) -> bool:
+ return _time_tracker.is_input_tick_accepted(stamped_tick, NetworkTime.tick)
+
+func _after_tick(tick : int) -> void:
+
+ _catch_up_fresh_projectiles(tick)
+
+ _handle_host_simulators(tick)
+ _handle_host_puppet_simulators(tick)
+ _handle_local_authoritative_simulators(tick)
+
+ _step_living_projectiles(tick)
+
+ # Everything was simulated for the current tick, the result is the state of
+ # the next tick. So everything is recorded and sent under tick + 1.
+ # History server only records owned simulator state properties.
+ _history_server._record_simulator(tick + 1)
+ _synchronization_server._synchronize_simulator(tick + 1)
+
+ # Collision shapes after this tick's movement, see _catch_up_fresh_projectiles.
+ _simulated_collision_recorder.record_tick(tick + 1)
+
+ # Trim old history that we dont need.
+ var trim_tick := tick - _simulation_history_size
+ if trim_tick >= 0:
+ _simulated_collision_recorder.trim_ticks(trim_tick)
+
+## 1- host simulator:
+## - Simulate the current tick with its inputs.
+## (input-sender inputs are recorded on after tick).
+## - Save the resulting state as tick + 1.
+## - Broadcast it togather with 2.
+func _handle_host_simulators(current_tick: int) -> void:
+ for simulator in _host_simulators:
+
+ var input_subjects := simulator.listened_input_sender._input_properties.get_subjects()
+ var state_subjects := simulator._state_properties.get_subjects()
+
+ # Save input properties before messing them up.
+ simulator.listened_input_sender._save_properties()
+
+ # Apply inputs of this tick.
+ _history_server._restore_input_sender_subjects(input_subjects, current_tick)
+
+ var is_fresh := _is_tick_fresh_for(simulator, current_tick)
+ simulator._run_simulation(NetworkTime.ticktime, current_tick, is_fresh)
+ _history_server._record_simulator_subjects(state_subjects, current_tick + 1)
+ _set_tick_simulated_for(simulator, current_tick)
+
+ # Restore messed up properties.
+ simulator.listened_input_sender._restore_properties()
+
+## 2- local authoritative simulator:
+## - We have inputs available locally up to tick + input lead.
+## (input-sender inputs are recorded on after tick).
+## - Apply the latest authoritative state received from host. (the truth)
+## - Simulate every tick from the truth up to tick + input lead, which results
+## in the state of tick + input lead + 1.
+##
+## Example, with a lead of 5 at tick 100, and the latest truth for tick 97:
+## restore the state of 97, then simulate ticks 97 .. 105 with their inputs.
+## The result is the state of tick 106, which is shown.
+func _handle_local_authoritative_simulators(current_tick: int) -> void:
+ # Our inputs are stamped this far ahead, so we simulate this far ahead too.
+ # This is the newest input we have, recorded on this tick's after_tick.
+ var predicted_tick := current_tick + _get_input_lead()
+
+ for simulator in _local_authoritative_simulators:
+
+ var state_subjects := simulator._state_properties.get_subjects()
+
+ # The host sends the state after its tick T as T + 1, so the truth can be
+ # a tick ahead of us. Nothing newer than our prediction can be truth.
+ var latest_truth_tick := _history_server.get_latest_simulator_for_snapshot(
+ state_subjects,
+ predicted_tick + 1)
+
+ var latest_truth_snapshot := _history_server._get_simulator_snapshot(latest_truth_tick)
+
+ # Save inputs before messing input properties.
+ simulator.listened_input_sender._save_properties()
+
+ if latest_truth_tick < 0 or not latest_truth_snapshot:
+ _logger.warning(
+ "Couldnt find any truth from host.\n" +
+ "Running simulation for only current tick."
+ )
+
+ # Apply our newest inputs.
+ _apply_input_at(simulator.listened_input_sender, predicted_tick)
+
+ var is_fresh := _is_tick_fresh_for(simulator, predicted_tick)
+ simulator._run_simulation(NetworkTime.ticktime, predicted_tick, is_fresh)
+ _history_server._record_simulator_subjects(state_subjects, predicted_tick + 1)
+ _set_tick_simulated_for(simulator, predicted_tick)
+
+ # Restore messed up properties.
+ simulator.listened_input_sender._restore_properties()
+ continue
+
+ # Apply truth from history, which has diffs merged into full state.
+ _history_server._restore_simulator_subjects(state_subjects, latest_truth_tick)
+
+ for i in range(latest_truth_tick, predicted_tick + 1):
+ # Apply inputs of this tick. If there's no input recorded for it,
+ # the latest input before it is applied. Right after spawning, the
+ # truth can be older than our first input, then the default input
+ # is applied, see _apply_input_at().
+ _apply_input_at(simulator.listened_input_sender, i)
+
+ var is_fresh := _is_tick_fresh_for(simulator, i)
+ simulator._run_simulation(NetworkTime.ticktime, i, is_fresh)
+ _history_server._record_simulator_subjects(state_subjects, i + 1)
+ _set_tick_simulated_for(simulator, i)
+
+ # Restore messed up properties.
+ simulator.listened_input_sender._restore_properties()
+
+## 3- host puppet.
+## - Simulate the current tick with its inputs.
+## - If inputs are missing, predict them, see InputSender.
+## - Save the resulting state as tick + 1, recorded in _after_tick.
+## - Broadcast them.
+func _handle_host_puppet_simulators(current_tick: int) -> void:
+ # Host puppets are simulated at the current tick, like everything else.
+ var simulated_tick := current_tick
+
+ for simulator in _host_puppet_simulators:
+
+ var input_sender := simulator.listened_input_sender
+
+ # Inputs are read from each player's own history, not from the per tick
+ # snapshot shared by all players. This way, whatever another player
+ # sends can't keep this player from being simulated.
+ # Apply the inputs of this tick, or the latest ones before it.
+ var latest_input_tick := _apply_input_at(input_sender, simulated_tick)
+
+ if latest_input_tick != simulated_tick:
+ # The input didn't arrive in time, predict it from the latest one we
+ # have. Same as a client filling a skipped stamp, see InputSender.
+ _logger.debug("No input for @%d, predicting it", [simulated_tick])
+ input_sender._set_input(input_sender._get_predicted_input(input_sender._get_input()))
+
+ var is_fresh := _is_tick_fresh_for(simulator, simulated_tick)
+ simulator._run_simulation(NetworkTime.ticktime, simulated_tick, is_fresh)
+
+ _set_tick_simulated_for(simulator, simulated_tick)
+
+# Steps every already-caught-up living projectile by a single tick.
+# No need to restore world state for living projectiles. This runs after
+# Simulators, so they see the world after this tick's movement, the state of
+# current_tick + 1.
+func _step_living_projectiles(current_tick: int) -> void:
+ var simulated_tick := current_tick
+
+ # Iterate in reverse because we are also removing.
+ var i := _living_projectiles.size() - 1
+ while i >= 0:
+ var projectile : Node = _living_projectiles[i]
+
+ if not is_instance_valid(projectile):
+ _logger.warning("While stepping living projectiles: projectile at index %s no longer valid, dropping", [i])
+ _living_projectiles.remove_at(i)
+ else:
+ _logger.trace("Stepping living projectile: %s at tick #%s", [projectile.name, simulated_tick])
+ projectile.step(NetworkTime.ticktime, simulated_tick)
+
+ if not projectile.is_alive():
+ _logger.trace("After stepping living projectile: %s projectile died at tick %s",
+ [projectile.name, simulated_tick]
+ )
+
+ _living_projectiles.remove_at(i)
+
+ i -= 1
+
+# Steps the freshly registered projectiles up to current_tick (not included).
+func _catch_up_fresh_projectiles(current_tick: int) -> void:
+ if _fresh_registered_projectiles_by_tick.is_empty():
+ return
+
+ var simulated_tick := current_tick
+ var oldest_tick : int = _fresh_registered_projectiles_by_tick.keys().min()
+
+ # Ensure oldest tick is not older than our history window.
+ oldest_tick = maxi(oldest_tick, current_tick - _simulation_history_size)
+
+ _logger.trace(
+ "Catching up fresh projectiles: oldest_tick=%s, simulated_tick=%s, pending_ticks=%s",
+ [oldest_tick, simulated_tick, _fresh_registered_projectiles_by_tick.keys()]
+ )
+
+ # Temp array to hold projectiles.
+ var fresh_projectiles : Array[Node] = []
+
+ # We need to capture current state of collision aware nodes. Because we will mess with their
+ # states during our operation. At the end of the operation we will restore to this state snapshot.
+ var current_collision_state_snapshot_dict := _simulated_collision_recorder.capture_current_state()
+
+ for tick in range(oldest_tick, simulated_tick):
+ # Restore simulation aware collision nodes to the state after this
+ # tick's movement, same as living projectiles see, see register_projectile.
+ _simulated_collision_recorder.restore_tick(tick + 1)
+
+ # Get projectiles registered at this tick and add it our fresh_projectiles array.
+ var registered_projectiles_arr_at_tick := _fresh_registered_projectiles_by_tick.get(tick, []) as Array
+ if not registered_projectiles_arr_at_tick.is_empty():
+ _logger.trace(
+ "Tick %s: %s registered projectiles are joining the projectile catch up phase: %s",
+ [tick, registered_projectiles_arr_at_tick.size(),
+ registered_projectiles_arr_at_tick.map(
+ func(p): return _get_projectile_debug_name(p))
+ ]
+ )
+
+ fresh_projectiles.append_array(registered_projectiles_arr_at_tick)
+ _fresh_registered_projectiles_by_tick.erase(tick)
+
+ # While iterating over ticks, step every fresh projectile we have.
+ var i := fresh_projectiles.size() - 1
+ while i >= 0:
+
+ var projectile : Node = fresh_projectiles[i]
+
+ if is_instance_valid(projectile):
+ projectile.step(NetworkTime.ticktime, tick)
+ _logger.trace("Projectile %s running catch up step at tick: #%s", [projectile.name, tick])
+ # Erase from our list if projectile is not alive anymore.
+ if not projectile.is_alive():
+ _logger.trace("Projectile %s is not alive, erasing at tick: #%s", [projectile.name, tick])
+ fresh_projectiles.remove_at(i)
+ else:
+ _logger.trace("During projectile catch up, projectile at index %s no longer valid, dropping", [i])
+ fresh_projectiles.remove_at(i)
+
+ i -= 1
+
+ # Anything registered for tick exact simulated_tick had no history to replay.
+ # They join living unstepped and gets its first step together with _step_living_projectiles.
+ var registered_at_simulated_tick := _fresh_registered_projectiles_by_tick.get(simulated_tick, []) as Array
+ if not registered_at_simulated_tick.is_empty():
+ _logger.trace(
+ "At Simulated tick #%s, %s projectile(s) joining to living projectiles without catch up.\n" +
+ "They will be stepped together with living projectiles on this tick. Projectile names %s",
+ [simulated_tick, registered_at_simulated_tick.size(),
+ registered_at_simulated_tick.map(
+ func(p): return _get_projectile_debug_name(p))
+ ]
+ )
+
+ fresh_projectiles.append_array(registered_at_simulated_tick)
+ _fresh_registered_projectiles_by_tick.erase(simulated_tick)
+
+ if not fresh_projectiles.is_empty():
+ _logger.trace(
+ "Projectile catch up is over: %s projectile(s) promoted to living: %s",
+ [fresh_projectiles.size(), fresh_projectiles.map(
+ func(p): return _get_projectile_debug_name(p))
+ ]
+ )
+
+ # Append our living projectiles list, they are part of the current simulation at this point,
+ # and they will be handled with function _step_living_projectiles.
+ _living_projectiles.append_array(fresh_projectiles)
+
+ # Since we changed states of simulation aware collision nodes by calling
+ # _simulated_collision_recorder.restore_tick, now we should restore them to current state which
+ # we recorded before doing this operation.
+ _simulated_collision_recorder.restore_snapshot(current_collision_state_snapshot_dict)
+
+# A tick is fresh the first time it's simulated for a node. Every peer only
+# ever simulates forward into new ticks: the host one tick at a time, local
+# authoritative peers re-simulate older ticks from the truth, then continue
+# into new ones. So a tick is fresh exactly when it's newer than the latest
+# tick simulated so far, no need to remember every tick. Example, local
+# authoritative peer with a lead of 5:
+#
+# at tick 100 | simulates 97 .. 105 | fresh: 105 only, 97 .. 104 were done
+# at tick 101 | simulates 98 .. 106 | fresh: 106 only
+# Applies the inputs of input_sender recorded at or before tick, and returns
+# the tick they were recorded at.
+#
+# If there are none at all, the default input is applied and -1 is returned.
+# This happens right after a player spawned: the client re-simulates from the
+# truth, which can be older than its first input, and the host simulates the
+# player before its first input arrives. Without this, whatever the input
+# properties hold at the moment would be applied for every such tick, e.g.
+# this frame's mouse movement, once per tick:
+#
+# client spawned @60, lead 8, first input stamped @68
+# truth @62 arrives, simulate 62 .. 69
+# 62 .. 67 | no input -> default input (was: this frame's live input)
+# 68 .. 69 | recorded inputs
+func _apply_input_at(input_sender: InputSender, tick: int) -> int:
+ var input_subjects := input_sender._input_properties.get_subjects()
+ var latest_input_tick := _history_server.get_latest_input_sender_for(input_subjects, tick)
+
+ if latest_input_tick < 0:
+ input_sender._set_input(input_sender._default_input)
+ else:
+ _history_server._restore_input_sender_subjects(input_subjects, tick)
+
+ return latest_input_tick
+
+func _is_tick_fresh_for(node: Node, tick: int) -> bool:
+ return tick > _latest_simulated_ticks.get(node, -1)
+
+func _set_tick_simulated_for(node: Node, tick: int) -> void:
+ if tick > _latest_simulated_ticks.get(node, -1):
+ _latest_simulated_ticks[node] = tick
+
+# Ticks start over when NetworkTime starts, so does freshness.
+func _reset_simulated_ticks() -> void:
+ _latest_simulated_ticks.clear()
+
+# Helper to get projectile debug name.
+func _get_projectile_debug_name(p) -> String:
+ return p.name if is_instance_valid(p) else ""
diff --git a/addons/netfox/simulated-collision-recorder.gd b/addons/netfox/simulated-collision-recorder.gd
new file mode 100644
index 000000000..306696714
--- /dev/null
+++ b/addons/netfox/simulated-collision-recorder.gd
@@ -0,0 +1,255 @@
+extends RefCounted
+class_name _SimulatedCollisionRecorder
+
+## Helper class for recording simulation aware areas and collision shapes.
+## Supports CollisionShape3D/2D, Area3D/2D.
+##
+## Used by [_SimulatorServer] internally.
+
+const SIMULATION_AWARE_GROUP := &"simulation_aware_collision"
+
+var _logger := NetfoxLogger._for_netfox("SimulatedCollisionRecorder")
+
+# tick (int) -> Dictionary of Node -> _CollisionSnapshot
+var _snapshots_by_tick : Dictionary = {}
+var _tracked_nodes : Array[Node] = []
+
+## Inner base struct for recording collision snapshot per type.
+## Specific types extends this base struct.
+## Having this solves 2 problems: 1- no unncessary memory, 2- no unneccessary checks for type.
+## Each derived struct only stores the fields relevant to its own node type.
+class _CollisionSnapshot:
+ func restore_node(_node: Node) -> void:
+ push_error("_CollisionSnapshot.restore_node is abstract, must be overridden.")
+
+class _ShapeSnapshot3D extends _CollisionSnapshot:
+ var global_transform : Transform3D
+ var disabled : bool
+
+ func _init(node: CollisionShape3D) -> void:
+ global_transform = node.global_transform
+ disabled = node.disabled
+
+ func restore_node(node: Node) -> void:
+ node.global_transform = global_transform
+ node.disabled = disabled
+
+class _ShapeSnapshot2D extends _CollisionSnapshot:
+ var global_transform : Transform2D
+ var disabled : bool
+
+ func _init(node: CollisionShape2D) -> void:
+ global_transform = node.global_transform
+ disabled = node.disabled
+
+ func restore_node(node: Node) -> void:
+ node.global_transform = global_transform
+ node.disabled = disabled
+
+class _AreaSnapshot3D extends _CollisionSnapshot:
+ var global_transform : Transform3D
+ var monitoring : bool
+ var monitorable : bool
+ var collision_layer : int
+
+ func _init(node: Area3D) -> void:
+ global_transform = node.global_transform
+ monitoring = node.monitoring
+ monitorable = node.monitorable
+ collision_layer = node.collision_layer
+
+ func restore_node(node: Node) -> void:
+ node.global_transform = global_transform
+ node.monitoring = monitoring
+ node.monitorable = monitorable
+ node.collision_layer = collision_layer
+
+class _AreaSnapshot2D extends _CollisionSnapshot:
+ var global_transform : Transform2D
+ var monitoring : bool
+ var monitorable : bool
+ var collision_layer : int
+
+ func _init(node: Area2D) -> void:
+ global_transform = node.global_transform
+ monitoring = node.monitoring
+ monitorable = node.monitorable
+ collision_layer = node.collision_layer
+
+ func restore_node(node: Node) -> void:
+ node.global_transform = global_transform
+ node.monitoring = monitoring
+ node.monitorable = monitorable
+ node.collision_layer = collision_layer
+
+## Initialize needs to be called by owner node.
+## Because refcounted classes doesnt have access to scene tree.
+func initialize(tree: SceneTree) -> void:
+ tree.node_added.connect(_on_node_added)
+
+ var existing_nodes := tree.get_nodes_in_group(SIMULATION_AWARE_GROUP)
+ _logger.trace(
+ "Initializing collision recorder, found %s existing node(s) in group %s",
+ [existing_nodes.size(), SIMULATION_AWARE_GROUP]
+ )
+
+ for node in existing_nodes:
+ _track(node)
+
+func _on_node_added(node: Node) -> void:
+ if node.is_in_group(SIMULATION_AWARE_GROUP):
+ _track(node)
+
+func _track(node: Node) -> void:
+ if node in _tracked_nodes:
+ return
+
+ if not _is_supported(node):
+ _logger.warning(
+ "Node %s is in %s group but isn't a supported type (CollisionShape3D/2D, Area3D/2D), ignoring." + \
+ "Only supported types should be in this group.",
+ [node.name, SIMULATION_AWARE_GROUP]
+ )
+ return
+
+ _tracked_nodes.push_back(node)
+ node.tree_exiting.connect(_untrack.bind(node), CONNECT_ONE_SHOT)
+ _logger.trace("Tracking node %s, total tracked: %s", [node.name, _tracked_nodes.size()])
+
+# Called when node's tree_exiting is fired so we can untrack now.
+# Old snapshot dicts aren't erased here, we remove invalid nodes while we iterate on record_tick().
+func _untrack(node: Node) -> void:
+ _tracked_nodes.erase(node)
+ _logger.trace("Untracked node %s (tree_exiting), total tracked: %s", [node.name, _tracked_nodes.size()])
+
+# Check if node is type of supported ones.
+func _is_supported(node: Node) -> bool:
+ return node is CollisionShape3D \
+ or node is CollisionShape2D \
+ or node is Area3D \
+ or node is Area2D
+
+# Takes the snapshot for a node with corresponding snapshot type and returns it.
+func _take_snapshot_for_node(node: Node) -> _CollisionSnapshot:
+ if node is CollisionShape3D:
+ return _ShapeSnapshot3D.new(node)
+ elif node is CollisionShape2D:
+ return _ShapeSnapshot2D.new(node)
+ elif node is Area3D:
+ return _AreaSnapshot3D.new(node)
+ elif node is Area2D:
+ return _AreaSnapshot2D.new(node)
+ return null
+
+## Record the given tick for simulation aware collision nodes.
+func record_tick(tick: int) -> void:
+ # Node -> _CollisionSnapshot
+ var snapshot_dict : Dictionary = {}
+
+ var i := _tracked_nodes.size() - 1
+ while i >= 0:
+ var node := _tracked_nodes[i]
+
+ # While we are iterating tracked_nodes, remove if anyone is invalid.
+
+ if not is_instance_valid(node):
+ _logger.trace("while recording tick(%s): dropping invalid node at index %s", [tick, i])
+ _tracked_nodes.remove_at(i)
+ i -= 1
+ continue
+
+ snapshot_dict[node] = _take_snapshot_for_node(node)
+ i -= 1
+
+ _logger.trace("recorded tick(%s): recorded %s node(s)", [tick, snapshot_dict.size()])
+ _snapshots_by_tick[tick] = snapshot_dict
+
+## Restore the collision aware nodes to given tick.
+## If any of the collision aware nodes was not alive for given tick,
+## This function will disable them.
+func restore_tick(tick: int) -> void:
+ if not _snapshots_by_tick.has(tick):
+ _logger.warning(
+ "No snapshot recorded for tick %s (maybe older history or never recorded). Skipping restore.",
+ [tick]
+ )
+ return
+
+ var snapshot_dict := _snapshots_by_tick[tick] as Dictionary
+ var restored_count := 0
+ var excluded_count := 0
+
+ for node in _tracked_nodes:
+ if not is_instance_valid(node):
+ continue
+
+ if snapshot_dict.has(node):
+ var snap : _CollisionSnapshot = snapshot_dict[node]
+ snap.restore_node(node)
+ restored_count += 1
+ else:
+ # Not tracked yet at this historical tick -> didn't exist in the sim yet.
+ _exclude_from_collision(node)
+ excluded_count += 1
+
+ _logger.trace(
+ "restored tick(%s): restored %s node(s), excluded %s node(s) not yet alive at this tick",
+ [tick, restored_count, excluded_count]
+ )
+
+## Excludes a node from all collision queries. Not snapshot-driven - a node with no
+## recorded snapshot at this tick has no data to restore from, only a fact to enforce
+## ("this didn't exist yet"), so this stays a plain type check against the live node.
+func _exclude_from_collision(node: Node) -> void:
+ _logger.trace("Excluding node %s from collision (not alive at this historical tick)", [node.name])
+ if node is CollisionShape3D or node is CollisionShape2D:
+ node.disabled = true
+ elif node is Area3D or node is Area2D:
+ node.monitoring = false
+ node.monitorable = false
+ node.collision_layer = 0
+
+## Capture the current state of simulation aware collision nodes.
+## This is used to restore the latest after our operation is done.
+## Internally [_SimulatorServer] will restore to this state by calling restore_snapshot.
+func capture_current_state() -> Dictionary:
+ # Node -> _CollisionSnapshot
+ var live : Dictionary = {}
+ var i := _tracked_nodes.size() - 1
+ while i >= 0:
+ var node := _tracked_nodes[i]
+ if not is_instance_valid(node):
+ _logger.trace("capturing current state(): dropping invalid node at index %s", [i])
+ _tracked_nodes.remove_at(i)
+ i -= 1
+ continue
+
+ live[node] = _take_snapshot_for_node(node)
+ i -= 1
+
+ _logger.trace("captured current state(): captured %s node(s)", [live.size()])
+ return live
+
+
+## Restore simulation aware collision nodes to given snapshot dict.
+func restore_snapshot(snapshot_dict: Dictionary) -> void:
+ var restored_count := 0
+
+ for node in snapshot_dict:
+
+ if not is_instance_valid(node):
+ continue
+
+ var collision_snapshot : _CollisionSnapshot = snapshot_dict[node]
+ collision_snapshot.restore_node(node)
+ restored_count += 1
+
+ _logger.trace("restored snapshot(): restored %s node(s) to captured live state", [restored_count])
+
+## Trim ticks that we dont need anymore.
+## Will trim every tick older than param beginning. (not inclusive)
+func trim_ticks(beginning: int) -> void:
+ for tick in _snapshots_by_tick.keys():
+ if tick < beginning:
+ _snapshots_by_tick.erase(tick)
+ _logger.trace("trimming tick#%s", [tick])
diff --git a/addons/netfox/simulator-time-tracker.gd b/addons/netfox/simulator-time-tracker.gd
new file mode 100644
index 000000000..b8b68b53c
--- /dev/null
+++ b/addons/netfox/simulator-time-tracker.gd
@@ -0,0 +1,413 @@
+extends RefCounted
+class_name _SimulatorTimeTracker
+
+## Helper class for keeping track of how each client's time relates to the
+## host's: the [b]input lead[/b] and the [b]view delay[/b].
+##
+## [b]Time model[/b]: the host's tick is the truth, the host simulates everyone
+## at its current tick. Clients stamp their inputs some ticks ahead of their
+## own tick, so the inputs arrive at the host before the host needs them. How
+## far ahead is the input lead.
+## [br][br]
+## [b]Input lead[/b] is computed by each client on its own, from the round
+## trip time measured by time synchronization:
+## [codeblock]
+## lead = ceil((rtt / 2 + rtt_jitter + margin) / ticktime)
+## [/codeblock]
+## where margin is [code]netfox/simulator/input_lead_margin[/code], in
+## seconds. Clients with a good connection get a small lead, clients with a bad
+## connection a big one, each only pays for its own connection. The host's own
+## inputs need no lead, so it is always 0 on the host.
+## [br][br]
+## [b]Maximum lead[/b]: the lead never goes above
+## [code]netfox/simulator/max_input_lead[/code]. Clients with an even worse
+## connection still play, but their inputs may arrive late, and the host
+## predicts them. The host drops inputs stamped further ahead than the maximum
+## lead plus [constant INPUT_TICK_TOLERANCE], see
+## [method is_input_tick_accepted]. Without that, a single input stamped far
+## in the future would push everyone's inputs out of the history buffers.
+## Example, with 64 ticks of history:
+## [codeblock]
+## host tick | 1000
+## input arrives stamped | 1100
+## per tick input buffer now holds | 1037 .. 1100
+## host needs the inputs of 1000 | gone from the buffer, for every player
+## [/codeblock]
+## [b]History size[/b]: inputs and predicted states are kept for ticks in the
+## future too, so the InputSender and Simulator history buffers hold
+## [code]history_limit[/code] ticks of the past, plus room for the future, see
+## [method get_future_history_ticks]. Example with the defaults, 64 ticks of
+## past and 1 second of maximum lead at 30 ticks per second:
+## [codeblock]
+## room for the future | 30 + 2 (tolerance) = 32 ticks
+## buffer size | 64 + 32 = 96 ticks
+## host tick | 1000
+## stamps accepted | up to 1032
+## past always kept | 937 .. 1000, whatever honest clients stamp
+## [/codeblock]
+## [br]
+## The lead is set once time synchronization is done, then moves by at most 1
+## tick every [constant LEAD_CHANGE_INTERVAL] seconds. No input is lost or
+## sent twice when it changes, see [_InputSenderServer]:
+## [br]- growing by 1 skips a stamp, which is filled with a predicted input,
+## see [code]_predict_input[/code] in [InputSender].
+## [br]- shrinking by 1 would stamp two inputs with the same tick, so the
+## second one is held for a tick and merged into the next one, see
+## [code]_merge_input[/code] in [InputSender].
+## [br][br]
+## [b]View delay[/b] is the number of ticks between the tick a client stamps
+## its inputs with ([code]tick + lead[/code]) and the tick of the host's
+## simulation that the client is seeing. When a client fires an input stamped
+## [code]t[/code], it was seeing the host's simulation at tick
+## [code]t - view_delay[/code]. [_SimulatorServer] uses this to find the tick
+## to rewind to when registering projectiles.
+## [br][br]
+## [b]Why the lead is part of the view delay[/b]: the host only knows the
+## stamped tick of a fire input, so the view delay has to use the same
+## numbers. Example, with a lead of 5:
+## [codeblock]
+## client tick when firing | 100
+## newest state the client sees | 97
+## fire input stamped | 100 + 5 = 105
+## view delay without lead | 100 - 97 = 3 -> host rewinds to 102, wrong
+## view delay with lead | 105 - 97 = 8 -> host rewinds to 97, right
+## [/codeblock]
+## [br]
+## [b]Measuring[/b]: clients measure their view delay every time a simulator
+## state arrives from the host, as [code]current tick - state tick[/code], and
+## keep that value until the next state arrives. The lead is added when the
+## sample is sent. Full states arrive at least every
+## [code]netfox/simulator/full_state_interval[/code] ticks, which keeps the
+## measurement fresh.
+## [br][br]
+## [b]Why on arrival[/b]: with diff states, the host sends nothing while
+## nothing changes. Computing [code]current tick - newest received tick[/code]
+## every tick instead would keep growing in that case, even though the client
+## still sees an up to date world. Example, with a real view delay of 10 ticks
+## and nothing changing after tick 100:
+## [codeblock]
+## client tick | arrives | every tick | on arrival
+## 110 | state for tick 100 | 10 | 10
+## 120 | nothing | 20 | 10
+## 130 | nothing | 30 | 10
+## 134 | full state for 124 | 10 | 10
+## [/codeblock]
+## If the client fires at tick 130, it sees the world as it is at tick 120,
+## since nothing changed between 100 and 120. With 30, the host would start the
+## projectile at tick 100 and step it 20 ticks too many, so it ends up further
+## along its path than where the client fired it. With 10, it starts at 120.
+## [br][br]
+## The downside is lag spikes: if states are stuck on the network instead,
+## the client can't tell that apart from "nothing changed", see the TODO below.
+## [br][br]
+## [b]Sending[/b]: clients send [code](stamped tick, view delay)[/code]
+## samples to the host, 6 bytes each, on an unreliable command. To keep traffic
+## low, a sample is sent:
+## [br]- periodically, every
+## [code]netfox/simulator/view_delay_send_interval[/code] seconds, and
+## [br]- immediately, if the view delay changed by at least
+## [code]netfox/simulator/view_delay_change_threshold[/code] seconds since the
+## last sample sent.
+## [br]Changes smaller than that are just network jitter, and are picked up by
+## the next periodic sample. A lost sample is fine too, the next one replaces it.
+## [br]Both settings are in seconds, so they behave the same at any tick rate.
+## They're converted to ticks with the current tick rate every time they're
+## used, so they follow the tick rate even if a client adjusts its tick rate
+## to the host's, see [code]netfox/time/tickrate_mismatch_action[/code].
+## [br][br]
+## [b]Storing[/b]: the host keeps a per peer history of samples, mapping
+## stamped ticks to view delays. This way every projectile is matched with the
+## view delay at the tick it was fired, even if fire events from different
+## ticks arrive at the same time, e.g. after a lag spike. Samples are kept in
+## buffers as big as the Simulator history, older ones are dropped
+## automatically. Samples stamped too far ahead are ignored, same as inputs.
+## [br][br]
+## Used by [_SimulatorServer] internally.
+
+# TODO: Detect lag spikes? During a spike we should use
+# current tick - newest received tick as view delay instead?
+
+## The input lead moves by at most 1 tick this often, in seconds, see the
+## class description.
+const LEAD_CHANGE_INTERVAL := 1.0
+
+## Clients' ticks follow the host's only as close as time synchronization
+## gets them. The host accepts inputs stamped this many ticks past the maximum
+## lead, so honest clients are never dropped for a tick of clock difference.
+## This also covers a client's predicted state, which is one tick past its
+## newest input, see [_SimulatorServer].
+const INPUT_TICK_TOLERANCE := 2
+
+var _owner: Node = null
+var _cmd_view_delay: _NetworkCommandServer.Command = null
+var _history_size: int = 64
+var _logger := NetfoxLogger._for_netfox("SimulatorTimeTracker")
+
+# Settings in seconds, set in initialize(). They're converted to ticks when
+# used, see the class description.
+var _input_lead_margin : float = 0.1
+var _max_input_lead : float = 1.0
+var _view_delay_send_interval : float = 0.25
+var _view_delay_change_threshold : float = 0.06
+
+# Room for the future in the history buffers, set in initialize(). Same value
+# the history buffers were created with, see get_future_history_ticks().
+var _future_history_ticks : int = 32
+
+# Client side: current input lead in ticks, 0 until round trip time is known
+var _input_lead : int = 0
+
+# Client side: whether the lead jumped to its first target yet
+var _has_input_lead : bool = false
+
+# Client side: tick of the last lead change, -1 if never changed
+var _last_lead_change_tick : int = -1
+
+# Client side: latest measured view delay, before adding the lead. Can be -1
+# on very low latency, see _on_simulator_state. Only valid once a state was
+# received, see _latest_received_tick.
+var _local_view_delay: int = 0
+
+# Client side: newest simulator state tick received from the host, -1 if
+# nothing received yet
+var _latest_received_tick: int = -1
+
+# Client side: last sample sent, -1 if nothing sent yet
+var _last_sent_tick: int = -1
+var _last_sent_view_delay: int = -1
+
+# Host side: peer id (int) -> _HistoryBuffer of stamped tick -> view delay
+var _view_delay_histories: Dictionary = {}
+
+## Initialize needs to be called by the owner node, from its _ready().
+## Commands must be registered in the same order on every peer, which is
+## guaranteed for autoloads.
+## [br][br]
+## [param history_size] is the size of the view delay sample buffers, the same
+## as the Simulator history buffers, including the room for the future.
+func initialize(owner: Node, command_server: _NetworkCommandServer, synchronization_server: _NetworkSynchronizationServer, history_size: int) -> void:
+ _owner = owner
+ _history_size = history_size
+
+ _input_lead_margin = ProjectSettings.get_setting("netfox/simulator/input_lead_margin", 0.1) as float
+ _max_input_lead = ProjectSettings.get_setting("netfox/simulator/max_input_lead", 1.0) as float
+ _view_delay_send_interval = ProjectSettings.get_setting("netfox/simulator/view_delay_send_interval", 0.25) as float
+ _view_delay_change_threshold = ProjectSettings.get_setting("netfox/simulator/view_delay_change_threshold", 0.06) as float
+ _future_history_ticks = get_future_history_ticks()
+
+ _cmd_view_delay = command_server.register_command(_handle_view_delay, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
+
+ synchronization_server._on_simulator.connect(_on_simulator_state)
+ NetworkTime.after_sync.connect(_on_after_sync)
+ NetworkTime.after_tick_loop.connect(_after_tick_loop)
+
+ owner.multiplayer.peer_disconnected.connect(_on_peer_disconnected)
+ owner.multiplayer.connected_to_server.connect(_reset_client)
+ owner.multiplayer.server_disconnected.connect(_reset_client)
+
+## Returns how many ticks the InputSender and Simulator history buffers keep
+## for the future, on top of [code]history_limit[/code] ticks of the past:
+## [code]netfox/simulator/max_input_lead[/code] in ticks, plus
+## [constant INPUT_TICK_TOLERANCE].
+## [br][br]
+## Static, so NetworkHistoryServer can size its buffers before
+## [_SimulatorServer] exists. Uses the tick rate at the time it's called.
+## Buffers are created at startup, before any tick rate adjustment.
+static func get_future_history_ticks() -> int:
+ var max_input_lead := ProjectSettings.get_setting("netfox/simulator/max_input_lead", 1.0) as float
+ return maxi(0, ceili(max_input_lead / NetworkTime.ticktime)) + INPUT_TICK_TOLERANCE
+
+## Returns the maximum input lead in ticks, see the class description.
+## [br][br]
+## Converted with the current tick rate. If the tick rate went up since the
+## history buffers were created, e.g. a client adjusted to a faster host, the
+## same seconds would be more ticks than the buffers have room for. So it's
+## capped to the room the buffers have.
+func get_max_input_lead_ticks() -> int:
+ var max_ticks := maxi(0, ceili(_max_input_lead / NetworkTime.ticktime))
+ return mini(max_ticks, _future_history_ticks - INPUT_TICK_TOLERANCE)
+
+## Returns whether an input stamped [param stamped_tick] is accepted, when our
+## current tick is [param current_tick]. Inputs stamped further ahead than the
+## maximum lead are not, see the class description.
+func is_input_tick_accepted(stamped_tick: int, current_tick: int) -> bool:
+ return stamped_tick <= current_tick + get_max_input_lead_ticks() + INPUT_TICK_TOLERANCE
+
+## Returns how many ticks ahead this peer stamps its inputs. Always 0 on the
+## host. Only changes between tick loops, so it's the same for a whole loop.
+func get_input_lead() -> int:
+ if _owner.multiplayer.is_server():
+ return 0
+ return _input_lead
+
+## Returns the view delay [param peer] had at [param tick], or [param default]
+## if there's no sample for it, e.g. the peer hasn't reported yet, or
+## [param tick] is older than the history.
+## [br][br]
+## [param tick] is a tick stamped by the peer, e.g. the tick of its fire input.
+func get_view_delay(peer: int, tick: int, default: int) -> int:
+ var history := _view_delay_histories.get(peer) as _HistoryBuffer
+ if history == null:
+ return default
+
+ var view_delay: Variant = history.get_latest_at(tick)
+ if view_delay == null:
+ return default
+
+ return view_delay as int
+
+## Client side: returns the view delay this client reports to the host, lead
+## included, or -1 if no simulator state arrived yet. Same value as the next
+## sample sent, see [method _send_view_delay].
+func get_reported_view_delay() -> int:
+ if _latest_received_tick < 0:
+ return -1
+ return maxi(0, _local_view_delay + _input_lead)
+
+## Host side: returns the newest view delay [param peer] reported, or -1 if it
+## hasn't reported yet. Not clamped to [code]max_view_delay_ticks[/code].
+func get_latest_view_delay(peer: int) -> int:
+ var newest_accepted_tick := NetworkTime.tick + get_max_input_lead_ticks() + INPUT_TICK_TOLERANCE
+ return get_view_delay(peer, newest_accepted_tick, -1)
+
+# Client side, called for every simulator state received from the host.
+func _on_simulator_state(snapshot: _Snapshot) -> void:
+ # Ignore states arriving out of order, the client already sees a newer one
+ if snapshot.tick < _latest_received_tick:
+ return
+
+ # The host sends the state after its tick T as T + 1. On very low latency,
+ # e.g. LAN, it can arrive while we're still at tick T, so this can be -1.
+ # Don't clamp it here, the lead is added when sending, and the sum is
+ # clamped there.
+ _latest_received_tick = snapshot.tick
+ _local_view_delay = NetworkTime.tick - snapshot.tick
+
+# Client side, called when NetworkTime starts ticking. Round trip time is known
+# by then, so the lead is set before the first input is stamped.
+func _on_after_sync() -> void:
+ _input_lead = 0
+ _has_input_lead = false
+ _last_lead_change_tick = -1
+
+ if _is_connected_client():
+ _update_input_lead()
+
+# Client side, called after every tick loop. The lead is updated here, so it
+# stays the same for a whole tick loop.
+func _after_tick_loop() -> void:
+ if not _is_connected_client():
+ return
+
+ _update_input_lead()
+ _send_view_delay()
+
+# Client side, moves the input lead towards its target, see class description.
+func _update_input_lead() -> void:
+ if not NetworkTime.is_initial_sync_done():
+ # Round trip time not measured yet
+ return
+
+ var rtt := NetworkTimeSynchronizer.rtt
+ var one_way := rtt / 2.0 + NetworkTimeSynchronizer.rtt_jitter + _input_lead_margin
+ var target := mini(ceili(one_way / NetworkTime.ticktime), get_max_input_lead_ticks())
+
+ if not _has_input_lead:
+ # First measurement, jump straight to it
+ _input_lead = target
+ _has_input_lead = true
+ _last_lead_change_tick = NetworkTime.tick
+ _logger.debug("Input lead set to %d ticks", [_input_lead])
+ return
+
+ if target == _input_lead:
+ return
+
+ var lead_change_interval_ticks := maxi(1, ceili(LEAD_CHANGE_INTERVAL / NetworkTime.ticktime))
+ if NetworkTime.tick - _last_lead_change_tick < lead_change_interval_ticks:
+ return
+
+ _input_lead += signi(target - _input_lead)
+ _last_lead_change_tick = NetworkTime.tick
+ _logger.debug("Input lead changed to %d ticks, target %d", [_input_lead, target])
+
+# Client side, sends a sample if it's time for a periodic one, or if the view
+# delay changed enough, see netfox/simulator/view_delay_change_threshold.
+func _send_view_delay() -> void:
+ if _latest_received_tick < 0:
+ # Nothing measured yet
+ return
+
+ # This is the tick whose inputs are gathered next, so a fire input made
+ # right after this gets this tick stamped with the lead. Both the tick and
+ # the view delay are in stamped tick numbers, see class description.
+ var tick := NetworkTime.tick + _input_lead
+ var view_delay := maxi(0, _local_view_delay + _input_lead)
+
+ # At least 1 tick, in case the interval is shorter than a tick
+ var send_interval_ticks := maxi(1, ceili(_view_delay_send_interval / NetworkTime.ticktime))
+ # At least 2 ticks, so +/- 1 tick of jitter never counts as a change
+ var change_threshold_ticks := maxi(2, ceili(_view_delay_change_threshold / NetworkTime.ticktime))
+
+ var is_periodic := _last_sent_tick < 0 or tick - _last_sent_tick >= send_interval_ticks
+ var has_changed := absi(view_delay - _last_sent_view_delay) >= change_threshold_ticks
+ if not is_periodic and not has_changed:
+ return
+
+ var buffer := StreamPeerBuffer.new()
+ buffer.put_u32(tick)
+ buffer.put_u16(mini(view_delay, 0xFFFF))
+ _cmd_view_delay.send(buffer.data_array, MultiplayerPeer.TARGET_PEER_SERVER)
+
+ _last_sent_tick = tick
+ _last_sent_view_delay = view_delay
+
+func _is_connected_client() -> bool:
+ var multiplayer := _owner.multiplayer
+ if not multiplayer.has_multiplayer_peer() or multiplayer.is_server():
+ return false
+
+ return multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
+
+# Host side, stores a sample reported by a client.
+func _handle_view_delay(sender: int, data: PackedByteArray) -> void:
+ if not _owner.multiplayer.is_server():
+ return
+
+ if data.size() != 6:
+ _logger.warning("Received malformed view delay from peer #%d, ignoring", [sender])
+ return
+
+ var buffer := StreamPeerBuffer.new()
+ buffer.data_array = data
+ var tick := buffer.get_u32()
+ var view_delay := buffer.get_u16()
+
+ # A sample stamped far ahead would wipe this peer's own samples, same as
+ # inputs would, see is_input_tick_accepted()
+ if not is_input_tick_accepted(tick, NetworkTime.tick):
+ _logger.warning(
+ "Dropping view delay from peer #%d stamped @%d, too far ahead of @%d",
+ [sender, tick, NetworkTime.tick])
+ return
+
+ # Ticks older than the history size are dropped by the buffer itself
+ var history := _view_delay_histories.get(sender) as _HistoryBuffer
+ if history == null:
+ history = _HistoryBuffer.new(_history_size)
+ _view_delay_histories[sender] = history
+
+ history.set_at(tick, view_delay)
+
+func _on_peer_disconnected(peer: int) -> void:
+ _view_delay_histories.erase(peer)
+
+# Client side, start measuring from scratch on every new connection.
+func _reset_client() -> void:
+ _local_view_delay = 0
+ _latest_received_tick = -1
+ _input_lead = 0
+ _has_input_lead = false
+ _last_lead_change_tick = -1
+ _last_sent_tick = -1
+ _last_sent_view_delay = -1
diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd
new file mode 100644
index 000000000..40c952958
--- /dev/null
+++ b/addons/netfox/simulator.gd
@@ -0,0 +1,313 @@
+@tool
+extends Node
+class_name Simulator
+
+## @experimental [Simulator] name is a wip. [br]
+## Simulates network logic depending on network authority. Make sure to read
+## them before using [Simulator].[br]
+##
+## There are 3 seperate workflows [Simulator] operate on. [br][br]
+##
+## 1- Host - this [Simulator] has network authority, but [InputSender]'s
+## input_node (your custom player_input.gdcript code) belongs to some other peer.
+## This would be your typical server (host) but doesnt have to be if you are going
+## for some custom solution (example: mesh network).[br]
+##
+## On host [Simulator] runs _simulated_tick functions with new inputs which
+## is received by [InputSender]. After running _simulated_tick with new received
+## inputs, [Simulator] broadcasts ground truth (state properties) to peers.
+##
+## 2- Authoritative peer - this [Simulator] doesnt have network authority, but
+## [InputSender]s input_node (your custom player_input.gdscript code) belongs to
+## local peer. This would be your typical player. [br]
+##
+## On authoritative peer, [Simulator] runs _simulated_tick with [InputSender]'s
+## fresh local inputs (inputs that may or may not have been sent to server at this point).
+## After applying true state, [Simulator] re-runs _simulated_tick to reach current
+## game state. [br][br]
+##
+## 3- Puppet peer - both [Simulator] and [InputSender]s input_node (your custom
+## player_input.gdscript code) doesnt have authority. This is how you see remote
+## players when you are playing the game. For example your friend is a puppet player
+## in your game. [br]
+##
+## On puppet peers, [Simulator] only applies truth received from host
+## For most games this will be enough. Even with [InputSender] broadcast toggled on from
+## project settings, there is no point in re-running _simulated_ticks because server
+## sends states with inputs at the same time. For puppet peers we simply dont know
+## their future inputs. [br][br]
+##
+## TODO: Simulator can have option to predict if input_broadcast is on for inputsender. [br]
+## [br]
+## [b]Ticks[/b]: simulating works the same way as rollback. For tick T, the
+## inputs of tick T are applied, then [code]_simulated_tick(delta, T, is_fresh)[/code]
+## is called, and the resulting state is recorded as the state of tick T + 1:
+## [codeblock]
+## func _simulated_tick(delta: float, tick: int, is_fresh: bool) -> void:
+## # Inputs of tick are applied here, the state you leave behind
+## # is the state of tick + 1
+## velocity = input.movement * speed
+## move_and_slide()
+## [/codeblock]
+## [code]is_fresh[/code] is true the first time a tick is simulated. Local
+## authoritative peers simulate the same tick again after receiving the truth
+## from the host, with [code]is_fresh[/code] false, so use it for effects like
+## sounds that should play once.
+
+## The root node for resolving node paths in properties. Defaults to the parent node.
+@export var root: Node = get_parent()
+
+## [Simulator] needs [InputSender] assigned to work with at the first place.
+## Any authority change to [InputSender]'s input node (example PlayerInput) requires
+## calling [method Simulator.process_settings].
+## Changing or assigning [InputSender] during runtime is not recommended by design, but also
+## requires call to [method Simulator.process_settings].
+@export var listened_input_sender : InputSender = null
+
+@export_group("State")
+## Properties that define the game state.
+## [br][br]
+## State properties are recorded for each tick.
+## State is restored when host broadcasts truth, [Simulator] then will accept this
+## as true state and apply it.[Simulator] will call _simulated_tick for the t.
+@export var state_properties: Array[String]
+
+# Simulated nodes.
+var _sim_nodes := [] as Array[Node]
+
+## Decides which peers will receive updates
+var visibility_filter := PeerVisibilityFilter.new()
+
+@onready var _logger: NetfoxLogger = NetfoxLogger._for_netfox("Simulator:" + root.name)
+
+var _state_properties := _PropertyPool.new()
+
+# Nodes with a schema registered through this Simulator, see set_schema()
+var _schema_nodes := _Set.new()
+
+var _properties_dirty: bool = false
+
+# Dictionary (root node) -> (managing simulator)
+# Used to check for foreign roots when gathering simulated nodes.
+static var _managed_roots := {}
+
+func _ready() -> void:
+ if Engine.is_editor_hint():
+ return
+
+ process_settings.call_deferred()
+
+ # Reprocess authority on connect
+ if NetworkEvents.enabled:
+ # User might change `multiplayer` - `NetworkEvents` handles that
+ NetworkEvents.on_client_start.connect(func(__): process_settings())
+ else:
+ multiplayer.connected_to_server.connect(process_settings)
+
+func _enter_tree() -> void:
+ if Engine.is_editor_hint():
+ return
+
+ _managed_roots[root] = self
+
+ if not visibility_filter:
+ visibility_filter = PeerVisibilityFilter.new()
+
+ if not visibility_filter.get_parent():
+ add_child(visibility_filter)
+
+func _exit_tree() -> void:
+ if Engine.is_editor_hint():
+ return
+
+ _managed_roots.erase(root)
+
+
+ for node in _sim_nodes + _state_properties.get_subjects():
+ NetworkSynchronizationServer.deregister(node)
+ NetworkIdentityServer.deregister_node(node)
+ NetworkHistoryServer.deregister(node)
+
+ SimulatorServer.deregister_simulator(self)
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_EDITOR_PRE_SAVE:
+ update_configuration_warnings()
+
+func _get_configuration_warnings() -> PackedStringArray:
+ if not root:
+ root = get_parent()
+
+ # Explore state and input properties
+ if not root:
+ return ["No valid root node found!"]
+
+ var result := PackedStringArray()
+ result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_simulator_state_properties",
+ func(node, prop):
+ add_state(node, prop)
+ ))
+
+ return result
+
+## Process settings.
+## [br][br]
+## Call this after any change to configuration. Updates based on authority too
+## ( calls process_authority ).
+func process_settings() -> void:
+ _sim_nodes.clear()
+
+ process_authority()
+
+ # Gather simulated nodes.
+ var managed_nodes := [root] + _collect_managed_nodes(root)
+ _logger.debug("Filtering managed nodes: %s", [managed_nodes])
+ for node in managed_nodes:
+ if node.has_method("_simulated_tick"):
+ _sim_nodes.push_back(node)
+
+ # Register identifiers
+ for node in _state_properties.get_subjects():
+ NetworkIdentityServer.register_node(node)
+
+ # Register visibility filter
+ for node in _state_properties.get_subjects():
+ NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter)
+
+## Process settings based on authority.
+## [br][br]
+## Call this whenever the authority of input node changes.
+## Make sure to do this at the same time on all peers.
+func process_authority():
+ # First de-register.
+ SimulatorServer.deregister_simulator(self)
+
+ for node in _state_properties.get_subjects():
+ for property in _state_properties.get_properties_of(node):
+ NetworkHistoryServer.deregister_simulator(node, property)
+ NetworkSynchronizationServer.deregister_simulator(node, property)
+
+ # Process authority
+ _state_properties.set_from_paths(root, state_properties)
+
+ if not listened_input_sender:
+ _logger.error("Simulator needs listened_input_sender configured and valid.")
+ return
+
+ # Register state properties.
+ for node in _state_properties.get_subjects():
+ for property in _state_properties.get_properties_of(node):
+ NetworkHistoryServer.register_simulator(node, property)
+ NetworkSynchronizationServer.register_simulator(node, property)
+
+ SimulatorServer.register_simulator(self)
+
+## Add a state property.
+## [br][br]
+## Settings will be automatically updated. The [param node] may be a string or
+## [NodePath] pointing to a node, or an actual [Node] instance. If the given
+## property is already tracked, this method does nothing.
+func add_state(node: Variant, property: String):
+ var property_path := PropertyEntry.make_path(root, node, property)
+ if not property_path or state_properties.has(property_path):
+ return
+
+ state_properties.push_back(property_path)
+ _properties_dirty = true
+ _reprocess_settings.call_deferred()
+
+## Set the schema for transmitting properties over the network.
+## [br][br]
+## The [param schema] must be a dictionary, with the keys being property path
+## strings, and the values are the associated [NetworkSchemaSerializer] objects.
+## Properties are interpreted relative to the [member root] node. The schema
+## should contain state properties, input properties are set on the
+## [member listened_input_sender] instead. Properties not specified in the
+## schema will use a generic fallback serializer. By using the right serializer
+## for the right property, bandwidth usage can be lowered.
+## [br][br]
+## The schema must be the same on every peer.
+## [br][br]
+## See [NetworkSchemas] for many common serializers.
+## [br][br]
+## Example:
+## [codeblock]
+## simulator.set_schema({
+## ":global_transform": NetworkSchemas.transform3f32(),
+## ":velocity": NetworkSchemas.vec3f32()
+## })
+## [/codeblock]
+func set_schema(schema: Dictionary) -> void:
+ # Remove previous schema
+ clear_schema()
+
+ # Register new schema
+ merge_schema(schema)
+
+## Add serializers from [param schema].
+## [br][br]
+## See [method set_schema] for specifying [param schema]. As opposed to [method
+## set_schema], this method updates the schema, instead of overriding it. If
+## a property had a serializer specified previously, this will replace it.
+func merge_schema(schema: Dictionary) -> void:
+ for prop in schema:
+ var prop_entry := PropertyEntry.parse(root, prop)
+ var serializer := schema[prop] as NetworkSchemaSerializer
+ NetworkSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer)
+ _schema_nodes.add(prop_entry.node)
+
+## Clear any serializers specified earlier.
+## [br][br]
+## See [method set_schema].
+func clear_schema() -> void:
+ for node in _schema_nodes:
+ NetworkSynchronizationServer.deregister_schema_for(node)
+ _schema_nodes.clear()
+
+## Check if this [Simulator] has authority over its inputs via listened_input_sender
+## This helper is used by SimulatorServer internally.
+func has_authority_over_inputs() -> bool:
+ if not listened_input_sender:
+ return false
+
+ return listened_input_sender.has_authority_over_input_nodes()
+
+func _reprocess_settings() -> void:
+ if not _properties_dirty or Engine.is_editor_hint():
+ return
+
+ _properties_dirty = false
+
+ process_settings()
+
+# Helper function to run simulation with given parameters.
+# This function is used by SimulatorServer internally.
+func _run_simulation(delta : float, tick : int, is_fresh : bool) -> void:
+ for node in _sim_nodes:
+ if node:
+ node.call("_simulated_tick", delta, tick, is_fresh)
+
+# Find managed nodes recursively from given root, ignoring branches managed by
+# a different [Simulator].
+func _collect_managed_nodes(root: Node) -> Array[Node]:
+ var result: Array[Node] = []
+ for child in root.get_children():
+ if _is_foreign_simulator_root(child):
+ continue
+ result.append(child)
+ result.append_array(_collect_managed_nodes(child))
+ return result
+
+# Returns true if the node is the root of a different [Simulator].
+func _is_foreign_simulator_root(node: Node) -> bool:
+ if not _managed_roots.has(node):
+ # No simulator, treat node as root
+ return false
+
+ if _managed_roots[node] == self:
+ # Node is our own root
+ return false
+
+ # Node is foreign root
+ return true
diff --git a/docs/netfox/guides/network-schemas.md b/docs/netfox/guides/network-schemas.md
index d6f1e5e44..8fe0a1c14 100644
--- a/docs/netfox/guides/network-schemas.md
+++ b/docs/netfox/guides/network-schemas.md
@@ -57,10 +57,10 @@ experience.
## Registering a schema
-Both [RollbackSynchronizer] and [StateSynchronizer] expose a `set_schema()`
-method, that can be used to register the schema used for transmitting
-properties over the network. This method takes a dictionary, with the keys
-being property path strings, and the values being serializers:
+[RollbackSynchronizer], [StateSynchronizer], InputSender and Simulator all
+expose a `set_schema()` method, that can be used to register the schema used for
+transmitting properties over the network. This method takes a dictionary, with
+the keys being property path strings, and the values being serializers:
```gdscript
rollback_synchronizer.set_schema({
diff --git a/docs/netfox/guides/simulation.md b/docs/netfox/guides/simulation.md
new file mode 100644
index 000000000..9fcadcb5c
--- /dev/null
+++ b/docs/netfox/guides/simulation.md
@@ -0,0 +1,262 @@
+# Simulation
+
+!!!warning
+ Simulation is *experimental*, meaning the API may change in breaking ways,
+ and may be less stable than other features.
+
+ *Simulator* is also a work in progress name, we couldn't find a better one
+ yet. Suggestions are welcome!
+
+Simulation is an alternative to [rollback], in the style of Counter-Strike's
+netcode. Inputs are sent to the host, the host simulates everyone and
+broadcasts the ground truth, and each player predicts only their own
+character.
+
+Provided by the *SimulatorServer* and *InputSenderServer* autoloads. Most of the
+time you do not need to use these - the [Simulator] and [InputSender] nodes
+handle simulation for you.
+
+## Simulation or rollback?
+
+| | Rollback | Simulation |
+|---|---|---|
+| Host | Rewinds and re-simulates when inputs arrive late | Runs one tick per tick, never changes the past |
+| Late inputs | Applied by re-simulating | Predicted, late ones are never applied |
+| Clients | Predict and re-simulate every rollback-aware node | Predict only their own player, show the truth for everything else |
+| Game state | Kept consistent across every node | Only the simulated node itself |
+
+Simulation is easier to code than rollback, because the game state is never
+rewound under your feet. In exchange, remote objects are not predicted, and
+interactions between players are decided by the host alone.
+
+## Time model
+
+The host's tick is the truth. The host simulates everyone, its own player and
+remote players, at its current tick. Clients stamp their inputs ahead of their
+own tick, so the inputs arrive before the host needs them. Clients predict
+their own player up to tick + input lead.
+
+Simulating tick T applies the inputs of tick T, calls `_simulated_tick(delta,
+T, is_fresh)`, and the resulting state is recorded as the state of tick T + 1.
+Example for the host, at tick 100:
+
+```
+after_tick | input of tick 100 is recorded
+SimulatorServer | inputs of 100 applied, _simulated_tick(delta, 100)
+ | state recorded and sent as tick 101
+after_tick_loop | state of tick 101 is shown
+```
+
+This also puts Simulators in the same tick numbers as [StateSynchronizer]s,
+which record state after a tick as tick + 1 too.
+
+An authoritative peer restores the latest truth, then re-simulates up to its
+newest input. Example, with a lead of 5 at tick 100, and the latest truth for
+tick 97: restore the state of 97, then simulate ticks 97 .. 105 with their
+inputs. The result is the state of tick 106, which is shown.
+
+## Input lead
+
+How far ahead clients stamp their inputs is the *input lead*. Each client
+computes it on its own, from the round trip time measured by
+[time synchronization]:
+
+```
+lead = ceil((rtt / 2 + rtt_jitter + margin) / ticktime)
+```
+
+Clients with a good connection get a small lead, clients with a bad connection
+a big one, each only pays for its own connection. The host's own inputs need no
+lead, so it is always 0 on the host.
+
+The lead never goes above `netfox/simulator/max_input_lead`. Clients with an
+even worse connection still play, but their inputs may arrive late, and the
+host predicts them. The host drops inputs stamped further ahead than the
+maximum lead.
+
+The lead moves by at most 1 tick every second. No input is lost or sent twice
+when it changes:
+
+* growing by 1 skips a stamp, which is filled with a predicted input
+* shrinking by 1 would stamp two inputs with the same tick, so the second one
+ is held for a tick and merged into the next one
+
+See [predicting and merging inputs] to customize these.
+
+## View delay
+
+*View delay* is the number of ticks between the tick a client stamps its
+inputs with (`tick + lead`) and the tick of the host's simulation that the
+client is seeing. When a client fires an input stamped `t`, it was seeing the
+host's simulation at tick `t - view_delay`. The host uses this to find the tick
+to rewind to when [registering projectiles](#projectiles).
+
+The host only knows the stamped tick of a fire input, so the lead is part of
+the view delay. Example, with a lead of 5:
+
+```
+client tick when firing | 100
+newest state the client sees | 97
+fire input stamped | 100 + 5 = 105
+view delay | 105 - 97 = 8 -> host rewinds to 97
+```
+
+Clients measure their view delay every time a simulator state arrives from the
+host, and send samples to the host periodically, or immediately if it changed
+enough. The view delay is reported by the client, so the host doesn't trust it
+blindly: it can't rewind further than `netfox/simulator/max_view_delay_ticks`.
+
+## Projectiles
+
+The host records simulation aware collision shapes and areas for every tick.
+This gives developers a common past world space, where collisions can be
+checked against what the firing player was seeing, with
+`force_raycast_update()` or `force_shapecast_update()`.
+
+Projectiles fired by clients are caught up on the host in this past world.
+Register them on the host with
+`SimulatorServer.register_projectile(projectile, firing_peer_id, fired_tick)`.
+
+`fired_tick` is the tick of the fire input, e.g. the tick passed to
+`_simulated_tick` when the fire input is applied, or the tick given by
+`input_applied`.
+
+```gdscript
+func _simulated_tick(delta: float, tick: int, is_fresh: bool) -> void:
+ if input.fire and multiplayer.is_server():
+ var bullet := bullet_scene.instantiate() as Bullet
+ get_tree().root.add_child(bullet)
+ bullet.global_transform = muzzle.global_transform
+ SimulatorServer.register_projectile(bullet, input.get_multiplayer_authority(), tick)
+```
+
+Projectiles must implement `step` and `is_alive`. `step` is called once for
+every simulated tick:
+
+```gdscript
+extends Node3D
+class_name Bullet
+
+@export var speed: float = 50.0
+
+@onready var _ray_cast: RayCast3D = $RayCast3D
+
+func step(delta: float, tick: int) -> void:
+ # Check the distance travelled this tick
+ _ray_cast.target_position = Vector3.FORWARD * speed * delta
+ _ray_cast.force_raycast_update()
+ if _ray_cast.is_colliding():
+ queue_free()
+ return
+
+ global_position += -global_basis.z * speed * delta
+
+func is_alive() -> bool:
+ return not is_queued_for_deletion()
+```
+
+The projectile is caught up from the tick of the host's simulation that the
+firing peer was seeing at `fired_tick`. Projectiles are stepped after
+Simulators, so stepping a projectile for tick T checks it against the world
+after this tick's movement, the state of T + 1. The same is true while catching
+up.
+
+### Immediate checks
+
+A projectile doesn't have to fly. For immediate checks, e.g. a hitscan weapon,
+do a single raycast in the first `step`, then free the node. It's not a true
+projectile, but it's checked against the same past world:
+
+```gdscript
+func step(delta: float, tick: int) -> void:
+ _ray_cast.force_raycast_update()
+ if _ray_cast.is_colliding():
+ _hit(_ray_cast.get_collider())
+ queue_free()
+
+func is_alive() -> bool:
+ return not is_queued_for_deletion()
+```
+
+### Simulation aware collision
+
+To catch up projectiles, the host records collision shapes and areas for every
+tick, and restores them while catching up. Add `CollisionShape3D`,
+`CollisionShape2D`, `Area3D` or `Area2D` nodes to the
+`simulation_aware_collision` group to have them recorded. Nodes that were not
+alive at a tick are disabled while catching up.
+
+!!!note
+ Nodes are picked up when they enter the tree. Add them to the group in the
+ scene, or in `_enter_tree()`.
+
+## History size
+
+Inputs and predicted states are kept for ticks in the future too, so the
+InputSender and Simulator history buffers hold `history_limit` ticks of the
+past, plus room for the future. Example with the defaults, 64 ticks of past and
+1 second of maximum lead at 30 ticks per second:
+
+```
+room for the future | 30 + 2 (tolerance) = 32 ticks
+buffer size | 64 + 32 = 96 ticks
+```
+
+## Settings
+
+*netfox/input_sender/input_redundancy*: each input packet also carries this
+many previous inputs, in case some packets are lost.
+
+*netfox/input_sender/history_limit*: ticks of past inputs kept.
+
+*netfox/input_sender/enable_input_broadcast*: send inputs to every peer, not
+only the host.
+
+*netfox/simulator/full_state_interval*: ticks between full states, diff states
+are sent in between. If set to 0, full states are never sent. If set to 1, only
+full states are sent.
+
+*netfox/simulator/enable_diff_states*: send only what changed since the
+previous tick between full states.
+
+*netfox/simulator/history_limit*: ticks of past simulator state kept, also how
+far back projectiles can be caught up.
+
+*netfox/simulator/max_view_delay_ticks*: most ticks the host rewinds when
+catching up a client's projectile.
+
+*netfox/simulator/input_lead_margin*: clients stamp their inputs ahead by `rtt
+/ 2 + jitter + margin`, in seconds.
+
+*netfox/simulator/max_input_lead*: clients never stamp their inputs further
+ahead than this, in seconds. The host drops inputs stamped further ahead.
+
+*netfox/simulator/view_delay_send_interval*: seconds between periodic view
+delay samples sent by clients.
+
+*netfox/simulator/view_delay_change_threshold*: view delay changes of at least
+this many seconds are sent immediately.
+
+## Limitations
+
+* Lead changes show as a one-tick skip or repeat of the local player.
+* Prediction errors are not smoothed, the local player snaps to the truth.
+* View delay can't tell a lag spike from "nothing changed".
+* Diff states are not acknowledged. A lost diff is only fixed by the next full
+ state.
+* Derived physics state, e.g. `is_on_floor()`, is not restored with the truth
+ when re-simulating. See [Rollback caveats].
+
+## Examples
+
+See `examples/simulated-player` for a [Simulator] driven character, and
+`examples/server-side-vehicle` for a physics tank using `input_applied` and
+[predicting and merging inputs].
+
+[rollback]: ./network-rollback.md
+[Simulator]: ../nodes/simulator.md
+[InputSender]: ../nodes/input-sender.md
+[StateSynchronizer]: ../nodes/state-synchronizer.md
+[time synchronization]: ./network-time-synchronizer.md
+[predicting and merging inputs]: ../nodes/input-sender.md#predicting-and-merging-inputs
+[Rollback caveats]: ../tutorials/rollback-caveats.md
diff --git a/docs/netfox/nodes/input-sender.md b/docs/netfox/nodes/input-sender.md
new file mode 100644
index 000000000..fa505a639
--- /dev/null
+++ b/docs/netfox/nodes/input-sender.md
@@ -0,0 +1,152 @@
+# InputSender
+
+!!!warning
+ InputSender is *experimental*, meaning the API may change in breaking
+ ways, and may be less stable than other features. Feedback is welcome!
+
+Stores inputs and sends them to the host. The host either runs them through a
+[Simulator], or emits `input_applied` to run your own host side logic with
+them.
+
+## Configuring input
+
+To use *InputSender*, add it as a child to the target node, and configure it:
+
+*Root* is the root node for resolving node paths in inputs. Defaults to the
+parent node.
+
+*Input properties* define the input for the game simulation. Input is recorded
+after every network tick. Make sure that nodes containing input properties are
+owned by their respective players.
+
+!!!note
+ *InputSender* has authority over its inputs if any of its input nodes has
+ authority. It's your responsibility to give all input nodes the same
+ owner.
+
+See [Property paths] on how to specify properties.
+
+## Gathering input
+
+Inputs are gathered once per tick, the same way as for rollback: set the input
+properties before the tick, e.g. on `NetworkTime.before_tick_loop`:
+
+```gdscript
+extends Node
+class_name PlayerInput
+
+var movement: Vector3 = Vector3.ZERO
+
+func _ready() -> void:
+ NetworkTime.before_tick_loop.connect(_gather)
+
+func _gather() -> void:
+ if not is_multiplayer_authority():
+ return
+
+ movement = Vector3(
+ Input.get_axis("move_west", "move_east"),
+ 0.0,
+ Input.get_axis("move_north", "move_south")
+ )
+```
+
+Clients stamp their inputs ahead of their own tick, so they arrive at the host
+before the host needs them. See [input lead].
+
+## Host side logic
+
+`input_applied(tick, is_predicted)` is emitted on the host, after every tick,
+when the inputs of `tick` are applied to the input properties. Use it to run
+host side logic with the inputs, e.g. for physics vehicles synchronized with a
+[StateSynchronizer]. Whatever you change here is recorded as the state of
+`tick + 1`, the same as with [Simulator] and rollback.
+
+```gdscript
+func _on_input_sender_input_applied(tick: int, is_predicted: bool) -> void:
+ engine_force = input.movement.y * engine_power
+ # Don't fire on a guessed input
+ if input.fire and not is_predicted:
+ fire(tick)
+```
+
+It's emitted for every player, including the host's own. If the inputs of the
+tick didn't arrive in time, `is_predicted` is true. Inputs arriving after their
+tick was applied are never applied, the host doesn't change the past.
+
+The signal is not emitted on clients, and only emitted if something is
+connected to it.
+
+## Predicting and merging inputs
+
+Sometimes netfox needs an input that was never gathered, or has to fit two
+gathered inputs into one tick:
+
+* **predict**: an input is needed for a tick, but there is none. This happens
+ when a client's input lead grows and a stamp is skipped, and when the host
+ simulates a player whose input didn't arrive in time.
+* **merge**: two inputs get the same stamp. This happens when a client's input
+ lead shrinks.
+
+Both are rare. By default, predicting copies the last input, and merging keeps
+the newer input. For example, inputs that are only true for a single tick, like
+a jump press, are copied by default when predicting, and the press happens
+twice. When merging, the default drops the older press.
+
+If the defaults don't fit, implement these methods in the *root* node's script:
+
+```gdscript
+# Return the input for a tick that has none. last is the latest input
+# before it. May be called for multiple ticks in a row with the same last.
+func _predict_input(last: Dictionary) -> Dictionary:
+ var predicted := last.duplicate()
+ predicted["Input:jump"] = false
+ return predicted
+
+# Return a single input made from two inputs that got the same tick.
+# older was gathered one tick before newer.
+func _merge_input(older: Dictionary, newer: Dictionary) -> Dictionary:
+ var merged := newer.duplicate()
+ merged["Input:jump"] = older["Input:jump"] or newer["Input:jump"]
+ return merged
+```
+
+Inputs are dictionaries, with the paths in *input properties* as keys, and the
+input values as values. Properties missing from the returned dictionary keep
+the value from `last` or `newer`.
+
+## Schemas
+
+Use `set_schema()` to pick serializers for input properties, and lower
+bandwidth. The schema must be the same on every peer:
+
+```gdscript
+input_sender.set_schema({
+ "Input:movement": NetworkSchemas.vec3f32(),
+ "Input:aim": NetworkSchemas.vec3f32(),
+ "Input:is_firing": NetworkSchemas.bool8()
+})
+```
+
+See [Network schemas] for more.
+
+## Changing configuration
+
+Call `process_settings()` after any change to configuration, or use
+`add_input()`, which updates settings automatically.
+
+Whenever the authority of the input nodes changes, call `process_authority()`.
+Make sure to do this at the same time on all peers. If a [Simulator] listens to
+this *InputSender*, update it too, see [Simulator](./simulator.md#changing-ownership).
+
+## Visibility
+
+*InputSender* has a `visibility_filter` that decides which peers will receive
+updates. See [Visibility management].
+
+[Simulator]: ./simulator.md
+[StateSynchronizer]: ./state-synchronizer.md
+[input lead]: ../guides/simulation.md#input-lead
+[Property paths]: ../guides/property-paths.md
+[Network schemas]: ../guides/network-schemas.md
+[Visibility management]: ../guides/visibility-management.md
diff --git a/docs/netfox/nodes/simulator.md b/docs/netfox/nodes/simulator.md
new file mode 100644
index 000000000..b270e82b8
--- /dev/null
+++ b/docs/netfox/nodes/simulator.md
@@ -0,0 +1,136 @@
+# Simulator
+
+!!!warning
+ Simulator is *experimental*, meaning the API may change in breaking ways,
+ and may be less stable than other features.
+
+ *Simulator* is also a work in progress name, we couldn't find a better one
+ yet. Suggestions are welcome!
+
+Simulates network logic depending on network authority. The host runs the
+simulation one tick per tick and broadcasts the ground truth, the player owning
+the input predicts it locally, everyone else shows the truth.
+
+*Simulator* is an alternative to [RollbackSynchronizer]: the host never
+rewinds or re-simulates, and only the local player is predicted. See
+[Simulation] on how it works, and when to use which.
+
+## Configuring state and input
+
+To use *Simulator*, add it as a child to the target node, next to an
+[InputSender], and configure it:
+
+*Root* is the root node for resolving node paths in properties. Defaults to
+the parent node.
+
+*Listened input sender* is the [InputSender] whose inputs drive the
+simulation. *Simulator* needs it assigned to work at all.
+
+*State properties* define the game state. State properties are recorded for
+each tick. When the host broadcasts the truth, *Simulator* accepts it as the
+true state and applies it. Make sure that nodes containing state properties
+are owned by the host.
+
+Input properties are configured on the [InputSender].
+
+See [Property paths] on how to specify properties.
+
+!!!warning
+ It is not recommended to have both state and input properties on the same
+ node. Nodes with state belong to the host, and nodes with input belong to
+ the player.
+
+## Writing simulated scripts
+
+During setup, *Simulator* finds all nodes under *root* that implement
+`_simulated_tick`, and calls them every time a tick is simulated.
+
+Simulating works the same way as rollback. For tick T, the inputs of tick T are
+applied, then `_simulated_tick(delta, T, is_fresh)` is called, and the
+resulting state is recorded as the state of tick T + 1:
+
+```gdscript
+extends CharacterBody3D
+
+@export var speed: float = 4.0
+@export var input: PlayerInput
+
+func _simulated_tick(delta: float, tick: int, is_fresh: bool) -> void:
+ # Inputs of tick are applied here, the state you leave behind
+ # is the state of tick + 1
+ velocity = input.movement.normalized() * speed
+
+ velocity *= NetworkTime.physics_factor
+ move_and_slide()
+ velocity /= NetworkTime.physics_factor
+```
+
+Note the usage of `physics_factor` - this is explained in [Rollback caveats].
+
+## Peers
+
+*Simulator* works in one of three ways, depending on authority:
+
+*Host* - *Simulator* has authority, the input belongs to some other peer, or to
+the host itself. The host runs `_simulated_tick` at its current tick with the
+inputs received by [InputSender], then broadcasts the ground truth. If the
+inputs of the tick didn't arrive in time, they're predicted, see
+[InputSender](./input-sender.md#predicting-and-merging-inputs).
+
+*Authoritative peer* - the input belongs to the local peer, this is your
+player. *Simulator* applies the latest truth received from the host, then
+re-runs `_simulated_tick` with fresh local inputs, up to the current tick plus
+the [input lead](../guides/simulation.md#input-lead).
+
+*Puppet peer* - neither *Simulator* nor the input has authority, this is how
+you see remote players. *Simulator* only applies the truth received from the
+host. We simply don't know their future inputs, so there's nothing to
+re-simulate.
+
+## Single fire events
+
+`is_fresh` is true the first time a tick is simulated. Authoritative peers
+simulate the same tick again after receiving the truth from the host, with
+`is_fresh` false. Use it for effects like sounds that should play once.
+
+## Schemas
+
+Use `set_schema()` to pick serializers for state properties, and lower
+bandwidth. The schema must be the same on every peer:
+
+```gdscript
+simulator.set_schema({
+ ":global_transform": NetworkSchemas.transform3f32(),
+ ":velocity": NetworkSchemas.vec3f32()
+})
+```
+
+Input properties get their schema on the [InputSender]. See [Network schemas]
+for more.
+
+## Changing configuration
+
+*Simulator* has to do some setup work whenever the state properties change.
+Call `process_settings()` after any change to configuration, or use
+`add_state()`, which updates settings automatically.
+
+Changing or assigning the *listened input sender* during runtime is not
+recommended, but also requires a call to `process_settings()`.
+
+## Changing ownership
+
+Whenever the authority of the [InputSender]'s input nodes changes, call
+`process_authority()`. Make sure to do this at the same time on all peers.
+
+## Visibility
+
+*Simulator* has a `visibility_filter` that decides which peers will receive
+updates. See [Visibility management].
+
+[RollbackSynchronizer]: ./rollback-synchronizer.md
+[InputSender]: ./input-sender.md
+[Simulation]: ../guides/simulation.md
+[Property paths]: ../guides/property-paths.md
+[Network schemas]: ../guides/network-schemas.md
+[Visibility management]: ../guides/visibility-management.md
+[Rollback caveats]: ../tutorials/rollback-caveats.md
diff --git a/examples/server-side-vehicle/scenes/server_side_tank.tscn b/examples/server-side-vehicle/scenes/server_side_tank.tscn
new file mode 100644
index 000000000..9869e778c
--- /dev/null
+++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn
@@ -0,0 +1,311 @@
+[gd_scene load_steps=12 format=3 uid="uid://f1annxuory74"]
+
+[ext_resource type="Script" path="res://addons/netfox/input-sender.gd" id="1_c04if"]
+[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/server_side_tank.gd" id="1_jtlcb"]
+[ext_resource type="PackedScene" uid="uid://uqytq0drkxtf" path="res://examples/server-side-vehicle/scenes/tank_shell.tscn" id="2_ea71k"]
+[ext_resource type="PackedScene" uid="uid://bsavthtpx4joi" path="res://examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn" id="2_o1mnl"]
+[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/tank_input.gd" id="3_8ufv1"]
+[ext_resource type="Script" path="res://addons/netfox/state-synchronizer.gd" id="4_qj6bi"]
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_tqf64"]
+size = Vector3(2.9885, 1.6003, 4.71182)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_qe0pd"]
+size = Vector3(2.01, 0.775, 2)
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_sclqb"]
+albedo_color = Color(0.188235, 0.188235, 0.188235, 1)
+metallic = 0.7
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_m7l4t"]
+albedo_color = Color(0.462745, 0.462745, 0.462745, 1)
+metallic = 0.79
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_g1au6"]
+albedo_color = Color(0.466667, 0.466667, 0.466667, 1)
+metallic = 0.45
+
+[node name="ServerSideTank" type="VehicleBody3D" node_paths=PackedStringArray("turret", "shell_spawn_point", "regular_camera", "focus_camera")]
+mass = 750.0
+center_of_mass_mode = 1
+center_of_mass = Vector3(0, 1.2, 0)
+linear_damp = 0.13
+angular_damp = 0.1
+script = ExtResource("1_jtlcb")
+turret = NodePath("Turret")
+shell_spawn_point = NodePath("Turret/Marker3D")
+shell_scene = ExtResource("2_ea71k")
+regular_camera = NodePath("Turret/RegularCamera3D")
+focus_camera = NodePath("Turret/FocusCamera3D")
+info_panel_scene = ExtResource("2_o1mnl")
+
+[node name="InputSender" type="Node" parent="." node_paths=PackedStringArray("root")]
+script = ExtResource("1_c04if")
+root = NodePath("..")
+input_properties = Array[String](["TankInput:movement", "TankInput:brake", "TankInput:mouse_movement", "TankInput:fire"])
+
+[node name="TankInput" type="Node" parent="."]
+script = ExtResource("3_8ufv1")
+
+[node name="StateSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")]
+script = ExtResource("4_qj6bi")
+root = NodePath("..")
+properties = Array[String]([":engine_force", ":brake", ":steering", ":global_transform", "Turret:transform", ":_last_fire_tick", ":score"])
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.29733, 0.0841393)
+shape = SubResource("BoxShape3D_tqf64")
+
+[node name="CollisionShape3D2" type="CollisionShape3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.44697, -0.240236)
+shape = SubResource("BoxShape3D_qe0pd")
+
+[node name="Body" type="CSGMesh3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.0007, 0.0944731)
+material_override = SubResource("StandardMaterial3D_sclqb")
+
+[node name="CSGBox3D" type="CSGBox3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.690109, 0)
+size = Vector3(3, 1.62169, 4.90454)
+
+[node name="CSGBox3D2" type="CSGBox3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 0.561549, 0.827443, 0, -0.827443, 0.561549, 0, 0, 2.24295)
+operation = 2
+size = Vector3(3.44184, 2.19637, 1)
+
+[node name="CSGBox3D3" type="CSGBox3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 0.913183, -0.40755, 0, 0.40755, 0.913183, 0, -0.000799894, -2.73253)
+operation = 2
+size = Vector3(3.35521, 1.22864, 1)
+
+[node name="Turret" type="CSGMesh3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.4627, 0)
+material_override = SubResource("StandardMaterial3D_m7l4t")
+
+[node name="CSGBox3D" type="CSGBox3D" parent="Turret"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.24471)
+size = Vector3(2, 0.75, 2)
+
+[node name="Barrel" type="CSGCylinder3D" parent="Turret"]
+transform = Transform3D(1, 0, 0, 0, -4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 2.6253)
+radius = 0.15
+height = 4.0
+sides = 16
+
+[node name="FocusCamera3D" type="Camera3D" parent="Turret"]
+transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, 0.209147, 4.3057)
+fov = 45.0
+
+[node name="RegularCamera3D" type="Camera3D" parent="Turret"]
+transform = Transform3D(-1, -2.26267e-08, 8.44439e-08, 0, 0.965926, 0.258819, -8.74228e-08, 0.258819, -0.965926, 0, 2.3423, -2.76887)
+fov = 90.0
+
+[node name="Marker3D" type="Marker3D" parent="Turret"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 4.83633)
+
+[node name="VehicleWheel3D" type="VehicleWheel3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, 2.14)
+use_as_traction = true
+use_as_steering = true
+wheel_roll_influence = 1.0
+wheel_radius = 0.6
+suspension_travel = 0.1
+suspension_stiffness = 50.0
+damping_compression = 0.3
+damping_relaxation = 0.5
+
+[node name="Wheel" type="CSGMesh3D" parent="VehicleWheel3D"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.000132322)
+material_override = SubResource("StandardMaterial3D_g1au6")
+
+[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D/Wheel"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.6
+height = 0.5
+sides = 16
+
+[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D/Wheel"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.2
+height = 0.8
+sides = 3
+
+[node name="VehicleWheel3D2" type="VehicleWheel3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, 0.766)
+use_as_traction = true
+wheel_roll_influence = 1.0
+wheel_radius = 0.6
+suspension_travel = 0.1
+suspension_stiffness = 50.0
+damping_compression = 0.3
+damping_relaxation = 0.5
+
+[node name="Wheel2" type="CSGMesh3D" parent="VehicleWheel3D2"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.000470459)
+material_override = SubResource("StandardMaterial3D_g1au6")
+
+[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D2/Wheel2"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.6
+height = 0.5
+sides = 16
+
+[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D2/Wheel2"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.2
+height = 0.8
+sides = 3
+
+[node name="VehicleWheel3D3" type="VehicleWheel3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, -0.618)
+use_as_traction = true
+wheel_roll_influence = 1.0
+wheel_radius = 0.6
+suspension_travel = 0.1
+suspension_stiffness = 50.0
+damping_compression = 0.3
+damping_relaxation = 0.5
+
+[node name="Wheel3" type="CSGMesh3D" parent="VehicleWheel3D3"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -3.03984e-06)
+material_override = SubResource("StandardMaterial3D_g1au6")
+
+[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D3/Wheel3"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.6
+height = 0.5
+sides = 16
+
+[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D3/Wheel3"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.2
+height = 0.8
+sides = 3
+
+[node name="VehicleWheel3D4" type="VehicleWheel3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, -1.9835)
+use_as_traction = true
+wheel_roll_influence = 1.0
+wheel_radius = 0.6
+suspension_travel = 0.1
+suspension_stiffness = 50.0
+damping_compression = 0.3
+damping_relaxation = 0.5
+
+[node name="Wheel4" type="CSGMesh3D" parent="VehicleWheel3D4"]
+material_override = SubResource("StandardMaterial3D_g1au6")
+
+[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D4/Wheel4"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.6
+height = 0.5
+sides = 16
+
+[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D4/Wheel4"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.2
+height = 0.8
+sides = 3
+
+[node name="VehicleWheel3D5" type="VehicleWheel3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, 2.13987)
+use_as_traction = true
+use_as_steering = true
+wheel_roll_influence = 1.0
+wheel_radius = 0.6
+suspension_travel = 0.1
+suspension_stiffness = 50.0
+damping_compression = 0.3
+damping_relaxation = 0.5
+
+[node name="Wheel5" type="CSGMesh3D" parent="VehicleWheel3D5"]
+material_override = SubResource("StandardMaterial3D_g1au6")
+
+[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D5/Wheel5"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.6
+height = 0.5
+sides = 16
+
+[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D5/Wheel5"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.2
+height = 0.8
+sides = 3
+
+[node name="VehicleWheel3D6" type="VehicleWheel3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, 0.76647)
+use_as_traction = true
+wheel_roll_influence = 1.0
+wheel_radius = 0.6
+suspension_travel = 0.1
+suspension_stiffness = 50.0
+damping_compression = 0.3
+damping_relaxation = 0.5
+
+[node name="Wheel6" type="CSGMesh3D" parent="VehicleWheel3D6"]
+material_override = SubResource("StandardMaterial3D_g1au6")
+
+[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D6/Wheel6"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.6
+height = 0.5
+sides = 16
+
+[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D6/Wheel6"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.2
+height = 0.8
+sides = 3
+
+[node name="VehicleWheel3D7" type="VehicleWheel3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, -0.618003)
+use_as_traction = true
+wheel_roll_influence = 1.0
+wheel_radius = 0.6
+suspension_travel = 0.1
+suspension_stiffness = 50.0
+damping_compression = 0.3
+damping_relaxation = 0.5
+
+[node name="Wheel7" type="CSGMesh3D" parent="VehicleWheel3D7"]
+material_override = SubResource("StandardMaterial3D_g1au6")
+
+[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D7/Wheel7"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.6
+height = 0.5
+sides = 16
+
+[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D7/Wheel7"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.2
+height = 0.8
+sides = 3
+
+[node name="VehicleWheel3D8" type="VehicleWheel3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, -1.9835)
+use_as_traction = true
+wheel_roll_influence = 1.0
+wheel_radius = 0.6
+suspension_travel = 0.1
+suspension_stiffness = 50.0
+damping_compression = 0.3
+damping_relaxation = 0.5
+
+[node name="Wheel8" type="CSGMesh3D" parent="VehicleWheel3D8"]
+material_override = SubResource("StandardMaterial3D_g1au6")
+
+[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D8/Wheel8"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.6
+height = 0.5
+sides = 16
+
+[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D8/Wheel8"]
+transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0)
+radius = 0.2
+height = 0.8
+sides = 3
+
+[connection signal="input_applied" from="InputSender" to="." method="_on_input_sender_input_applied"]
diff --git a/examples/server-side-vehicle/scenes/server_side_vehicle_example.tscn b/examples/server-side-vehicle/scenes/server_side_vehicle_example.tscn
new file mode 100644
index 000000000..f3a18d45c
--- /dev/null
+++ b/examples/server-side-vehicle/scenes/server_side_vehicle_example.tscn
@@ -0,0 +1,33 @@
+[gd_scene load_steps=5 format=3 uid="uid://g5axutycr4rn"]
+
+[ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="1_h2lt4"]
+[ext_resource type="PackedScene" uid="uid://cf4xd6s672bo6" path="res://examples/server-side-vehicle/scenes/tank_arena.tscn" id="2_t7ktp"]
+[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/player_spawner.gd" id="3_55dug"]
+[ext_resource type="PackedScene" uid="uid://f1annxuory74" path="res://examples/server-side-vehicle/scenes/server_side_tank.tscn" id="4_rnbcl"]
+
+[node name="ServerSideVehicleExample" type="Node"]
+
+[node name="Network Popup" parent="." instance=ExtResource("1_h2lt4")]
+
+[node name="TankArena" parent="." instance=ExtResource("2_t7ktp")]
+
+[node name="Network" type="Node" parent="."]
+
+[node name="PlayerSpawner" type="Node" parent="Network" node_paths=PackedStringArray("spawn_points")]
+script = ExtResource("3_55dug")
+player_scene = ExtResource("4_rnbcl")
+spawn_points = [NodePath("../../SpawnPoints/Marker3D"), NodePath("../../SpawnPoints/Marker3D2"), NodePath("../../SpawnPoints/Marker3D3"), NodePath("../../SpawnPoints/Marker3D4")]
+
+[node name="SpawnPoints" type="Node3D" parent="."]
+
+[node name="Marker3D" type="Marker3D" parent="SpawnPoints"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 20.285, 1, 16.224)
+
+[node name="Marker3D2" type="Marker3D" parent="SpawnPoints"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 26.6316, 1, -26.2491)
+
+[node name="Marker3D3" type="Marker3D" parent="SpawnPoints"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -19.381, 1, -30.0326)
+
+[node name="Marker3D4" type="Marker3D" parent="SpawnPoints"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6.68788, 1, 12.6846)
diff --git a/examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn b/examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn
new file mode 100644
index 000000000..becc13b34
--- /dev/null
+++ b/examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn
@@ -0,0 +1,71 @@
+[gd_scene load_steps=2 format=3 uid="uid://bsavthtpx4joi"]
+
+[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd" id="1_1v7fb"]
+
+[node name="ServerSideVehicleInfoPanel" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+script = ExtResource("1_1v7fb")
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+layout_mode = 1
+anchors_preset = 2
+anchor_top = 1.0
+anchor_bottom = 1.0
+offset_left = 32.0
+offset_top = -192.0
+offset_right = 156.0
+offset_bottom = -32.0
+grow_vertical = 0
+
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
+layout_mode = 2
+
+[node name="PeerLabel" type="Label" parent="MarginContainer/VBoxContainer"]
+layout_mode = 2
+text = "Server Side Vehicle Example
+Peer#"
+
+[node name="InfoLabel" type="Label" parent="MarginContainer/VBoxContainer"]
+layout_mode = 2
+text = "Controls:
+Mouse (use alt+tab) -> Move Turret
+F / Mouse Wheel -> Zoom
+WASD : Move
+Space : Brake
+Left Click: Shoot"
+
+[node name="MarginContainer2" type="MarginContainer" parent="."]
+layout_mode = 1
+anchors_preset = 7
+anchor_left = 0.5
+anchor_top = 1.0
+anchor_right = 0.5
+anchor_bottom = 1.0
+offset_left = -161.0
+offset_top = -161.0
+offset_right = 161.0
+offset_bottom = -104.0
+grow_horizontal = 2
+grow_vertical = 0
+
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer2"]
+layout_mode = 2
+
+[node name="ReloadLabel" type="Label" parent="MarginContainer2/VBoxContainer"]
+layout_mode = 2
+text = "Reloading"
+horizontal_alignment = 1
+
+[node name="ReloadProgressBar" type="ProgressBar" parent="MarginContainer2/VBoxContainer"]
+layout_mode = 2
+
+[node name="ScoreLabel" type="Label" parent="MarginContainer2/VBoxContainer"]
+layout_mode = 2
+text = "Score: 0"
+horizontal_alignment = 1
diff --git a/examples/server-side-vehicle/scenes/tank_arena.tscn b/examples/server-side-vehicle/scenes/tank_arena.tscn
new file mode 100644
index 000000000..cc7db0e81
--- /dev/null
+++ b/examples/server-side-vehicle/scenes/tank_arena.tscn
@@ -0,0 +1,353 @@
+[gd_scene load_steps=11 format=3 uid="uid://cf4xd6s672bo6"]
+
+[sub_resource type="PhysicalSkyMaterial" id="PhysicalSkyMaterial_e1ky4"]
+rayleigh_coefficient = 4.0
+turbidity = 324.81
+ground_color = Color(0.396078, 0.356863, 0.290196, 1)
+
+[sub_resource type="Sky" id="Sky_7krho"]
+sky_material = SubResource("PhysicalSkyMaterial_e1ky4")
+
+[sub_resource type="Environment" id="Environment_gm8cr"]
+background_mode = 2
+sky = SubResource("Sky_7krho")
+fog_light_color = Color(0.54902, 0.584314, 0.741176, 1)
+fog_light_energy = 2.1
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_lr3b6"]
+albedo_color = Color(0.576471, 0.239216, 0.341176, 1)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_47cjw"]
+size = Vector3(75, 1, 75)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_1e1cg"]
+size = Vector3(1, 4, 76)
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_e5vwx"]
+albedo_color = Color(0.305882, 0.478431, 0.835294, 1)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_8or65"]
+size = Vector3(1, 4, 10)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_av0i6"]
+size = Vector3(1, 4, 35)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_ktkvh"]
+size = Vector3(1, 2, 5)
+
+[node name="TankArena" type="Node3D"]
+
+[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
+environment = SubResource("Environment_gm8cr")
+
+[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
+transform = Transform3D(0.923136, -0.326969, -0.202263, -0.0453632, -0.615034, 0.787194, -0.381787, -0.717513, -0.582593, 0, 1.32503, 0)
+light_color = Color(0.996078, 0.992157, 0.988235, 1)
+shadow_enabled = true
+
+[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="."]
+transform = Transform3D(0.483696, 0.725813, 0.489116, -0.00508283, -0.5565, 0.830832, 0.875221, -0.404356, -0.265487, 0, 1.32503, 0)
+light_color = Color(0.94902, 0.870588, 0.854902, 1)
+light_energy = 0.5
+shadow_enabled = true
+
+[node name="Ground" type="StaticBody3D" parent="."]
+
+[node name="Ground" type="CSGBox3D" parent="Ground"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
+material_override = SubResource("StandardMaterial3D_lr3b6")
+size = Vector3(75, 1, 75)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
+shape = SubResource("BoxShape3D_47cjw")
+
+[node name="Walls" type="Node3D" parent="."]
+
+[node name="RegularWall" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 38, 2, 0)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall"]
+shape = SubResource("BoxShape3D_1e1cg")
+
+[node name="RegularWall11" type="CSGBox3D" parent="Walls/RegularWall"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 76)
+
+[node name="RegularWall11" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -38, 2, 0)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall11"]
+shape = SubResource("BoxShape3D_1e1cg")
+
+[node name="RegularWall11" type="CSGBox3D" parent="Walls/RegularWall11"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 76)
+
+[node name="RegularWall12" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-4.37114e-08, 0, -1, 0, 1, 0, 1, 0, -4.37114e-08, 0, 2, -38)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall12"]
+shape = SubResource("BoxShape3D_1e1cg")
+
+[node name="RegularWall11" type="CSGBox3D" parent="Walls/RegularWall12"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 76)
+
+[node name="RegularWall13" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-4.37114e-08, 0, -1, 0, 1, 0, 1, 0, -4.37114e-08, 0, 2, 38)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall13"]
+shape = SubResource("BoxShape3D_1e1cg")
+
+[node name="RegularWall11" type="CSGBox3D" parent="Walls/RegularWall13"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 76)
+
+[node name="RegularWall18" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(0.752783, 0, -0.658269, 0, 1, 0, 0.658269, 0, 0.752783, -25.36, 2, 29.0161)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall18"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 10)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall18"]
+shape = SubResource("BoxShape3D_8or65")
+
+[node name="RegularWall19" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(0.0668312, 0, -0.997764, 0, 1, 0, 0.997764, 0, 0.0668312, 32.9951, 2, 13.5674)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall19"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 10)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall19"]
+shape = SubResource("BoxShape3D_8or65")
+
+[node name="LongWall" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -20.8742, 2, 0)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/LongWall"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 35)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/LongWall"]
+shape = SubResource("BoxShape3D_av0i6")
+
+[node name="LongWall2" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(0.861292, 0, -0.50811, 0, 1, 0, 0.50811, 0, 0.861292, 6.95699, 2, -22.6634)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/LongWall2"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 35)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/LongWall2"]
+shape = SubResource("BoxShape3D_av0i6")
+
+[node name="RegularWall10" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(0.866025, 0, -0.5, 0, 1, 0, 0.5, 0, 0.866025, 7.36388, 2, 7.70559)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall10"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 10)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall10"]
+shape = SubResource("BoxShape3D_8or65")
+
+[node name="ShortWall12" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(0.827414, 0, -0.561592, 0, 1, 0, 0.561592, 0, 0.827414, 27.1415, 2, 15.854)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall12"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall12"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="RegularWall20" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.0668311, 0, 0.997764, 0, 1, 0, -0.997764, 0, -0.0668311, -33.3546, 2, -22.8314)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall20"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 10)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall20"]
+shape = SubResource("BoxShape3D_8or65")
+
+[node name="ShortWall13" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.827414, 0, 0.561593, 0, 1, 0, -0.561593, 0, -0.827414, -27.5009, 2, -25.118)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall13"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall13"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="ShortWall2" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-5.96046e-08, 0, -1, 0, 1, 0, 1, 0, -5.96046e-08, 11.9641, 2, 3.66263)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall2"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall2"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="ShortWall3" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(1, 0, -1.58933e-08, 0, 1, 0, 1.58933e-08, 0, 1, 4.91346, 2, 14.2493)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall3"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall3"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="RegularWall14" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.587997, 0, 0.808863, 0, 1, 0, -0.808863, 0, -0.587997, -10.7772, 2, -23.9271)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall14"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 10)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall14"]
+shape = SubResource("BoxShape3D_8or65")
+
+[node name="ShortWall4" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(0.406498, 0, 0.913652, 0, 1, 0, -0.913652, 0, 0.406498, -16.6237, 2, -22.1033)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall4"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall4"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="ShortWall5" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.913652, 0, 0.406497, 0, 1, 0, -0.406497, 0, -0.913652, -5.87835, 2, -28.9097)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall5"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall5"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="RegularWall16" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.757463, 0, -0.652879, 0, 1, 0, 0.652879, 0, -0.757463, -13.2699, 2, 21.8568)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall16"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 10)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall16"]
+shape = SubResource("BoxShape3D_8or65")
+
+[node name="ShortWall8" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.944141, 0, 0.329542, 0, 1, 0, -0.329542, 0, -0.944141, -10.9688, 2, 27.5324)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall8"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall8"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="ShortWall9" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.329542, 0, -0.944141, 0, 1, 0, 0.944141, 0, -0.329542, -18.6406, 2, 17.3868)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall9"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall9"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="RegularWall17" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.757463, 0, -0.652879, 0, 1, 0, 0.652879, 0, -0.757463, 12.3245, 2, 21.8568)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall17"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 10)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall17"]
+shape = SubResource("BoxShape3D_8or65")
+
+[node name="ShortWall10" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.944141, 0, 0.329542, 0, 1, 0, -0.329542, 0, -0.944141, 14.6257, 2, 27.5324)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall10"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall10"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="ShortWall11" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.329542, 0, -0.944141, 0, 1, 0, 0.944141, 0, -0.329542, 6.95385, 2, 17.3868)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall11"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall11"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="RegularWall15" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.871008, 0, -0.491269, 0, 1, 0, 0.491269, 0, -0.871008, 25.1636, 2, -16.4828)
+
+[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall15"]
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 4, 10)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall15"]
+shape = SubResource("BoxShape3D_8or65")
+
+[node name="ShortWall6" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.860955, 0, 0.508681, 0, 1, 0, -0.508681, 0, -0.860955, 26.3044, 2, -10.4656)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall6"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall6"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="ShortWall7" type="StaticBody3D" parent="Walls"]
+transform = Transform3D(-0.508681, 0, -0.860955, 0, 1, 0, 0.860955, 0, -0.508681, 20.7763, 2, -21.9212)
+
+[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall7"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+material_override = SubResource("StandardMaterial3D_e5vwx")
+size = Vector3(1, 2, 5)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall7"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+shape = SubResource("BoxShape3D_ktkvh")
+
+[node name="Camera3D" type="Camera3D" parent="."]
+transform = Transform3D(0.965926, 0.0449435, -0.254887, 0, 0.984808, 0.173648, 0.258819, -0.167731, 0.951251, -13.068, 8.651, 29.624)
+current = true
diff --git a/examples/server-side-vehicle/scenes/tank_shell.tscn b/examples/server-side-vehicle/scenes/tank_shell.tscn
new file mode 100644
index 000000000..750fa53d0
--- /dev/null
+++ b/examples/server-side-vehicle/scenes/tank_shell.tscn
@@ -0,0 +1,22 @@
+[gd_scene load_steps=4 format=3 uid="uid://uqytq0drkxtf"]
+
+[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/tank_shell.gd" id="1_8exh1"]
+
+[sub_resource type="BoxMesh" id="BoxMesh_qktr7"]
+size = Vector3(0.2, 0.2, 1)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_rtbd2"]
+size = Vector3(0.2, 0.2, 1)
+
+[node name="TankShell" type="Node3D"]
+script = ExtResource("1_8exh1")
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
+mesh = SubResource("BoxMesh_qktr7")
+
+[node name="Area3D" type="Area3D" parent="."]
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Area3D"]
+shape = SubResource("BoxShape3D_rtbd2")
+
+[connection signal="body_entered" from="Area3D" to="." method="_on_area_3d_body_entered"]
diff --git a/examples/server-side-vehicle/scripts/player_spawner.gd b/examples/server-side-vehicle/scripts/player_spawner.gd
new file mode 100644
index 000000000..c923812bd
--- /dev/null
+++ b/examples/server-side-vehicle/scripts/player_spawner.gd
@@ -0,0 +1,74 @@
+extends Node
+
+## Example player spawner script for server side tank example.
+
+@export var player_scene: PackedScene
+@export var spawn_points: Array[Marker3D] = []
+
+var avatars: Dictionary = {}
+
+func _ready():
+ NetworkEvents.on_client_start.connect(_handle_connected)
+ NetworkEvents.on_server_start.connect(_handle_host)
+ NetworkEvents.on_peer_join.connect(_handle_new_peer)
+ NetworkEvents.on_peer_leave.connect(_handle_leave)
+ NetworkEvents.on_client_stop.connect(_handle_stop)
+ NetworkEvents.on_server_stop.connect(_handle_stop)
+
+func _handle_connected(id: int):
+ # Spawn an avatar for us
+ _spawn(id)
+
+func _handle_host():
+ # Spawn own avatar on host machine
+ _spawn(1)
+
+func _handle_new_peer(id: int):
+ # Spawn an avatar for new player
+ _spawn(id)
+
+func _handle_leave(id: int):
+ if not avatars.has(id):
+ return
+
+ var avatar = avatars[id] as Node
+ avatar.queue_free()
+ avatars.erase(id)
+
+func _handle_stop():
+ # Remove all avatars on game end
+ for avatar in avatars.values():
+ avatar.queue_free()
+ avatars.clear()
+
+func _spawn(id: int):
+ var avatar = player_scene.instantiate() as Node
+ avatars[id] = avatar
+ avatar.name += " #%d" % id
+ avatar.position = get_next_spawn_point(id)
+
+ # Avatar is always owned by server
+ avatar.set_multiplayer_authority(1)
+
+ print("Spawned avatar %s at %s" % [avatar.name, multiplayer.get_unique_id()])
+
+ # Avatar's input object is owned by player
+ var input = avatar.find_child("TankInput")
+ if input != null:
+ input.set_multiplayer_authority(id)
+ print("Set input(%s) ownership to %s" % [input.name, id])
+
+ add_child(avatar)
+
+func get_next_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3:
+ # The same data is used to calculate the index on all peers
+ # As a result, spawn points are the same, even without sync
+ var idx := peer_id * 37 + spawn_idx * 19
+ idx = hash(idx)
+ idx = idx % spawn_points.size()
+
+ return spawn_points[idx].global_position
+
+# Used when tanks die and we need to reposition them.
+func get_random_spawn_point() -> Vector3:
+ return spawn_points.pick_random().global_position
diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd
new file mode 100644
index 000000000..05568ab67
--- /dev/null
+++ b/examples/server-side-vehicle/scripts/server_side_tank.gd
@@ -0,0 +1,147 @@
+extends VehicleBody3D
+
+## Script example for server side coded tank.
+
+@export_category("movement")
+@export var engine_power := 450.0
+@export var brake_force := 45.0
+@export var max_steering_angle := 75.0
+@export var steering_lerp_factor := 0.05
+@export_category("turret_settings")
+@export var turret : Node3D
+@export var traverse_speed := 0.0004
+@export var tilt_speed := 0.0001
+@export var tilt_lower_limit := -30.0
+@export var tilt_upper_limit := 30.0
+@export var shell_spawn_point : Marker3D = null
+@export var shell_scene : PackedScene = null
+@export var fire_cooldown_tick : int = 180
+@export_category("camera")
+@export var regular_camera : Camera3D = null
+@export var focus_camera : Camera3D = null
+@export_category("ui")
+@export var info_panel_scene : PackedScene = null
+
+@onready var input_sender : InputSender = $InputSender as InputSender
+@onready var tank_input : Node = $TankInput as Node
+
+var score := 0
+
+@onready var _turret_default_transform : Transform3D = self.turret.transform
+var _turret_traverse := 0.0 # yaw
+var _turret_tilt := 0.0 # pitch
+var _last_fire_tick := 0
+
+
+# Called when the node enters the scene tree for the first time.
+func _ready():
+ _last_fire_tick = NetworkTime.tick
+ if tank_input.get_multiplayer_authority() == multiplayer.get_unique_id():
+ if info_panel_scene:
+ var panel := info_panel_scene.instantiate()
+ add_child(panel)
+ print("Setting camera true on %s" %[name])
+ regular_camera.current = true
+
+func _unhandled_input(event):
+ # Dont process local inputs on other players tanks
+ if not tank_input.is_multiplayer_authority():
+ return
+
+ if event.is_action_pressed("focus"):
+ if focus_camera.current:
+ focus_camera.current = false
+ regular_camera.current = true
+ else:
+ regular_camera.current = false
+ focus_camera.current = true
+
+# Moves vehicle on hosts.
+func _handle_movement(movement : Vector2) -> void:
+ if not is_multiplayer_authority():
+ return
+
+ if movement.y != 0.0:
+ if movement.y < 0:
+ engine_force = engine_power
+ else:
+ engine_force = -engine_power
+
+ else:
+ # No move input
+ engine_force = 0
+
+ # Brake
+ if tank_input.brake:
+ brake = brake_force
+ else:
+ brake = 0.0
+
+ # Steering
+ steering = lerp(steering, deg_to_rad(max_steering_angle) * -movement.x, steering_lerp_factor)
+
+# Moves the turret on the host.
+func _move_turret(mouse_input : Vector2) -> void:
+ # Return if not host.
+ if not is_multiplayer_authority():
+ return
+
+ _turret_traverse -= mouse_input.x * traverse_speed
+ _turret_tilt += mouse_input.y * tilt_speed
+
+ turret.basis = _turret_default_transform.basis
+ turret.basis = turret.basis.rotated(Vector3.UP, _turret_traverse)
+
+ _turret_tilt = clamp(_turret_tilt, deg_to_rad(tilt_lower_limit), deg_to_rad(tilt_upper_limit))
+ turret.basis = turret.basis.rotated(turret.basis.x, _turret_tilt)
+
+# Fires only on the host..
+func _fire(tick : int) -> void:
+ if tick - _last_fire_tick < fire_cooldown_tick:
+ return
+
+ print("Firing!")
+ _last_fire_tick = tick
+ var shell := shell_scene.instantiate() as Node3D
+ get_tree().root.add_child(shell)
+ shell.global_transform = shell_spawn_point.global_transform
+ shell.firing_tank = self
+
+# Called by InputSender when an input has to be predicted, see InputSender.
+# Firing only lasts a single tick, and mouse movement is a per tick delta, so
+# repeating them would fire and turn twice.
+func _predict_input(last: Dictionary) -> Dictionary:
+ var predicted := last.duplicate()
+ predicted["TankInput:fire"] = false
+ predicted["TankInput:mouse_movement"] = Vector2.ZERO
+ return predicted
+
+# Called by InputSender when two inputs have to be merged into one, see
+# InputSender. Keep a shot from either input, and add up mouse movement.
+func _merge_input(older: Dictionary, newer: Dictionary) -> Dictionary:
+ var merged := newer.duplicate()
+ merged["TankInput:fire"] = older["TankInput:fire"] or newer["TankInput:fire"]
+ merged["TankInput:mouse_movement"] = older["TankInput:mouse_movement"] + newer["TankInput:mouse_movement"]
+ return merged
+
+# Called only on server.
+func die() -> void:
+ if not is_multiplayer_authority():
+ return
+
+ var player_spawner = get_parent()
+ global_position = player_spawner.get_random_spawn_point()
+ _turret_tilt = 0
+ _turret_traverse = 0
+
+# Called on the host after every tick, for every tank, with the inputs of the
+# tick applied. The tank is a physics vehicle synchronized with a
+# StateSynchronizer, so the host moves it and clients see the result.
+func _on_input_sender_input_applied(tick: int, is_predicted: bool) -> void:
+ _handle_movement(tank_input.movement)
+ _move_turret(tank_input.mouse_movement)
+
+ # Don't fire on a guessed input. _predict_input already clears fire, this
+ # is just to be explicit.
+ if tank_input.fire and not is_predicted:
+ _fire(tick)
diff --git a/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd b/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd
new file mode 100644
index 000000000..13142058b
--- /dev/null
+++ b/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd
@@ -0,0 +1,26 @@
+extends Control
+
+# Server side vehicle info panel
+@onready var peer_label : Label = $MarginContainer/VBoxContainer/PeerLabel as Label
+@onready var reload_progress_bar = $MarginContainer2/VBoxContainer/ReloadProgressBar
+@onready var reload_label = $MarginContainer2/VBoxContainer/ReloadLabel
+@onready var score_label = $MarginContainer2/VBoxContainer/ScoreLabel
+
+# Called when the node enters the scene tree for the first time.
+func _ready():
+ peer_label.text = "Server Side Vehicle Example \n Peer#" + str(multiplayer.get_unique_id())
+
+func _process(_delta):
+ var tank = get_parent()
+ if not tank:
+ return
+
+ var percentage = (NetworkTime.tick - tank._last_fire_tick) as float / tank.fire_cooldown_tick as float
+ reload_progress_bar.value = percentage * 100.0
+
+ if reload_progress_bar.value > 99:
+ reload_label.text = "Loaded"
+ else:
+ reload_label.text = "Loading"
+
+ score_label.text = "Score: " + str(tank.score)
diff --git a/examples/server-side-vehicle/scripts/tank_input.gd b/examples/server-side-vehicle/scripts/tank_input.gd
new file mode 100644
index 000000000..8cfc85439
--- /dev/null
+++ b/examples/server-side-vehicle/scripts/tank_input.gd
@@ -0,0 +1,72 @@
+extends Node
+
+## ServerSideTank input script
+
+var movement := Vector2.ZERO
+var brake := false
+var mouse_movement := Vector2.ZERO
+var fire := false
+
+var _mouse_movement_buffer := Vector2.ZERO
+var _fire_buffer := false
+
+func _ready():
+ NetworkTime.before_tick_loop.connect(_gather)
+ # Fire is gathered for every tick, not only once per tick loop, so a
+ # press is only sent once even if a loop runs multiple ticks. It's
+ # gathered before the tick, so InputSenderServer records it on the same
+ # tick, after the tick.
+ NetworkTime.before_tick.connect(func(_dt, _t): _gather_always())
+
+func _process(_delta) -> void:
+ if not is_multiplayer_authority():
+ return
+
+ if Input.is_action_just_pressed("weapon_fire"):
+ _fire_buffer = true
+
+func _notification(what):
+ if not is_multiplayer_authority():
+ return
+
+ if what == NOTIFICATION_WM_WINDOW_FOCUS_IN:
+ Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
+
+func _gather():
+ if not is_multiplayer_authority():
+ return
+
+ # Get the input direction and handle the movement/deceleration.
+ # As good practice, you should replace UI actions with custom gameplay actions.
+ var mx = Input.get_axis("move_west", "move_east")
+ var mz = Input.get_axis("move_north", "move_south")
+ movement = Vector2(mx, mz)
+ brake = Input.is_action_pressed("move_jump")
+
+ mouse_movement = _mouse_movement_buffer if _mouse_movement_buffer else Vector2.ZERO
+ _mouse_movement_buffer = Vector2.ZERO
+
+func _gather_always():
+ if not is_multiplayer_authority():
+ return
+
+ fire = _fire_buffer
+ _fire_buffer = false
+
+func _input(event: InputEvent) -> void:
+ if not is_multiplayer_authority():
+ return
+
+ if event.is_action_pressed("escape"):
+ if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
+ Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
+ else:
+ Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
+
+func _unhandled_input(event: InputEvent) -> void:
+ if not is_multiplayer_authority():
+ return
+
+ if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and event is InputEventMouseMotion:
+ _mouse_movement_buffer.x += event.relative.x
+ _mouse_movement_buffer.y += event.relative.y
diff --git a/examples/server-side-vehicle/scripts/tank_shell.gd b/examples/server-side-vehicle/scripts/tank_shell.gd
new file mode 100644
index 000000000..e9c0c704d
--- /dev/null
+++ b/examples/server-side-vehicle/scripts/tank_shell.gd
@@ -0,0 +1,25 @@
+extends Node3D
+
+# Tank Shell script
+
+# Only can kill tanks on server.
+
+@export var speed := 50.0
+
+# Set this from firing tank
+var firing_tank : Node = null
+
+# Called every frame. 'delta' is the elapsed time since the previous frame.
+func _process(delta):
+ global_position += global_transform.basis.z * speed * delta
+
+func _on_area_3d_body_entered(body):
+ if multiplayer.is_server():
+ if body.has_method("die"):
+ body.die()
+ if firing_tank:
+ print("%s killed another tank +1 score!" %firing_tank.name)
+ firing_tank.score += 1
+
+
+ queue_free()
diff --git a/examples/simulated-player/scenes/simulated_player.tscn b/examples/simulated-player/scenes/simulated_player.tscn
new file mode 100644
index 000000000..431aeeb8e
--- /dev/null
+++ b/examples/simulated-player/scenes/simulated_player.tscn
@@ -0,0 +1,35 @@
+[gd_scene load_steps=7 format=3 uid="uid://co2srxfbf8dac"]
+
+[ext_resource type="Script" path="res://examples/simulated-player/scripts/simulated_player.gd" id="1_4n6wb"]
+[ext_resource type="Script" path="res://examples/simulated-player/scripts/simulated_player_input.gd" id="2_8xdxw"]
+[ext_resource type="Script" path="res://addons/netfox/input-sender.gd" id="3_1fmuj"]
+[ext_resource type="Script" path="res://addons/netfox/simulator.gd" id="4_mmxoi"]
+
+[sub_resource type="CapsuleMesh" id="CapsuleMesh_icq3d"]
+
+[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_05200"]
+
+[node name="SimulatedPlayer" type="CharacterBody3D"]
+collision_layer = 3
+collision_mask = 3
+script = ExtResource("1_4n6wb")
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
+mesh = SubResource("CapsuleMesh_icq3d")
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
+shape = SubResource("CapsuleShape3D_05200")
+
+[node name="Input" type="Node" parent="."]
+script = ExtResource("2_8xdxw")
+
+[node name="InputSender" type="Node" parent="." node_paths=PackedStringArray("root")]
+script = ExtResource("3_1fmuj")
+root = NodePath("..")
+input_properties = Array[String](["Input:movement", "Input:jump"])
+
+[node name="Simulator" type="Node" parent="." node_paths=PackedStringArray("root", "listened_input_sender")]
+script = ExtResource("4_mmxoi")
+root = NodePath("..")
+listened_input_sender = NodePath("../InputSender")
+state_properties = Array[String]([":global_transform", ":velocity"])
diff --git a/examples/simulated-player/scripts/simulated_player.gd b/examples/simulated-player/scripts/simulated_player.gd
new file mode 100644
index 000000000..8f0070957
--- /dev/null
+++ b/examples/simulated-player/scripts/simulated_player.gd
@@ -0,0 +1,38 @@
+extends CharacterBody3D
+
+const SPEED = 150
+const JUMP_VELOCITY = 6.0
+
+# Get the gravity from the project settings to be synced with RigidBody nodes.
+var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
+
+@onready var input = $Input
+
+func _simulated_tick(delta : float, _tick : int, _is_fresh : bool):
+ # Add the gravity.
+ if not is_on_floor():
+ velocity.y -= gravity * delta
+
+ # Handle Jump.
+ if input.jump and is_on_floor():
+ velocity.y = JUMP_VELOCITY
+
+ # Get the input direction and handle the movement/deceleration.
+ # As good practice, you should replace UI actions with custom gameplay actions.
+ var input_dir = Vector2(input.movement.x, input.movement.z)
+ var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
+ if direction:
+ velocity.x = direction.x * SPEED * delta
+ velocity.z = direction.z * SPEED * delta
+ else:
+ velocity.x = move_toward(velocity.x, 0, SPEED * delta)
+ velocity.z = move_toward(velocity.z, 0, SPEED * delta)
+
+# print("input_jump is :%s" %input.jump)
+# print("input_movement is :%s" %input.movement)
+# print("velocity is :%s" %velocity)
+# print("position is before move and slide: %s" %position)
+ velocity *= NetworkTime.physics_factor
+ move_and_slide()
+ velocity /= NetworkTime.physics_factor
+# print("position is after move and slide: %s" %position)
diff --git a/examples/simulated-player/scripts/simulated_player_input.gd b/examples/simulated-player/scripts/simulated_player_input.gd
new file mode 100644
index 000000000..ac41bb3c4
--- /dev/null
+++ b/examples/simulated-player/scripts/simulated_player_input.gd
@@ -0,0 +1,22 @@
+extends Node
+
+# Input script for simulated player example.
+
+var movement: Vector3 = Vector3.ZERO
+var jump: bool = false
+
+func _ready():
+ NetworkTime.before_tick_loop.connect(_gather)
+
+func _gather():
+ if not is_multiplayer_authority():
+ return
+
+ # Get the input direction and handle the movement/deceleration.
+ # As good practice, you should replace UI actions with custom gameplay actions.
+ var mx = Input.get_axis("ui_left", "ui_right")
+ var mz = Input.get_axis("ui_up", "ui_down")
+ movement = Vector3(mx, 0, mz)
+
+ jump = Input.is_action_pressed("move_jump")
+# print("inputs: movement %s, jump %s" %[movement, jump])
diff --git a/examples/simulated-player/simulated_player_example.tscn b/examples/simulated-player/simulated_player_example.tscn
new file mode 100644
index 000000000..d06bf5bc8
--- /dev/null
+++ b/examples/simulated-player/simulated_player_example.tscn
@@ -0,0 +1,24 @@
+[gd_scene load_steps=7 format=3 uid="uid://1m61lxyrckhr"]
+
+[ext_resource type="PackedScene" uid="uid://cngy6hs8ohodj" path="res://examples/shared/scenes/map-square.tscn" id="1_t21fn"]
+[ext_resource type="PackedScene" uid="uid://cncdbq72u50j3" path="res://examples/shared/scenes/environment.tscn" id="2_epr86"]
+[ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="3_q107i"]
+[ext_resource type="PackedScene" uid="uid://bpf1jdr255nr0" path="res://examples/shared/ui/time-display.tscn" id="4_qcrnb"]
+[ext_resource type="Script" path="res://examples/shared/scripts/player-spawner.gd" id="5_3uvn6"]
+[ext_resource type="PackedScene" uid="uid://co2srxfbf8dac" path="res://examples/simulated-player/scenes/simulated_player.tscn" id="6_15sfs"]
+
+[node name="SimulatedPlayerExample" type="Node3D"]
+
+[node name="Square Map" parent="." instance=ExtResource("1_t21fn")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -3.80249, 0)
+
+[node name="Environment" parent="." instance=ExtResource("2_epr86")]
+
+[node name="Network Popup" parent="." instance=ExtResource("3_q107i")]
+
+[node name="Time Display" parent="." instance=ExtResource("4_qcrnb")]
+
+[node name="Players" type="Node" parent="." node_paths=PackedStringArray("spawn_root")]
+script = ExtResource("5_3uvn6")
+player_scene = ExtResource("6_15sfs")
+spawn_root = NodePath(".")
diff --git a/mkdocs.yml b/mkdocs.yml
index 133737960..347101da3 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -62,6 +62,7 @@ nav:
- 'netfox/guides/network-performance.md'
- 'netfox/guides/network-command-server.md'
- 'netfox/guides/network-schemas.md'
+ - 'netfox/guides/simulation.md'
- 'netfox/guides/visibility-management.md'
- 'netfox/guides/netfox-sharp.md'
- Nodes:
@@ -70,6 +71,8 @@ nav:
- 'netfox/nodes/predictive-synchronizer.md'
- 'netfox/nodes/state-synchronizer.md'
- 'netfox/nodes/rewindable-action.md'
+ - 'netfox/nodes/simulator.md'
+ - 'netfox/nodes/input-sender.md'
- netfox.noray:
- Guides:
- 'netfox.noray/guides/noray.md'
diff --git a/project.godot b/project.godot
index 38dc8ae37..c14a71052 100644
--- a/project.godot
+++ b/project.godot
@@ -136,6 +136,13 @@ escape={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"echo":false,"script":null)
]
}
+focus={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"echo":false,"script":null)
+, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
+, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null)
+]
+}
[netfox]