Godot 4.4 manual spawning and syncing for multiplayer

Godot Version

4.4

Question

Hi,
I’m trying to setup manual multiplayer spawning and syncing with rpc calls. I’m collecting player state and trying to attempt to update the client according to a delta with full states being sent when a player connects and disconnects but I am at a loss and haven’t been able to make it work.

Anyone able to give a brief explanation (with examples if possible)

Current Setup

  • Note i did not include any RPC calls as I’ve established that they are not the issue and the data is indeed being transferred.
  • Yes i know get_tree().get_multiplayer() works but both the client and server have multiple multiplayer connections and I can use that.
  • Yes I know that there is no anti cheat measure implemented in the server yet I’m just trying to get the implementation working first.
  • No I cannot use the multiplayer spawner or synchronizer as the server structure is drastically different from the client

Server Side

battle.gd:

This is the instance of each battle/match, this is where the world state is managed

var full_world_state: Dictionary
var last_world_state: Dictionary
var world_state_delta: Dictionary

func _physics_process(_delta: float) -> void:
	if full_world_state.is_empty(): # Checks if there is any game state
		return
	else:
		if last_world_state.is_empty(): # Checks if we have a last state
			last_world_state = full_world_state.duplicate(true)
		else:
			world_state_delta = calculate_state_delta(last_world_state, full_world_state) # Gets the world state delta
	
	world_state_delta["T"] = (Time.get_unix_time_from_system() * 1000)
	get_parent().return_state_delta(world_state_delta, self)


func calculate_state_delta(last_state: Dictionary, current_state: Dictionary) -> Dictionary:
	var delta: Dictionary = {}
	
	# Iterate through all items in the current state
	for player_id in current_state:
		# Get the sub-dictionaries for the current item
		var current_item = current_state[player_id]
		var last_item = {}
		if last_state.has(player_id):
			last_item = last_state[player_id]

		var item_delta = {}

		# Check for changes in Position ("P")
		if current_item.has("P"):
			if not last_item.has("P") or current_item.P != last_item.P:
				item_delta["P"] = current_item.P

		# Check for changes in Rotation ("R")
		if current_item.has("R"):
			if not last_item.has("R") or current_item.R != last_item.R:
				item_delta["R"] = current_item.R

		# If any changes were found for this item, add it to the main delta
		if not item_delta.is_empty():
			delta[player_id] = item_delta

	return delta

func update_player_state(player_state: Dictionary, player_id: int) -> void:
	if full_world_state.has(player_id): # Checks if player state exists
		if full_world_state[player_id]["T"] < player_state["T"]: # Checks if player_state is newer
			full_world_state[player_id] = player_state
	else:
		full_world_state[player_id] = player_state # Adds player_state if it doesnt exist

func remove_player_state(player_id: int) -> void:
	full_world_state.erase(player_id)
	world_state_delta.erase(player_id)

func get_full_world_state() -> Dictionary:
	full_world_state["T"] = (Time.get_unix_time_from_system() * 1000)
	return full_world_state

matchmaker.gd

This is where each battle is instanced and also determines what data goes to what player

func find_battle(player_id: int) -> void:
	if ongoing_battles == {}:
		_create_battle()
		_join_battle(player_id)
	else:
		_join_battle(player_id)

func _create_battle() -> void:
	var battle_scene: Node = packed_battle_scene.instantiate()
	battle_scene.name = "Battle " + str(battle_counter)
	
	battle_scene.map = "dev_map"
	battle_scene.team_blue = "united_states"
	battle_scene.team_red = "germany"
	battle_scene.max_players = 20
	
	matchmaker.add_child(battle_scene, true)
	ongoing_battles[battle_scene] = []
	battle_counter += 1

func _join_battle(player_id: int) -> void:
	var result: bool = false
	var map: String
	var battle_joined: Node
	for battle in ongoing_battles:
		var players_in_battle = ongoing_battles[battle]
		if players_in_battle.size() < battle.max_players:
			ongoing_battles[battle].append(player_id)
			map = battle.map
			battle_joined = battle
			result = true
			
			if battle.blue_team_list.size() <= battle.red_team_list.size():
				battle.blue_team_list.append(player_id)
			else:
				battle.red_team_list.append(player_id)
			break
	
	if result:
		GameServer.return_join_battle(map, player_id)
		GameServer.spawn_new_player(ongoing_battles[battle_joined], player_id, Vector3(1, 1, 1))
		if ongoing_battles[battle_joined].size() >= 1:
			GameServer.return_full_world_state(battle_joined.get_full_world_state(), player_id)
		player_to_battle_map[player_id] = battle_joined
	else:
		print("No available battles found. Player was not added.")

func player_left(player_id: int) -> void:
	var battle: Node
	if player_to_battle_map.has(player_id):
		battle = player_to_battle_map[player_id]
		GameServer.despawn_player(ongoing_battles[battle], player_id)
		if battle.blue_team_list.has(player_id):
			battle.blue_team_list.erase(player_id)
		if battle.red_team_list.has(player_id):
			battle.red_team_list.erase(player_id)
		ongoing_battles[battle].erase(player_id)
		player_to_battle_map.erase(player_id)
		
		battle.remove_player_state(player_id) # Removes the player's state from the battle
		var full_world_state: Dictionary = battle.get_full_world_state()
		for player in battle:
			GameServer.return_full_world_state(full_world_state, player)

func update_state(player_state: Dictionary, player_id: int) -> void:
	var battle: Node
	if player_to_battle_map.has(player_id):
		battle = player_to_battle_map[player_id]
	
	battle.update_player_state(player_state, player_id)

func return_state_delta(world_state_delta: Dictionary, battle: Node) -> void:
	for player in ongoing_battles[battle]:
		GameServer.return_world_state_delta(world_state_delta, player)

Client Side

Player.gd

this is where the player sends the updates (called by the _physics_process)

func define_player_state() -> void:
	player_state = {"T": (Time.get_unix_time_from_system() * 1000), "P": get_global_position(), "R": get_rotation()}
	GameServer.send_player_state(player_state)

PlayerHandler

The playerhandler is what ties it all together

var last_state_time: float
var full_state_sent: bool = false

func spawn_new_player(player_id: int, position: Vector3, rotation: Vector3 = Vector3.ZERO) -> void:
	if GameServer.get_multiplayer().get_unique_id() == player_id:
		return
	
	var new_player: CharacterBody3D = player_template.instantiate()
	new_player.position = position
	new_player.name = str(player_id)
	new_player.rotation_degrees = rotation
	player_handler.add_child(new_player, true)

func despawn_player(player_id: int) -> void:
	if player_handler.has_node(str(player_id)):
		player_handler.get_node(str(player_id)).queue_free()

func update_world_state(world_state: Dictionary, is_full_state: bool) -> void:
	if full_state_sent and not is_full_state:
		full_state_sent = false
		return
	elif last_state_time < world_state["T"]:
		for player in world_state:
			if str(player) == "T":
				continue
			if player == GameServer.get_multiplayer().get_unique_id():
				continue
			if player_handler.has_node(str(player)):
				var player_node: CharacterBody3D = player_handler.get_node(player)
				if player.has("P"):
					var new_position: Vector3 = player["P"]
					player_node.move_player(new_position)
				if player.has("R"):
					var new_rotation: Vector3 = player["R"]
					player_node.rotate_player(new_rotation)
			else:
				if player.has("P") and player.has("R"):
					spawn_new_player(player, player["P"], player["R"])
				elif player.has("P"):
					spawn_new_player(player, player["P"])
	
	full_state_sent = is_full_state # Checks if state recieved is full state or a delta

End Goal

  • have a setup that buffers world state to be able to lerp
  • have the lerping work with world_state_delta updates
  • But most of all have a synced multiplayer world