Physics based object grabbing with multiplayer issue

Godot Version

4.4.stable

Question

`Only the host of the game is able to grab the objects. Whenever the client/joiner attempts to pick the object, it jitters back and forward, when letting go it reverts to the normal position before i grabbed the object. Essentially only the host has control.


The grabbing script:

class_name InventoryAndGrabbing

@onready var player = $".."
@onready var interaction = $"../Camera/rigidGrip"
@onready var hand = $"../Camera/distance"

var picked_object: RigidBody3D = null
var gravitatePower: float = 120.0
var hand_distance: float = 1.0
var hand_min_distance: float = 1.0
var hand_max_distance: float = 2.0
var hand_distance_speed: float = 0.5

var previous_error := Vector3.ZERO
const P = 80.0  # Proportional gain
const I = 2.0    # Integral gain
const D = 12.0   # Derivative gain
var integral := Vector3.ZERO

func _ready():
	hand.position = Vector3(0.0, 0.0, -hand_distance)

func _input(event):
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_WHEEL_UP:
			hand_distance = clamp(hand_distance + hand_distance_speed, hand_min_distance, hand_max_distance)
		elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
			hand_distance = clamp(hand_distance - hand_distance_speed, hand_min_distance, hand_max_distance)
		hand.position = Vector3(0.0, 0.0, -hand_distance)
	
	if event.is_action_pressed("left click") and picked_object == null:
		pick_object()
	if event.is_action_released("left click") and picked_object != null:
		drop_object()

func _physics_process(delta):
	if picked_object != null:
		var a = picked_object.global_transform.origin
		var b = hand.global_transform.origin
		var error = b - a
		var mass_factor = 1.0 / picked_object.mass
		
		integral += error * delta
		var derivative = (error - previous_error) / delta
		previous_error = error
		
		var force = (P * error + I * integral + D * derivative) * mass_factor
		
		# Velocity damping in lateral directions
		var current_vel = picked_object.linear_velocity
		var lateral_vel = current_vel - current_vel.project(error.normalized())
		picked_object.apply_central_impulse(-lateral_vel * mass_factor * delta * 60)
		
		# Apply main force while preserving vertical velocity for gravity
		var current_vertical_velocity = picked_object.linear_velocity.y
		picked_object.apply_central_force(force)
		picked_object.linear_velocity.y = current_vertical_velocity

func pick_object():
	var collider = interaction.get_collider()
	if collider != null and collider is RigidBody3D:
		picked_object = collider
		picked_object.angular_damp = 2.0 + (4.0 / picked_object.mass)
		picked_object.contact_monitor = true
		picked_object.max_contacts_reported = 1
		picked_object.set_use_continuous_collision_detection(true)

func drop_object():
	if picked_object != null:
		picked_object.set_use_continuous_collision_detection(false)
		picked_object.angular_damp = 0.5
		picked_object = null
		integral = Vector3.ZERO
		previous_error = Vector3.ZERO

The multiplayer logic:


var enet_peer = ENetMultiplayerPeer.new()
@export var player_scene : PackedScene

func _on_host_button_pressed():
	var host_ip = get_local_ip()
	print("Hosting on IP: ", host_ip)
	
	var port_input_text = $CanvasLayer/PortInput.text.strip_edges()
	if port_input_text == "" or port_input_text.to_int() <= 0:
		print("Please insert a valid port number before hosting.")
		return
	
	var port = port_input_text.to_int()
	
	enet_peer.create_server(port)
	multiplayer.multiplayer_peer = enet_peer
	multiplayer.peer_connected.connect(add_player)
	multiplayer.peer_disconnected.connect(remove_player)
	
	add_player(multiplayer.get_unique_id())
	upnp_setup(port)
	$CanvasLayer.hide()

func _on_join_button_pressed():
	var ip = $CanvasLayer/IPInput.text.strip_edges()
	if ip.is_empty():
		print("Please enter an IP address.")
		return
	
	var port_input_text = $CanvasLayer/joinPortInput.text.strip_edges()
	if port_input_text == "" or port_input_text.to_int() <= 0:
		print("Please insert a valid port number before joining.")
		return
	
	var port = port_input_text.to_int()
	
	enet_peer.create_client(ip, port)
	multiplayer.multiplayer_peer = enet_peer
	$CanvasLayer.hide()

func add_player(peer_id):
	var player = player_scene.instantiate()
	player.name = str(peer_id)
	add_child(player)

func remove_player(peer_id):
	var player = get_node_or_null(str(peer_id))
	if player:
		player.queue_free()

func get_local_ip():
	var addresses = IP.get_local_addresses()
	for address in addresses:
		if address.begins_with("192.168.") or address.begins_with("10.") or address.begins_with("172."):
			return address
	return "IP not found"

func upnp_setup(port):
	var upnp = UPNP.new()
	var discover_result = upnp.discover()
	assert(discover_result == UPNP.UPNP_RESULT_SUCCESS, "UPNP Discover Failed! Error %s" % discover_result)
	assert(upnp.get_gateway() and upnp.get_gateway().is_valid_gateway(), "UPNP Invalid Gateway!")
	var map_result = upnp.add_port_mapping(port)
	assert(map_result == UPNP.UPNP_RESULT_SUCCESS, "UPNP Port Mapping Failed! Error %s" % map_result)
	print("Success! Join Address: %s" % upnp.query_external_address())

func _unhandled_input(event):
	if Input.is_action_just_pressed("quit"):
		get_tree().quit()

func _on_toggle_ip_button_pressed():
	var ip_label = $CanvasLayer/LocalIPLabel
	ip_label.visible = !ip_label.visible
	if ip_label.visible:
		ip_label.text = "Local IP: " + get_local_ip()```

