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 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) ## 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 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 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