by Pivec Labs
Tutorial Multiplayer VR

NetCrafter Relay — VR Tutorial

Build a host-relayed multiplayer VR experience — tracked head/hand sync, world-space UI, on top of OpenWorlds VR for Unity Third Person.

By Pivec Labs

Time: 120–150 minutes
Prerequisites: NetCrafter Relay installed; your own self-hosted coturn + rendezvous instance up and running (see the Server Deployment Guide); Allow downloads over HTTP set to Always allowed in Player Settings (Edit → Project Settings → Player → Other Settings → Configuration — a fresh project defaults to blocking this, which fails the very first connection attempt with InvalidOperationException: Insecure connection not allowed; see Getting Started); Unity's Input System package active (Edit → Project Settings → Player → Active Input Handling set to Input System Package or Both — this project ships set to Input System Package); TextMeshPro essentials imported (Window → TextMeshPro → Import TMP Essentials); OpenWorlds VR for Unity Third Person, version 1.3.2 or higher, imported from the Unity Asset Store; a compatible XR headset and OpenXR runtime for actually testing in a headset (a desktop fallback mode is covered in Part 1, but this tutorial's VR-specific behaviour is only meaningfully testable on real hardware). Recommended (not required): read the Chat Quick Start and Animated Multiplayer Character tutorials first — this tutorial reuses their Login/Lobby flow and sync-system concepts without re-explaining them from scratch.

What you'll build: A shared VR space where every connected player sees everyone else's head and hands tracked live — host-relayed, 2-4 players, world-space UI (there's no desktop screen to put a Screen Space Overlay canvas on when you're wearing a headset). OpenWorlds VR for Third Person supplies the XR rig, avatar IK, and hand tracking; NetCrafter Relay supplies the networking. This is the only NetCrafter Relay tutorial that depends on a second paid, same-studio asset — see Part 0 for exactly what that means for what ships where.

This tutorial is experimental. VR support for Relay is newer and far less battle-tested than the rest of this package (Chat, Co-op, Character, Racing) — it works, and the reference implementation here is a real, working demo, but it hasn't been through the same depth of real-world testing those have. If you're building a major/production VR project, use NetCrafter Pro for SFS3 instead — its VR support is mature and proven. Treat this tutorial as a working starting point for smaller projects or your own experimentation, not yet as a production-hardened path.

01

Part 0 — What's different about this tutorial#

Like every other NetCrafter Relay tutorial, this one is Login → Lobby (real join code, Host/Join) → Gameplay, built on RelayConnectionManager, NetCrafterEventDispatcher, Scene Events, and NetCrafterSyncBinding. Four things are genuinely new:

  1. World-space UI, not Screen Space Overlay. Chat/Co-op/Character/Racing all use an ordinary screen-space canvas, because every one of them is played on a monitor. A player wearing a headset has no monitor to overlay onto — the Login/Lobby/Gameplay canvas in this tutorial is a world-space Canvas, placed a short distance in front of wherever the player starts, sized and scaled for a comfortable reading distance in VR. See Part 7.
  2. OpenWorlds VR for Unity Third Person is a second paid Pivec Labs asset, not a bundled dependency. It is sold separately, is not free, and this package does not — and cannot — ship any of its content. This isn't a licensing technicality this tutorial works around; it's the same relationship NetCrafter Relay itself has to NetCrafter (its SFS3 sibling) — two independent products, each useful on its own, that happen to combine well. Concretely: the schema, the generated networking code, VRDemoController.cs, and the small helper scripts (VRColorManager, PlayerNameDisplay, RemoteAnimationEventReceiver, RemoteVRCharacterController) all ship and all compile with or without OpenWorlds VR imported. The parts that only make sense with it — the XR rig, the tracked player prefab, the remote-avatar prefab's IK wiring — are things you build in your own project, following Part 1 and Part 7 below. A scripting define, NETCRAFTER_VR_EXAMPLE, gates the handful of lines that touch real OpenWorlds VR types; without it defined, the demo still compiles and the Lobby/Login flow still works, it just has no VR player to configure (see Part 7.7 and Common Mistakes).
  3. Head/hand pose rides NetCrafter Relay's own sync rules directly, in world space — not a manually-packed relative-position message. Every tracked point (root, head, left hand, right hand) gets its own Transform_Position + Transform_Rotation sync rule, applied by NetCrafterSyncBinding exactly like Character's or Racing's transform sync — because NetCrafterSyncBinding already reads and writes Transform.position/Transform.rotation in world space, a small child GameObject sitting wherever the tracked point actually is just works, with no relative-math reconstruction needed on either end. That's 8 sync rules total (4 tracked points × position + rotation) — see Part 6.
  4. Locomotion state and desktop-mode are messages, not sync rules, because there's no integer sync target. NetCrafterSyncBinding supports Animator_Float and Animator_Bool, not Animator_Int, and the OpenWorlds VR animator drives locomotion off a set of mutually-exclusive direction bools (WalkForward, RunLeft, InAir, …) rather than a blend-tree float. Rather than stretch the sync system to fit, this tutorial packs those bools into one wire-friendly animState int (0–10) and sends it, along with grounded and isDesktopMode, as an edge-triggered RequestPlayerStatus/PlayerStatusUpdate message pair — only when something actually changes, not on a fixed per-frame cadence. See Part 3.4 and Part 4.2.