I don’t see how you are sending the new position of the grabbed object to every player. I’m guessing you’re using the MultiplayerSynchronizer. That node only synchronizes from the multiplayer authority (by default, the host) of the synchronizer node to every other peer. If another peer tries to move the synchronized node the synchronizer will reset its position to its position on the multiplayer authority.

Read through High-level multiplayer — Godot Engine (stable) documentation in English and think about how you want object grabbing to work from a multiplayer perspective. What happens if multiple people grab at the same time? Can all players always grab all objects? Then you probably use RPCs to handle that logic; MultiplayerSynchronizer might be a little too simple for this hard problem.

yeah, you’re right, im using the synchronizer node. I should definitely read the docs more, as im not experienced with multiplayer too much.

I took your advice about RPCs to handle the logic, and managed to cook up a script that allows every player to grab an object, and it syncs across everyone.

However, the objects aren’t synced by any external force (like player colliding into a ball, etc.)-- because I disabled the MultiplayerSynchronizer node’s rigid body position syncing.

If i enable both, it causes jittering and sluggish grabbing for the other peers. they might be fighting over the object’s position and velocity, causing the jitter.

perhaps an idea is to use the MultiplayerSynchronizer only for non-grabbed objects-- when an object is grabbed, disable the synchronizer and rely on RPCs, then re-enable it when released. But that might be complex to manage.

Do you know of a way to sync player collision interactions into the rigidbodies without conflicting with the grabbing mechanism?


The grabbing script:

class_name InventoryAndGrabbing

@onready var player = $".."
@onready var interaction = $"../Camera/rigidGrip"
@onready var hand = $"../Camera/distance"

var picked_object: RigidBody3D = null
var gravitatePower: float = 120.0
var hand_distance: float = 1.0
var hand_min_distance: float = 1.0
var hand_max_distance: float = 2.0
var hand_distance_speed: float = 0.5

# PID-like control parameters
var previous_error := Vector3.ZERO
const P = 80.0  # Proportional gain
const I = 2.0    # Integral gain
const D = 12.0   # Derivative gain
var integral := Vector3.ZERO

func _ready():
	hand.position = Vector3(0.0, 0.0, -hand_distance)

