foxssake / netfox

Addons for building multiplayer games with Godot

Home Page:https://foxssake.github.io/netfox/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

RigidBody sync doesn't work properly

Suero152 opened this issue · comments

I'm developing a multiplayer game, I'm using WebRTC to connect peers to the host and this one host peer with the id 1 ( signalling its the server ) , the problem is that peers that join can't sync with older peers, even if correct connected to the WebRTC Network, also, everything seems to be unsynced in the incorrect spot.

RollbackSynchronizer is syncing this state: :global_transform.origin

online_player.gd

extends PlayerNode

@export var input: PlayerInput

@onready var rollback_synchronizer = $RollbackSynchronizer

# CAUTION
# Please, don't make your changes to the player node here,
# only do such if you understand what you're doing.
# prefer to modify "player.gd" instead.
# CAUTION

func shoot_ball(_play_audio):
	if is_multiplayer_authority():
		if $MultiplayerSynchronizer.get_multiplayer_authority() != multiplayer.get_unique_id():
			rpc_id(name.to_int(), '_kick_debounce')
		super(false)
	KickAudio.play()

func _ready():
	super()
	var player_id = name.to_int()
	await get_tree().process_frame
	
	
	set_multiplayer_authority(1)
	input.set_multiplayer_authority(player_id)
	rollback_synchronizer.process_settings()
	
	
	if player_id != OnlineManager.id:
		$Camera.queue_free()
		$PlayerContact.queue_free()


func _physics_process(_delta):
	pass

func _rollback_tick(delta, _tick, _is_fresh):
	move(delta)

func _process(_delta):
	
	if $MultiplayerSynchronizer.get_multiplayer_authority() == multiplayer.get_unique_id():
		if Input.is_action_pressed('kick') and kick_button_released:
			kicking = true
		else:
			kicking = false
			if !Input.is_action_pressed('kick'):
				kick_button_released = true

	if kicking and skill_in_use == '':
		player_border.texture = border_spritesheet['active']
		if ball_node and !kick_debounce:
			shoot_ball(true)
	elif skill_in_use != '':
		player_border.texture = border_spritesheet[skill_in_use]
	else:
		player_border.texture = border_spritesheet['normal']

func move(delta: float) -> void:
	
	if input.movement != Vector2.ZERO:
		var velocity = input.movement.normalized() * move_speed * delta
		apply_central_impulse(velocity)
	else:
		var opposite_force = -linear_velocity * deceleration_factor * delta
		apply_central_impulse(opposite_force)
		
		
	# CAUTION! this is very simplistic implementation of an skill, this is intended to change drastically.
	if Input.is_action_pressed('skill_1') and (Time.get_ticks_msec() - last_skill_time) > skill_cooldown * 1000:
		skill_in_use = 'dash'
		apply_central_impulse(Vector2(input.movement.normalized()) * dash_power * delta)
		last_skill_time = Time.get_ticks_msec()
		await get_tree().create_timer(0.45).timeout
		skill_in_use = ''

@rpc("authority")
func _kick_debounce():
	kick_button_released = false
	kick_debounce = true
	kicking = false
	await get_tree().create_timer(0.1).timeout
	kick_debounce = false

player_input.gd

extends BaseNetInput
class_name PlayerInput

var movement: Vector2 = Vector2.ZERO

func _gather():
	if not is_multiplayer_authority():
		return
	movement = Vector2(
		Input.get_action_strength("move_right") - Input.get_action_strength("move_left"),
		Input.get_action_strength("move_down") - Input.get_action_strength("move_up")
	)

image

image

simplescreenrecorder-2024-02-05_22.02.13.webm

Hi @Suero152, I half-remember seeing an email notification about changing :global_transform.origin to :global_position - did that solve the issue? Property paths can't do properties of properties, only properties of other nodes, e.g. PlayerInput:movement, but not PlayerInput:movement.x.

What should I do? In fact I created another issue because global_transform wasnt working, so I needed to change it to global_transform.origin, which worked, but now not all the peers are synced

I haven't worked with p2p in Godot yet, so I'm not that familiar. It looks like the input gets to the server, but the server fails to broadcast the new state to the other players.

Do you get any errors or warnings? What is the config for MultiplayerSynchronizer?

