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..e95b2576a
--- /dev/null
+++ b/addons/netfox/input-sender.gd
@@ -0,0 +1,249 @@
+@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 provides signals to code host and client side logic.
+## [InputSender] signals are tied and emitted on [signal NetworkTime.on_tick].
+##
+## @experimental:
+## InputSenderServer assumes input snapshots arrive as whole. (atomic), if snapshot
+## arrives with multiple parts, [InputSender] signals wont be reliable to
+## code game logic.
+
+## Emitted if host received input from remote owner of input_properties.
+## Emitted for inputs that arrive within missing-input-history range.
+## (see project settings netfox/input-sender/missing-input-history)
+## InputSenderServer handles applying received input internally before emitting this signal.
+## Use this signal to code host side logic.
+signal network_input(tick : int)
+
+## Emitted if local peer has authority over input_property nodes.
+## This signal is emitted for host players too.
+## InputSenderServer will apply latest local inputs for this tick internally before
+## emitting this signal.
+## Use this signal to code client side logic which doesnt interfere with actual game state.
+## Examples: Playing a sound, showing a visual effect.
+## Dont use this signal to code same game logic on client side as it will not likely
+## be same with remote host machine, it will cause syncing issues if you are already
+## using some other method to syncronize game state (Syncronizers/Simulators).
+signal local_input(tick : int)
+
+## Emitted if input is missing.
+## Input is considered missing if host did not receive input for a tick more than
+## missing-input-history. (See project settings netfox/input-sender).
+## Host will try to find and apply previous known inputs before emitting this signal.
+## If host cant find any previous known input, latest_known_input_tick will be -1.
+## Also see late_input signal. An input will be considered missing at first,
+## but later it might arrive late. In that sceneario late_input signal will be emitted too.
+## Use this signal to code host side prediction logic.
+signal missing_input(for_tick : int, latest_known_input_tick : int)
+
+## Emitted when input arrived so late that it past the missing history size.
+## (see project settings netfox/input-sender/missing-input-history)
+## InputSenderServer will apply received input state internally before emitting this signal.
+## If a late input also past the history limit of input-sender, it will be dropped.
+signal late_input(for_tick : int)
+
+## 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
+
+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] = []
+
+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)
+
+ if not NetworkTime.is_initial_sync_done():
+ # Wait for time sync to complete
+ await NetworkTime.after_sync
+
+ process_settings.call_deferred()
+
+func _exit_tree():
+ if Engine.is_editor_hint():
+ return
+
+ 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)
+
+ # 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()
+
+## 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
+
+## Get latest input data available for this [InputSender].
+## Used by [Simulator] node internally.
+func get_latest_received_information_tick(current_tick : int) -> int:
+ return NetworkHistoryServer.get_latest_input_sender_for(
+ _input_properties.get_subjects(),
+ current_tick)
+
+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()
+
+# Helper function to apply given snapshot for only this node.
+# TODO Applying whole snapshot and iterating over ticks would be nicer
+# if we decide to have singleton for this
+func _apply_snapshot_for_self(snapshot : _Snapshot) -> void:
+ _logger.trace("Applying snapshot for self :%s", [snapshot])
+ for subject in _input_properties.get_subjects():
+ for property in _input_properties.get_properties_of(subject):
+
+ if snapshot.has_property(subject, property):
+ var value := snapshot.get_property(subject, property)
+ # TODO is this should be node.set_indexed ??
+ subject.set_indexed(property, value)
+
+# 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..a3c2eafc5 100644
--- a/addons/netfox/netfox.gd
+++ b/addons/netfox/netfox.gd
@@ -169,7 +169,46 @@ var SETTINGS: Array[Dictionary] = [
"name": "netfox/events/enabled",
"value": true,
"type": TYPE_BOOL
- }
+ },
+ # Input Sender
+ {
+ "name": "netfox/input_sender/input_redundancy",
+ "value": 3,
+ "type" : TYPE_INT
+ },
+ {
+ "name": "netfox/input_sender/history_limit",
+ "value": 64,
+ "type" : TYPE_INT
+ },
+ {
+ "name": "netfox/input_sender/missing_input_history",
+ "value": 16,
+ "type" : TYPE_INT
+ },
+ {
+ "name": "netfox/input_sender/enable_input_broadcast",
+ "value": false,
+ "type" : TYPE_BOOL
+ },
+ # Simulator
+ {
+ "name": "netfox/simulator/full_state_interval",
+ "value": 24,
+ "type": TYPE_INT,
+ "hint": PROPERTY_HINT_RANGE,
+ "hint_string": "0,60,or_greater"
+ },
+ {
+ "name": "netfox/simulator/enable_diff_states",
+ "value": true,
+ "type": TYPE_BOOL
+ },
+ {
+ "name": "netfox/simulator/history_limit",
+ "value": 64,
+ "type" : TYPE_INT
+ },
]
const AUTOLOADS: Array[Dictionary] = [
@@ -220,7 +259,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 +301,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/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd
index df2c81196..956042fc2 100644
--- a/addons/netfox/rollback/rollback-synchronizer.gd
+++ b/addons/netfox/rollback/rollback-synchronizer.gd
@@ -86,9 +86,9 @@ func process_settings() -> void:
for node in _sim_nodes + _state_properties.get_subjects() + _input_properties.get_subjects():
RollbackSimulationServer.deregister_node(node)
_sim_nodes.clear()
-
process_authority()
+
# Register nodes for simulation and liveness
var managed_nodes := [root] + _collect_managed_nodes(root)
_logger.debug("Filtering managed nodes: %s", [managed_nodes])
diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd
new file mode 100644
index 000000000..beb1b11e3
--- /dev/null
+++ b/addons/netfox/servers/input-sender-server.gd
@@ -0,0 +1,287 @@
+extends Node
+class_name _InputSenderServer
+
+# @public class
+
+## Handles [InputSender] related operations.
+
+## InputSenderServer assumes input snapshots arrive as whole. (atomic), if snapshot
+## arrives with multiple parts, [InputSender] signals wont be reliable to
+## code game logic.
+
+static var _logger := NetfoxLogger._for_netfox("InputSenderServer")
+
+var _history_server : NetworkHistoryServer = null
+var _synchronization_server : NetworkSynchronizationServer = null
+var _simulator_server : SimulatorServer = null
+
+var _missing_inputs_history_size : int = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16)
+var _input_sender_history_size : int = ProjectSettings.get_setting("netfox/input_sender/history_limit", 64)
+
+# Ticks that we received new input snapshots.
+var _ticks_that_has_new_snapshot : Array[int] = []
+
+# Maps InputSender -> InputSenderHistory (inner class, look at the end of this script.)
+var _per_input_sender_history : Dictionary = {}
+
+func _ready():
+ # Ensure dependencies
+ if not _history_server: _history_server = NetworkHistoryServer
+ if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer
+ if not _simulator_server: _simulator_server = SimulatorServer
+
+ # Record inputs similiar to rollback so that users can use same methods
+ # on their input scripts.
+ # For good reasons, We do logic and run InputSender signals on NetworkTime.on_tick
+ # This means local players wont have input information for their first tick.
+ # Its not really like await processframe but this will only add slight delay that wont have
+ # Important effects on the game itself.
+ NetworkTime.after_tick.connect(func(_dt, tick):
+ _history_server._record_input_sender(tick)
+ _synchronization_server._synchronize_input_sender(tick)
+ )
+
+ _synchronization_server._on_input_sender.connect(_on_received_input_snapshot)
+
+ # About when we should process our inputs:
+ # It doesnt make sense to process them when we receive them, as it will break the
+ # work order of [Simulator]s or [TickInterpolator]s, or we might want this to work
+ # with physics. It would be unreliable.
+ # Therefore we need to process them in a fixed point.
+ # Processing them on every tick doesnt make sense, since host may run multiple
+ # ticks and we probably wont even have new inputs to process anyway.
+ # In conculusion it makes sense to process them after a tick loop run.
+ #
+ # But if we run logic on after_tick_loop, our effect on the game state is lost.
+ # Because most setups tends to use state-syncronizer with input-sender.
+ # state-syncronzier records on every tick and restores after tick loop.
+ #
+ # To make sure our effect isnt lost to this order, we run logic on tick.
+ NetworkTime.on_tick.connect(_on_tick)
+
+# Whenever we receive input snapshot, we investigate and fetch _earliest_input tick
+# This is later used to iterate over saved snapshots and emit signals.
+func _on_received_input_snapshot(snapshot : _Snapshot) -> void:
+ if snapshot.is_empty():
+ return
+
+ if not _ticks_that_has_new_snapshot.has(snapshot.tick):
+ _ticks_that_has_new_snapshot.push_back(snapshot.tick)
+
+# After a tick has been run
+# Handle new snapshots
+# Handle missing inputs.
+# Handle local inputs.
+#
+# Since we are modifying InputSenders before emitting their signals,
+# Their input properties will be messed up, this means users inputs wont be
+# recorded properly on NetworkTime.after_tick.
+# To prevent that, this function records their current state and restores at the
+# end of this function.
+func _on_tick(_delta : float, tick : int) -> void:
+ # First save input-sender states.
+ _save_input_sender_states()
+
+ _handle_new_snapshots(tick)
+
+ # We need to emit missing_input signal for old ticks.
+ var missing_tick := tick - _missing_inputs_history_size
+ if missing_tick >= 0:
+ _handle_missing_tick(missing_tick)
+
+ # DANGER
+ # We do tick -1 for current local inputs, this is explained on _ready comments.
+ _handle_local_inputs(tick - 1)
+
+ # Logic is done, restore input-sender states.
+ _restore_input_sender_states()
+
+ # Now remove older history data that we keep.
+ _trim_input_sender_histories(tick)
+
+# Handles new input-sender snapshots.
+# This function iterates over _ticks_that_has_new_snapshot.
+# If tick is within range of missing-input-history,
+# applies/emits network_input.
+# If its not within range,
+# it applies/emits late_input.
+# Clears _ticks_that_has_new_snapshot after done.
+func _handle_new_snapshots(current_tick : int) -> void:
+ if _ticks_that_has_new_snapshot.is_empty():
+ return
+
+ # So ticks are processed in ascending order.
+ _ticks_that_has_new_snapshot.sort()
+
+
+ for i in _ticks_that_has_new_snapshot:
+
+ # This line saves processing because we fetch snapshot once per tick.
+ var snapshot := _history_server._get_input_sender_snapshot(i)
+ if not snapshot:
+ # This situation shouldnt happen anyway.
+ continue
+
+ # Iterate over input senders and check if there is new input in tick
+ for input_sender in _per_input_sender_history.keys():
+ if not _is_there_new_input_for_input_sender(input_sender, i, snapshot):
+ # No new input, we already processed this one.
+ continue
+
+ # Did not received before
+ # If tick i past the missing_history_size, consider it late.
+ input_sender._apply_snapshot_for_self(snapshot)
+ if i <= current_tick - _missing_inputs_history_size:
+ # Its a late input.
+ input_sender.late_input.emit(i)
+ else:
+ # Its within range, consider it as network input.
+ input_sender.network_input.emit(i)
+
+ # Notify SimulatorServer about this network_input.
+ # Why? Read Simulators and SimulatorServer to learn.
+ _simulator_server._notify_input_sender_received_network_input(input_sender, i)
+
+ # Set as handled anyway for both situation.
+ _set_input_received_for_input_sender(input_sender, i)
+
+ _ticks_that_has_new_snapshot.clear()
+
+# Iterates over snapshot for given tick
+# Checks _per_input_sender_history for given tick.
+# If input_sender did not received anything for this tick
+# It assumes input is missing and emits input_missing.
+# Call this function after calling _handle_new_snapshots.
+# Param for tick must be the value of current_tick - missing_input_history_size.
+func _handle_missing_tick(for_tick : int) -> void:
+
+ for input_sender in _per_input_sender_history.keys():
+ var input_sender_history := _per_input_sender_history[input_sender] as InputSenderHistory
+
+ if not input_sender_history.did_receive_for_tick(for_tick):
+ # We didnt receive anything for this input sender for this tick.
+ # Try to find latest input and emit missing_input.
+
+ var latest_tick := _history_server.get_latest_input_sender_for(
+ input_sender._input_properties.get_subjects(), for_tick)
+
+ if latest_tick >= 0:
+ var snapshot := _history_server._get_input_sender_snapshot(latest_tick)
+ if snapshot:
+ input_sender._apply_snapshot_for_self(snapshot)
+ else:
+ # If no snapshot, we need to emit signal with -1
+ latest_tick = -1
+
+ # We may be able to find snapshot or not, emit anyway
+ input_sender.missing_input.emit(for_tick, latest_tick)
+
+# Iterates over input-senders and emits local input with latest recorded input available.
+# Only does so if input-sender is authoritative peer.
+
+# Duplicated comment from _ready:
+#
+# "For good reasons, We do logic and run InputSender signals on NetworkTime.on_tick
+# This means local players wont have input information for their first tick.
+# Its not really like await processframe but this will only add slight delay that wont have
+# important effects on the game itself."
+func _handle_local_inputs(for_tick : int) -> void:
+
+ # Fetch snapshot once before loop to prevent unneccessary fetches.
+ var snapshot := _history_server._get_input_sender_snapshot(for_tick)
+ if not snapshot:
+ # This shouldnt happen anyway.
+ return
+
+ for input_sender in _per_input_sender_history.keys():
+ if input_sender.has_authority_over_input_nodes():
+ # Its authoritative player.
+
+ # We know that snapshots are recorded for each tick on NetworkTime.after_tick.
+ # We can use it, no need to query get_latest_for here.
+ input_sender._apply_snapshot_for_self(snapshot)
+ input_sender.local_input.emit(for_tick)
+
+# Saves all input-sender input properties.
+# This is done before messing with properties.
+func _save_input_sender_states() -> void:
+ for input_sender in _per_input_sender_history.keys():
+ input_sender._save_properties()
+
+# Restores all input-sender input properties.
+# This is done after messing with properties.
+func _restore_input_sender_states() -> void:
+ for input_sender in _per_input_sender_history.keys():
+ input_sender._restore_properties()
+
+func _register_input_sender(input_sender : InputSender) -> void:
+ _per_input_sender_history[input_sender] = InputSenderHistory.new()
+
+func _deregister_input_sender(input_sender : InputSender) -> void:
+ _per_input_sender_history.erase(input_sender)
+
+# Helper that sets input received for param input sender on given tick.
+func _set_input_received_for_input_sender(input_sender : InputSender, tick : int) -> void:
+ var history : InputSenderHistory = _per_input_sender_history[input_sender] as InputSenderHistory
+ if history:
+ history.set_as_received_for_tick(tick)
+
+## Check if there is a new input available for given input sender on given tick within matching snapshot.
+func _is_there_new_input_for_input_sender(input_sender : InputSender, tick : int, snapshot : _Snapshot) -> bool:
+ var history : InputSenderHistory = _per_input_sender_history[input_sender] as InputSenderHistory
+ var received_before := history.did_receive_for_tick(tick)
+
+ if received_before:
+ return false
+
+ # We need to check if snapshot has this input senders properties
+ if snapshot:
+ for property_entry in input_sender._property_entries:
+ if snapshot.has_property(property_entry.node, property_entry.property):
+ # There is new information available indeed
+ return true
+
+ # No new information
+ return false
+
+# Erases old input sender history data that we dont need anymore.
+func _trim_input_sender_histories(current_tick : int) -> void:
+ # Since we cant merge older history than input-sender-history
+ # we can erase data older than that.
+
+ var erase_ticks_older_than_inclusive : int = current_tick - _input_sender_history_size
+
+ if erase_ticks_older_than_inclusive > 0:
+ for input_sender_history in _per_input_sender_history.values():
+ input_sender_history.erase_old_ticks(erase_ticks_older_than_inclusive)
+
+func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null):
+ _history_server = p_history_server
+ _synchronization_server = p_synchronization_server
+
+## Inner class to remember and compare
+## Did we receive any input before for given tick?
+## stored for specific InputSender.
+class InputSenderHistory extends RefCounted:
+
+ # Maps ticks to bool (we received input before = true, never received = false)
+ var _history : Dictionary = {}
+
+ ## Set param tick as received
+ func set_as_received_for_tick(tick : int) -> void:
+ _history[tick] = true
+
+ ## Check if we received input for given tick.
+ ## Returns false if we dont have information for that tick.
+ func did_receive_for_tick(tick : int) -> bool:
+ return _history.get(tick, false)
+
+ ## Erase old ticks that we dont need anymore.
+ func erase_old_ticks(older_than_inclusive : int) -> void:
+ var to_erase : Array[int] = []
+ for tick in _history.keys():
+ if tick <= older_than_inclusive:
+ to_erase.push_back(tick)
+
+ for erase_tick in to_erase:
+ _history.erase(erase_tick)
diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd
index 6d511f022..f8aae33e6 100644
--- a/addons/netfox/servers/network-history-server.gd
+++ b/addons/netfox/servers/network-history-server.gd
@@ -5,19 +5,25 @@ 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
+var _input_sender_history_size := ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) as int
+var _simulator_history_size := ProjectSettings.get_setting("netfox/simulator/history_limit", 64) as int
var _ignored_subjects := _Set.new()
@@ -25,11 +31,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 +67,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 +90,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)
@@ -111,6 +143,16 @@ 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)
+
## 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:
@@ -161,6 +203,17 @@ 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()
+ )
+
func _restore_rollback_input(tick: int) -> bool:
return _restore_latest(tick, _rb_input_history)
@@ -170,6 +223,12 @@ func _restore_rollback_state(tick: int) -> bool:
func _restore_synchronizer_state(tick: int) -> bool:
return _restore_latest(tick, _sync_history)
+func _restore_input_sender(tick: int) -> bool:
+ return _restore_latest(tick, _input_sender_history)
+
+func _restore_simulator(tick: int) -> bool:
+ return _restore_latest(tick, _simulator_history)
+
func _get_rollback_input_snapshot(tick: int) -> _Snapshot:
return _rb_input_snapshots.get_at(tick)
@@ -179,6 +238,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)
@@ -191,6 +256,14 @@ 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)
+
+func _merge_simulator(snapshot: _Snapshot) -> bool:
+ _merge_snapshot(snapshot, _simulator_snapshots, true)
+ return _merge_history(snapshot, _simulator_history, true)
+
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):
diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd
index 430b2ac95..dc2c65d47 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 _last_sync_state_sent := _Snapshot.new(0)
var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) as bool
@@ -48,6 +51,14 @@ 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 _last_simulator_state_sent := _Snapshot.new(0)
+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
@@ -58,6 +69,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
@@ -65,6 +81,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
@@ -105,6 +123,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:
@@ -136,6 +180,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)
@@ -175,7 +223,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:
@@ -286,6 +334,93 @@ 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 host.
+ for node in _input_sender_owned_properties.get_subjects():
+ notified_peers.add(1)
+ 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
+
+ var snapshot := NetworkHistoryServer._get_simulator_snapshot(tick)
+ if not snapshot:
+ 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
+
+ if is_full:
+ # Send full states
+ for peer in multiplayer.get_peers():
+ var filter := func(subject): return _is_node_visible_to(peer, subject)
+
+ var data := _dense_serializer.write_for(peer, snapshot, _simulator_owned_properties, filter)
+ if data.is_empty():
+ # Peer can't see anything, send nothing
+ continue
+
+ _cmd_full_simulator.send(data, peer)
+
+ NetworkPerformance.push_full_state_props(snapshot.size())
+ NetworkPerformance.push_sent_state_props(snapshot.size())
+ else:
+ var diff := _Snapshot.make_patch(_last_simulator_state_sent, snapshot)
+
+ # Send diffs
+ for peer in multiplayer.get_peers():
+ var filter := func(subject): return _is_node_visible_to(peer, subject)
+
+ var data := _sparse_serializer.write_for(peer, diff, _simulator_owned_properties, filter)
+ if data.is_empty():
+ # Peer can't see anything, send nothing
+ continue
+
+ _cmd_diff_simulator.send(data, peer)
+
+ NetworkPerformance.push_full_state_props(snapshot.size())
+ NetworkPerformance.push_sent_state_props(diff.size())
+
+ # Remember last sent state for diffing
+ # NOTE: This is a shared instance, theoretically shouldn't screw things up
+ _last_simulator_state_sent = snapshot
+
func _init(
p_command_server: _NetworkCommandServer = null,
p_history_server: _NetworkHistoryServer = null,
@@ -317,7 +452,13 @@ func _ready():
_cmd_full_state = _command_server.register_command(_handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
_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)
+
+ # TODO which one makes sense ordered or not ?
+ _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)
@@ -371,6 +512,41 @@ 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:
+ 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)
+ snapshot.sanitize(sender)
+
+ _logger.trace("Ingested simulator full state: %s", [snapshot])
+ if NetworkHistoryServer._merge_simulator(snapshot):
+ _on_simulator.emit(snapshot)
+
+func _handle_diff_simulator(sender : int, data : PackedByteArray) -> void:
+ var buffer := StreamPeerBuffer.new()
+ buffer.data_array = data
+
+ var snapshot := _sparse_serializer.read_from(sender, _simulator_properties, buffer)
+ snapshot.sanitize(sender)
+
+ _logger.trace("Ingested simulator diff state: %s", [snapshot])
+ if NetworkHistoryServer._merge_simulator(snapshot):
+ _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..ba7433525
--- /dev/null
+++ b/addons/netfox/servers/simulator-server.gd
@@ -0,0 +1,256 @@
+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:
+## SimulatorServer has to know details about each simulators input-senders.
+## Inputs might not always arrive at correct order.
+## Inputs might not always arrive.
+## Since iterating over every snapshot and check for them is already done by
+## InputSenderServer, we will expose a function for InputSenderServer to notify
+## us with the input state of simulators.
+## SimulatorServer will not consider late or missing inputs to derive simulation.
+
+var _history_server : _NetworkHistoryServer = null
+var _synchronization_server : _NetworkSynchronizationServer = null
+
+var _simulation_history_size : int = ProjectSettings.get_setting("netfox/simulator/history_limit", 64)
+
+# Simulators that has input authority.
+# These simulators would be your typical local players.
+# If this is host, these simulators also owns state.
+# But that doesnt need an extra category.
+# For this category, we accept the truth from latest known state
+# and apply local inputs until we reach current tick.
+var _authoritative_simulators : Array[Simulator] = []
+
+# Simulators that has no input authority.
+# These simulators belong to remote players.
+#
+# On host:
+# These simulators also owns state.
+# We run simulation one tick per received network_input.
+#
+# On other players:
+# These simulators are not doing any simulation at all.
+# These simulators are accepting latest truth and applying that state.
+var _puppet_simulators : Array[Simulator] = []
+
+# Maps InputSender to Simulator.
+# We keep this to easily reach in reverse fashion when we need it.
+var _input_sender_to_simulator : Dictionary = {}
+
+# Maps simulator to simulator-input-history (inner class at the end of the file)
+# SimulatorInputHistories are buffered on _notify_input_sender_received_network_input,
+# SimulatorInputHistories are consumed on _after_tick_loop.
+var _simulator_to_simulator_input_history : Dictionary = {}
+
+func _ready():
+ # Ensure dependencies
+ if not _history_server: _history_server = NetworkHistoryServer
+ if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer
+
+ # Just like rollback, record and synchronize after tick.
+ # TODO if we find out that physics dont work like this, find better timing.
+ NetworkTime.after_tick.connect(func(_dt, tick):
+ _history_server._record_simulator(tick)
+ _synchronization_server._synchronize_simulator(tick)
+ )
+
+ # We do our simulating logic after tick loop, similiar to rollback in general.
+ # TODO if we find out that physics dont work like this, find better timing.
+ NetworkTime.after_tick_loop.connect(_after_tick_loop)
+
+# Do simulating logic depending on authorities.
+func _after_tick_loop() -> void:
+
+ _handle_authoritatives()
+ _handle_puppets()
+
+ # Since we consumed, we can now clear this.
+ _simulator_to_simulator_input_history.clear()
+
+func _handle_authoritatives() -> void:
+ var current_tick := NetworkTime.tick
+
+ if is_multiplayer_authority():
+ # This is host.
+ # On host we need to advance authoritative player with our local inputs.
+ # In another words - this is a host playing locally.
+ for simulator in _authoritative_simulators:
+
+ # DANGER we are doing current_tick -1 because input-senders are recorded
+ # on after-tick.
+ var input_snapshot := _history_server._get_input_sender_snapshot(current_tick -1)
+
+ if not input_snapshot:
+ # no input snapshot for some reason, this shouldnt really happen.
+ continue
+
+ var input_sender := simulator.listened_input_sender
+ # Save current properties so that we dont mess with input recording.
+ input_sender._save_properties()
+
+ input_sender._apply_snapshot_for_self(input_snapshot)
+ # TODO do we use ticktime for delta value???
+ simulator._run_simulation(NetworkTime.ticktime, current_tick)
+
+ # Restore input_sender properties after messing with inputs
+ input_sender._restore_properties()
+ else:
+ # This is local authoritative player.
+ # On local players we need to first accept latest received truth from host.
+ # Then simulate our local inputs to reach current state.
+
+ for simulator in _authoritative_simulators:
+
+ var latest_state_tick := _history_server.get_latest_simulator_for(
+ simulator._state_properties.get_subjects(), current_tick)
+
+ if latest_state_tick >= 0:
+ var latest_state_snapshot := _history_server._get_simulator_snapshot(latest_state_tick)
+
+ if latest_state_snapshot:
+ simulator._apply_snapshot_for_self(latest_state_snapshot)
+
+ # Whether we found latest snapshot or not, we need to iterate over our local inputs now.
+
+ var simulation_start_tick := latest_state_tick
+
+ # Dont let it past the history size.
+ if simulation_start_tick < current_tick - _simulation_history_size:
+ simulation_start_tick = current_tick - _simulation_history_size
+
+ # Dont let it become negative.
+ if simulation_start_tick < 0:
+ simulation_start_tick = 0
+
+ # DANGER since we only have inputs available up to current_tick -1
+ # We will stop at current_tick - 1 INCLUSIVE
+ # Why -1 ? Read InputSenderServer.
+
+ # Record input_sender state before messing with inputs.
+ simulator.listened_input_sender._save_properties()
+
+ for i in range(simulation_start_tick, current_tick):
+ var input_snapshot := _history_server._get_input_sender_snapshot(i)
+
+ # We should have input snapshot available for ourselves anyway...
+ if input_snapshot:
+ simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot)
+ # TODO do we use ticktime for delta value???
+ simulator._run_simulation(NetworkTime.ticktime, i)
+
+ # Restore input_sender state after messing with inputs.
+ simulator.listened_input_sender._restore_properties()
+
+func _handle_puppets() -> void:
+ var current_tick := NetworkTime.tick
+
+ if is_multiplayer_authority():
+ # This is host,
+ # On host we derive the simulation forward with new network_inputs.
+
+ for simulator in _puppet_simulators:
+
+ # Check if we have new input for this simulator
+ var simulator_input_history := _simulator_to_simulator_input_history.get(simulator) as SimulatorInputHistory
+
+ if not simulator_input_history:
+ continue
+
+ var ticks_with_new_inputs : Array[int] = simulator_input_history.get_ticks_with_new_inputs()
+
+ if ticks_with_new_inputs.is_empty():
+ continue
+
+ # We dont need to record/restore input_sender state as we dont own input.
+
+ for new_input_tick in ticks_with_new_inputs:
+ var input_snapshot := _history_server._get_input_sender_snapshot(new_input_tick)
+
+ if not input_snapshot:
+ # This shouldnt happen anyway.
+ continue
+
+ simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot)
+ # TODO do we use ticktime for delta value???
+ simulator._run_simulation(NetworkTime.ticktime, new_input_tick)
+ else:
+ # This is not host
+ # We only accept the true state which is broadcasted to us.
+
+ for simulator in _puppet_simulators:
+
+ var latest_state_snapshot_tick := _history_server.get_latest_simulator_for(
+ simulator._state_properties.get_subjects(), current_tick)
+
+ if latest_state_snapshot_tick >= 0:
+ var state_snapshot := _history_server._get_simulator_snapshot(latest_state_snapshot_tick)
+
+ if state_snapshot:
+ simulator._apply_snapshot_for_self(state_snapshot)
+
+## InputSenderServer will call this function to notify that param input_sender
+## received a new network_input on param on_tick. This network_input must also be
+## within range of missing_input_history size.
+## (See projectSettings netfox/input-sender/missing-input-history)
+## (Also read InputSenderServer)
+func _notify_input_sender_received_network_input(input_sender : InputSender, on_tick : int) -> void:
+ var matching_simulator := _input_sender_to_simulator.get(input_sender) as Simulator
+
+ if not matching_simulator:
+ return
+
+ var simulator_input_history := _simulator_to_simulator_input_history.get(matching_simulator) as SimulatorInputHistory
+
+ if not simulator_input_history:
+ simulator_input_history = SimulatorInputHistory.new()
+ _simulator_to_simulator_input_history[matching_simulator] = simulator_input_history
+
+ simulator_input_history.push_back_new_input_tick(on_tick)
+
+# Register a simulator node.
+# Will check for authority over inputs and categorize by it.
+func _register_simulator(simulator : Simulator) -> void:
+ if simulator.has_authority_over_inputs():
+ _authoritative_simulators.push_back(simulator)
+ else:
+ _puppet_simulators.push_back(simulator)
+
+ _input_sender_to_simulator[simulator.listened_input_sender] = simulator
+
+# Deregister a simulator node.
+func _deregister_simulator(simulator : Simulator) -> void:
+ _puppet_simulators.erase(simulator)
+ _authoritative_simulators.erase(simulator)
+
+ _input_sender_to_simulator.erase(_input_sender_to_simulator.find_key(simulator))
+
+func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null):
+ _history_server = p_history_server
+ _synchronization_server = p_synchronization_server
+
+## Inner class to keep Simulator related input ticks organized.
+## Whenever InputSenderServer notifies us about input-sender-recived-network-input.
+## If we have simulator for that given input-sender
+## We will store that tick with the help of this class.
+## Later on we will consume these stored ticks to run simulation.
+class SimulatorInputHistory extends RefCounted:
+
+ var _ticks_with_new_inputs : Array[int] = []
+
+ func push_back_new_input_tick(new_input_tick : int) -> void:
+ if not _ticks_with_new_inputs.has(new_input_tick):
+ _ticks_with_new_inputs.push_back(new_input_tick)
+
+ func get_ticks_with_new_inputs() -> Array[int]:
+ return _ticks_with_new_inputs
diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd
new file mode 100644
index 000000000..1e5015fe5
--- /dev/null
+++ b/addons/netfox/simulator.gd
@@ -0,0 +1,255 @@
+@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]
+
+## 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()
+
+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
+
+ if not NetworkTime.is_initial_sync_done():
+ # Wait for time sync to complete
+ await NetworkTime.after_sync
+
+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)
+
+ if not NetworkTime.is_initial_sync_done():
+ # Wait for time sync to complete
+ await NetworkTime.after_sync
+
+ process_settings.call_deferred()
+
+func _exit_tree() -> void:
+ _managed_roots.erase(root)
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_EDITOR_PRE_SAVE:
+ update_configuration_warnings()
+ elif what == NOTIFICATION_PREDELETE:
+ for node in _sim_nodes + _state_properties.get_subjects():
+ NetworkSynchronizationServer.deregister(node)
+ NetworkIdentityServer.deregister_node(node)
+ NetworkHistoryServer.deregister(node)
+
+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()
+
+## 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 apply given snapshot for only this node.
+# TODO (same todo with input_sender)?
+# Applying whole snapshot and iterating over ticks would be nicer
+# if we decide to have singleton for this
+func _apply_snapshot_for_self(snapshot : _Snapshot) -> void:
+ _logger.trace("Applying snapshot for self :%s", [snapshot])
+ for subject in _state_properties.get_subjects():
+ for property in _state_properties.get_properties_of(subject):
+
+ if snapshot.has_property(subject, property):
+ var value := snapshot.get_property(subject, property)
+ # TODO is this should be node.set_indexed ??
+ subject.set_indexed(property, value)
+
+# Helper function to run simulation with given parameters.
+# This function is used by SimulatorServer internally.
+func _run_simulation(delta : float, tick : int) -> void:
+ for node in _sim_nodes:
+ if node:
+ node.call("_simulated_tick", delta, tick)
+
+# 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/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..f804fa18d
--- /dev/null
+++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn
@@ -0,0 +1,313 @@
+[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="local_input" from="InputSender" to="." method="_on_input_sender_local_input"]
+[connection signal="missing_input" from="InputSender" to="." method="_on_input_sender_missing_input"]
+[connection signal="network_input" from="InputSender" to="." method="_on_input_sender_network_input"]
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..23d247964
--- /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..577fef997
--- /dev/null
+++ b/examples/server-side-vehicle/scripts/server_side_tank.gd
@@ -0,0 +1,138 @@
+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 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
+
+func _on_input_sender_local_input(tick):
+ print("Input sender local input is emitted on peer:%s" %multiplayer.get_unique_id())
+ _handle_movement(tank_input.movement)
+ _move_turret(tank_input.mouse_movement)
+
+ if tank_input.fire:
+ _fire(tick)
+
+
+func _on_input_sender_missing_input(_current_tick, _latest_known_input_tick):
+ print("Input is missing on :%s" %name)
+
+
+func _on_input_sender_network_input(tick):
+ _handle_movement(tank_input.movement)
+ _move_turret(tank_input.mouse_movement)
+
+ if tank_input.fire:
+ _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..8c9475ef5
--- /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..600705666
--- /dev/null
+++ b/examples/server-side-vehicle/scripts/tank_input.gd
@@ -0,0 +1,68 @@
+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)
+ NetworkTime.after_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..5a3f5dcc9
--- /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..aa7711a05
--- /dev/null
+++ b/examples/simulated-player/scripts/simulated_player.gd
@@ -0,0 +1,39 @@
+extends CharacterBody3D
+
+const SPEED = 30
+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):
+ print("Running simulated tick.")
+ # 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
+ velocity.z = direction.z * SPEED
+ else:
+ velocity.x = move_toward(velocity.x, 0, SPEED)
+ velocity.z = move_toward(velocity.z, 0, SPEED)
+
+ 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..11f97bb0f
--- /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..0ac165d7c
--- /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, -1.65592, 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/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]