Scope, stated plainly: host-relay-only star topology (joiners never talk to each other directly, only through whichever client is hosting), 2-4 players, ~15 seconds of silence before a disconnect is detected. If the host disconnects, there is no migration — remaining joiners see a clear status message and that session is over, same as every other tutorial in this package.

02

Part 1 — Local VR setup (no networking yet)#

This part has nothing to do with NetCrafter Relay — it gets one local player walking around in VR, with OpenWorlds VR's own rig and IK working, entirely offline. This tutorial cannot give you OpenWorlds VR's own setup steps verbatim — that asset's own documentation is the source of truth for its XR rig, sample scene, and character-selection flow, and it may change between versions. What follows is what this tutorial's own networking code needs to find once you're done with that setup — treat it as a checklist to confirm against OpenWorlds VR's own instructions, not a replacement for them.

1.1 Import OpenWorlds VR for Unity Third Person

  1. Get OpenWorlds VR for Unity Third Person, version 1.3.2 or higher, from the Unity Asset Store. Import it via Package Manager → My Assets, same as any other Asset Store package.
  2. Importing it will pull in its own package dependencies through Unity's Package Manager (confirmed at the version this tutorial was written against: XR Interaction Toolkit, XR Plugin Management, OpenXR Plugin, Cinemachine, and the Input System package) — let Package Manager resolve these automatically rather than installing them separately first.
  3. Follow OpenWorlds VR's own documentation to get its sample/demo scene running with one local player: character selection, the XR rig, hand tracking. Confirm you can see your own avatar's head and hands move with your headset and controllers before moving on — that's the point at which OpenWorldsVRManager.Instance.player becomes non-null, which is where this tutorial's own code starts caring.
  4. Desktop fallback mode. This tutorial's networking code checks OpenWorldsVRManager.Instance.isHeadsetOffHead (and a direct XR head-node validity check) to decide whether a player is actually wearing a headset — this is the isDesktopMode flag in Part 3.4. You can exercise the Login/Lobby UI and the networking flow with the headset off, but you won't see tracked head/hand movement without one.

1.2 Confirm the three scene singletons