Also, to be clear, p2p is not supported officially, but I'm happy to help here!

Hello, thanks so much for replying and offering your help, I'll provide most info I can down below, Let's try to fix my issue and make netfox better to p2p :) .

Godot Output:

Invalid property path error happens some times and I believe its due to the character spawn process, after the player is spawned it doesnt happens anymore.

image

Network Profiler of Host ( peer 1 ):
image

Network profiler of Peer 2:
image

Rollback synchronizer config:
image

Multiplay synchronizer config:
image

Player input gdscript:

extends BaseNetInput
class_name PlayerInput

var movement: Vector2 = Vector2.ZERO

func _gather():
	if not is_multiplayer_authority():
		return
	movement = Vector2(
		Input.get_action_strength("move_right") - Input.get_action_strength("move_left"),
		Input.get_action_strength("move_down") - Input.get_action_strength("move_up")
	)

Player gdscript:

extends PlayerNode

@export var input: PlayerInput

@onready var rollback_synchronizer = $RollbackSynchronizer

func _ready():
	super()
	var player_id = name.to_int()
	await get_tree().process_frame
	
	
	set_multiplayer_authority(1)
	input.set_multiplayer_authority(player_id)
	rollback_synchronizer.process_settings()



func _physics_process(_delta):
	pass

func _rollback_tick(delta, _tick, _is_fresh):
	move(delta)

func _process(_delta):
    # REDACTED

func move(delta: float) -> void:
	
	if input.movement != Vector2.ZERO:
		var velocity = input.movement.normalized() * move_speed * delta
		apply_central_impulse(velocity)
	else:
		var opposite_force = -linear_velocity * deceleration_factor * delta
		apply_central_impulse(opposite_force)
		
		
	# CAUTION! this is very simplistic implementation of an skill, this is intended to change drastically.
	if Input.is_action_pressed('skill_1') and (Time.get_ticks_msec() - last_skill_time) > skill_cooldown * 1000:
		skill_in_use = 'dash'
		apply_central_impulse(Vector2(input.movement.normalized()) * dash_power * delta)
		last_skill_time = Time.get_ticks_msec()
		await get_tree().create_timer(0.45).timeout
		skill_in_use = ''

Spawning player gdscript:

extends Node2D

var player = preload("res://scenes/characters/online_player.tscn")
var last_world_state_time = 0



func _ready():
	OnlineManager.player_joined.connect(_on_player_joined)
	OnlineManager.player_left.connect(_on_player_left)

func _on_player_joined(new_peer_id, _presence):
	if new_peer_id:
		for peer_id in OnlineManager.currentLobby.peer_ids:
			if !(get_node_or_null(str(peer_id))):
				SpawnPlayer(peer_id, Vector2(0,0))

func _on_player_left(peer_id, _presence):

	if peer_id:
		RemovePlayer(peer_id)


func SpawnPlayer(player_id: int, spawn_position: Vector2):
	var new_player = player.instantiate()
	new_player.name = str(player_id)
	new_player.position = spawn_position
	add_child(new_player)

func RemovePlayer(player_id: int):
	var player_node:RigidBody2D = get_node(str(player_id))
	remove_child(player_node)

Some webrtc connection codes, not the full script, only essential parts:

func RTCPeerConnected(new_peer_id):

	var players : Dictionary = currentLobby.players
	var player: Player
	
	for key in players:
		var _player: Player = players[key]
		if _player.peer_id == new_peer_id:
			player = _player

	if player:
		currentLobby.peer_ids[new_peer_id] = player.session_id
		var peer_session_id = currentLobby.peer_ids[new_peer_id]
		if peer_session_id:
			NetworkTime.start()
			player_joined.emit(new_peer_id, null) # Remember to sync this with presences inside: currentLobby.presences
	
func RTCPeerDisconnected(peer_id):
	var peer_session_id = OnlineManager.currentLobby.peer_ids[peer_id]
	if peer_session_id:
		OnlineManager.currentLobby.peer_ids.erase(peer_id)
		player_left.emit(peer_id, null) # Remember to sync this with presences inside: currentLobby.presences

