Multiplayer - bidirectional syncronization

Godot Version

v4.3

Question

I am trying to learn about how Multiplayer works. I am following the blog post about Scene Replication. In the chapter about Synchronizing properties I made a simpler example. I just created a Control and a Checkbutton. Then I added to MultiplayerSynchronizer the property CheckButton:button_pressed. I created three instances (1 server and 2 clients). The checkbutton gets synchronized across the three instances, but I can only press the CheckButton in the server, not in the 2 clients. Why is that?

Authority, it controls the direction

You would need to make another synchronizer that is authed to the client that sends the intent to change the state. And the authority will order events and send back the state. This is called central authority and it will prevent desync. As asynchronous packets can get dicy. And if you trust the authority your peers will have a harder time cheating.

This will also introduce a delay unless you apply client side prediction. Change the state now and ignore incoming authed state until it goes your way. But if you predict wrong how do you reconcile?

Bidirectional is a little easier with RPCs you just have a func that is reliable, call_local and any_peer (no authority). But you then lose easy authority control and starts to fill your code with net-code. :sweat: (Even though the packet is reliable it could still desync between peers in this setup, who is the source of truth when everything is shared?) You could sync time clocks, timestamp the change and only accept the freshest value. But that wouldn’t prevent cheating if this is a game mechanic.

1 Like

Thanks pennyloafers for your response. I am a newbie. Regarding the first paragraph, how can I do that? (or where can I read about it?)

My scene looks like:

  • Multiplayer
    • UI
      • Net
        • Options
          • Label
          • Host
          • Connect
          • Remote
    • Game
      • CheckButton
      • MultiplayerSynchronizer

I though that given that MultiplayerSynchronizer is available in all the instances it would be enough by setting the authority to each client. So I tried the following:

func start_game(peer):
	# Hide the UI and unpause to start the game.
	$UI.hide()
	$Game.show()
	get_tree().paused = false
	$Game/MultiplayerSynchronizer.set_multiplayer_authority(peer.get_unique_id())   #<<<<<<

But there are thousands of errors so this doesn’t look good.

That is expected, they only allow for one direction. Yes the multiplayer node exists on all peers and all agree on the authority (defaults to 1 the host). The authority peer will broadcast the state and will not accept changes that come from peers who may change their instance authority. (Which is secure and prevents cheating. )

For three peers, you will need three multiplayer synchronizers per process. One authed to each peer.

This gets more involved and Usually you use a multiplayerspawner to spawn a new peer scene that will be authed to the peer. A custom spawn function is useful here.

# do these before hand
$MultiplayerSpawner.spawn_function = my_custom_spawn
multiplayer.peer_connected.connect(_on_peer_connected)

func _on_peer_connected(peer_id:int):
  $MultiplayerSpawner.spawn(peer_id)

func my_custom_spawn(id):
  var player = preload("res://player.tscn").instantiate()
  player.name = str(id) 
  player.set_multiplayer_authority(id) 
  return player

to visually explain: this is your original setup

When you change authority for each peer you get authority errors because each thinks its the authority and no one accepts the others data.

What the prescribed use for “bi-directional” MultiplayerSynchronizers look like every one has a channel to eachother.

Thanks for the clarifications.

I managed to get the expected behaviour with the following:

func _on_check_button_pressed() -> void:
	#print(multiplayer.multiplayer_peer.get_unique_id())
	assign_authority.rpc(multiplayer.multiplayer_peer.get_unique_id())
	
@rpc("any_peer", "call_local")
func assign_authority(peer_id: int):
	#print("assigning peer_id: ", peer_id)
	$Game/MultiplayerSynchronizer.set_multiplayer_authority(peer_id)
1 Like

Nice, that’s an interesting take.

You will still have potential desync problems when the network quality is low.

Maybe this could mitigate that risk?

@rpc("any_peer", "call_local", "reliable")

At this stage I am not worried about latency.

I checked and it works with the server plus the client. But it is not working with one server and two clients. I was expecting that the rpc would be called in all the peers. How can I do that?

The way you have the code it should propagation to all peers, you can also try rpc_id(0, <method>) to broadcast. The path though may not be obvious. (Idk if it goes to the host first and is relayed to the peer. Or does it go directly to the peer? I haven’t investigates that level yet. )

The docs suggest the message goes to the server then other peers. You can try turning server_relay off to see if this changes behavior.

Adding reliable does not guarantee the time in which it is delivered, nor does it take into account when other packets for the same object arrive. It can only guarantee it will get delivered*. ( *well maybe, it will try real hard. There is a retry limit.)

On Localhost it will probably work 99.99% of the time as the packets never leave your computer. Real world networks, with delay and network congestion between clients, it will get into trouble.

Options

You can mitigate this if you pass a timestamp in the RPC when setting the auth, only changing it if the timestamp is newer then the last time it changed. But the timestamp will all have to come from the same clock source. And that is another topic anyway.

Secondary Concern

Then there comes the context on what it controls. In the simplest sense, lets say the first to hit the check box wins. Lets also say it appears in a random position and fairly to all peers. (I.e. Peers are time synced).

When every one goes to press it, peer2 was physically fastest and should win, but peer1, being the host, sets it first because it had no delay.

Now do you just declare peer1 the winner? No that’s not fair, you now need to wait until all the peers responses have come in to decide who the winner is. But who makes this decision?

This is yet another layer of complexity you need to add. Although this complexity isnt unique to this case. My main point is you need to take into consideration what this is controlling. it could be insignificant, but most likely will contribute to some competitiveness that players will demand fairness.

If you want a good read, check out this article series from Gaffer On Games

  1. Introduction to Networked Physics
  2. Deterministic Lockstep
  3. Snapshot Interpolation
  4. Snapshot Compression
  5. State Synchronization

They are meant to be read in order. Make sure to read them all, especially the last one.

I got a simple solution:

func _on_check_button_pressed() -> void:
	if !multiplayer.is_server():
		toggle_button.rpc()

@rpc("any_peer", "call_remote", "reliable")
func toggle_button():
	$Game/CheckButton.button_pressed = !$Game/CheckButton.button_pressed

I observed that multiplayer.get_peers() for the clients is just the server. That’s why originally only one client and the server got updated, but not the other client.

I will read carefully your suggestions to get a better understanding.

1 Like

This second solution is better, you shouldn’t run into a desync issue with one authority.