Back to Blog

Godot XR Multiplayer: Under the Hood of Spatial Sync and Time-Rewind Netcode

Published on May 31, 2026
Godot XR Multiplayer: Under the Hood of Spatial Sync and Time-Rewind Netcode

In a nutshell

Optimize your godot xr multiplayer network stack with battle-tested spatial sync and time-rewind netcode patterns to eliminate VR motion sickness.

Building a godot xr multiplayer game is a fast track to producing severe physical motion sickness if your spatial synchronization falls behind by even a single frame. When a player moves their head or hands at a native 90Hz refresh rate, any networked jitter or late-state synchronization creates a jarring disconnect between their physical body and the virtual world. Resolving this spatial latency requires bypassing traditional flat-screen netcode paradigms and implementing custom predictive tracking buffers.

By examining advanced temporal mechanics and custom netcode patterns, you can keep client cameras and physics entities running in absolute sync without overloading your server bandwidth.

Under the Hood: The Extreme Challenge of Spatial Sync in VR Multiplayer

In a typical flat-screen multiplayer game, you synchronize a single character capsule collider consisting of a 3D position vector (12 bytes) and a 1D rotation float (4 bytes). Sending this 16-byte payload at 30Hz results in roughly 480 bytes per second of raw data. In a godot xr multiplayer context, you must synchronize three separate high-frequency tracked components simultaneously: the head-mounted display (HMD) and two motion controllers. Each tracked point requires a 3D position vector (12 bytes) and a 3D orientation quaternion (16 bytes), totaling 28 bytes per point.

Synchronizing all three points represents 84 bytes per frame. At a standard 90Hz refresh rate, this demands 7,560 bytes (7.56 KB) per second of raw payload. Add standard UDP packet overhead (28 bytes per header), and a single client saturates up to 10.08 KB/s. In an 8-player lobby, the server must process and broadcast up to 564 KB/s of high-frequency data, creating massive downstream congestion and packet queue delays.

While standard flat-screen games can tolerate minor discrepancies—as detailed in our guide on how to fix player location desync in Unreal Engine multiplayer—VR games are completely unforgiving. Even a 3-frame spatial desync can trigger acute vestibular mismatch, leaving players physically nauseous. To combat this, developers must transition from rigid server-authoritative replication to client-side prediction schemes with historical delta reconciliation.

5 Spatial Lessons from Godot XR Community Game Jam V Entries

The fifth Godot XR Community Game Jam V featured 98 participants submitting 23 games focused on the theme "Rewind". These projects pushed the boundaries of Godot 4's spatial architectures, demonstrating creative applications of temporal manipulation in immersive spaces. Below are the key spatial synchronization and netcode lessons we extracted from the top five entries of the jam.

1. Tactile Physics and Interaction States (Inspired by Rewind Tower)

The tournament-winning entry, Rewind Tower, features a tactile tower defense system where players wind up mechanical units on a coin-operated board and rewind them to keep them in battle. In a multiplayer scenario, synchronizing physical grabs and analog spring-winding states introduces critical race conditions.

If two players attempt to grab the same unit simultaneously, a naive replication system will cause the object to rapidly teleport between the two players as they battle for network authority. To solve this, developers must implement a deterministic priority queue on the server that temporarily designates one client as the physical authority, locking out external grab inputs until the object is released.

2. Hand-Held Analog Controls and Continuous Input Sync (Inspired by Chrono Crank)

Taking second place, Chrono Crank relies on a steampunk-style handheld device with a large physical crank used to manipulate the flow of time. Replicating physical crank rotation requires synchronizing a high-precision, continuous float value across all connected players.

Analog levers and knobs require continuous position updates rather than binary state triggers. Attempting to sync these interactive physics coordinates using high-frequency RPCs is a common pitfall that mirrors the exact issues developers face when fixing the multiplayer RPC replication issues that break world states.

Instead, you should synchronize the dial's localized rotation angle as a compressed float variable, interpolating the state on receiving clients using Hermite spline interpolation.

3. Spatial Recording and Scrubbing (Inspired by NeuroCorp Training Demo)

In the third-place project, NeuroCorp Training Demo, players are teleported into immersive recordings of past physical events, allowing them to scrub backward and forward through time to complete tasks. In a shared lobby, synchronization of these temporal scrub events requires a global state buffer.

