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>
6.9 KiB
High-level replication rebuild
Rebuild VRyHungry's multiplayer on Godot's stock MultiplayerSpawner and
MultiplayerSynchronizer, so that adding a new object to the game requires no
networking code at all. Replaces the bespoke spawn payloads, per-scene
replication configs, and grab-authority handshake in place today.
Baseline being replaced: logs/mptest_report.txt, 146/146 checks passing as of
2026-07-28 19:47. That is the bar the rebuild has to clear.
The constraint that shapes everything
MultiplayerSpawner replicates node creation, name, and deletion — nothing
more. All state comes from MultiplayerSynchronizer plus a
SceneReplicationConfig. There is no engine switch for "sync everything
regardless of what it is".
The way to get there anyway: a SceneReplicationConfig is a plain Resource,
so it can be built in code from a convention and attached at runtime. One
helper then covers every object in the game, and nothing has to be authored
per scene.
Verified engine behaviour
Established by test/spike/, run against Godot 4.7.stable. These are load
bearing — the design below is only correct because these hold.
| # | Question | Result |
|---|---|---|
| Q1 | Does default auto-spawn carry the spawn properties of a synchronizer attached at runtime, not baked into the .tscn? |
Yes. A peer joining 4s after the spawn received pos=(5,6,7) health=42 label=hello. |
| Q2 | Do ON_CHANGE properties reach a late joiner, and do later changes propagate? |
Yes, both. |
| Q3 | Can set_visibility_for(peer, false) suppress one peer's updates without despawning the node there? |
No. The node is despawned and respawned on every visibility flip. Unusable — it would make a held object vanish from the holder's hand. |
| Q4 | Can handing one of two synchronizers to a client let it drive the transform locally while server-owned state keeps arriving? | Yes. Client showed its own (1,1,1) and ignored the server's competing (-9,-9,-9); the server saw (1,1,1); server-owned health=123 still arrived. |
Two traps worth recording. set_visibility_for is only an override on top of
public_visibility, which defaults to true — calling it alone does nothing.
And the spike's first version chained relative timers, which drifted enough that
the client's observations straddled the server's actions; it now runs both peers
on one absolute clock.
Design
Layer 1 — Net/net_replication.gd
The convention-based config builder. NetReplication.attach(node) gives a node
two generated synchronizers:
NetSync— script state. Always server-owned; gameplay outcomes are the server's to decide.ON_CHANGE.NetXform—position+quaternion. Server-owned while the object is loose, handed to a peer for the duration of a hold.ALWAYS.
What gets replicated, by convention:
- a
Node3Dreplicatespositionandquaternion(neverscale— nothing in this game animates scale as gameplay state) - every script variable, on the node and 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 cross the wire: @onready references, cached lookups,
per-frame bookkeeping.
ON_CHANGE for state is a deliberate correctness choice, not just bandwidth.
Under ALWAYS the synchronizer assigns every property every tick on receiving
peers, so every replicated setter becomes a per-frame hot path — which is how
this project previously ended up rebuilding every plate's visuals 60 times a
second and rewriting four physics properties per item per tick.
Layer 2 — Net/net_world.gd
- Spawnable scenes registered by sorted directory scan of
Items/,Containers/,Stations/,Prefabs/. Auto-spawn transmits an index into that array, so the order must be identical on every peer or clients build the wrong scene. spawn()= instantiate → set transform/name →add_childunder the content root. Nospawn_function, no payload dictionary.despawn()=queue_free()on the server. The_despawn_static_itemRPC disappears: with nothing baked into the world scene, the spawner covers every case.NetReplication.attachruns from the content root'schild_entered_treeon both peers. That is early enough for the synchronizers to enter the tree inside the spawner's ownadd_child, which is what lets them pick up the spawn payload (Q1).- Client gating becomes one generic rule applied at the same hook: disable
_process, disable everyXRToolsSnapZone, freezeRigidBody3D. This replaces_gate_station+gate_existing_stationsand applies to anything, not just things that happen to be stations.
Layer 3 — Net/net_grab.gd and Net/net_stations.gd
Two RPCs for the entire game: request_grab(item) and
request_release(item, xform, lin, ang).
A client's hand picks the item up locally the instant the player grabs — no
round trip — and asks the server to confirm. The server grants by moving
NetXform authority to that peer (Q4), which is precisely what makes the local
copy stop applying inbound transform updates. On release, authority returns to
the server, which decides where the item actually ends up: server-authoritative
snapping into stations lives in net_stations.gd, generic over the station
group rather than per-station.
Deleted outright: Net/net_pickable.gd (209 lines), net_held_by and its
held-state juggling, _set_item_authority, the grant/reject/force-release
negotiation, and _gate_station.
Layer 4 — tests
test/mp_test_driver.gd (1458 lines) splits into orchestration, steps,
asserts, snapshot, and report modules. The scenario list and the sync audit
carry over unchanged — that audit compares what is actually rendered on both
peers, which is what caught the plate-visuals desync that targeted assertions
missed. Assertions written against old internals (net_held_by, "the client is
the item's authority after grabbing") are rewritten against the new model.
Risks
- Replacing a green suite. Mitigated by running the headless suite after each layer rather than only at the end.
- The harness changes alongside the code it checks. Mitigated by keeping scenario list and audit logic byte-identical wherever possible, so it remains an independent check rather than one shaped to fit the new code.
- Blanket state replication picks up more vars than the old hand-authored
configs.
ON_CHANGEmakes this cheap, but any setter reached this way still has to be idempotent and null-guarded — aspawn = trueproperty fires its setter before the node is in the tree, so@onreadyrefs are null there. - Client→client transform relies on
SceneMultiplayer.server_relay(on by default) to forward a holder's updates to the other clients. Fine for a listen-server, worth remembering if the topology ever changes.