Once a local player is active, confirm HeadFollower, LHandFollower, and RHandFollower components exist somewhere in the scene (they're scene-level singletons, not children of the player object — OpenWorlds VR activates them once the player is assigned). Part 7 attaches NetCrafterSyncBinding components directly to these three objects — if you can't find them, something about Part 1.3's setup didn't complete, not a NetCrafter Relay problem.

1.3 Import NetCrafter Relay

If you haven't already, import the NetCrafter Relay package into this project (Assets → Import Package → Custom Package…, or however you obtained it from the Asset Store). Confirm Assets/PivecLabs/NetCrafterRelay/ exists in the Project window afterward.

Everything above this line is entirely OpenWorlds-VR-specific, not a NetCrafter Relay concern. If you already have a local VR player walking around with working head/hand tracking, there was nothing relay-specific to change here. The interesting part starts now.

03

Part 2 — Create the schema#

Tools → Pivec Labs → NetCrafter Relay. Click + Create Definition, enter VRDemo, click Create.

This tutorial's own shipped schema (Definitions/VRDemo/) was built exactly this way, through the real editor tabs — nothing in it is hand-typed text pretending to be generated output. You can use the shipped one directly and skip to Part 7, or delete it and rebuild it yourself by following Parts 2–6 to learn the system firsthand — both paths produce the same result.

04

Part 3 — Define the messages#

Open the Gameplay Messages tab. Create the following five.

3.1 Login — client tells the host its display name

FieldValue
Message NameLogin
DirectionClientToHost
TransportReliable
Host Handler NameHandleLogin

Add one field: displayName (type string).

3.2 PlayerJoined — host announces a player (new or already-known) to someone

FieldValue
Message NamePlayerJoined
DirectionHostToClient
TransportReliable
Host Handler Name(leave blank)

Add four fields: playerId (int), displayName (string), colorIndex (int), spawnIndex (int). Same pattern as Character/Racing — colorIndex/spawnIndex are computed host-side from senderId (Part 4.1), never trusted from the joining client.

3.3 PlayerLeft — host announces a player disconnected

FieldValue
Message NamePlayerLeft
DirectionHostToClient
TransportReliable
Host Handler Name(leave blank)

Add one field: playerId (int).

Only covers a joiner disconnecting — the host detects it (OnPeerDisconnected, ~15s of silence) and broadcasts this message to everyone else. If the host itself disconnects, each remaining joiner detects that independently and locally — see Part 7.7.

3.4 RequestPlayerStatus — a joiner's locomotion/grounded/desktop-mode state changed

FieldValue
Message NameRequestPlayerStatus
DirectionClientToHost
TransportReliable
Host Handler NameHandlePlayerStatus

Add three fields: animState (int), grounded (bool), isDesktopMode (bool).

animState is a single wire-friendly int (0–10) standing in for OpenWorlds VR's mutually-exclusive locomotion bools, since there's no Animator_Int sync target:

animStateMeaning
0Idle
1WalkForward
2WalkBackward
3WalkLeft
4WalkRight
5RunForward
6RunBackward
7RunLeft
8RunRight
9InAir
10JumpLand

3.5 PlayerStatusUpdate — host's broadcast of a player's locomotion/grounded/desktop-mode state

FieldValue
Message NamePlayerStatusUpdate
DirectionHostToClient
TransportReliable
Host Handler Name(leave blank)

Add four fields: playerId (int), animState (int), grounded (bool), isDesktopMode (bool).

There's no message here for head/hand/root position or rotation — those replicate through the sync system (Part 6), sent at most 20 times a second each (see the interval discussion there). The two messages above are edge-triggered — sent only when animState/grounded/isDesktopMode actually change, polled locally every 0.1s — not on a fixed per-frame cadence, which matters a lot more here than in Chat/Co-op since a VR session already has eight continuous sync rules running per remote player.

05

Part 4 — Define the Host Actions#

Open the Host Actions tab. Create two.

4.1 HandleLogin

FieldValue
Handler NameHandleLogin
Trigger MessageLogin
Response EventPlayerJoined
Logic TypeCustom

Custom Host Code (the same roster-replay pattern as every other tutorial's HandleLogin — see the Character or Racing tutorial for the full explanation of why the replay loop exists):

C#
string displayName = trigger.displayName;
int colorIndex = senderId % 8;
int spawnIndex = senderId % 4;

foreach (var kv in VRGameState.Players)
{
    var existing = new PlayerJoinedEvent {
        playerId = kv.Key, displayName = kv.Value.displayName,
        colorIndex = kv.Value.colorIndex, spawnIndex = kv.Value.spawnIndex
    };
    connection.SendToPeer(senderId, "PlayerJoined", existing.Serialize(), true);
}

VRGameState.Players[senderId] = new VRGameState.PlayerInfo {
    displayName = displayName, colorIndex = colorIndex, spawnIndex = spawnIndex
};
var joined = new PlayerJoinedEvent {
    playerId = senderId, displayName = displayName, colorIndex = colorIndex, spawnIndex = spawnIndex
};
connection.BroadcastToAll("PlayerJoined", joined.Serialize(), true);

// BroadcastToAll never loops back to the host's own incoming-message pipeline, so without
// this direct call the HOST never registers a newly-joined player locally.
VRDemoController.Instance?.HostRegisterPlayerLocally(senderId, displayName, colorIndex, spawnIndex);

Logic Type must actually be set to Custom, or this code is silently ignored — confirmed the hard way while building this tutorial. BroadcastAll/SenderOnly/TargetUser are fixed, hardcoded response templates that never look at the Custom Host Code field at all, even if it isn't empty — there's no error, no warning, the generated output simply doesn't contain it. If your generated HostActionRouter.cs is missing a Host Action's custom logic entirely (not just auto-matched fields), this is the first thing to check. See Common Mistakes.

4.2 HandlePlayerStatus

FieldValue
Handler NameHandlePlayerStatus
Trigger MessageRequestPlayerStatus
Response EventPlayerStatusUpdate
Logic TypeBroadcastAll

Leave Custom Host Code blank. This is the first Host Action in any NetCrafter Relay tutorial that needs none at all: every response field name (animState, grounded, isDesktopMode) matches a trigger field name exactly, and the generator auto-fills response.playerId = senderId as a special case on top of that — so BroadcastAll's own fixed template already does everything this handler needs, with zero Custom Host Code. If you're ever unsure whether a Host Action truly needs custom logic, this is the test: do every response field either auto-match a trigger field by name, or need to be playerId? If yes, leave it blank.

06

Part 5 — Configure Relay Settings and generate#

Open Settings → Relay Settings and fill in your own coturn/rendezvous details (identical fields to every other tutorial — Relay Host, Relay Port 3478, Rendezvous URL, Credential Mode StaticSecret, TURN Username, Shared Secret). Click Save Settings.

Open Build Code, click Validate Definition, then Generate All Files. This writes to Definitions/VRDemo/Generated/:

  • Client/LoginRequest.cs, Client/PlayerJoinedEvent.cs, Client/PlayerLeftEvent.cs, Client/RequestPlayerStatusRequest.cs, Client/PlayerStatusUpdateEvent.cs, Client/RelayConnectionManager.cs
  • Host/HostActionRouter.cs
  • Config/RelayConfig.json

Let Unity recompile and check the Console for CS0103: The name 'VRGameState' does not exist or CS0103: The name 'VRDemoController' does not exist — if you see either, confirm Assets/PivecLabs/Examples/VRDemo/Scripts/ exists with the files from Part 7 (this tutorial ships them already, so a fresh import shouldn't hit this).

07

Part 6 — Create the eight sync rules#

Asset Name and Rule Name are two different things, set two different ways. Asset Name is the filename Unity gives the new .asset file in the Project window — right after creating it, Unity puts the name into an editable text field right there in the Project window; type the Asset Name column's value into that field before doing anything else. Rule Name is a separate, actual field inside the asset, set afterward in the Inspector — this is the name the runtime actually uses to key sync data, and it deliberately doesn't have to match the filename.

Every rule below is a SyncRuleDefinition asset, created in Assets/PivecLabs/Examples/VRDemo/ via right-click → Create → PivecLabs → NetCrafterRelay → Sync Rule.

Asset nameRule NameSync TargetSync ModeInterval
Sync_VRRootPositionVRRootPositionTransform_PositionOnChange0.05
Sync_VRRootRotationVRRootRotationTransform_RotationOnChange0.05
Sync_VRHeadPositionVRHeadPositionTransform_PositionOnChange0.05
Sync_VRHeadRotationVRHeadRotationTransform_RotationOnChange0.05
Sync_VRLHandPositionVRLHandPositionTransform_PositionOnChange0.05
Sync_VRLHandRotationVRLHandRotationTransform_RotationOnChange0.05
Sync_VRRHandPositionVRRHandPositionTransform_PositionOnChange0.05
Sync_VRRHandRotationVRRHandRotationTransform_RotationOnChange0.05

Set Target Component Type to Transform on all eight.

Create and rename one row at a time — don't create all eight, then rename them. Right-click → Create → ... → Sync Rule drops the new asset straight into an editable rename field in the Project window; typing the Asset Name column's value there immediately, before creating the next one, is what keeps every asset's default name unique. Skip this with eight rules in a row and Unity will silently append " 1", " 2" to keep filenames unique — the fix, if you already have one: select it, press F2, retype the correct name.

Why OnChange with a 0.05 interval, not Interval mode. OnChange sends the instant a tracked value actually moves, with interval acting as a maximum staleness backstop — a forced resend if nothing has changed in that long, not a fixed send cadence. A VR session already runs eight of these per remote player (double Character's six, on top of two more schema messages) — 0.05 keeps each individually well under this package's ~1200–1400 byte safe payload guidance while still reading as live tracked movement, not choppy. If bandwidth becomes a real concern with more players, lowering the hand rules' update rate (hands move faster/more erratically than a head, visually, but matter less when a player glances away) is a reasonable place to start tuning before touching the root or head rules.

08

Part 7 — Build the scene#

Same idea as NetCrafter's own SFS3 VR tutorial: take the OpenWorlds VR scene you already have working from Part 1, save it under a new name, and add NetCrafter Relay directly into that same scene. There's no separate "relay scene" to merge in — everything below happens in the one scene you're already standing in.

This package also ships VRDemoScene.unity (Assets/PivecLabs/Examples/VRDemo/), built the same way but without any VR content (OpenWorlds VR can't be imported into this authoring project — Part 0.2). It's a working reference showing what the finished result of this Part looks like — open it if you want to see the wiring before building your own — but build your own copy directly in your VR scene using the steps below, the same tool buttons (Prefab Generator, Scene Assistant) every other tutorial already uses.

7.1 Save your VR scene as your multiplayer scene

Open your OpenWorlds VR scene from Part 1 — the one with a working local player, head/hand tracking, and the three follower singletons confirmed. File → Save As, give it a new name (for example VRMultiplayerScene.unity). Work in this new scene from now on — never modify the original sample/demo scene it came from.

7.2 Add sync bindings to your player prefab's root

This isn't a new prefab you create — it's whichever existing character model OpenWorldsVRManager.Instance.player ends up pointing at (e.g. LabMan1.prefab, under OpenWorlds VR's own Example Models/Prefabs/). Select that prefab asset's root directly (double-click into Prefab Mode, or select it in the Project window). Twice: Add Component → NetCrafter → Sync Binding, then immediately drag VRRootPosition/VRRootRotation (Part 6) onto the two bindings' Rule fields before adding the next one — or just select the prefab root and run GameObject → NetCrafter Relay → Auto-Wire VR Local Player Root, which does the same thing in one click and is safe to re-run.

Leave Has Authority unchecked and Object Id at 0 on both — VRDemoController.ConfigureLocalPlayerWhenReady() (Part 7.7) calls Configure(id, authority) on every local binding the moment login succeeds. Setting these by hand risks locking in against the wrong value before Configure() ever runs — see the Character tutorial's Part 7.1 for the full mechanism.

If you add your own character models to OpenWorldsVRManager's own selection list (ListofPlayerModels — it's explicitly designed to be extended this way), each one needs this same step done on it too. It isn't automatic, and a model that's missing it won't error or crash — ConfigureLocalPlayerWhenReady() just has nothing to configure, so that player's position/rotation silently never reaches anyone else, leaving a frozen avatar on every other client's screen with no symptom visible locally. VRDemoController checks for this directly and logs a clear error naming exactly which rule (VRRootPosition and/or VRRootRotation) is missing from whatever model actually spawned, pointing back at the Auto-Wire tool above — watch the Console the first time you test a new model. The shipped models (LabMan1-4) are already wired, including two (LabMan3/LabMan4) that inherited the setup automatically by being Prefab Variants of LabMan2 — if your own custom models are built the same way (as variants of a model you've already wired), you may not need to run the tool on each one individually.

7.3 Add sync bindings to the three follower singletons

On each of HeadFollower, LHandFollower, and RHandFollower (Part 1.2) — the same scene-level singleton objects OpenWorlds VR itself drives, not copies — add two NetCrafterSyncBinding components each:

ObjectRule 1Rule 2
HeadFollowerVRHeadPositionVRHeadRotation
LHandFollowerVRLHandPositionVRLHandRotation
RHandFollowerVRRHandPositionVRRHandRotation

Leave all six unchecked, same reasoning as Part 7.2 — ConfigureLocalPlayerWhenReady() finds all three followers (polling for up to 3 seconds, since they're activated asynchronously after player assignment — see Part 1.2) and configures their bindings too.

7.4 Build the remote avatar prefab

Step 1 — Duplicate, unpack, and check. Duplicate your player prefab (the one from Part 7.2), rename the duplicate RemoteVRCharacterPrefab. If the original wasn't already unpacked, right-click the duplicate → Prefab → Unpack Completely now, before touching any components — a duplicated prefab instance can still be linked to the same source as the original, and editing one can silently affect the other, or saving the duplicate can produce a Prefab Variant instead of an independent prefab (same confirmed trap as every other tutorial's remote-prefab step).

Step 2 — Remove local-only components. Remove whatever OpenWorlds VR component(s) drive local input/tracking directly from the XR rig (your own local-only movement/tracking script) — a remote avatar is driven entirely by incoming network data, never by this machine's own XR devices. Leave the Animator in place, and confirm its Controller field is still assigned (the same controller your local player model uses, e.g. OpenWorldsVR_Animator.controller) — an Animator with no Controller assigned plays no animations and holds no pose at all, which looks exactly like a deformed/broken remote avatar even though every sync value arriving over the network is correct. Duplicating the prefab in Step 1 normally carries this reference over automatically, but it's easy to lose if the remote prefab was built some other way (a fresh prefab, a different source model, or the Animator component itself got removed and re-added) — always worth a direct look at the Inspector rather than assuming.

Step 3 — Add RemoteVRCharacterController, VRColorManager, PlayerNameDisplay. Assets/PivecLabs/Examples/VRDemo/Scripts/RemoteVRCharacterController.cs ships with the package:

Show RemoteVRCharacterController.csships with the package — nothing to type
C#
using System.Reflection;
using UnityEngine;

namespace PivecLabs.NetCrafterRelay.Examples.VRDemo
{
    public class RemoteVRCharacterController : MonoBehaviour
    {
        public Transform headTarget;
        public Transform lHandTarget;
        public Transform rHandTarget;
        public Vector3 bodyTargetLocalOffset = Vector3.zero;
        public bool enableIK = true;

        // PlayerIKSolver ships compiled in OpenWorldsVR.dll with no source in this project --
        // every field/method below is reached entirely via reflection, confirmed against the
        // real DLL, not guessed. headTarget/lHandTarget/rHandTarget must already exist as
        // children of this prefab (Step 4) -- NetCrafterSyncBinding's Rule field is
        // Inspector-only with no public setter, so there is no way to wire a freshly-created
        // GameObject to the correct rule from code.

        public void ApplyStatus(int animState, bool grounded, bool isDesktopMode)
        {
            // Maps animState back onto the per-direction Animator bools, sets Grounded,
            // and toggles IsHeadActive/IsLHandActive/IsRHandActive off while isDesktopMode
            // is true -- see Part 0.4 and Part 3.4.
        }

        // ...full source in the shipped file.
    }
}

On RemoteVRCharacterPrefab's root: Add Component → Remote VR Character Controller, VR Color Manager, Player Name Display.

Step 4 — Build the three IK target children. Under the prefab root, create three empty children: HeadTarget, LHandTarget, RHandTarget (these exact names — the auto-wire tool below, and RemoteVRCharacterController itself, both depend on them). Then either wire the rest by hand, or let the editor do it: select the prefab root and use GameObject → NetCrafter Relay → Auto-Wire VR Remote Character — it adds and assigns all 8 NetCrafterSyncBinding components (position + rotation on the root, plus each of the three children) and wires RemoteVRCharacterController's Head Target/L Hand Target/R Hand Target fields in one click. Safe to re-run — it reuses any binding already pointed at the right rule (or left unassigned) instead of adding duplicates, so running it again after manually tweaking something won't undo your work. By hand: on each child, add the matching two NetCrafterSyncBinding components — the same six rules as Part 7.3 (VRHeadPosition/VRHeadRotation on HeadTarget, and so on), left unchecked — then drag the three Transforms into RemoteVRCharacterController's fields yourself.

RemoteVRCharacterController creates a fourth target, RemoteBodyTarget, itself at runtime — it carries no sync binding (nothing on the wire targets the body/hips), it's just a fixed local offset so the IK solver's spine always matches the root's own synced rotation, for free, via ordinary Unity parenting. Nothing to build for this one.

Step 5 — Name tag. Add a world-space NameCanvas child (not on the prefab root — see Common Mistakes) with a NameText TMP text child. Wire NameCanvas/NameText into PlayerNameDisplay's fields.

Step 6 — Save and remove from the Hierarchy. Before saving, reset the prefab root's own Transform to Position (0, 0, 0) and Rotation (0, 0, 0) (right-click the Transform component → Reset) — a duplicated prefab (Step 1) commonly keeps whatever position/rotation its source happened to be at in the scene, and a remote instance spawned at that leftover offset can end up far enough away to never be seen at all, with no error to explain why, until the first VRRootPosition sync update happens to arrive and move it. Drag RemoteVRCharacterPrefab into Assets/PivecLabs/Examples/VRDemo/Prefabs/, then delete it from the Hierarchy.

7.5 Build the UI

Directly in your saved VR scene (Part 7.1):

  1. Canvas — name it UICanvas. Set Render Mode to World Space (not Screen Space Overlay — Part 0.1), position it a short distance in front of where your player starts (for example (0, 1.6, 1.5)), and scale it down for a comfortable reading size (world-space canvases are sized in pixels — a few hundred pixels wide at a scale around 0.0015 on all three axes reads as a normal-sized panel at arm's length; adjust to taste once you can see it in headset).
  2. LoginPanel: TMP Text (Title, "VR Demo"), TMP Input Field (DisplayNameInput, placeholder "Enter your name..."), TMP Button (ContinueButton, "Continue").
  3. LobbyPanel (unchecked by default): TMP Button (HostButton, "Host Game"), TMP Button (JoinButton, "Join Game"), TMP Input Field (JoinCodeInput, placeholder "Join code..."), TMP Text (JoinCodeDisplay, blank), TMP Text (StatusText, blank).
  4. GameplayPanel (unchecked by default): TMP Text (PlayerListText, blank).

Check for an EventSystem before adding one. OpenWorlds VR's own sample scene commonly already includes one — adding a second causes duplicate-input warnings and unpredictable UI focus. If you do need to add one, make sure it uses Input System UI Input Module, not the legacy Standalone Input Module — this project's Active Input Handling is set to Input System Package (Prerequisites), and the legacy module throws an exception at runtime under that setting.

7.6 Create the RelayConnectionManager prefab and add the relay/event GameObjects

Open Scenes → Prefab Generator, confirm VRDemo is selected, click Create RelayConnectionManager Prefab (writes to Definitions/VRDemo/Prefabs/, a sibling of Generated/ — never touched by code regeneration; this tutorial already ships this prefab if you're using the shipped schema, so this only matters if you rebuilt the schema from scratch in Part 2).

Open Scenes → Scene Assistant, confirm VRDemo is still selected, confirm Add Event Dispatcher is checked, click Add to Existing Scene. This adds, directly into your VR scene:

  • RelayConnectionManager — an instance of the prefab you just created, added already disabled.
  • HostActionRouter — VRDemo's generated host-side logic dispatcher.
  • NetCrafterEventDispatcher.
  • NetCrafterSyncManager — added automatically because VRDemo has SyncRuleDefinition assets (Part 6).

Why the RelayConnectionManager GameObject starts disabled. Its Start() calls Connect() unconditionally, which defaults to hosting a new session when no join code is pending — active from the instant the scene loads, it would start hosting before Login has even been looked at. VRDemoController.Awake() also forces it inactive defensively, the same belt-and-suspenders pattern every tutorial in this package uses.

Then: Settings → Scene Events → Add Router to Scene — creates a SceneEventRouter GameObject listing PlayerJoined, PlayerLeft, and PlayerStatusUpdate, each with an empty On Received slot (wired in 7.7).

7.7 Attach and wire VRDemoController, and enable the define

Create an empty GameObject named NetworkManager, drag the script onto it.

  1. On NetworkManager, drag RemoteVRCharacterPrefab.prefab (Part 7.4) into VRDemoController's Remote Character Prefab field, and wire every other field: Relay Connection Manager Object → the RelayConnectionManager GameObject; Login Panel/Display Name Input, Lobby Panel/Join Code Input/Join Code Display/Status Text, Gameplay Panel/Player List Text → the matching objects from Part 7.5.
  2. Wire the buttons and events: ContinueButton → On Click() → VRDemoController.OnLoginClicked(); HostButton → VRDemoController.OnHostClicked(); JoinButton → VRDemoController.OnJoinClicked(); SceneEventRouter's PlayerJoined binding → On Received → VRDemoController.OnPlayerJoinedReceived(); PlayerLeft binding → VRDemoController.OnPlayerLeftBroadcastReceived(); PlayerStatusUpdate binding → VRDemoController.OnPlayerStatusUpdateReceived().
  3. Edit → Project Settings → Player → Scripting Define Symbols (for your active platform) → add NETCRAFTER_VR_EXAMPLE, then let Unity recompile.
Show VRDemoController.csships with the package — nothing to type
C#
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.EventSystems;
using TMPro;
using PivecLabs.Generated.VR;
using PivecLabs.NetCrafterRelay;

#if NETCRAFTER_VR_EXAMPLE
using PivecLabs.OpenWorldsVR;
#endif

namespace PivecLabs.NetCrafterRelay.Examples.VRDemo
{
    public class VRDemoController : MonoBehaviour
    {
        public static VRDemoController Instance { get; private set; }

        public GameObject relayConnectionManagerObject;
        public GameObject loginPanel;
        public TMP_InputField displayNameInput;
        public GameObject lobbyPanel;
        public TMP_InputField joinCodeInput;
        public TMP_Text joinCodeDisplay;
        public TMP_Text statusText;
        public GameObject gameplayPanel;
        public TMP_Text playerListText;
        public GameObject remoteCharacterPrefab;

        // ...full source in the shipped file. NETCRAFTER_VR_EXAMPLE guards only the
        // specific method-body lines that reference real OpenWorldsVR types (not the
        // whole file -- see Part 0.2 and the Common Mistakes entry on this define) --
        // the class, its fields, and the Login/Lobby/Gameplay flow compile and work
        // unconditionally either way, because the generated HostActionRouter.cs calls
        // VRDemoController.Instance?.HostRegisterPlayerLocally(...) directly and that
        // reference must always resolve.
    }
}

Confirmed live in every other tutorial, and unchanged here: a departed player's avatar stays behind forever if the departing player was the HOST, not a joiner — unless both sides of disconnect detection are wired. The host branch of SendLoginWhenConnected() subscribes to OnPeerDisconnected so the host can broadcast PlayerLeft. The joiner branch subscribes too — a joiner's only peer is always the host (always network id 0), so OnPeerDisconnected firing there reuses the same cleanup a PlayerLeft broadcast would have triggered, plus a StatusText message, since this is the whole session ending, not just one fewer player. There's no host migration — a known, disclosed limitation, same as every other tutorial.

09

Part 8 — Run it#

This part could not be verified on real hardware while writing this tutorial — see the confidence note at the end of this document. Everything through a clean compile and careful code reading has been confirmed; actually standing in a headset next to another tracked player has not.

  1. Run one copy — the host, headset on. Enter a name, Continue, Host Game. Confirm the Lobby panel (a world-space canvas, readable at arm's length) shows a join code.
  2. Run 1–3 more copies — joiners (headset or desktop/isDesktopMode fallback). Enter a name, Continue, type in the join code, Join Game.
  3. On every client, confirm:
    • Every other connected player's avatar appears, correctly coloured (colorIndex), with a readable name tag that stays upright and doesn't fight the avatar's own rotation.
    • Another player's head and hand movements show up live on your screen — not just a position jump every half-second, actual smooth tracked movement.
    • Walking, running in different directions, jumping, and landing all show the matching animation on every other client (Part 3.4's animState table).
    • Taking a headset off (or toggling isDesktopMode for testing) makes that player's remote avatar stop reaching toward stale head/hand positions on everyone else's screen, rather than freezing mid-gesture.
  4. Quit one joiner. After a short delay (host-side peer-timeout detection, ~15s), that avatar disappears from every remaining client and the player list drops them.
  5. With at least one joiner still connected, quit the host instead. After the same delay, each remaining joiner's StatusText shows "Host disconnected — connection lost." and the host's own now-frozen avatar disappears from their scene.

If hosting or joining fails outright, StatusText on the Lobby panel shows a plain-language message instead of a silent, unexplained Lobby screen. If avatars appear but never move, see Common Mistakes before assuming the network itself is broken.

10

Common mistakes#

A Host Action's Custom Host Code never shows up in the generated output. Confirmed directly while building this tutorial's own HandleLogin — Logic Type must be Custom. BroadcastAll/SenderOnly/TargetUser never read the Custom Host Code field at all, with no warning that it was ignored. If a Host Action genuinely needs custom logic beyond auto-matched fields, double-check Logic Type before assuming the generator is broken.

The demo compiles and the Login/Lobby flow works, but no VR player ever gets configured. NETCRAFTER_VR_EXAMPLE isn't defined (Part 7.7) — either OpenWorlds VR isn't imported yet, or the define wasn't added after it was. Check the Console for [VRDemo] NETCRAFTER_VR_EXAMPLE is not defined warnings.

Merging this tutorial's Login/Lobby UI directly into OpenWorlds VR's own sample scene (rather than building a scene from scratch): Login/Lobby buttons visibly react to clicks (hover/pressed color changes normally) but onClick never fires — no log, no error, completely silent, while an InputField's own Submit-on-Enter still works fine on the same canvas. Confirmed live, traced into XR Interaction Toolkit's own UIInputModule.ProcessPointerButton(): a Selectable's pointer-down/up visual feedback always fires on whatever was actually pressed, unconditionally — but onClick only fires if that same object is also what a freshly recomputed raycast resolves to at release time. Once OpenWorlds VR's own XR interaction machinery is live in the scene, those two stop agreeing, silently. Two things to check, both load-bearing: Show Selector On Start on the OpenWorldsVRManager component should be checked (see the Prerequisites-adjacent note above) so a player doesn't spawn, and its interactors don't activate, before your own Login/Lobby flow has finished — this tutorial's VRDemoController.ConfigureLocalPlayerWhenReady() simply waits for OpenWorldsVRManager.Instance.player to become non-null, the same way the SFS3 tutorial's own VRNetworkManager does, rather than forcing a default model itself (Part 7). Second, and the part that actually fixes the click itself: every Login/Lobby button in this scene has a ClickOnPointerDown component added — it fires on IPointerDownHandler directly, the same mechanism an InputField already uses to gain focus, sidestepping the press/release mismatch entirely instead of depending on it. Remove each button's onClick wiring once ClickOnPointerDown is in place — confirmed live, leaving both wired to the same handler caused the handler to run twice for a single click often enough to matter: onClick isn't reliably suppressed by the mismatch above, just inconsistent, so some clicks fired both, starting two overlapping connect/spawn sequences and throwing a NullReferenceException deep in OpenWorlds VR's own per-frame character-controller code as they raced. Add ClickOnPointerDown (and remove onClick) on any button that stops responding the same way in a scene like this.

Same merged-scene setup, a different symptom: the desktop mouse cursor disappears somewhere around Login/Lobby, with only the Unity Editor's own Escape-releases-cursor-lock behavior (Play Mode only, not present in a real build) bringing it back. Confirmed live — unrelated to the click bug above. OpenWorldsVRManager's own FadeIn()/FadeOut() (driving its character-selection panel's fade animation in and out as it's shown, and again once the player actually picks a character) unlock and lock Cursor.lockState directly, on a schedule that has nothing to do with which of this tutorial's own panels is active — and since the panel is genuinely interactive the whole time Login/Lobby are showing (Part 7.7), a player clicking a character at any point, even before finishing Login, can trigger this. VRDemoController.Update() re-asserts an unlocked, visible cursor every single frame while gameplayPanel isn't active, so OpenWorlds VR's own fade timers can never leave the cursor stuck hidden behind Login or Lobby — left alone during actual Gameplay, since cursor lock for desktop/PCtoVR mouselook at that point is OpenWorlds VR's own concern, not this controller's.

A remote avatar's head/hands don't move at all, but its root does. Check that all six NetCrafterSyncBinding → Rule fields on RemoteVRCharacterPrefab's HeadTarget/LHandTarget/RHandTarget children are actually assigned (Part 7.4, Step 4) — an unassigned rule self-disables with a Console warning, same failure mode as every other tutorial. Also confirm RemoteVRCharacterController's Head Target/L Hand Target/R Hand Target fields are wired — if they're null, it logs an error and skips adding PlayerIKSolver entirely.

The name tag spins or jitters as the avatar turns. PlayerNameDisplay must billboard the NameCanvas child, never the avatar root — billboarding the root fights the root's own synced rotation every frame, with no error ever logged. See Part 7.4, Step 5.

Joiners see each other's avatars fine, but the host sees no one. Confirmed pattern from every other tutorial in this package — this is what happens without HandleLogin's HostRegisterPlayerLocally call (Part 4.1). BroadcastToAll never loops back to the host's own incoming-message pipeline, so the host's OnPlayerJoinedReceived() never fires for anyone.

NullReferenceException clicking Host Game or Join Game. Same cause as every other tutorial: the RelayConnectionManager GameObject's component is a different schema's generated class, most likely from copying a GameObject/prefab out of another tutorial's scene instead of this one. Delete it and re-add via Scenes → Scene Assistant → Add to Existing Scene.

Keyboard-entry fields (name/join code) can't be focused while wearing a headset. By design, not a bug — IsKeyboardAvailable() checks isHeadsetOffHead and the XR head node's tracking validity, and only enables those input fields when neither is tracking. Take the headset off (or test in desktop/isDesktopMode fallback) to type.

Testing two players on one physical machine (one Editor instance, one built player) with a real headset plugged in produces unpredictable tracking/input in both instances. Confirmed live — a single headset can't be cleanly shared between two processes both trying to claim it through the same XR runtime. Use GameObject → NetCrafter Relay → Build Desktop-Only VR Demo Player... to produce a genuinely separate build that never initializes XR at all — it temporarily disables XR Plug-in Management's "Initialize XR on Startup" and adds the NETCRAFTER_VR_DESKTOP_ONLY define for that one build only, then restores your project's own (headset-aware) settings afterward, whether the build succeeds or fails. Run that build alongside your normal Editor instance (which keeps the real headset) with no contention, or hand it to a tester who doesn't own a headset at all — remote avatars still react correctly to other players' real VR state either way, since that's driven entirely by network data (isDesktopMode), never by this machine's own XR hardware.

11

What's Next#

FeatureDescription
Voice chatLayer a Reliable/Unreliable audio-chunk message or a dedicated voice SDK on top of the existing Login/Lobby flow
Hand-gesture pickupsA grab/release trigger following Co-op's host-authoritative TryCollectPickup pattern, keyed off hand-collider overlap instead of a 2D/3D trigger
Teleport locomotion syncIf your OpenWorlds VR setup uses teleport rather than continuous movement, consider OnChange-only sync (no backstop interval) for the root rule, since a teleport is a single large discrete jump, not continuous motion
Per-hand grip stateA grounded-style bool per hand (gripping/open), same edge-triggered message pattern as animState
Spectator/non-VR viewerA screen-space camera following the action, reusing the same PlayerJoined/PlayerStatusUpdate broadcasts a VR client already receives

Tutorial by Pivec Labs — NetCrafter Relay