by Pivec Labs
⏱ 60–90 min VR Tutorial

Multiplayer VR with NetCrafter

Build a multiplayer VR game — synchronise head and hand tracking across players, driven by OpenWorlds VR for Third Person and NetCrafter over SmartFoxServer 3.

Time: 60–90 minutes 11 parts C# & Java 2+ headsets
🥽
OpenWorlds VR for Third Person

What it is: A Unity Asset Store package providing a full VR rig — XR camera/controller tracking, arm/head IK, character swapping, and Game Creator 2 support.

v1.3.1 or higher required — this tutorial reflects OpenWorldsVR.dll's public API as of that version.

🧩
NetCrafter

What it is: Installed and working — you completed the Chat or Character tutorial.

Namespace for this project: VRCharacter

🖥
SmartFoxServer 3

What it is: Running locally — same setup as the earlier tutorials.

New zone: VRCharacterZone on port 9977

☕
Maven + JDK 21

What it is: Same build tools used in the Chat/Character tutorials.

Already detected by NetCrafter if an earlier tutorial is working.

About the Examples folder

Assets/PivecLabs/Examples/VR/ ships with finished reference scripts (VRNetworkManager.cs, VRUIManager.cs, RemoteVRCharacterController.cs, etc.) for comparison once you're done — it is not a shortcut. There is no prebuilt scene included, since a working VR scene depends on the OpenWorlds VR rig you set up yourself in Part B; build everything by following the steps below.

Placeholder note: these scripts are wrapped in NETCRAFTER_VR_EXAMPLE/NETCRAFTER_VR_GENERATED compile guards, which are off by default — they only compile once OpenWorlds VR for Third Person is actually installed and you've added those define symbols yourself (Edit → Project Settings → Player → Scripting Define Symbols). Until then, any component referencing them will show as "missing script" in the Inspector — that's expected, not a bug.

A1

Prerequisites

Before starting this tutorial you need:

  • OpenWorlds VR for Third Person with Game Creator Support v1.3.1 or higher installed (Unity Asset Store)
  • NetCrafter imported
  • SmartFoxServer 3 running locally
  • Java JDK 21+ and Apache Maven installed (same as the Chat/Character tutorials)
  • NETCRAFTER_VR_EXAMPLE added to Scripting Define Symbols (Edit → Project Settings → Player → Other Settings)

Two defines, two purposes

NETCRAFTER_VR_EXAMPLE guards the five hand-written scripts — they compile safely even before generation. NETCRAFTER_VR_GENERATED guards code inside those scripts that references NetCrafter's generated types. You add it in Part B2, after generating client code.

⚠️
This tutorial does not modify the OpenWorldsVR source project. Everything here targets a project where you have imported the OpenWorlds VR asset (as an end user would), alongside NetCrafter. Do not edit files inside the OpenWorlds VR package itself.
A2