func _input(event):
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_WHEEL_UP:
			hand_distance = clamp(hand_distance + hand_distance_speed, hand_min_distance, hand_max_distance)
		elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
			hand_distance = clamp(hand_distance - hand_distance_speed, hand_min_distance, hand_max_distance)
		hand.position = Vector3(0.0, 0.0, -hand_distance)
	
	if event.is_action_pressed("left click") and picked_object == null:
		pick_object()
	if event.is_action_released("left click") and picked_object != null:
		drop_object()

func _physics_process(delta):
	if picked_object != null and picked_object.get_multiplayer_authority() == multiplayer.get_unique_id():
		var a = picked_object.global_transform.origin
		var b = hand.global_transform.origin
		var error = b - a
		var mass_factor = 1.0 / picked_object.mass
		
		# PID-like controller calculations
		integral += error * delta
		var derivative = (error - previous_error) / delta
		previous_error = error
		
		# Calculate force with PID terms
		var force = (P * error + I * integral + D * derivative) * mass_factor
		
		# Velocity damping in lateral directions
		var current_vel = picked_object.linear_velocity
		var lateral_vel = current_vel - current_vel.project(error.normalized())
		picked_object.apply_central_impulse(-lateral_vel * mass_factor * delta * 60)
		
		# Apply main force while preserving vertical velocity for gravity
		var current_vertical_velocity = picked_object.linear_velocity.y
		picked_object.apply_central_force(force)
		picked_object.linear_velocity.y = current_vertical_velocity
		
		# Sync position and velocity with other peers
		rpc("update_grabbed_object", picked_object.get_path(), picked_object.global_transform.origin, picked_object.linear_velocity)

@rpc("any_peer", "unreliable")
func update_grabbed_object(object_path: NodePath, position: Vector3, velocity: Vector3):
	var obj = get_node_or_null(object_path)
	if obj and obj != picked_object and obj.get_multiplayer_authority() != multiplayer.get_unique_id():
		obj.global_transform.origin = position
		obj.linear_velocity = velocity

func pick_object():
	var collider = interaction.get_collider()
	if collider != null and collider is RigidBody3D:
		# Set authority and notify others
		collider.set_multiplayer_authority(multiplayer.get_unique_id())
		rpc("set_object_authority", collider.get_path(), multiplayer.get_unique_id())
		# Local setup
		picked_object = collider
		picked_object.angular_damp = 2.0 + (4.0 / picked_object.mass)
		picked_object.contact_monitor = true
		picked_object.max_contacts_reported = 1
		picked_object.set_use_continuous_collision_detection(true)

func drop_object():
	if picked_object != null:
		# Reset authority to server and notify others
		picked_object.set_multiplayer_authority(1)
		rpc("set_object_authority", picked_object.get_path(), 1)
		# Local cleanup
		picked_object.set_use_continuous_collision_detection(false)
		picked_object.angular_damp = 0.5
		picked_object = null
		integral = Vector3.ZERO
		previous_error = Vector3.ZERO

@rpc("any_peer", "reliable")
func set_object_authority(object_path: NodePath, authority_id: int):
	var obj = get_node_or_null(object_path)
	if obj:
		obj.set_multiplayer_authority(authority_id)

also side note, when both players grab an object at the same time, i was eventually going to implement some magnetic/resistance force from the players grabbing a single object-- however thats a bit too complicated at the moment. So for now, only one person can grab an object at a time.

Glad you figured out RPCs! Again, I think it depends on your goals for the game. The simplest way would be to broadcast the position constantly from the authority (whoever that might be). You can test changing the authority on the mulitplayer synchronizer but I would not be surprised if it doesn’t work. The high level spawner and synchronizer nodes often do not work well outside of their intended use case.

There is no trivial way to synchronize collisions like you seem to want. You can look at Rocket League for an example, they needed to do client side prediction on every ball and car https://www.youtube.com/watch?v=ueEmiDM94IE. Also take a look at Introduction to Networked Physics | Gaffer On Games and Client-Side Prediction and Server Reconciliation - Gabriel Gambetta

Don’t worry, you don’t need to hyper-optimize your game’s bandwidth usage or switch to a dedicated server. However, I think you might end up wanting prediction and all those links are good references.