Muliplayer Syncronizer on Client Glitches Between Two Locations

Godot Version

4.6.2

Question

Hi! The game I’m creating is two PlayerBody2D’s pushing a RigidBody2D ball around. The issue is that on the server, the ball is in the correct position, but the client’s screen has the ball changing between the correct truth position that the server sees, and a different position, which I’m assuming is the client’s perceived truth position.

I’m not sure why the multiplayer_syncronizer position that I have inside the Coconut’s scene tree is swapping between two values. I’m new to this and any help would be appreciated, thanks!

EDIT: I’ve added linear_velocity to the multiplayer synchronizer and the position of the objects are closer, but they still jitter. I think now the client is modeling more accurately to the server, but I don’t know why the position keeps snapping instead of just being overwritten from the server. Here’s what that update looks like:

Here are some relevant code files:

coconut_spawner.gd

extends MultiplayerSpawner


@export var coconut_scene : PackedScene

var coconut_count : int = 0


# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(_delta: float) -> void:
	
	if Input.is_action_just_pressed("ui_accept"):
		spawn_coconut.rpc_id(1) # Send the input only to the server.


# Call local is required if the server is also a player.
@rpc("any_peer", "call_local", "reliable")
func spawn_coconut() -> void:
	
	if not multiplayer.is_server():
		return
	
	# The server knows who sent the input.
	#var sender_id : int = multiplayer.get_remote_sender_id()
	# Process the input and affect game logic.

	coconut_count += 1
	var coconut : Coconut = coconut_scene.instantiate()
	coconut.position = Vector2(600, 300)
	coconut.name = "Coconut_" + str(coconut_count)
	#$YSortedSprites.add_child(coconut)
	get_node(spawn_path).call_deferred("add_child", coconut)

player.gd

class_name player extends CharacterBody2D


var player_number : int


const SPEED = 500.0



func _enter_tree() -> void:
	set_multiplayer_authority(name.to_int())
	

func _physics_process(_delta: float) -> void:

	if not is_multiplayer_authority():
		return
	
	var direction : Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	velocity = direction * SPEED
	
	move_and_slide()
	
	# After move_and_slide() we apply "impulses" to physics objects
	var push_force : float = 60.0 # represents the player's inertia
	for i in get_slide_collision_count():
		var c : KinematicCollision2D = get_slide_collision(i)
		if c.get_collider() is RigidBody2D:
			c.get_collider().apply_central_impulse(c.get_normal() * -1 * push_force)

In both scenarios, the coconut was jittery on the client because the physics simulation fights with the network state arriving from the server. You’re correct about why the second video looks better: in your first attempt, you were running the physics simulation on the client with incomplete information.

Here is where it gets ugly. It might seem like the solution is just setting some flags in the physics engine to make it “smarter,” but sadly none of those approaches will work. From here, solutions split and depend on what you want to accomplish and how you want accomplish them.

The first thing you can try is making the coconut kinematic on clients and interpolating through code. This is commonly referred to as making the coconut a proxy object of the authority’s simulated coconut (in this case, the server). Then, you can “yield” simulation authority to the client at collision boundaries. You can get really far with this approach, and I recommend reading this series of articles by Glenn Fiedler about networked physics. The downside of the proposed approach in the article, is that it allows clients to cheat easily.

If your game needs to be competitive, you’ll need some sort of rollback with client-side prediction and server reconciliation. You can check out the netfox addon for this type of architecture.

Try enabling physics interpolation in the settings. I would also try adding angular_velocity to the synchronizer for the heck of trying.

There will always be some jitter, because it is a physics object that updates at a lower fixed rate than your monitor does, that is also trying to communicate over the internet or ethernet.

Maybe consider a different approach for “hitting” the ball as well. RigidBodies should be able to handle their physics on their own. Try removing the apply impulse and see what happens when you walk into it. I’m not familiar with 2D physics games, so can’t tell you for sure, I just know I tend to overcomplicate things too.

Thank you for the responses. I’ve tried to learn and test those other ideas, but I didn’t get far. Everyone links that Networking Physics article, and I understand the concepts, but I’m not sure how that gets implemented in Godot’s High-Level Multiplayer.