Use the OpenWorldsVR Sample Scene

  1. Open the OpenWorldsVR sample scene that ships with the asset (the one containing a working VR character — check the asset's own Documentation/ or Example Scenes/ folder for the exact scene name)
  2. Enter Play Mode and confirm:
    • The character moves with the thumbstick
    • Head tracking follows your HMD
    • Both hand controllers track and the avatar's arms follow via IK
  3. Stop Play Mode. File → Save As, and save a working copy as:
path
Assets/Scenes/VRMultiplayerScene.unity

Work in VRMultiplayerScene from now on — do not modify the original sample scene.

⚠️
If the character doesn't move or track:
  • Confirm an OpenWorldsVRManager component exists in the scene and is enabled
  • Confirm your OpenXR runtime is running and a headset is connected (or PCtoVR is enabled on OpenWorldsVRManager for desktop testing)
  • Check the Console for OpenWorldsVRManager instance is not initialized errors — this usually means the manager GameObject was deleted or disabled
  • If your VR headset is not tracking or the character does not respond to controller input, your OpenXR configuration may need updating. See the OpenWorlds documentation for OpenXR setup: OpenWorlds VR — OpenXR Configuration Guide

Desktop / No-Headset Mode

⚠️
Critical — Set Project-wide Input Actions

NetCrafter VR requires the OpenWorldsVR input action asset to be set as the Project-wide Input Actions. Without this, keyboard and mouse input will not work in desktop/off-head mode.

To fix:

  1. In the Project window, navigate to: Assets/Plugins/OpenWorlds/OpenWorldsInputs/
  2. Select OpenWorlds InputSystem Actions (.inputactions file)
  3. In the Inspector, click: "Assign as the Project-wide Input Actions"
  4. If you see a warning saying different actions are currently assigned, click the button to reassign

Without this step:

  • VR controller input will work ✓
  • Keyboard WASD will NOT work ✗
  • Mouse look will NOT work ✗
  • Off-head desktop mode will NOT work ✗

This step is required once per project.

To allow keyboard WASD control when no headset is connected (or when the headset is removed):

  1. In the Hierarchy, find the OpenWorldsVRManager GameObject
  2. In the Inspector, enable the PCtoVR checkbox

With PCtoVR enabled:

  • WASD keys control movement
  • Mouse controls look direction
  • Keyboard jump works
  • Input switches automatically between VR headset and keyboard based on whether the headset is being worn

This is useful for

  • Testing multiplayer without a headset
  • Players who want to join a VR session from a desktop machine
  • Development and debugging

Desktop mode is detected live, not once at connect time

VRNetworkManager.SendPlayerUpdate() reads OpenWorldsVRManager.Instance.isHeadsetOffHead fresh on every single send (20–50 Hz), not as a one-time decision made when the player joins. A player can take the headset on/off at any point during play, and this must be reflected within one send interval either way — an earlier version of this tutorial decided desktop mode once at connect time (even with a short startup poll added for it), which failed exactly this test. The live value is also sent to peers as the isDesktopMode field (see Part B1), so RemoteVRCharacterController can disable that player's own head/hand IK on every other peer while they have no headset on, instead of IK-resolving toward the fixed desktopHeadPos/desktopLHandPos/desktopRHandPos placeholder pose below.

VRNetworkManager.UpdateHeadsetOffHeadFlag()/PollHeadsetOffHeadFlag() keep isHeadsetOffHead itself correct, bidirectionally, for the player's whole session — needed because of the XRRigCamera-starts-inactive limitation described elsewhere in this tutorial, where OpenWorldsVRManager's own detection never gets scheduled at all.

A3

Components Overview

Before writing any networking code, it helps to know exactly which transforms and components drive the VR character, since multiplayer sync has to reproduce them on a remote avatar that has no XR rig of its own.

ComponentRole
OpenWorldsVRManager.Instance.playerThe character root GameObject — spawned at runtime, holds Animator and (for the local player only) CharacterController
HeadFollowerScene-singleton MonoBehaviour; its transform tracks the XR camera (HMD) each LateUpdate
LHandFollower / RHandFollowerScene-singleton MonoBehaviours; their transforms track the left/right XR controllers
PlayerIKSolverDrives the character's arm/head IK from the followers (local player) or from network data (remote player)
CharacterControllerAdded to player at runtime by OpenWorldsVRManager; handles thumbstick movement
⚠️
Important: HeadFollower, LHandFollower, and RHandFollower are NOT child GameObjects of the player character. They are scene-level objects managed by OpenWorldsVRManager.

For the local player this is handled automatically by OpenWorldsVR.

For remote players, the OpenWorldsVR SDK exposes dedicated target fields:

  • remoteHeadTarget
  • remoteLHandTarget
  • remoteRHandTarget

VRNetworkManager sets these automatically when spawning a remote character. You do not need to manually set up follower objects for remote players.

PlayerIKSolver (the CCD-style IK solver) is compiled into OpenWorldsVR.dll with no source shipped in the project. It was reflected directly against the compiled assembly to confirm its real public API — the field/method names below are not guesses. Two relevant facts it exposes:

  • remotePlayer (bool) — flips the solver into "read targets from network data" mode
  • remoteHeadTarget, remoteLHandTarget, remoteRHandTarget (GameObject) — a first-class remote-player path already built into the SDK: when remotePlayer is true, the solver reads IK targets from these instead of the live XR followers

This is what RemoteVRCharacterController uses: it creates three small driven GameObjects under the remote avatar, adds its own PlayerIKSolver instance in remotePlayer mode pointed at them, and every frame sets their local position/rotation directly from the network stream (no lerp — the solver's own ikBlendTime handles smoothing).

What we sync

DataSourceWire representation
Character root positionplayer.transform.position3 floats (posX/Y/Z)
Character root yawplayer.transform.eulerAngles.y1 float (rotY) — upright only
Head poseHeadFollower.transform, relative to the player root3 floats + 4-float quaternion
Left hand poseLHandFollower.transform, relative to the player root3 floats + 4-float quaternion
Right hand poseRHandFollower.transform, relative to the player root3 floats + 4-float quaternion
Animator stateSpeed (float), Grounded (bool), state hash + normalized time2 floats + 1 int + 1 float

Why "relative to the player root" and not .localPosition?

HeadFollower/LHandFollower/RHandFollower are not parented under player, so their .localPosition is relative to whatever their own parent is (the XR rig), not the character. Sending that directly would only reconstruct correctly by coincidence. Instead VRNetworkManager computes player.transform.InverseTransformPoint(follower.position) (and the matching rotation delta) before sending — genuinely player-relative, so RemoteVRCharacterController can reproduce it by parenting its own driven targets under the remote root and just setting .localPosition/.localRotation.

B1

Create the VRCharacter Schema

Open the NetCrafter window: Tools → Pivec Labs → NetCrafter Pro. Click New Schema and name it VRCharacter.

SmartFox Settings tab

In the NetCrafter window, go to Settings → SmartFox Settings tab and configure:

FieldValue
Host127.0.0.1
Zone NameVRCharacterZone
Extension NameVRCharacter
Extension Classcom.piveclabs.vrcharacter.VRCharacterExtension
TCP Port9977
Enable MMO☐ unchecked
⚠️
Leave Enable MMO unchecked — confirmed via a live test (2026-10-07): with MMO enabled, GameRoom is created as an SFS3 MMORoom, which tracks each user's position through its own native MMO API and auto-evicts anyone whose position isn't updated through that API within userMaxLimboSeconds (50s by default) — silently removing every player from the room ~50 seconds after joining, regardless of whether their connection is still alive. This tutorial's position sync is a custom PlayerUpdate/PlayerStateUpdate extension broadcast that never touches that native API, so MMO mode only works against it. If you've already generated/deployed the zone config with MMO enabled, regenerate and redeploy it after unchecking this, then restart SFS3 — check the server log for type = MMORoom vs type = SFSRoom on the Room created line for VRCharacterZone to confirm which one is actually running.

Project Settings tab

In the NetCrafter window, go to Settings → Project Settings tab and configure:

FieldValue
NamespaceVRCharacter
Overwrite Files✓
⚠️
Confirm the namespace is exactly VRCharacter before generating (Part B2). The example scripts in Assets/PivecLabs/Examples/VR/Scripts/ are already written inside namespace VRCharacter { ... } so they see the generated types without an extra using — if you generate under a different namespace, either rename it back or add a using directive to all five scripts.

ClientToServer message — PlayerUpdate

In the NetCrafter window, go to Definition → Messages tab. Click + to create a new message.

  • Set Direction to ClientToServer
  • Name it: PlayerUpdate
  • Set Transport to: ExtensionRequest
  • Set Server Handler Name to: HandlePlayerUpdate

Then add the following fields using the + button in the Fields section — 31 fields total, grouped here by what they represent (add them all to the same message):

Root (4):

FieldType
posXfloat
posYfloat
posZfloat
rotYfloat

Head (7):

FieldType
headPosXfloat
headPosYfloat
headPosZfloat
headRotXfloat
headRotYfloat
headRotZfloat
headRotWfloat

Left hand (7):

FieldType
lHandPosXfloat
lHandPosYfloat
lHandPosZfloat
lHandRotXfloat
lHandRotYfloat
lHandRotZfloat
lHandRotWfloat

Right hand (7):

FieldType
rHandPosXfloat
rHandPosYfloat
rHandPosZfloat
rHandRotXfloat
rHandRotYfloat
rHandRotZfloat
rHandRotWfloat

Animator + misc (6):

FieldType
animStateint
groundedfloat
stateHashint
stateTimefloat
colorIndexint
isDesktopModebool

isDesktopMode

True while the sender has no headset (see Part A2's desktop-mode fallback pose). RemoteVRCharacterController uses this to disable that player's head/hand IK on every other peer's machine, the same way OpenWorldsVRManager.PlayerIKOff() disables it for the local player when off-head — without this, a desktop-mode player's remote avatar permanently reaches for the fixed placeholder pose instead of just hanging naturally.

animState

animState replaces the generic Speed float used in other tutorials. OpenWorldsVR uses discrete bool-based animation states rather than a continuous speed blend. Value meanings: 0=Idle, 1=WalkFwd, 2=WalkBack, 3=WalkLeft, 4=WalkRight, 5=RunFwd, 6=RunBack, 7=RunLeft, 8=RunRight, 9=InAir, 10=JumpLand. See VRNetworkManager.GetAnimState() for the full mapping.

⚠️
PlayerUpdate has 31 fields total — the most of any NetCrafter tutorial. Take care to add all fields correctly. The schema validator will catch any missing fields before code generation.

Why grounded is a float on the wire, not a bool

OpenWorldsVRManager's own Animator Controller uses Grounded as a bool parameter (confirmed: animator.SetBool(grounded, Physics.SphereCast(...)) in OpenWorldsVRManager.cs). VRNetworkManager reads it with GetBool and packs it as 0f/1f; RemoteVRCharacterController unpacks it back to a bool and calls SetBool. Calling Animator.SetFloat on a bool-typed parameter is a silent no-op in Unity — if a remote avatar's landing animation never triggers, this is the first thing to check.

ServerToClient events — leave Server Handler Name blank on all three

PlayerStateUpdate:

In the NetCrafter window, go to Definition → Messages tab. Click + to create a new message.

  • Set Direction to ServerToClient
  • Name it: PlayerStateUpdate
  • Set Transport to: ExtensionEvent
  • Leave Server Handler Name blank

Then add the same fields as PlayerUpdate, except colorIndex (that's only sent once, via PlayerJoined — RemoteVRCharacterController never reads a colorIndex field from a state update), plus:

FieldType
playerIdint

That's still 31 fields total: the 30 PlayerUpdate fields minus colorIndex, plus playerId.

PlayerJoined:

Same pattern — Definition → Messages tab, + to create a new message, Direction ServerToClient, Transport ExtensionEvent, Server Handler Name left blank. Name it: PlayerJoined.

Then add the following fields:

FieldType
playerIdint
playerNamestring
colorIndexint
spawnIndexint

PlayerLeft:

Same pattern — Definition → Messages tab, + to create a new message, Direction ServerToClient, Transport ExtensionEvent, Server Handler Name left blank. Name it: PlayerLeft.

Then add the following field:

FieldType
playerIdint

Server Actions

In the NetCrafter window, go to Definition → Server Actions tab. Click + to create a new server action.

Configure:

  • Handler Name: HandlePlayerUpdate
  • Trigger Message: PlayerUpdate
  • Response Event: PlayerStateUpdate
  • Logic Type: BroadcastAll
⚠️
Only ONE server action is needed. PlayerJoined and PlayerLeft are handled automatically by SFS3's room lifecycle.
B2

Generate All Code

⚠️
Before generating, confirm Project Settings → Namespace is VRCharacter — not Character, not left over from a previous schema. Generating under the wrong namespace means the example scripts (already written for namespace VRCharacter) won't see the generated types without editing every using directive by hand.
  1. Code → Build Code → Validate Definition, then Generate All Files
  2. Code → Server Code → Generate Server Code
  3. Build JAR (mvn package) — wait for BUILD SUCCESS
  4. Deploy to SFS3

Key generated files land in Assets/PivecLabs/NetCrafter/Definitions/VRCharacter/Generated/Client/:

  • PlayerUpdateMessage.cs
  • PlayerJoinedEvent.cs / PlayerLeftEvent.cs
  • PlayerStateUpdateEvent.cs
  • SmartFoxConnectionManager.cs

Enable the generated type guard

  1. Edit → Project Settings → Player → Other Settings → Scripting Define Symbols
  2. Add NETCRAFTER_VR_GENERATED
  3. Apply

Both NETCRAFTER_VR_EXAMPLE and NETCRAFTER_VR_GENERATED should be present now.

B3

Create the Remote VR Character Prefab

Step 1Duplicate the base model, not a live runtime instance

In OpenWorldsVRManager's Inspector, find List of Player Models and locate the ModelPrefab reference for the character you're using (e.g. LabMan). Drag that prefab asset into the Hierarchy, and rename the instance RemoteVRCharacterPrefab.

⚠️
Duplicate the source model prefab, not the live player GameObject from a Play Mode session. CharacterController and PlayerIKSolver are added to player at runtime by OpenWorldsVRManager — they are not baked into the model prefab, so there is nothing to remove here (unlike the Character tutorial's ThirdPersonController/StarterAssetsInputs/PlayerInput, which are baked into that prefab).
⚠️
Animator Controller required

The remote character prefab's Animator component must have the OpenWorldsVR animator controller assigned:

  1. Open the remote prefab in Prefab Editor
  2. Find the Animator component
  3. Assign: OpenWorldsVR_Animator.controller (found in Assets/Plugins/OpenWorlds/Example Scenes/Example Animations/)

Without this assignment the remote character will freeze and log: Animator is not playing an AnimatorController

Step 2Add network components

On RemoteVRCharacterPrefab, use Add Component to attach:

  • RemoteVRCharacterController
  • VRColorManager
  • PlayerNameDisplay

RemoteVRCharacterController.Awake() adds its own PlayerIKSolver instance and CapsuleCollider at runtime — do not add either manually.

Step 3Set up the name display

Identical to the Character tutorial's B3 step 4:

  1. Right-click RemoteVRCharacterPrefab → UI → Canvas, name it NameCanvas, Render Mode → World Space
  2. Position X=0, Y=2.2, Z=0, Rotation 0,0,0, Scale 0.01, 0.01, 0.01
  3. Rect Transform: Width 200, Height 50
  4. Right-click NameCanvas → UI → Text - TextMeshPro, name it NameText, centre-aligned, font size 36
  5. On PlayerNameDisplay: drag NameText into Name Text; leave Camera Transform and Billboard Target empty (both assigned/derived at runtime)

Why PlayerNameDisplay has a Billboard Target field

PlayerNameDisplay is attached to the character root (Step 2 above) — the same GameObject RemoteVRCharacterController rotates every frame in Update(). Its billboard logic must NOT rotate this.transform: Unity runs every component's Update() before any LateUpdate(), so a billboard in PlayerNameDisplay.LateUpdate() acting on the root would silently overwrite the rotation RemoteVRCharacterController just set, every frame, with no error ever logged — this was the actual cause of an entire session's worth of "remote avatar won't rotate" reports before it was found by disabling the component directly. Instead it billboards billboardTarget — the NameCanvas child — auto-derived from nameText's parent if left empty. Only wire Billboard Target explicitly if NameText isn't a direct child of NameCanvas in your hierarchy.

Step 4Save as a prefab, then remove from the Hierarchy

Drag RemoteVRCharacterPrefab into:

path
Assets/PivecLabs/Examples/VR/Prefabs/RemoteVRCharacterPrefab.prefab

Delete it from the Hierarchy afterward.

Components on the final prefab

ComponentStatus
Transform✓ Kept
Animator✓ Kept
RemoteVRCharacterController✓ Added
VRColorManager✓ Added
PlayerNameDisplay✓ Added
CharacterController— Never present (only added to the local player at runtime)
PlayerIKSolver— Added at runtime by RemoteVRCharacterController, not in the prefab
RemoteAnimationEventReceiver— Added at runtime by RemoteVRCharacterController, not in the prefab

Calibration fields on RemoteVRCharacterController

These are tuned live in Play Mode against your specific avatar's proportions, not guessed in advance — the shipped defaults were confirmed against a live two-machine test, but re-tune them if your character model's arm/body proportions differ:

  • mirrorHandsX — enable only if a remote avatar's hand appears on the wrong side of the body (not just facing the wrong way). Off by default — confirmed not needed against this tutorial's own test avatar, but a prefab built from a different base model's rig may need it.
  • handRotationOffsetEuler — extra rotation applied to both hands if they're in the right place but facing the wrong way (e.g. palm backwards).
  • bodyTargetLocalOffset — local position offset for the spine/body IK target, root-relative. Raise Y if the spine looks twisted/collapsed.
  • desktopHeadPos / desktopLHandPos / desktopRHandPos on VRNetworkManager — the fixed pose sent for a player with no headset on (see Part A2). If remote arms look folded in desktop mode, tune these until the pose looks natural, then bake the result in as the new defaults.
B4

Scene Setup

Create the NetworkManager

  1. Hierarchy → Create Empty, name it NetworkManager, reset its Transform
  2. Add Component → VRNetworkManager
  3. Drag RemoteVRCharacterPrefab.prefab into the Remote Character Prefab field

Add the ConnectionManager prefab

In the NetCrafter window: Scenes → Prefab Generator → Create ConnectionManager Prefab, then Scene Assistant → Add to Existing Scene if it isn't already in your Hierarchy.

Scene Events wiring

Scenes → Scene Events → Add Router to Scene, then wire:

EventTarget Method
PlayerJoinedVRNetworkManager.OnPlayerJoined()
PlayerLeftVRNetworkManager.OnPlayerLeft()
PlayerStateUpdateVRNetworkManager.OnPlayerStateUpdate()

VRNetworkManager subscribes to RoomJoined itself in Start() via NetCrafterEventDispatcher — no manual wiring needed for that one.

B5

How VRNetworkManager Works

VRNetworkManager.cs is the top-level coordinator — same shape as CharacterNetworkManager from the Character tutorial, with VR-specific differences:

  • Connect() — called by the login button; calls SmartFoxConnectionManager.ConnectAndLogin(playerName), not Login() directly. SmartFoxConnectionManager no longer opens its socket automatically in Start() — doing so left it sitting idle from scene load until the player finished typing a name, which most SFS3 server configs will drop for exceeding the unauthenticated-connection timeout, forcing an app restart. ConnectAndLogin() opens the socket now and logs in once the CONNECTION event actually fires.
  • OnRoomJoined() → starts WaitForLocalPlayerThenSend(), a coroutine that polls until OpenWorldsVRManager.Instance.player exists. This wait is necessary because OpenWorldsVRManager spawns the character prefab asynchronously (LoadPrefabCoroutine) — it is very likely still null the instant the room is joined.
  • Once the player exists, it locates HeadFollower/LHandFollower/RHandFollower via FindAnyObjectByType<T>() (they're scene singletons, not children of player — see Part A3) and starts the send loop.
  • SendPlayerUpdate() — runs at 20 Hz (50 Hz while the Speed Animator parameter indicates movement). Computes head/hand pose relative to the player root (InverseTransformPoint/rotation delta) and packs everything into a PlayerUpdateMessage.
  • OnPlayerJoined() / OnPlayerLeft() — spawn/destroy remote avatar GameObjects.
  • OnPlayerStateUpdate() — forwards the deserialized event straight to the matching RemoteVRCharacterController.SetNetworkState(...).

Assign in the Inspector:

FieldValue
Remote Character PrefabRemoteVRCharacterPrefab
Ui ManagerUIManager (auto-found if left empty)
B6

Login UI

VRUIManager.cs manages the same two panels as CharacterUIManager — a login screen and a game panel — with one deliberate omission: it never touches Cursor.lockState/Cursor.visible. VR input comes from the XR controllers via OpenWorldsVRManager, not the desktop mouse, so there is no cursor to lock or unlock. (OpenWorldsVRManager already manages Cursor.lockState itself for its PC-to-VR fallback mode — VRUIManager deliberately stays out of that.)

Create the Canvas

Right-click Hierarchy → UI → Canvas (Screen Space - Overlay for a desktop test build, or a world-space canvas parented under the rig for a pure-VR login flow), name it UICanvas.

Login Panel

  1. Right-click UICanvas → UI → Panel — name it LoginPanel
  2. TMP Text title: VR Demo
  3. TMP InputField PlayerNameInput, placeholder Enter your name...
  4. TMP Button ConnectButton, label Connect
  5. TMP Text StatusText

Game Panel

  1. Right-click UICanvas → UI → Panel — name it GamePanel, set inactive
  2. TMP Text PlayerListText

Attach and wire

  1. Empty GameObject UIManager, attach VRUIManager
  2. Wire all Inspector fields
  3. Wire ConnectButton On Click → VRNetworkManager.Connect()
B7

SFS3 Zone Config

  1. Code → Config Generator → Refresh Preview
  2. Generate VRCharacterZone.zone.xml
  3. Deploy Config to SFS3

Or deploy manually to:

path
C:\Users\YourName\SmartFoxServer3\server\zones\VRCharacterZone.zone.xml

Restart SFS3 and confirm in the server log:

log
{VRCharacter}: VRCharacterExtension initialized
B8

Full Test Sequence

Start SmartFoxServer 3

Run SmartFoxServer3.exe. Confirm VRCharacterExtension initialized in the server log before proceeding.

First player

  1. Put on your headset, press Play in Unity (or launch a standalone build)
  2. Enter a name, click Connect
  3. Confirm you can move and your hands/head track normally — the local experience is unchanged from Part A2

Console output while waiting

VRNetworkManager waits for two conditions after login:

  • Character selected (OpenWorldsVRManager.Instance.player becomes non-null)
  • Hand followers activated (LHandFollower and RHandFollower are briefly inactive during character loading — VRNetworkManager polls for up to 3 seconds)

Console sequence:

[VR] Waiting for character selection...
[VR] Character selected. Starting VR sync...
[VR] Followers found. Starting send loop.

Second player

Multiplayer VR testing needs a second tracked client. Options, in order of convenience:

  • A second headset on a second PC running a standalone build
  • A second headset on the same PC if your OpenXR runtime supports it (uncommon)
  • Enable PCtoVR on OpenWorldsVRManager for a desktop-only second instance — this lets you verify networking and IK reproduction without a second headset, though head/hand tracking will just reflect the desktop input mapping rather than real HMD motion

Expected result:

  • Both players see each other's avatars
  • A remote avatar's head turns and hands move to match the other player's actual tracked motion, reproduced through that avatar's own PlayerIKSolver (in remotePlayer mode)
  • The player list updates in both instances; closing one instance removes its avatar from the other

If a remote avatar's head/hands don't move but the body does track correctly

Check the Console for PlayerIKSolver has no public field '...' warnings from RemoteVRCharacterController — this means the installed OpenWorldsVR version renamed a field this tutorial depends on. Re-verify the field names (see the code comments in RemoteVRCharacterController.cs for how they were confirmed against v1.3.1 or higher) and update InitializeRemoteIKSolver() accordingly.

If there are no such warnings, check whether that player is in desktop/off-head mode on their own machine — RemoteVRCharacterController deliberately disables a player's own head/hand IK on every other peer while isDesktopMode is true for them (see the next section), mirroring OpenWorldsVRManager.PlayerIKOff() for the local player. This is correct behavior, not a bug, if that specific player genuinely has no headset on. RemoteVRCharacterController.enableIK (Inspector-visible, default true) can also rule out PlayerIKSolver entirely as a diagnostic — set it false on the prefab and confirm root position/rotation still track correctly with IK removed from the picture.

GC

Server Extension Code

NetCrafter generates the Java server extension skeleton. You must extend it with join/leave/disconnect logic. Replace the generated VRCharacterExtension.java with the listing below, and replace the generated HandlePlayerUpdate.java with the handler listing.

Why userRooms?

When a client disconnects abruptly (process killed, network drop), SFS3 removes the user from their room before firing USER_DISCONNECT. This means user.getLastJoinedRoom() returns null inside the disconnect handler, so a remote avatar whose player disconnected without a clean leave would otherwise never be removed. The userRooms HashMap records the room at join time, providing a reliable reference for sending PlayerLeft to the remaining players — same fix used in every other NetCrafter tutorial.

java — VRCharacterExtension.java
package com.piveclabs.vrcharacter;

import com.smartfoxserver.core.ISFSEvent;
import com.smartfoxserver.core.SFSEventParam;
import com.smartfoxserver.core.SFSEventType;
import com.smartfoxserver.entities.Room;
import com.smartfoxserver.entities.User;
import com.smartfoxserver.entities.data.ISFSObject;
import com.smartfoxserver.entities.data.SFSObject;
import com.smartfoxserver.extensions.SFSExtension;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

public class VRCharacterExtension extends SFSExtension {

    private final Map<Integer, Room> userRooms = new HashMap<>();

    @Override
    public void init() {
        addRequestHandler("PlayerUpdate", HandlePlayerUpdate.class);
        addEventHandler(SFSEventType.USER_JOIN_ROOM,  this::onUserJoinRoom);
        addEventHandler(SFSEventType.USER_LEAVE_ROOM, this::onUserLeaveRoom);
        addEventHandler(SFSEventType.USER_DISCONNECT, this::onUserDisconnect);
        trace("VRCharacterExtension initialized");
    }

    // ── Join: track room, announce to existing players ──────────────────

    private void onUserJoinRoom(ISFSEvent event) {
        User user = (User) event.getParameter(SFSEventParam.USER);
        Room room = (Room) event.getParameter(SFSEventParam.ROOM);
        if (room == null || user == null) return;
        userRooms.put(user.getId(), room);

        int    colorIndex = user.getId() % 8;
        int    spawnIndex = user.getId() % 8;
        String playerName = user.getName();

        // Tell existing players about the new joiner
        ISFSObject joinMsg = buildPlayerJoined(user.getId(), playerName, colorIndex, spawnIndex);
        Collection<User> others = room.getUserManager().getAllUsers();
        others.removeIf(u -> u.getId() == user.getId());
        if (!others.isEmpty()) send("PlayerJoined", joinMsg, others);

        // Tell the new joiner about everyone already in the room
        for (User existing : room.getUserManager().getAllUsers()) {
            if (existing.getId() == user.getId()) continue;
            int ec = existing.getId() % 8;
            int es = existing.getId() % 8;
            ISFSObject existingMsg = buildPlayerJoined(existing.getId(), existing.getName(), ec, es);
            send("PlayerJoined", existingMsg, user);
        }
    }

    // ── Leave: clean map, announce to remaining players ─────────────────

    private void onUserLeaveRoom(ISFSEvent event) {
        User user = (User) event.getParameter(SFSEventParam.USER);
        Room room = (Room) event.getParameter(SFSEventParam.ROOM);
        if (user == null) return;
        userRooms.remove(user.getId());
        if (room == null) return;
        ISFSObject leftMsg = buildPlayerLeft(user.getId());
        Collection<User> remaining = room.getUserManager().getAllUsers();
        if (!remaining.isEmpty()) send("PlayerLeft", leftMsg, remaining);
    }

    // ── Disconnect: use cached room (getLastJoinedRoom is null here) ─────

    private void onUserDisconnect(ISFSEvent event) {
        User user = (User) event.getParameter(SFSEventParam.USER);
        if (user == null) return;
        Room room = userRooms.remove(user.getId());
        if (room == null) return; // leave already fired and cleaned up
        ISFSObject leftMsg = buildPlayerLeft(user.getId());
        Collection<User> remaining = room.getUserManager().getAllUsers();
        if (!remaining.isEmpty()) send("PlayerLeft", leftMsg, remaining);
    }

    // ── Helpers ──────────────────────────────────────────────────────────

    private ISFSObject buildPlayerJoined(int playerId, String name, int colorIndex, int spawnIndex) {
        ISFSObject obj = SFSObject.newInstance();
        obj.putInt("playerId", playerId);
        obj.putString("playerName", name);
        obj.putInt("colorIndex", colorIndex);
        obj.putInt("spawnIndex", spawnIndex);
        return obj;
    }

    private ISFSObject buildPlayerLeft(int playerId) {
        ISFSObject obj = SFSObject.newInstance();
        obj.putInt("playerId", playerId);
        return obj;
    }
}
java — handlers/HandlePlayerUpdate.java
package com.piveclabs.vrcharacter.handlers;

import com.smartfoxserver.extensions.BaseClientRequestHandler;
import com.smartfoxserver.bitswarm.data.TransportType;
import com.smartfoxserver.entities.Room;
import com.smartfoxserver.entities.User;
import com.smartfoxserver.entities.data.ISFSObject;
import com.smartfoxserver.entities.data.SFSObject;
import java.util.Collection;

public class HandlePlayerUpdate extends BaseClientRequestHandler {

    @Override
    public void handleClientRequest(User sender, ISFSObject params,
                                    TransportType transport) {
        Room room = sender.getLastJoinedRoom();
        if (room == null) return;

        ISFSObject response = SFSObject.newInstance();
        // Authoritative sender id first — never trust a client-sent playerId.
        response.putInt("playerId", sender.getId());

        response.putFloat("posX", params.getFloat("posX"));
        response.putFloat("posY", params.getFloat("posY"));
        response.putFloat("posZ", params.getFloat("posZ"));
        response.putFloat("rotY", params.getFloat("rotY"));

        response.putFloat("headPosX", params.getFloat("headPosX"));
        response.putFloat("headPosY", params.getFloat("headPosY"));
        response.putFloat("headPosZ", params.getFloat("headPosZ"));
        response.putFloat("headRotX", params.getFloat("headRotX"));
        response.putFloat("headRotY", params.getFloat("headRotY"));
        response.putFloat("headRotZ", params.getFloat("headRotZ"));
        response.putFloat("headRotW", params.getFloat("headRotW"));

        response.putFloat("lHandPosX", params.getFloat("lHandPosX"));
        response.putFloat("lHandPosY", params.getFloat("lHandPosY"));
        response.putFloat("lHandPosZ", params.getFloat("lHandPosZ"));
        response.putFloat("lHandRotX", params.getFloat("lHandRotX"));
        response.putFloat("lHandRotY", params.getFloat("lHandRotY"));
        response.putFloat("lHandRotZ", params.getFloat("lHandRotZ"));
        response.putFloat("lHandRotW", params.getFloat("lHandRotW"));

        response.putFloat("rHandPosX", params.getFloat("rHandPosX"));
        response.putFloat("rHandPosY", params.getFloat("rHandPosY"));
        response.putFloat("rHandPosZ", params.getFloat("rHandPosZ"));
        response.putFloat("rHandRotX", params.getFloat("rHandRotX"));
        response.putFloat("rHandRotY", params.getFloat("rHandRotY"));
        response.putFloat("rHandRotZ", params.getFloat("rHandRotZ"));
        response.putFloat("rHandRotW", params.getFloat("rHandRotW"));

        // animState is int, grounded is float (0f/1f) — match the C# client's
        // wire types exactly (see Part B1's "why grounded is a float" note).
        response.putInt  ("animState",  params.getInt("animState"));
        response.putFloat("grounded",   params.getFloat("grounded"));
        response.putInt  ("stateHash",  params.getInt("stateHash"));
        response.putFloat("stateTime",  params.getFloat("stateTime"));
        response.putInt  ("colorIndex", params.getInt("colorIndex"));
        // Lets each peer disable THIS player's remote head/hand IK instead of
        // resolving toward a placeholder pose — see Part A2's desktop-mode note.
        response.putBool ("isDesktopMode", params.getBool("isDesktopMode"));

        Collection<User> others = room.getUserManager().getAllUsers();
        others.removeIf(u -> u.getId() == sender.getId());
        if (!others.isEmpty())
            send("PlayerStateUpdate", response, others);
    }
}

After editing the Java files, rebuild and redeploy: click Deploy to SFS3 in the NetCrafter window → Code → Server Code tab.

Then restart SmartFoxServer 3 and press Play in Unity to test.

NEXT

What's Next

You have a working multiplayer VR game. Here are natural next features:

In-Game Voice/Chat

Add a SendChat message (from the Chat tutorial) to the VRCharacter schema; hook the UI to VRUIManager.

Full-Body / Leg IK

This tutorial only syncs head + hands; IsBodyActive is currently false on the remote solver since no hip/root pose is sent — a future revision could add a BodyFollower-derived field.

Hand Grab Sync

GrabObjectHandAdjusted (OpenWorldsVR) isn't networked here; grabbed-object ownership would need its own message type.

Lag Compensation

At 20–50 Hz the lerp looks smooth at low latency; for higher latency, buffer a fixed receive delay the same way the Character tutorial suggests.