When a player scrubs the recording, the system must synchronize the playback timestamp across all clients. Because loading an entire room's physical history frame-by-frame consumes substantial memory, the network layer must serialize frame data using index-based keyframe structures, allowing clients to interpolate locally between discrete position samples rather than streaming continuous transform frames.

4. Parallel Spatial Timelines (Inspired by Last Minute)

Last Minute, which finished in fourth place, is an escape room game where players manipulate time to solve puzzles in real-time. Managing asynchronous time states inside a multiplayer game room is an architectural nightmare.

If Player A rewinds a puzzle box to its state 10 seconds ago while Player B is currently holding a key from that box, the server must decouple the spatial timeline of the box from the global player timeline. To achieve this, the game world must be structured into independent temporal zones, allowing individual spatial nodes to process history buffers independently while keeping active players locked to the current system tick rate.

5. Multi-Entity Prediction and Platform Synced Motion (Inspired by ScrewTheTime)

ScrewTheTime secured fifth place as a VR platformer where the player controls both their character and the temporal state of moving platforms. Synchronizing a player standing on a moving, rewinding platform in VR requires parent-child coordinate space transforms.

If the server only synchronizes absolute global coordinates, the tiny latency variations between the platform's movement and the player's HMD updates will cause the player to slide off the platform or shake uncontrollably. To fix this, child the player's spatial origin point to the moving platform node on the client-side, translating all local head movement calculations relative to the platform's local basis before broadcasting updates to the server.

Deep Dive: Building a Delta-Compressed Spatial Recording System in Godot 4

To build a highly responsive godot xr multiplayer experience with rewind capabilities, you must implement a spatial recording buffer that compresses tracked positions. The following production-grade GDScript 4 class records local tracking data (HMD and controllers) into a ring buffer, executes delta compression to minimize network traffic, and performs spherical linear interpolation (SLERP) for smooth playback on remote clients.

# xr_spatial_sync_manager.gd
extends Node
class_name XRSpatialSyncManager

# Inner class to store high-frequency spatial tracking data for a single frame
class TrackingFrame extends RefCounted:
	var timestamp: float = 0.0
	var head_pos: Vector3 = Vector3.ZERO
	var head_rot: Quaternion = Quaternion.IDENTITY
	var left_hand_pos: Vector3 = Vector3.ZERO
	var left_hand_rot: Quaternion = Quaternion.IDENTITY
	var right_hand_pos: Vector3 = Vector3.ZERO
	var right_hand_rot: Quaternion = Quaternion.IDENTITY

	# Compress the frame into a lightweight dictionary for network serialization
	func serialize() -> Dictionary:
		return {
			"t": timestamp,
			"hp": [head_pos.x, head_pos.y, head_pos.z],
			"hr": [head_rot.x, head_rot.y, head_rot.z, head_rot.w],
			"lp": [left_hand_pos.x, left_hand_pos.y, left_hand_pos.z],
			"lr": [left_hand_rot.x, left_hand_rot.y, left_hand_rot.z, left_hand_rot.w],
			"rp": [right_hand_pos.x, right_hand_pos.y, right_hand_pos.z],
			"rr": [right_hand_rot.x, right_hand_rot.y, right_hand_rot.z, right_hand_rot.w]
		}

	# Decompress a network dictionary back into a fully typed frame
	static func deserialize(data: Dictionary) -> TrackingFrame:
		var frame = TrackingFrame.new()
		frame.timestamp = data["t"]
		frame.head_pos = Vector3(data["hp"][0], data["hp"][1], data["hp"][2])
		frame.head_rot = Quaternion(data["hr"][0], data["hr"][1], data["hr"][2], data["hr"][3])
		frame.left_hand_pos = Vector3(data["lp"][0], data["lp"][1], data["lp"][2])
		frame.left_hand_rot = Quaternion(data["lr"][0], data["lr"][1], data["lr"][2], data["lr"][3])
		frame.right_hand_pos = Vector3(data["rp"][0], data["rp"][1], data["rp"][2])
		frame.right_hand_rot = Quaternion(data["rr"][0], data["rr"][1], data["rr"][2], data["rr"][3])
		return frame