In response to my original question, the reason that the objects seem to jitter is that the MultiplayerSyncornizer and RigidBody2D update their positions at different points in the code. RigidBody2D’s don’t like getting their positions manually updated anywhere in the code, and the MultiplayerSyncronizer will update the position in one of those bad areas. You need to synchronize a placeholder position, and then update that position in the Godot _integrate_forces() function. The following code will use that integrate_forces function in the coconut.gd, which removes the jitter! Then in the player.gd script, we use an RPC function that will apply the impulse in the server’s physics engine. I think this works conceptually, but right now, the client will “push” an object that it’s next to as soon as it gets to it, send that to the server. Then that happens ~5 more times before the server responds to the first push. Therefore, the object is flung with an impulse 5x the size. This issue will only worsen once latency worsens.

coconut.gd

class_name Coconut extends RigidBody2D


# these are configured as "Watch" in the MultiplayerSynchronizer
@export var replicated_position : Vector2
@export var replicated_rotation : float
@export var replicated_linear_velocity : Vector2
@export var replicated_angular_velocity : float


func _integrate_forces(_state : PhysicsDirectBodyState2D) -> void:
	# Synchronizing the physics values directly causes problems since you can't
	# directly update physics values outside of _integrate_forces. This is
	# an attempt to resolve that problem while still being able to use
	# MultiplayerSynchronizer to replicate the values.

	# The object owner makes shadow copies of the physics values.
	# These shadow copies get synchronized by the MultiplyaerSynchronizer
	# The client copies the synchronized shadow values into the 
	# actual physics properties inside integrate_forces
	if is_multiplayer_authority():
		replicated_position = position
		replicated_rotation = rotation
		replicated_linear_velocity = linear_velocity
		replicated_angular_velocity = angular_velocity
	else:
		position = replicated_position
		rotation = replicated_rotation
		linear_velocity = replicated_linear_velocity
		angular_velocity = replicated_angular_velocity

player.gd


func _physics_process(delta: float) -> void:

	if not is_multiplayer_authority():
		return
	
	var direction : Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	velocity = direction * SPEED
	
	move_and_slide()
	
	#if direction != Vector2.ZERO:
		#get_collider().apply_central_impulse(direction.normalized() * SPEED * delta)
	
	 #After move_and_slide() we apply "impulses" to physics objects
	var push_force : float = 60.0 # represents the player's inertia
	for i in get_slide_collision_count():
		var c : KinematicCollision2D = get_slide_collision(i)
		if c.get_collider() is RigidBody2D:
			print("colliding on " + name + " with " + str(c.get_collider().get_path()))
			#c.get_collider().apply_central_impulse(c.get_normal() * -1 * push_force)
			#apply_force_to_body.rpc_id(1, inst_to_dict(c.get_collider()), c.get_normal() * -1 * push_force)
			if multiplayer.is_server():
				apply_force_to_body.rpc_id(1, c.get_collider().get_path(), c.get_normal() * -1 * push_force)
			else:
				# TODO why does this get called so much more than the other?
				# we're trying to tell the server to apply an impulse now
				# This approach doesn't seem valid... it's gonna get worse when I move off localhost...
				# TODO what if we tried to switch the multiplayer authority before we apply the impulse?
				apply_force_to_body.rpc_id(1, c.get_collider().get_path(), c.get_normal() * -1 * push_force) #/ 2)


@rpc("any_peer", "call_local", "unreliable")
func apply_force_to_body(rigid_body_name : String, impulse : Vector2):
#func apply_force_to_body(rigid_body_dict : Dictionary, impulse : Vector2):
	if multiplayer.is_server():
		print("applying impulse on " + name)
		#var rigid_body : RigidBody2D = dict_to_inst(rigid_body_dict)
		var rigid_body : RigidBody2D = get_node(rigid_body_name)
		rigid_body.apply_central_impulse(impulse)

This reddit post had the same issue:

RigidBody2D MultiplayerSynchronizer flickering/authority fighting? : r/godot

And this other post was the code I ended up using:

MultiplayerSynchronizer and RigidBody : r/godot

I’m gonna switch to a non-physics project for now and let this problem simmer. :slight_smile:

Hey I just wanted to follow up on this to say you should try setting freeze = true in the ready function. This is how I do it in my project. Then all you have to do is sync the position and rotation, no velocity

I also spent a lot of time trying to figure out an algorithm and client side prediction and this simple 2 lines of code fixed it for me


func _ready() → void:

if !is_multiplayer_authority():
     freeze = true