# WebRTC Connection with other peers
func connectPeer(player: Player):

	# CAUTION also check for session id and user id to ensure user cant run multiple instances of the game and join a game he's already in.
	if WebRTCPeer and currentLobby and (player.peer_id != self.id) and !(WebRTCPeer.has_peer(player.peer_id)):
		var RTCPeerConnection : WebRTCPeerConnection = WebRTCPeerConnection.new()
		
		RTCPeerConnection.initialize({
			"iceServers" : [{ "urls": ["stun:stun.l.google.com:19302"] }]
		})
		
		
		RTCPeerConnection.session_description_created.connect(self.offerCreated.bind(player.peer_id))
		RTCPeerConnection.ice_candidate_created.connect(self.iceCandidateCreated.bind(player.peer_id))
		
		WebRTCPeer.add_peer(RTCPeerConnection, player.peer_id)
		
		if player.peer_id < self.id:

			await get_tree().create_timer(.2).timeout # Theres a delay to create the offer so everything is setup on both sides.
			RTCPeerConnection.create_offer()
			
func offerCreated(type, data, peer_id):
	if !WebRTCPeer.has_peer(peer_id):
		return
		
	WebRTCPeer.get_peer(peer_id).connection.set_local_description(type, data)
	
	if type == "offer":
		sendOffer(peer_id, data)
	else:
		sendAnswer(peer_id, data)
	pass
	
	
func sendOffer(peer_id, data):
	if currentLobby:
		var json = JSON.stringify({"org_peer_id": self.id, "peer_id": peer_id, "offer_data": data})
		await NakamaConnection.socket.send_match_state_async(currentLobby.match_id, Message.offer,json)

	pass

func sendAnswer(peer_id, data):
	if currentLobby:
		var json = JSON.stringify({"org_peer_id": self.id, "peer_id": peer_id, "offer_data": data})

		await NakamaConnection.socket.send_match_state_async(currentLobby.match_id, Message.answer,json)

	pass

func iceCandidateCreated(midName, indexName, sdpName, peer_id):
	if currentLobby:
		var json = JSON.stringify({"org_peer_id": self.id, "peer_id": peer_id, 
		"mid": midName, "index": indexName, "sdp": sdpName})
		

		
		await NakamaConnection.socket.send_match_state_async(currentLobby.match_id, Message.candidate,json)
	pass

Thank you for the detailed post! Also nice to see some Nakama in there, I plan to try that too some day 🙂


So what is currently happening is that all the clients send their inputs to the server, and the server can update everyone's state based on that input. The clients can also update their own state, because they know their own input.

The reason the clients don't sync is that RollbackSynchronizer has no properties it can sync. That's what the invalid property path errors mean - those properties will be ignored. I see that global_transform didn't work in #199. Could you try global_position or just position? If those don't work, then it's probably something specific to rigid bodies and will need some research on what's special about how Godot handles those 🙂

Thanks for replying again.

I tested your suggestions as you can see in the vids I linked, but both behaved the same way as using global_transform , since you can't manually edit a RigidBody2D position like that, you HAVE to use the .origin property in order to it to work.

I don't think It's global_transform.origin fault, because as you can see in the first first video I've sent in this topic, some clients in fact syncs ( which can mean that it's not global_transform.origin fault, I can be wrong tho ) , but some others not.

First video of this topic:

As you can see, some clients are being synced, which is good, but some others aren't

simplescreenrecorder-2024-02-05_22.02.13.webm

Video trying your solution such as global_position and position

simplescreenrecorder-2024-02-09_14.56.10.webm
simplescreenrecorder-2024-02-09_14.53.30.webm

I don't think It's global_transform.origin fault

RollbackSynchronizer definitely can't use :global_transform.origin as a property path, and thus can't sync that value as I have explained above.


You can't manually edit a RigidBody2D position like that, you HAVE to use the .origin property in order to it to work

I have suspected the first part, but wasn't aware of having to use .origin. Would you be ok with sharing some version of your project so I can take a look and experiment a bit on my end? Alternatively, as a workaround, I think you could try creating a dummy variable like my_pos, copying the position into that variable in the rollback tick, and setting global_transform.origin to my_pos in _physics_process or wherever it's appropriate.

The idea is to have a dummy variable that can be used to sync the position, and then apply that position in a way that is appropriate for RigidBody2D.