# --- Configuration Properties ---
@export var sync_threshold_position: float = 0.005 # 5mm threshold for delta-compression
@export var sync_threshold_rotation: float = 0.02  # ~1.15 degrees threshold for angular changes
@export var buffer_max_time: float = 2.0           # Store up to 2 seconds of local history
@export var playback_interpolation_delay: float = 0.033 # 33ms interpolation window

# --- Node References ---
@export var xr_origin: XROrigin3D
@export var xr_camera: XRCamera3D
@export var left_controller: XRController3D
@export var right_controller: XRController3D

# --- Internal Buffers ---
var history_buffer: Array[TrackingFrame] = []
var last_sent_frame: TrackingFrame = null

func _ready() -> void:
	if not xr_origin or not xr_camera or not left_controller or not right_controller:
		push_warning("XRSpatialSyncManager: Tracked nodes are not fully assigned!")

# Record the current spatial state of the VR user
func capture_current_frame() -> TrackingFrame:
	var frame = TrackingFrame.new()
	frame.timestamp = Time.get_ticks_msec() / 1000.0
	
	# Capture relative transforms inside the XR origin space
	frame.head_pos = xr_camera.transform.origin
	frame.head_rot = xr_camera.transform.basis.get_rotation_quaternion()
	frame.left_hand_pos = left_controller.transform.origin
	frame.left_hand_rot = left_controller.transform.basis.get_rotation_quaternion()
	frame.right_hand_pos = right_controller.transform.origin
	frame.right_hand_rot = right_controller.transform.basis.get_rotation_quaternion()
	
	return frame

# Push a captured frame into our sliding history ring buffer
func store_frame(frame: TrackingFrame) -> void:
	history_buffer.append(frame)
	
	# Prune frames older than the maximum buffer timeline window
	var expiration_time = frame.timestamp - buffer_max_time
	while history_buffer.size() > 0 and history_buffer[0].timestamp < expiration_time:
		history_buffer.remove_at(0)

# Evaluate if the current frame differs enough from the last sync frame to justify sending a network packet
func check_delta_compression(current: TrackingFrame) -> bool:
	if last_sent_frame == null:
		return true # Always send the initial setup frame
	
	# Check spatial distance changes
	var head_pos_diff = current.head_pos.distance_squared_to(last_sent_frame.head_pos)
	var left_pos_diff = current.left_hand_pos.distance_squared_to(last_sent_frame.left_hand_pos)
	var right_pos_diff = current.right_hand_pos.distance_squared_to(last_sent_frame.right_hand_pos)
	
	if head_pos_diff > (sync_threshold_position * sync_threshold_position) or \
	   left_pos_diff > (sync_threshold_position * sync_threshold_position) or \
	   right_pos_diff > (sync_threshold_position * sync_threshold_position):
		return true
		
	# Check angular orientation changes
	var head_rot_diff = current.head_rot.angle_to(last_sent_frame.head_rot)
	var left_rot_diff = current.left_hand_rot.angle_to(last_sent_frame.left_hand_rot)
	var right_rot_diff = current.right_hand_rot.angle_to(last_sent_frame.right_hand_rot)
	
	if head_rot_diff > sync_threshold_rotation or \
	   left_rot_diff > sync_threshold_rotation or \
	   right_rot_diff > sync_threshold_rotation:
		return true
		
	return false

# Rewind or playback history buffer: interpolates between tracked frame states at a specific timestamp
func get_interpolated_state(target_time: float) -> TrackingFrame:
	if history_buffer.is_empty():
		return null
		
	# Clamp target time to available history
	if target_time <= history_buffer[0].timestamp:
		return history_buffer[0]
	if target_time >= history_buffer[-1].timestamp:
		return history_buffer[-1]
		
	# Find surrounding frames
	var prev_frame = history_buffer[0]
	var next_frame = history_buffer[-1]
	
	for i in range(history_buffer.size() - 1):
		if history_buffer[i].timestamp <= target_time and history_buffer[i + 1].timestamp >= target_time:
			prev_frame = history_buffer[i]
			next_frame = history_buffer[i + 1]
			break
			
	var span = next_frame.timestamp - prev_frame.timestamp
	var factor = 0.0
	if span > 0.0001:
		factor = (target_time - prev_frame.timestamp) / span
		
	# Perform linear and spherical linear interpolation
	var interpolated = TrackingFrame.new()
	interpolated.timestamp = target_time
	interpolated.head_pos = prev_frame.head_pos.lerp(next_frame.head_pos, factor)
	interpolated.head_rot = prev_frame.head_rot.slerp(next_frame.head_rot, factor)
	interpolated.left_hand_pos = prev_frame.left_hand_pos.lerp(next_frame.left_hand_pos, factor)
	interpolated.left_hand_rot = prev_frame.left_hand_rot.slerp(next_frame.left_hand_rot, factor)
	interpolated.right_hand_pos = prev_frame.right_hand_pos.lerp(next_frame.right_hand_pos, factor)
	interpolated.right_hand_rot = prev_frame.right_hand_rot.slerp(next_frame.right_hand_rot, factor)
	
	return interpolated

