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
-14
View File
@@ -15,17 +15,6 @@ size = Vector3(0.6, 0.12802735, 0.6)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_gkb3v"]
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_hob"]
properties/0/path = NodePath(".:time_cooked")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:cooking_result_time")
properties/1/spawn = true
properties/1/replication_mode = 1
properties/2/path = NodePath(".:cooking_result")
properties/2/spawn = true
properties/2/replication_mode = 1
[sub_resource type="Animation" id="Animation_7uuqv"]
length = 0.001
tracks/0/type = "value"
@@ -262,9 +251,6 @@ size = Vector3(0.55, 0.835, 0.072)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.003479004, -0.45215607, 0.24655426)
size = Vector3(0.55, 0.096, 0.14)
[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=768992259]
replication_config = SubResource("SceneReplicationConfig_np_hob")
[node name="ProgressBar3D" parent="." unique_id=654673176 instance=ExtResource("3_7uuqv")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.74736404, -0.09216304)
+1
View File
@@ -1,3 +1,4 @@
[gd_scene format=3 uid="uid://cwwnbx5uat3fw"]
[node name="CSGBox3D" type="CSGBox3D" unique_id=725518829]
-11
View File
@@ -15,14 +15,6 @@ stereo = true
[sub_resource type="BoxShape3D" id="BoxShape3D_ai6d4"]
size = Vector3(0.5, 0.128, 0.5)
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_sink"]
properties/0/path = NodePath(".:time_washed")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:is_washing")
properties/1/spawn = true
properties/1/replication_mode = 1
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_0gbf8"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_bvwq1"]
@@ -234,9 +226,6 @@ transform = Transform3D(1, 0, 0, 0, 0.9367828, -0.34991136, 0, 0.34991136, 0.936
operation = 2
size = Vector3(1, 1.2053223, 0.352417)
[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1601242574]
replication_config = SubResource("SceneReplicationConfig_np_sink")
[node name="ProgressBar3D" parent="." unique_id=654673176 instance=ExtResource("4_1pinr")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8164611, -0.035980098)
+59 -44
View File
@@ -29,16 +29,21 @@ enum TableState {
EATING
}
## State is server-authoritative and synced (see table.tscn's Sync node);
## state transitions only ever run where NetworkManager.owns_world() is true
## (the whole station's _process is gated off elsewhere for non-owners, see
## NetworkManager._gate_station). The setters below just refresh the display,
## so both the server (via _setState) and clients (via incoming sync) show
## the same text. Use _setState(), never assign _state directly.
var _state: TableState = TableState.EMPTY: set = _set_state
var _state_time: float = 0.0: set = _set_state_time
var _state_duration: float = 0.0 # set in _set_state_time
var _unsatisfied_orders: Array[String] = []: set = _set_unsatisfied_orders
## State is server-authoritative and replicated. Transitions only ever run where
## NetworkManager.owns_world() is true — the whole station's _process is gated
## off for non-owners (NetWorld._gate) — and the setters below just refresh the
## display, so the server (via _setState) and clients (via incoming sync) show
## the same thing. Use _setState(), never assign `state` directly.
##
## These are deliberately not underscore-prefixed: that prefix is what marks a
## variable as private and unreplicated (see NetReplication), and this is exactly
## the state that has to reach every peer. state_duration is replicated for the
## same reason — the progress bar divides by it, and on a client that never ran a
## transition it would otherwise still be zero.
var state: TableState = TableState.EMPTY: set = _set_state
var state_time: float = 0.0: set = _set_state_time
var state_duration: float = 0.0 # set in _setState alongside state_time
var unsatisfied_orders: Array[String] = []: set = _set_unsatisfied_orders
func _ready() -> void:
@@ -74,7 +79,7 @@ func _ready() -> void:
func _on_object_picked_up(_item) -> void:
print("Table: object picked up: ", _item)
if _state == TableState.EATING:
if state == TableState.EATING:
return
_absorb_item_if_correct(_item)
@@ -98,14 +103,21 @@ func _absorb_item_if_correct(_item: Node) -> void:
# Absorm items from the plate we want
for food_item in plate.container.contained_items:
print("Table: held a plate with FoodItem: ", food_item.id)
if food_item.id in _unsatisfied_orders:
if food_item.id in unsatisfied_orders:
print("Table: held FoodItem is in unsatisfied orders, removing it")
_unsatisfied_orders.erase(food_item.id)
if _unsatisfied_orders.size() > 0 and _state != TableState.EATING:
# Reassign rather than mutate in place. This property has a setter that
# refreshes the label, and mutating an array never fires it — so the
# peer that actually served the food would be the one peer whose table
# still showed the order outstanding. duplicate() also preserves the
# Array[String] typing the property requires.
var remaining := unsatisfied_orders.duplicate()
remaining.erase(food_item.id)
unsatisfied_orders = remaining
if unsatisfied_orders.size() > 0 and state != TableState.EATING:
_setState(TableState.WAITING_FRIEND)
# Table has everything it wants. Start eating
elif _unsatisfied_orders.is_empty() and _state != TableState.EATING:
elif unsatisfied_orders.is_empty() and state != TableState.EATING:
GAME_MANAGER.money += food_item.sell_value
audio_player.stream = money_sound
audio_player.play()
@@ -114,7 +126,7 @@ func _absorb_item_if_correct(_item: Node) -> void:
func place_order() -> void:
print("Table: place_order()")
var new_orders = _unsatisfied_orders.duplicate()
var new_orders = unsatisfied_orders.duplicate()
new_orders.append(GAME_MANAGER.get_random_meal())
new_orders.append(GAME_MANAGER.get_random_meal())
_set_unsatisfied_orders(new_orders)
@@ -122,7 +134,9 @@ func place_order() -> void:
func satisfyAllOrders() -> void:
print("Table: satisfyAllOrders()")
_unsatisfied_orders.clear()
# Assigned, not cleared in place, for the same reason as in
# _absorb_item_if_correct: clear() would not fire the setter.
unsatisfied_orders = []
clearAllPlates()
_setState(TableState.EMPTY)
@@ -146,42 +160,42 @@ func _set_snap_zones_enabled(value: bool) -> void:
## Server-only state transition: sets the new state's timer and assigns
## _state (whose setter refreshes the display on every peer).
## state (whose setter refreshes the display on every peer).
func _setState(newState: TableState) -> void:
print("Table set _state: ", TableState.keys()[newState])
print("Table set state: ", TableState.keys()[newState])
match newState:
TableState.EMPTY:
_state_time = 5.0
_state_duration = 5.0
state_time = 5.0
state_duration = 5.0
TableState.THINKING:
_state_time = thinking_duration
_state_duration = thinking_duration # Can't be done in _set_state(), will set duration inside timer
state_time = thinking_duration
state_duration = thinking_duration # Can't be done in _set_state(), will set duration inside timer
TableState.WAITING_PRIMARY:
_state_time = primary_duration
_state_duration = primary_duration
state_time = primary_duration
state_duration = primary_duration
TableState.WAITING_FRIEND:
_state_time = friend_duration
_state_duration = friend_duration
state_time = friend_duration
state_duration = friend_duration
TableState.EATING:
_state_time = eating_duration
_state_duration = eating_duration
state_time = eating_duration
state_duration = eating_duration
_set_snap_zones_enabled(false)
_state = newState
state = newState
## Pure presentation, driven off the current (locally authoritative or
## synced-from-server) state. Runs on every peer.
func _refresh_display() -> void:
# _state, _state_time and _unsatisfied_orders are replicated with spawn=true,
# state, state_time and unsatisfied_orders are replicated with spawn=true,
# and MultiplayerSpawner applies a spawn payload BEFORE the node enters the
# tree — so these setters fire while the @onready children below are still
# null. Bail out until _ready() has resolved them; _ready() calls back in
# once it has, so nothing that arrived early is lost.
if not progress_bar or not label_3d or not label_3d_time:
return
match _state:
match state:
TableState.EMPTY:
progress_bar.set_bar_visible(false)
label_3d.text = "empty"
@@ -189,47 +203,48 @@ func _refresh_display() -> void:
progress_bar.set_bar_visible(false)
label_3d.text = lbl_thinking
TableState.WAITING_PRIMARY:
label_3d.text = "%s\n%s" % [TableState.keys()[_state], "\n".join(_unsatisfied_orders)]
label_3d.text = "%s\n%s" % [TableState.keys()[state], "\n".join(unsatisfied_orders)]
progress_bar.set_bar_visible(true)
progress_bar.override_fill_color(Color.RED if _state_time < 10 else Color.YELLOW)
progress_bar.override_fill_color(Color.RED if state_time < 10 else Color.YELLOW)
TableState.WAITING_FRIEND:
label_3d.text = "%s\n%s" % [TableState.keys()[_state], "\n".join(_unsatisfied_orders)]
label_3d.text = "%s\n%s" % [TableState.keys()[state], "\n".join(unsatisfied_orders)]
progress_bar.set_bar_visible(true)
progress_bar.override_fill_color(Color.RED if _state_time < 5 else Color.YELLOW)
progress_bar.override_fill_color(Color.RED if state_time < 5 else Color.YELLOW)
TableState.EATING:
label_3d.text = lbl_eating
progress_bar.set_bar_visible(false)
label_3d_time.text = "%.1f" % _state_time
progress_bar.set_progress(clampf(float(_state_time) / _state_duration, 0.0, 1.0))
label_3d_time.text = "%.1f" % state_time
var progress := state_time / state_duration if state_duration > 0.0 else 0.0
progress_bar.set_progress(clampf(progress, 0.0, 1.0))
func _set_state(value: TableState) -> void:
_state = value
state = value
_refresh_display()
func _set_state_time(value: float) -> void:
_state_time = value
state_time = value
_refresh_display()
func _set_unsatisfied_orders(value: Array[String]) -> void:
_unsatisfied_orders = value
unsatisfied_orders = value
_refresh_display()
func _process(delta: float) -> void:
# Wait for state to finish
if _state_time > 0.0:
_state_time = max(0.0, _state_time - delta)
if state_time > 0.0:
state_time = max(0.0, state_time - delta)
return
if GAME_MANAGER.game_state == GAME_MANAGER.GameState.GAME_OVER:
return
# When state timer is finished, do this stuff before moving to next state
match _state:
match state:
TableState.EMPTY:
print("Table State EMPTY finish")
_setState(TableState.THINKING)
-14
View File
@@ -15,17 +15,6 @@ radius = 0.6
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_24d3s"]
albedo_color = Color(0.31, 0.21576, 0.1333, 1)
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_table"]
properties/0/path = NodePath(".:_state")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:_state_time")
properties/1/spawn = true
properties/1/replication_mode = 1
properties/2/path = NodePath(".:_unsatisfied_orders")
properties/2/spawn = true
properties/2/replication_mode = 1
[node name="Table" type="StaticBody3D" unique_id=1863572470 groups=["station"]]
script = ExtResource("1_2vcpj")
money_sound = ExtResource("2_jslhm")
@@ -189,9 +178,6 @@ pixel_size = 0.003
billboard = 2
text = "20.1s"
[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=2000411505]
replication_config = SubResource("SceneReplicationConfig_np_table")
[node name="ProgressBar3D" parent="." unique_id=654673176 instance=ExtResource("3_kjf1i")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.3676775, 0)
y_billboard = true