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.
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.
What it is: Installed and working — you completed the Chat or Character tutorial.
Namespace for this project: VRCharacter
What it is: Running locally — same setup as the earlier tutorials.
New zone: VRCharacterZone on port 9977
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.
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_EXAMPLEadded 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.
Use the OpenWorldsVR Sample Scene¶
- Open the OpenWorldsVR sample scene that ships with the asset (the one containing a working VR character — check the asset's own
Documentation/orExample Scenes/folder for the exact scene name) - 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
- Stop Play Mode. File → Save As, and save a working copy as:
Assets/Scenes/VRMultiplayerScene.unity
Work in VRMultiplayerScene from now on — do not modify the original sample scene.
- Confirm an
OpenWorldsVRManagercomponent exists in the scene and is enabled - Confirm your OpenXR runtime is running and a headset is connected (or
PCtoVRis enabled onOpenWorldsVRManagerfor desktop testing) - Check the Console for
OpenWorldsVRManager instance is not initializederrors — 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
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:
- In the Project window, navigate to:
Assets/Plugins/OpenWorlds/OpenWorldsInputs/ - Select OpenWorlds InputSystem Actions (
.inputactionsfile) - In the Inspector, click: "Assign as the Project-wide Input Actions"
- 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):
- In the Hierarchy, find the
OpenWorldsVRManagerGameObject - 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.
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.
| Component | Role |
|---|---|
OpenWorldsVRManager.Instance.player | The character root GameObject — spawned at runtime, holds Animator and (for the local player only) CharacterController |
HeadFollower | Scene-singleton MonoBehaviour; its transform tracks the XR camera (HMD) each LateUpdate |
LHandFollower / RHandFollower | Scene-singleton MonoBehaviours; their transforms track the left/right XR controllers |
PlayerIKSolver | Drives the character's arm/head IK from the followers (local player) or from network data (remote player) |
CharacterController | Added to player at runtime by OpenWorldsVRManager; handles thumbstick movement |
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:
remoteHeadTargetremoteLHandTargetremoteRHandTarget
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" moderemoteHeadTarget,remoteLHandTarget,remoteRHandTarget(GameObject) — a first-class remote-player path already built into the SDK: whenremotePlayeris 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
| Data | Source | Wire representation |
|---|---|---|
| Character root position | player.transform.position | 3 floats (posX/Y/Z) |
| Character root yaw | player.transform.eulerAngles.y | 1 float (rotY) — upright only |
| Head pose | HeadFollower.transform, relative to the player root | 3 floats + 4-float quaternion |
| Left hand pose | LHandFollower.transform, relative to the player root | 3 floats + 4-float quaternion |
| Right hand pose | RHandFollower.transform, relative to the player root | 3 floats + 4-float quaternion |
| Animator state | Speed (float), Grounded (bool), state hash + normalized time | 2 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.
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:
| Field | Value |
|---|---|
| Host | 127.0.0.1 |
| Zone Name | VRCharacterZone |
| Extension Name | VRCharacter |
| Extension Class | com.piveclabs.vrcharacter.VRCharacterExtension |
| TCP Port | 9977 |
| Enable MMO | ☐ unchecked |
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:
| Field | Value |
|---|---|
| Namespace | VRCharacter |
| Overwrite Files | ✓ |
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):
| Field | Type |
|---|---|
posX | float |
posY | float |
posZ | float |
rotY | float |
Head (7):
| Field | Type |
|---|---|
headPosX | float |
headPosY | float |
headPosZ | float |
headRotX | float |
headRotY | float |
headRotZ | float |
headRotW | float |
Left hand (7):
| Field | Type |
|---|---|
lHandPosX | float |
lHandPosY | float |
lHandPosZ | float |
lHandRotX | float |
lHandRotY | float |
lHandRotZ | float |
lHandRotW | float |
Right hand (7):
| Field | Type |
|---|---|
rHandPosX | float |
rHandPosY | float |
rHandPosZ | float |
rHandRotX | float |
rHandRotY | float |
rHandRotZ | float |
rHandRotW | float |
Animator + misc (6):
| Field | Type |
|---|---|
animState | int |
grounded | float |
stateHash | int |
stateTime | float |
colorIndex | int |
isDesktopMode | bool |
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.
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:
| Field | Type |
|---|---|
playerId | int |
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:
| Field | Type |
|---|---|
playerId | int |
playerName | string |
colorIndex | int |
spawnIndex | int |
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:
| Field | Type |
|---|---|
playerId | int |
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
PlayerJoined and PlayerLeft are handled automatically by SFS3's room lifecycle.Generate All Code¶
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.- Code → Build Code → Validate Definition, then Generate All Files
- Code → Server Code → Generate Server Code
- Build JAR (mvn package) — wait for
BUILD SUCCESS - Deploy to SFS3
Key generated files land in Assets/PivecLabs/NetCrafter/Definitions/VRCharacter/Generated/Client/:
PlayerUpdateMessage.csPlayerJoinedEvent.cs/PlayerLeftEvent.csPlayerStateUpdateEvent.csSmartFoxConnectionManager.cs
Enable the generated type guard
- Edit → Project Settings → Player → Other Settings → Scripting Define Symbols
- Add
NETCRAFTER_VR_GENERATED - Apply
Both NETCRAFTER_VR_EXAMPLE and NETCRAFTER_VR_GENERATED should be present now.
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.
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).The remote character prefab's Animator component must have the OpenWorldsVR animator controller assigned:
- Open the remote prefab in Prefab Editor
- Find the Animator component
- Assign:
OpenWorldsVR_Animator.controller(found inAssets/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:
RemoteVRCharacterControllerVRColorManagerPlayerNameDisplay
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:
- Right-click
RemoteVRCharacterPrefab→ UI → Canvas, name itNameCanvas, Render Mode → World Space - Position
X=0, Y=2.2, Z=0, Rotation0,0,0, Scale0.01, 0.01, 0.01 - Rect Transform: Width
200, Height50 - Right-click
NameCanvas→ UI → Text - TextMeshPro, name itNameText, centre-aligned, font size36 - On
PlayerNameDisplay: dragNameTextinto 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:
Assets/PivecLabs/Examples/VR/Prefabs/RemoteVRCharacterPrefab.prefab
Delete it from the Hierarchy afterward.
Components on the final prefab
| Component | Status |
|---|---|
| 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/desktopRHandPosonVRNetworkManager— 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.
Scene Setup¶
Create the NetworkManager
- Hierarchy → Create Empty, name it
NetworkManager, reset its Transform - Add Component →
VRNetworkManager - Drag
RemoteVRCharacterPrefab.prefabinto 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:
| Event | Target Method |
|---|---|
PlayerJoined | VRNetworkManager.OnPlayerJoined() |
PlayerLeft | VRNetworkManager.OnPlayerLeft() |
PlayerStateUpdate | VRNetworkManager.OnPlayerStateUpdate() |
VRNetworkManager subscribes to RoomJoined itself in Start() via NetCrafterEventDispatcher — no manual wiring needed for that one.
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; callsSmartFoxConnectionManager.ConnectAndLogin(playerName), notLogin()directly.SmartFoxConnectionManagerno longer opens its socket automatically inStart()— 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 theCONNECTIONevent actually fires.OnRoomJoined()→ startsWaitForLocalPlayerThenSend(), a coroutine that polls untilOpenWorldsVRManager.Instance.playerexists. This wait is necessary becauseOpenWorldsVRManagerspawns 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/RHandFollowerviaFindAnyObjectByType<T>()(they're scene singletons, not children ofplayer— see Part A3) and starts the send loop. SendPlayerUpdate()— runs at 20 Hz (50 Hz while theSpeedAnimator parameter indicates movement). Computes head/hand pose relative to the player root (InverseTransformPoint/rotation delta) and packs everything into aPlayerUpdateMessage.OnPlayerJoined()/OnPlayerLeft()— spawn/destroy remote avatarGameObjects.OnPlayerStateUpdate()— forwards the deserialized event straight to the matchingRemoteVRCharacterController.SetNetworkState(...).
Assign in the Inspector:
| Field | Value |
|---|---|
| Remote Character Prefab | RemoteVRCharacterPrefab |
| Ui Manager | UIManager (auto-found if left empty) |
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
- Right-click
UICanvas→ UI → Panel — name itLoginPanel - TMP Text title:
VR Demo - TMP InputField
PlayerNameInput, placeholderEnter your name... - TMP Button
ConnectButton, labelConnect - TMP Text
StatusText
Game Panel
- Right-click
UICanvas→ UI → Panel — name itGamePanel, set inactive - TMP Text
PlayerListText
Attach and wire
- Empty GameObject
UIManager, attachVRUIManager - Wire all Inspector fields
- Wire
ConnectButtonOn Click →VRNetworkManager.Connect()
SFS3 Zone Config¶
- Code → Config Generator → Refresh Preview
- Generate VRCharacterZone.zone.xml
- Deploy Config to SFS3
Or deploy manually to:
C:\Users\YourName\SmartFoxServer3\server\zones\VRCharacterZone.zone.xml
Restart SFS3 and confirm in the server log:
{VRCharacter}: VRCharacterExtension initialized
Full Test Sequence¶
Start SmartFoxServer 3
Run SmartFoxServer3.exe. Confirm VRCharacterExtension initialized in the server log before proceeding.
First player
- Put on your headset, press Play in Unity (or launch a standalone build)
- Enter a name, click Connect
- 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.playerbecomes non-null) - Hand followers activated (
LHandFollowerandRHandFollowerare 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
PCtoVRonOpenWorldsVRManagerfor 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(inremotePlayermode) - 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.
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.
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;
}
}
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.
What's Next¶
You have a working multiplayer VR game. Here are natural next features: