# 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 `Node3D` replicates `position` and `quaternion` (never `scale` — 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_child` under the content root. No `spawn_function`, no payload dictionary. - `despawn()` = `queue_free()` on the server. The `_despawn_static_item` RPC disappears: with nothing baked into the world scene, the spawner covers every case. - `NetReplication.attach` runs from the content root's `child_entered_tree` on **both** peers. That is early enough for the synchronizers to enter the tree inside the spawner's own `add_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 every `XRToolsSnapZone`, freeze `RigidBody3D`. This replaces `_gate_station` + `gate_existing_stations` and 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_CHANGE` makes this cheap, but any setter reached this way still has to be idempotent and null-guarded — a `spawn = true` property fires its setter *before* the node is in the tree, so `@onready` refs 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.