In a wider context, I think it could be worth to sync not just the position, but angular- and linear velocity. Netfox can certainly sync these, but some wrapper code could be needed to interact with the physics system.

Where can I get in touch to send you my project? I'll send to you so you can analyze it and maybe improve netfox code somehow within my use case.

I'll try your dummy variable solution later when I can and reply to you if it worked or not, thank you so much for helping, I really appreciate! :)

My contacts:

Discord: @sueroo
Telegram: @Suerow

@elementbound

Hello, again!

I tried doing your solution with multiple approachs, but most of them didn't worked or made the character laggy, that's because _physics_process enters in a race condition with _rollback_tick, so, somehow, they will be snapping both variables to the initial position. Let's say that if the player starts in Vector2(10,10) and starts to trying move he will stay on Vector2(10,10) forever.

I know this is the reason because now I don't get any error about non-existing properties and debugged player_position value, which was fine when not being set together with global_transform.origin

I tried setting the character position to the player_position var both in _physics_process and _integrate_forces ( which is another way to change a RigidBody2D position ) , but I still get the same error...

_integrate_forces version:

func _integrate_forces(state):
	if reset_state:
		state.transform = Transform2D(0.0, player_position)
		reset_state = false

func _physics_process(delta):
		# Apply movement forces based on input
		move(delta)
		reset_state = true


func _rollback_tick(delta, _tick, _is_fresh):
		# Update the player's position from the synchronized value
		player_position = global_transform.origin

physics_process version:

func _physics_process(delta):
		# Apply movement forces based on input
		move(delta)
		global_transform.origin = player_position

func _rollback_tick(delta, _tick, _is_fresh):
		# Update the player's position from the synchronized value
		player_position = global_transform.origin

If you want any further details or some debugging info say to me, thanks one more time for guiding me ;) .

Sources:
How to set position of Rigidbody2D

This is starting to look like an issue with syncing physics objects. I'll add you on discord.

For anyone else reading, I'll update this thread.

Okay, I'll be waiting for the contact.

Discord: @sueroo

tl;dr: Closing, physics-based multiplayer is really not possible without manual physics stepping support in Godot.


A good approach for physics-based interactions over the network would be the same thing netfox currently does for rollback - inputs are sent to the server and simulated locally, then the server responds with the true state. Due to latency, the server will receive older inputs, so it will roll back its state to the earliest input, and re-run the simulation based on known inputs. It broadcasts updates along the way.

Clients do the same, they receive the true state from the server, but for older ticks, so the rest are simulated locally and adjusted as updates arrive.

For both client and server, we need to resimulate ticks, which needs manual physics stepping ( i.e. calling some method to ask the physics engine to simulate a single tick ). This is currently not supported by Godot. Ok, then why not just simulate the local player as usual, send the inputs to the server, then adjust based on server response?

We can update the state, but it will not be picked up by the physics server and we can't do a proper resimulation from there, due to the missing manual stepping support. So the best the server can do is to treat all incoming inputs as current, even if they were sent a few ticks ago. Which would lead to constant inconsistencies, having the client jitter around as it readjusts based on server response.

And even with manual stepping, there'd be the challenge of physics interaction, e.g. what happens when a client thinks a collision happened and simulates it, but then the server says it didn't happen.


So what can we do? Although not the most responsive approach, but a fully server-controlled solution could work. Players only send their inputs ( and don't simulate any movement ), and wait for the server to respond. The server will treat every input as the most recent one, so no timestamping is needed.

The clients receive the state, but for display, they use a fixed delay ( e.g. 50ms ). This introduces a fixed component of latency, since the game will display the state as it was 50ms ago, but that also means that the network has an extra 50ms to correct course if a packet gets lost or arrives late. Note that this latency will also make the controls feel less responsive.


Alternatively, depending on the game, the necessary subset of physics can be re-implemented, since custom code can be written to be compatible with rollback. Note that collisiion detection would also have to be done by custom code, since the rollbacks are not picked up by Godot's physics server.

PhysicsServer3D.body_set_state might work for forced transform updates for physics objects.


netfox doesn't have explicit support for the above, but the custom tick loop and TickInterpolator can be useful. History support could be added on to StateSynchronizer to support display offsets similar to RollbackSynchronizer.