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>
Four bugs from real cross-network play:
- Player/net_player.gd: the per-peer spawn-spread offset (added to avoid
stacking joiners on top of each other) could push a joining client up
to 12 units from center, but the floor is only 15x15 (~7.5 unit
half-extent) — landing a client off the edge. Bounded to ~2.7 units.
- Player/net_player.gd: the glove scenes used for remote-hand visuals
carry hand.gd (XRToolsHand), whose root node sets top_level = true and
repositions itself to its parent's transform every physics frame,
expecting to be parented under a live XRController3D. Parented under
the plain avatar node instead, it fought both the local transform copy
and the replicated sync every tick — whichever wrote last that frame
won, which read as hands stuck near origin except momentarily. Disabled
physics processing on those nodes; nothing else in the script matters
without a real controller ancestor.
- Net/net_pickable.gd: grabbing an item sends an RPC to the server to
confirm authority, but the item's regular state sync travels on a
different channel with no ordering guarantee against that RPC. A
stale "still loose" sync packet could arrive after an optimistic local
grab but before the confirmation, and was being treated as a real
authority loss, force-dropping the item — explaining "first attempt
fails, second succeeds". Now a sync update can't yank an item out of
our own hand mid-grab; only an explicit rejection or an actual loss of
authority can.
- Net/net_pickable.gd: the non-authority path zeroes collision_mask and
forces freeze_mode to KINEMATIC, but nothing ever restored either when
a peer regained authority (e.g. the server after a client's release) —
so a released item stayed collision-less forever, looking physics-less.
Now both are restored from the item's own baked values whenever a peer
owns a loose (not actively held) item again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The per-peer spread offset used the raw ENet peer id directly
((peer_id - 1) * 1.5), assuming ids are small sequential numbers. Real
peer ids are large effectively-random 32-bit values, so a joining
client's XR rig was being shifted by hundreds of millions of units off
the origin — the world had actually replicated correctly, the player
was just teleported far away from all of it, with float precision bad
enough at that range to jitter the view and destabilize physics.
Bound the offset to a small deterministic slot instead; the host still
keeps its original baked spot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>