extends Node class_name NetReplication ## Builds MultiplayerSynchronizers for any node by convention, so no scene needs ## a hand-authored replication config. ## ## Every replicated node gets TWO synchronizers, both generated here: ## ## NetSync script state (is_dirty, contained_ids, time_cooked, ...). ## Always owned by the server. Gameplay outcomes are the server's to ## decide, so this never changes hands. ## NetXform position + quaternion. Owned by the server while the object is ## loose, and handed to a peer for as long as that peer holds it. ## ## Splitting them is what makes client-side grab prediction work. A ## MultiplayerSynchronizer never applies inbound state on the peer that owns it, ## so giving the holder NetXform means their own hand drives the object with no ## round trip and no fight with the server's copy — while is_dirty and friends ## keep flowing one way, server to client, exactly as before. ## ## The obvious alternative, per-peer visibility (set_visibility_for), does NOT ## work: MultiplayerSpawner also uses synchronizer visibility to decide whether ## a node should exist on a peer, so hiding an object from its holder despawns ## it in their hand. Verified in test/spike/. ## ## The convention for what gets replicated: ## * a Node3D replicates `position` and `quaternion` (never `scale` — nothing ## in this game animates scale as gameplay state) ## * every script variable, on the node and on any scripted descendant, whose ## name does not start with "_" and whose declared type is serialisable ## ## Underscore-prefixed vars are the opt-out, and they already mark exactly the ## state that must not be replicated: @onready node references, cached lookups, ## per-frame bookkeeping. ## SceneReplicationConfig's replication modes. The enum is not exposed under a ## friendly name in GDScript, so they are spelled out here. const MODE_ALWAYS := 1 const MODE_ON_CHANGE := 2 const SYNC_NAME := "NetSync" const XFORM_NAME := "NetXform" ## Types that survive a network round trip. Object/Callable/Signal/RID cannot be ## serialised, and are exactly what @onready and cached references hold. const SYNCABLE_TYPES := [ TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_STRING_NAME, TYPE_VECTOR2, TYPE_VECTOR3, TYPE_QUATERNION, TYPE_TRANSFORM3D, TYPE_COLOR, TYPE_PACKED_STRING_ARRAY, TYPE_ARRAY, ] ## Adds both synchronizers to `node`, unless they are already there. Idempotent. ## ## Must run on EVERY peer, not just the authority: the client's copy is built by ## MultiplayerSpawner straight from the .tscn, so it only has synchronizers if ## something puts them there. NetWorld calls this from the content root's ## child_entered_tree on both sides — early enough that they enter the tree ## inside the spawner's own add_child(), which is what lets them pick up the ## spawn payload (verified in test/spike/: a late joiner receives position and ## script state from a synchronizer that exists only at runtime). static func attach(node: Node) -> void: if node is Node3D and not node.has_node(XFORM_NAME): _add_sync(node, XFORM_NAME, _transform_config()) if not node.has_node(SYNC_NAME): _add_sync(node, SYNC_NAME, state_config(node)) static func _add_sync(node: Node, sync_name: String, config: SceneReplicationConfig) -> void: var sync := MultiplayerSynchronizer.new() sync.name = sync_name sync.replication_config = config # Sync every network tick. ON_CHANGE properties are only sent when they # actually change regardless of this interval. sync.replication_interval = 0.0 # Inherit the node's authority rather than defaulting to the server. Items are # server-owned and this changes nothing for them, but a player avatar sets its # authority to the peer it belongs to in _enter_tree — which has already run by # the time we get here — and its synchronizer has to agree, or the owning peer # would be receiving its own head and hands back from the server. sync.set_multiplayer_authority(node.get_multiplayer_authority()) node.add_child(sync) ## Transform-only config. ALWAYS rather than ON_CHANGE: a carried or simulated ## object changes every tick anyway, so ON_CHANGE would only add a comparison ## per property per tick. spawn = true so a replicated object arrives already in ## the right place instead of sitting at the origin for a frame. static func _transform_config() -> SceneReplicationConfig: var config := SceneReplicationConfig.new() _add(config, ".:position", true, MODE_ALWAYS) _add(config, ".:quaternion", true, MODE_ALWAYS) return config ## Script-state config for `node` and its scripted descendants. Public so the ## tests can inspect what the convention picked up without spinning up a session. ## ## ON_CHANGE, not ALWAYS: the value is then only sent — and, crucially, only ## ASSIGNED on the receiving peer — when it actually changes. Under ALWAYS every ## replicated setter becomes a per-tick hot path, which is how this project ## previously ended up rebuilding every plate's visuals 60 times a second. static func state_config(node: Node) -> SceneReplicationConfig: var config := SceneReplicationConfig.new() for path in _script_var_paths(node, node): _add(config, path, true, MODE_ON_CHANGE) return config static func _add(config: SceneReplicationConfig, path: String, spawn: bool, mode: int) -> void: var np := NodePath(path) config.add_property(np) config.property_set_spawn(np, spawn) config.property_set_replication_mode(np, mode) ## Whether a script comes from a third-party addon rather than this game. ## ## Addon components are local plumbing, and replicating their configuration is ## not merely wasteful — it is actively wrong. godot-xr-tools' snap zones and ## pickables both expose a public `enabled`, which is precisely the flag each ## peer has to set for ITSELF: a client disables its stations' snap zones because ## placement is the server's decision, and disables a pickable another player is ## holding. Replicating those meant the server helpfully sent `enabled = true` ## straight back over every client's gate, so stations on clients went on ## grabbing objects out of the local player's hands. ## ## Their values are authored in the scene file and therefore already identical on ## every peer, so nothing is lost by leaving them alone. static func _is_addon(script: Script) -> bool: return script.resource_path.begins_with("res://addons/") ## Whether a typed array holds something that can cross the wire. ## ## An array of nodes cannot, and this game has several: ItemContainer's ## meal_positions/side_positions are Array[Node3D], and contained_items is ## Array[FoodItem]. Replicating one would try to serialise live node references. ## ## GDScript reports the element type three different ways, so all three are ## handled here (verified against the real scripts): ## Array[String] hint 23, hint_string "4:" -> element type 4 ## Array[Node3D] hint 23, hint_string "24/34:Node3D" -> element type 24 ## Array[FoodItem] hint 31, hint_string "FoodItem" -> a class name ## An untyped Array reports an empty hint_string and is excluded too: it can hold ## anything, including nodes, so there is no safe answer. static func _is_syncable_array(prop: Dictionary) -> bool: var hint_string := str(prop["hint_string"]) if hint_string.is_empty(): return false var head := hint_string.split(":")[0].split("/")[0] if not head.is_valid_int(): # A bare class name, e.g. "FoodItem". return false return SYNCABLE_TYPES.has(head.to_int()) and head.to_int() != TYPE_ARRAY ## Every ":" on `node` and its scripted descendants. ## Descends through children but stops at anything carrying its own synchronizer ## — that subtree replicates itself and must not be replicated twice. static func _script_var_paths(root: Node, node: Node) -> Array[String]: var paths: Array[String] = [] var prefix: String = "." if node == root else str(root.get_path_to(node)) var script: Script = node.get_script() as Script # Note the descent below still happens for an addon-scripted node — only its # own properties are skipped. plate.tscn's root is godot-xr-tools' pickable.gd # and its PlateController child is where the game state actually lives. if script and _is_addon(script): script = null if script: for prop in script.get_script_property_list(): var prop_name := str(prop["name"]) if prop_name.begins_with("_"): continue if not (prop["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE): continue if not SYNCABLE_TYPES.has(prop["type"]): continue if prop["type"] == TYPE_ARRAY and not _is_syncable_array(prop): continue paths.append("%s:%s" % [prefix, prop_name]) for child in node.get_children(): if child is MultiplayerSynchronizer or child is MultiplayerSpawner: continue if child.has_node(SYNC_NAME) or child.has_node(XFORM_NAME): continue paths.append_array(_script_var_paths(root, child)) return paths