Multiplayer xr - players that join follow player one's position

Godot Version

4.2 stable


Hi! I have a multiplayer game and with the regular Camera3D each player controls and views the game from their own player. So I know that works.

I have the same setup using XRcameras, but when players join their view is stuck to player 1’s, even if that player is not using VR (however, for reference, the xrcamera node is still on all player scenes, just not being used.) They still control their own character as expected though, outside of the camera being stuck to player 1’s position

The multiplayer synchronizer has the xrcamera and xrorigin positions & rotations.

Curious if there’s any resources on getting multiplayer with VR to work that I could look at?

Here’s the player code for good measure:

extends CharacterBody3D

const SPEED = 5.0
const JUMP_VELOCITY = 4.5
@export var force = 5
@export var mouse_sens = 0.005
@export var cam_sens = SPEED / 2
var flying = false
var camera_anglev=0

var DiscScene = preload("res://scenes/frolf_disc.tscn")

# Get the gravity from the project settings to be synced with RigidBody nodes.
var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
@onready var xr_camera = $XROrigin3D/XRCamera3D
@onready var camera = $CamPivot/Camera3D
@onready var cam_pivot = $CamPivot
@onready var throwpoint = $CamPivot/Camera3D/ThrowPoint
@onready var menu = $CamPivot/Menu

func _enter_tree():

#TODO: Separate XR camera & player somehow so there's only one instance?
func _ready():
	#Only run this if we're allowed
	if (Global.singleplayer == false and not is_multiplayer_authority()):
	#Avoid falling through the world for some reason when first connecting to server
	elif (Global.singleplayer == false and is_multiplayer_authority()):
	#Setup VR vs flatscreen
	var xr_interface = XRServer.find_interface("OpenXR")
	if xr_interface and xr_interface.is_initialized():
		xr_camera.current = true
		# Turn off v-sync!
		# Change our main viewport to output to the HMD
		get_viewport().use_xr = true
		camera.current = true
		print("OpenXR not initialized, fallback to flatscreen.")

func _unhandled_input(event):
	#Only run this if we're allowed
	if (Global.singleplayer == false and not is_multiplayer_authority()):  return
	#Pause button
	if event.is_action_pressed("ui_cancel"):
		#Release mouse & toggle pause
		if menu.visible:
		menu.visible = !menu.visible
	#If paused, don't move camera
	if menu.visible == true:	
	#toggle flying mode
	if event.is_action_pressed("fly"):
		flying = !flying
	if event.is_action_pressed("respawn"):
	#Capture mouse
	if event is InputEventMouseButton:
	#Camera Movement
	if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
		if event is InputEventMouseMotion:
			cam_pivot.rotate_y(-event.relative.x * mouse_sens)
			camera.rotate_x(-event.relative.y * mouse_sens)

func respawn():
	global_position = get_tree().root.get_node("World/ParkingLot").global_position

#Helper - Update disc position & return the node
func spawn_disc():
	#Add disc to scene
	var disc = DiscScene.instantiate()
	disc.position = throwpoint.position	#Local position avoids glitches
	disc.freeze = true					#Avoid applying gravity while holding
	add_child(disc)						#Add after position to avoid glitches

func update_disc():
	var disc = get_node("FrolfDisc")
	disc.global_position = throwpoint.global_position
	disc.rotation = Vector3(camera.rotation.x, cam_pivot.rotation.y, 0)
	return disc

func throw_disc():
	#Throw disc
	var disc = update_disc()
	disc.freeze = false
	disc.apply_impulse(-disc.basis.z * force)

func _physics_process(delta):
	#Only run this if we're allowed
	if (Global.singleplayer == false and not is_multiplayer_authority()):  return
	#Exit if paused
	if menu.visible == true:
	# Add the gravity.
	if not is_on_floor() and not flying:
		velocity.y -= gravity * delta
	# Handle jump.
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = JUMP_VELOCITY
	#Throw the disc
	var disc_held = get_node_or_null("FrolfDisc")
	if Input.is_action_just_pressed("throw"):
	elif disc_held and !Input.is_action_just_released("throw"):
		#Hold disc until throw
	elif disc_held and Input.is_action_just_released("throw"):

	# Get the input & look direction, handle the movement/deceleration.
	var input_dir = Input.get_vector("left", "right", "forward", "backward")
	var rotate_dir = Input.get_vector("look_right", "look_left", "look_down", "look_up")
	var direction = (cam_pivot.transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
	if rotate_dir:
		cam_pivot.rotate_y(rotate_dir.x * delta * cam_sens)
		camera.rotate_x(rotate_dir.y * delta * cam_sens)
	if direction:
		velocity.x = direction.x * SPEED
		velocity.z = direction.z * SPEED
		velocity.x = move_toward(velocity.x, 0, SPEED)
		velocity.z = move_toward(velocity.z, 0, SPEED)
	#Make sure the camera doesn't look too far up or down
	camera.rotation.x = clampf(camera.rotation.x, -deg_to_rad(45), deg_to_rad(70))

The thing to keep in mind is that the XR nodes are special because they are positioned by tracking data coming from YOUR headset.

This causes 2 issues when networking:

  1. You now have both your network logic trying to position your nodes and the XR tracking system trying to position your nodes. That’s going to fail.

  2. If you have 4 players, and you end up with 4 XROrigin3D nodes, and 4 XRCamera3D nodes, all 4 sets of nodes are going to get their positioning information from the XR tracking system.

So you need to change your networking approach. Instead of using the same scenes and nodes for all players, you need two child scenes.

  1. a child scene for your player using the XROrigin3D and XRCamera3D nodes that you instantiate for your player entity, which takes its positioning from the tracking system and your user input and sends updates of its positioning to the networking server.

  2. a child scene for all the other players that uses normal nodes that take their positioning from the network.

There are a number of variations on this theme but the bottom line is, don’t try and replicate the XR nodes over the network as is, you just end up fighting between the tracking system and the replication over the network.


So I was working on this while waiting for my post’s approval and I decided to create an autoload for the VR so that there’s only one VR rig instantiated at a time, and it’s no longer a child of the player. Instead, the player sets the position of the xr origin if it’s the multiplayer authority. That and I don’t sync it with the multiplayersync anymore, so your were right about that.

That seemed to work for now when I test one flatscreen host and a VR client, but I haven’t had a chance to test multiple connected VR users. Theoretically I think it should work though since now there’s only one xrcamera on the whole scene at any time and the issue went away.

I also haven’t actually implemented VR controls yet, just using gamepad/KB+m control so we’ll see how that goes. But for now I’m gonna mark this as solved.


That sounds like an excellent solution as well!

1 Like