"A code-built world can run TWICE on a network join and stack a second world on the first"
▸ SYMPTOM
Your world is built in code (procedural placement of props, climb fields, ropes, etc.). On a network join you notice the joining client has roughly twice as much of some things as the host. Concretely, in one verified session: host reported rows=74 climbs=8 ropes=4 throwables=10; the joining client reported rows=96 climbs=16 ropes=8 throwables=20 — exactly 2× the climb fields, ropes, and throwables, while boxes=14 and kit=38 stayed single.
A world-hash guard that compares peers then flags a mismatch, and depending on your collision setup the doubled geometry can be visible or subtly wrong.
▸ CAUSE
A network-joining client can run the scene's code-built world twice: once when the local StartupScene loads, and again on the networked-session scene handoff. If the world author is not idempotent — it clears its static tracking lists at the top of each build but never destroys the GameObjects a prior build created — the second build stacks a whole second world on top of the first.
The tell for which mechanism doubled a given count:
- Counts sourced from a live scene scan (
Scene.GetAllComponents<T>()) double, because there really are two sets of objects in the scene. - Counts sourced from a per-build static list (cleared at the top of each build) stay single, because the list only reflects the most recent build.
That split is the proof it was a second build, not network replication: a non-networked component (one with no [Sync]/NetworkSpawn, which physically cannot replicate) still doubled. Replication cannot explain a doubled non-replicated component; a second OnStart-driven build can.
▸ FIX
Two independent fixes, do both:
-
Make the code-built world author idempotent. Before rebuilding, destroy the prior world root (or skip the redundant build entirely on a joining client). A second
OnStarton join is real — design for it:snippetprotected override void OnStart() { WorldRoot?.Destroy(); // tear down any prior build WorldRoot = new GameObject("World"); BuildWorld(WorldRoot); } -
Compute any determinism / MP-sync world hash from the build RECIPE, not a live scan. Hash the static placement lists (the inputs to the build), not
Scene.GetAllComponents. A recipe hash cannot be perturbed by a second build or a replicated proxy, so a join-time world-hash guard compares each peer's own deterministic build apples-to-apples.
▸ WHY IT WORKS
The doubling comes from state that accumulates in the scene surviving across two builds. Destroying the prior root removes that accumulation, so a second build produces one world, not two. And hashing the recipe rather than the live scene decouples the integrity check from scene population entirely — the number of GameObjects currently alive (one build, two builds, or a set of replicated proxies) no longer changes the hash, so the guard measures the thing you actually care about: whether two peers agree on what to build.
- Published