Design + spike for high-level replication rebuild

Rebuild the multiplayer layer on stock MultiplayerSpawner/Synchronizer so
adding an object to the game needs no networking code.

MultiplayerSpawner only replicates node creation and deletion, so "sync
everything regardless of what it is" has to come from a SceneReplicationConfig
built by convention in code. NetReplication does that, giving every node two
generated synchronizers: NetSync for script state (always server-owned) and
NetXform for position (handed to whoever is holding the object).

test/spike/ establishes the four engine behaviours the design rests on. Two
are worth flagging: per-peer visibility CANNOT be used to stop the server
fighting a client's held object, because MultiplayerSpawner despawns and
respawns the node on every visibility flip; and set_visibility_for is only an
override on top of public_visibility, so calling it alone does nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
algodoogle
2026-07-28 21:42:30 +01:00
parent 59c62b73e6
commit 0ebd0a4a85
9 changed files with 513 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
extends Node3D
## Throwaway spike. Answers the questions about Godot's high-level replication
## that the rebuild depends on, before any of the real code is rewritten.
##
## Q1 Does default (auto) MultiplayerSpawner replication carry the spawn
## properties of synchronizers attached AT RUNTIME rather than baked into
## the .tscn? [CONFIRMED]
## Q2 Do ON_CHANGE properties reach a peer that joins AFTER the value was
## set, and do later changes propagate? [CONFIRMED]
## Q3 Can set_visibility_for(peer, false) suppress a peer's updates without
## despawning the node on it? [REFUTED — the
## node is despawned and respawned on every visibility flip, so this
## cannot be used to stop the server fighting a client's held object.]
## Q4 Does handing ONE of a node's two synchronizers (NetXform) to a client
## let that client drive the transform locally — ignoring the server's
## inbound updates — while the other (NetSync) keeps pushing server-owned
## state to it? This is the grab-prediction mechanism.
##
## Both peers run on one ABSOLUTE clock (see _at) rather than chains of relative
## timers: the first version of this spike drifted enough that the client's
## reports straddled the server's actions and read as inconclusive.
const PORT := 24599
const ITEM := "res://test/spike/spike_item.tscn"
var _log: FileAccess
var _is_server := false
var _start_ms: int = 0
func _ready() -> void:
var args := OS.get_cmdline_user_args()
_is_server = args.has("--server")
_start_ms = Time.get_ticks_msec()
_open_log()
# Registered identically on both peers BEFORE connecting: auto-spawn sends an
# index into this array, so a mismatch builds the wrong scene on the client.
$Spawner.add_spawnable_scene(ITEM)
# The hook under test: every peer attaches the generated synchronizers as the
# node enters the tree. On the server that is our own add_child; on the
# client it is MultiplayerSpawner's.
$Content.child_entered_tree.connect(_on_content_child)
if _is_server:
_run_server()
else:
_run_client()
func _on_content_child(node: Node) -> void:
if node is MultiplayerSynchronizer:
return
NetReplication.attach(node)
_say("attached synchronizers to %s" % node.name)
## Waits until `t` seconds after this scene started. Both peers start within a
## few hundred ms of each other, which is close enough to interleave their
## actions and observations deterministically.
func _at(t: float) -> void:
var target := _start_ms + int(t * 1000.0)
while Time.get_ticks_msec() < target:
await get_tree().process_frame
## set_multiplayer_authority is a local call, so it has to run on every peer for
## them to agree on who is driving the transform.
@rpc("authority", "call_local", "reliable")
func _set_xform_authority(path: NodePath, peer: int) -> void:
var node := get_node_or_null(path)
if not node:
return
node.get_node(NetReplication.XFORM_NAME).set_multiplayer_authority(peer)
_say("NetXform authority of %s -> peer %d" % [node.name, peer])
func _item() -> Node3D:
return $Content.get_node_or_null("SpikeItem") as Node3D
# --- server ----------------------------------------------------------------
func _run_server() -> void:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(PORT, 4)
if err != OK:
_say("FATAL: create_server failed: %s" % error_string(err))
_finish()
return
multiplayer.multiplayer_peer = peer
_say("server up on %d" % PORT)
# t=1: spawn BEFORE anyone joins, so the client receives it purely as a
# late-joiner replay — the same path as joining a running game.
await _at(1.0)
var item: Node3D = load(ITEM).instantiate()
item.name = "SpikeItem"
item.position = Vector3(5, 6, 7)
item.health = 42
item.label = "hello"
$Content.add_child(item)
_say("spawned SpikeItem at %s health=%d label=%s" % [item.position, item.health, item.label])
await _at(7.0)
if multiplayer.get_peers().is_empty():
_say("FATAL: no client connected by t=7")
_finish()
return
var client: int = multiplayer.get_peers()[0]
_say("client connected: %d" % client)
# t=8: change an ON_CHANGE value well after the client joined (Q2b).
await _at(8.0)
item.health = 7
item.label = "changed"
_say("changed health=7 label=changed")
# t=12: hand the transform to the client, as if it had just grabbed the item.
await _at(12.0)
_set_xform_authority.rpc(item.get_path(), client)
# t=13: the server now tries to move it and also changes server-owned state.
# The move must NOT reach the client (it owns the transform); the state must.
await _at(13.0)
item.position = Vector3(-9, -9, -9)
item.health = 123
_say("server set pos=%s health=123 while the CLIENT owns NetXform" % item.position)
await _at(15.5)
_say("Q4 server's view of item pos (expect the CLIENT's 1,1,1) -> %s" % item.position)
# t=17: take it back, as if the client had released it, and move it.
await _at(17.0)
_set_xform_authority.rpc(item.get_path(), 1)
await _at(17.5)
item.position = Vector3(-3, -3, -3)
_say("server reclaimed NetXform and set pos=%s" % item.position)
# Stay alive past the client's last report, so a shutdown-induced despawn
# can't be mistaken for anything else.
await _at(24.0)
_say("server done")
_finish()
# --- client ----------------------------------------------------------------
func _run_client() -> void:
# Deliberately late, so the spawn is replayed rather than observed live.
await _at(5.0)
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client("127.0.0.1", PORT)
if err != OK:
_say("FATAL: create_client failed: %s" % error_string(err))
_finish()
return
multiplayer.multiplayer_peer = peer
_say("client connecting...")
await _at(7.5)
_report("Q1/Q2a late-join spawn (expect pos=5,6,7 health=42 label=hello)")
await _at(10.0)
_report("Q2b after change (expect health=7 label=changed)")
# t=13.5: we now own NetXform. Drive the item ourselves, the way a held
# object's grab driver would, and see whether the server's competing write
# from t=13 gets ignored here and whether ours reaches the server.
await _at(13.5)
var item := _item()
if item:
item.position = Vector3(1, 1, 1)
_say("client set pos=(1,1,1) while owning NetXform")
await _at(15.0)
_report("Q4 client owns NetXform (expect pos=1,1,1 — NOT -9; health=123 — server state still arrives)")
await _at(20.0)
_report("Q4b server reclaimed (expect pos=-3,-3,-3)")
_say("client done")
_finish()
func _report(what: String) -> void:
var item := _item()
if not item:
_say("%s -> NO NODE (item absent on client)" % what)
return
_say("%s -> pos=%s health=%d label=%s private=%d" % [
what, item.position, item.health, item.label,
item._private_should_not_replicate,
])
# --- logging ---------------------------------------------------------------
func _open_log() -> void:
var who := "server" if _is_server else "client"
var dir := ProjectSettings.globalize_path("res://logs")
DirAccess.make_dir_recursive_absolute(dir)
_log = FileAccess.open(dir.path_join("spike_%s.log" % who), FileAccess.WRITE)
func _say(s: String) -> void:
var line := "[%s t=%5.1f] %s" % [
"SERVER" if _is_server else "CLIENT",
(Time.get_ticks_msec() - _start_ms) / 1000.0, s,
]
print(line)
if _log:
_log.store_line(line)
_log.flush()
func _finish() -> void:
if _log:
_log.flush()
get_tree().quit()