Rebuild multiplayer on the stock spawner and synchronizer

Adding an object to the game no longer requires any networking code. The
MultiplayerSpawner runs in its default mode — no spawn_function, no payload
dictionary — and NetReplication builds each object's SceneReplicationConfig
from a convention, so scenes carry no hand-authored replication at all.

Every replicated node gets two generated synchronizers: NetSync for script
state, always server-owned, and NetXform for position, handed to whoever is
holding the object. That split is what makes grab prediction work — a
synchronizer never applies inbound state on the peer that owns it, so a
player's own hand drives an object with no round trip while is_dirty and
friends keep flowing one way from the server.

Interaction is now two RPCs for the whole game (NetGrab), and client gating is
one rule applied to every object (NetWorld). Deleted: net_pickable.gd, the
replicated net_held_by field and its held-state juggling, the
grant/reject/force-release negotiation, the static-item despawn RPC, and the
per-scene replication configs. Authority is the single source of truth for who
simulates an object.

Two things the convention had to learn, both found by the test suite:

  * Addon scripts are excluded. godot-xr-tools' snap zones and pickables expose
    a public `enabled`, which is exactly the flag each peer must set for itself
    — so replicating it meant the server sent `enabled = true` back over every
    client's gate, and stations went on grabbing objects out of the local
    player's hands.
  * Arrays of nodes are excluded. Array[Node3D] and Array[FoodItem] would
    otherwise try to serialise live node references.

table.gd's replicated state loses its underscore prefix, which now marks a
variable as private and unreplicated; two in-place array mutations there were
skipping their setters, and the progress bar could divide by zero on a client.

Suite: 146/146 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
algodoogle
2026-07-28 22:04:22 +01:00
parent 0ebd0a4a85
commit 61d92052ac
35 changed files with 877 additions and 1091 deletions
+21 -12
View File
@@ -544,10 +544,10 @@ func _do_grab(item: Node3D, label: String) -> Dictionary:
return {"ok": false, "detail": "%s is held by %s, not our hand. %s"
% [label, item.get_picked_up_by(), _diag(item)]}
_log(" holding %s; waiting for authority..." % label)
if not await _wait_until(func(): return item.get_multiplayer_authority() == multiplayer.get_unique_id(),
if not await _wait_until(func(): return _xform_authority(item) == multiplayer.get_unique_id(),
"authority handoff", 5.0):
return {"ok": false, "detail": "grabbed %s, but authority stayed with peer %d. %s"
% [label, item.get_multiplayer_authority(), _diag(item)]}
return {"ok": false, "detail": "grabbed %s, but NetXform authority stayed with peer %d. %s"
% [label, _xform_authority(item), _diag(item)]}
_log(" authority is ours (peer %d)" % multiplayer.get_unique_id())
return {"ok": true, "detail": "grabbed %s via %s; %s" % [label, closest.name, _diag(item)]}
@@ -572,12 +572,13 @@ func _do_drop(item: Node3D, label: String) -> Dictionary:
# that owns world logic. A client's own snap zone doing it means that peer is
# running station logic it has no authority for.
if by is XRToolsSnapZone and not NetworkManager.owns_world():
return {"ok": false, "detail": "%s on this client grabbed %s out of our hand; station snap zones must only run on the world owner"
% [by.get_parent().name, label]}
if not await _wait_until(func(): return not is_instance_valid(item) or item.get_multiplayer_authority() == 1,
return {"ok": false, "detail": "%s on this client grabbed %s out of our hand; station snap zones must only run on the world owner (zone enabled=%s snap_mode=%d processing=%s in_grab_area=%d)"
% [by.get_parent().name, label, by.enabled, by.snap_mode,
by.is_processing(), by._object_in_grab_area.size()]}
if not await _wait_until(func(): return not is_instance_valid(item) or _xform_authority(item) == 1,
"authority return to server", 5.0):
return {"ok": false, "detail": "dropped %s, but authority stayed with peer %d"
% [label, item.get_multiplayer_authority()]}
return {"ok": false, "detail": "dropped %s, but NetXform authority stayed with peer %d"
% [label, _xform_authority(item)]}
return {"ok": true, "detail": "dropped %s (now held by %s)" % [label, by]}
@@ -1197,14 +1198,22 @@ func _check_zone_empty(station_name: String) -> Dictionary:
# --- Diagnostics -----------------------------------------------------------
## Who is currently driving this object's transform. Under the high-level model
## the object's own authority never changes — only its NetXform's does, and that
## is what "who is holding this" means now.
func _xform_authority(item: Node) -> int:
if not is_instance_valid(item):
return 0
var xform := item.get_node_or_null(NetReplication.XFORM_NAME)
return xform.get_multiplayer_authority() if xform else 0
func _diag(item: Node3D) -> String:
if not is_instance_valid(item):
return "item[freed]"
var np := item.get_node_or_null("NetPickable")
var pc := item.get_node_or_null("PlateController")
var s := "%s[pos=%s authority=%d net_held_by=%s" % [
item.name, item.global_position, item.get_multiplayer_authority(),
np.net_held_by if np else "-",
var s := "%s[pos=%s xform_authority=%d" % [
item.name, item.global_position, _xform_authority(item),
]
if item is XRToolsPickable:
s += " enabled=%s can_pick_up=%s layer=%d held_by=%s" % [
-1
View File
@@ -63,7 +63,6 @@ environment = SubResource("Environment_bvwq1")
spawn_path = NodePath("../WorldContent")
[node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565]
_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn")
spawn_path = NodePath("../Players")
[node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=4404969]