This system ensures that even if you drop frames or experience network latency variance, you can reconstruct past transforms accurately. Remote clients can seamlessly interpolate historical tracking states, keeping VR hand gestures and interactions looking incredibly fluid.

Implementing Time-Rewind in Multiplayer: The Manual Nightmare

If you choose to write this entire system by hand, you face significant infrastructure overhead. You must set up room orchestrators, host custom authoritative server instances in Linux environments, and configure raw UDP sockets via ENetMultiplayerPeer. To avoid player disconnects on home networks, you must build custom STUN/TURN traversal servers to bypass Carrier-Grade NAT (CGNAT) firewalls.

Additionally, maintaining a persistent global state database that can process thousands of high-frequency records per second requires specialized clustering configurations. You will spend 4 to 6 weeks of dedicated engineering time just writing backend infrastructure rather than designing your game.

Building this yourself requires setting up load balancers, database sharding, and SSL cert management — easily 4-6 weeks of work. With horizOn, these backend services come pre-configured, letting you ship your game instead of your infrastructure.

Simplifying Godot XR Multiplayer with horizOn

Instead of wasting development cycles on complex low-level networking and server provisioning, you can integrate horizOn directly into your project. The platform's real-time room engine provides optimized WebRTC and low-latency UDP pipelines designed explicitly for high-frequency VR data serialization.

By leveraging horizOn's pre-configured room structures, your game can automatically pair users based on regional latency metrics, keeping spatial synchronizations tight. Because all backend routing, security tokens, and player matchmaking are managed via a simple SDK, you can deploy a scalable, multi-region VR experience in minutes.

4 Netcode Best Practices for Godot XR Multiplayer

Applying proper netcode conventions ensures that your game performs cleanly regardless of individual client network quality. Incorporate these four techniques into your next spatial layout:

  1. Decouple Network Tick Rate from Physics Frame Rate: Never lock your network replication tick rate directly to the client's HMD refresh rate. Run your game logic and transform synchronization at a stable 30Hz or 45Hz, and use client-side linear and spherical linear interpolation to reconstruct the native 90Hz/120Hz frames locally.
  2. Prioritize HMD Tracking Updates Over Controllers: Head tracking is the primary contributor to VR simulator sickness. Allocate higher network priority and larger packet bandwidth slices to the user's camera transform, while applying heavier delta compression and lower priority limits to hand transforms.
  3. Enforce Distance and Orientation Thresholds: Do not broadcast packets if a player's spatial changes are negligible. Set up explicit delta-compression guard bands (e.g., 5mm of position movement or 0.02 radians of rotation) to eliminate idle updates and conserve bandwidth.
  4. Employ Client-Side Authority for Physical Grabs: When a player grabs an interactive object, instantly switch the node's network authority to the grabbing client. This prevents local lag from creating a visible separation between their controller and the held item, while allowing the server to asynchronously audit final object placement.

Next Steps: Ship Your XR Multiplayer Experience

Developing high-frequency multiplayer environments in VR requires a careful balance of visual stability and strict bandwidth management. By incorporating delta compression, local historical buffering, and client-side authority, you can build immersive environments that keep players stable and connected.

Ready to scale your multiplayer backend? Try horizOn for free or check out the API docs.


Source: Godot XR Community Game Jam V

This dashboard is made with love by Projectmakers

© 2026 projectmakers.de

unknown-v1.87.6 / unknown-v--