How to save, then broadcast server-side player states

Godot Version

4.3

Background

So, I’m still refactoring my netcode. But I’ve gotten to the point where I’m starting to have a solid understanding of the code I’m working with and am starting to implement advanced features.

Before I ask my question, I need to explain how my netcode works. I’ll explain it in steps:

  1. The project has a main scene with 2 nodes. the Client_Manager and Server_Manager, that manage the client and server respectively.
  2. Upon opening the project, the game will check if the game file is a client or a server via if OS.has_feature("dedicated_server"):.
  3. Depending on the result, one of the managers will be deleted. If it’s a client, delete the server manager. If it’s a server, delete the client manager. After that, the surviving node is renamed to Network_Manager. This is so the client and server can communicate between separate game files.
  4. Execution is now split up between the client and server.

Client:

The client manager script simply allows the client to send inputs to the server via rpc_id(1) and the id that sent it. This input is then received by the server and is applied to the client’s player.

@rpc("any_peer","call_remote","reliable",0)
func network_jump(Client_ID) -> void:
	pass #to pass checksum

func _process(delta: float) -> void:
	
	if Input.is_action_just_pressed("jump"):
		network_jump.rpc_id(1, multiplayer.get_unique_id())

Server:

The server receives input from the client and applies to their player. However, this only happens on the server-side. This is intentional, as the server is the main source of truth.

# In the server network manager

@rpc("any_peer","call_remote","reliable",0)
func network_jump(Client_ID) -> void:
	if not multiplayer.is_server(): return
	
	if Player_Container.has_node(str(Client_ID)):
		Player_Container.get_node(str(Client_ID)).jump()
	else: return

# What the server calls in the player script

func jump() -> void:
	
	if is_on_floor():
		velocity.y = Jump_Force

Question

Now with the background out the way, I can explain what I want to do.

I want to collect the server-side player positions (+ other stats later), then use an RPC to broadcast the new player states all clients. I’m having trouble finding a method to capture a state.

I also need the player state capture to contain a history (in an array or dictionary) so I can implement client-side interpolate between those states, client-prediction, and server-side validation.

I do have a working server clock that ticks 60 times per second (same as _physics_process), a way to retrieve a timestamp, and an array of client id’s and their players (Connected_Client_Players). But I could still use some help with the rest.

Incomplete code

func collect_world_state() -> void:
	if not multiplayer.is_server(): return
	
	var Player_State = {}
	
	for player in Connected_Client_Players:
		Player_State = {
			"Time": Server_Clock,
		}
	
	print(Player_State)

@rpc("authority","call_remote","reliable",0)
func broadcast_world_state() -> void:
	if not multiplayer.is_server(): return
	
	# ???

I don’t think there’s any elegant way to do that in GDScript. You can try to dynamically get properties using get() or NodePaths kinda like the MultiplayerSynchronizer. Maybe the simplest way would be to make every object define it’s own get_state()/set_state() function (potentially some redundant code but you won’t get stuck trying to refactor your networking code every time you add/change an object).

What I was thinking was programmatically creating an array of dictionaries for each connected client using a for loop every tick.

Some pseudocode:

func collect_world_state() -> void:
	if not multiplayer.is_server(): return
	
	var Player_State : Array[Dictionary] = []
	
	for player in Connected_Client_Players:
		{
			"Time": Server_Clock,
			"Position": #???
		}

Solution:

I wanted to add a reply so this question doesn’t go unanswered. I got a ton of help from @pennyloafers via PM. I cannot thank them enough for the knowledge I gained.

I also learned a ton about arrays/dictionaries and how to use and manipulate them via the reading/referencing the entire documentation, asking questions, and google research. I used to be intimidated by arrays/dictionaries, but not anymore! Same goes for for loops.

This info is also based on the same sever-client system I detailed on the first reply. So, I won’t repeat it. (Also also, this solution is jank. But that’s because I haven’t refined the system yet. This is just a base overview)

Alright, onto the solution:

Step 1:

In server_manager.gd, I first started by creating an array of every client’s node path. Since their name is always their id, I can identify the client and their owning player at the same time.

# Top of server manager script:
var Connected_Client_Players : Array[Node] = []

# In the server's load_player function:
Connected_Client_Players.append(Player_Container.get_node(str(id)))

# In the server's unload_player function:
if Connected_Client_Players.has(Player_Container.get_node(str(id))):
		Connected_Client_Players.erase(Player_Container.get_node(str(id)))

This will save, track, and remove player node path/id references when needed. This is the foundation of this system.

Step 2:

Every server tick (I have it set to tick 60 times per second), the server will collect the state of every player using a for loop to get some stats out of every array element (client player).

Then, this all gets saved to an even bigger array named New_Player_State. This is the state of the whole world.

Edit: Also the difference between "String" and &"String" is that the & symbol (ampersand) accesses the literal form of the string.

"" = String
&"" = StringName

StringNames are slightly faster to compare values according the the documentation, Penny’s explanation, and my own research.

func get_new_player_state(Connected_Client_Players) -> void:
	if not multiplayer.is_server(): return
	if Connected_Client_Players.is_empty() == true: return
	
	var New_Player_State : Array[Dictionary] = []
	
	# Get the state of all connected players
	for player in Connected_Client_Players:
		New_Player_State.append(
			{&"Time": Server_Clock,
			&"NodePath": player.get_path(),
			&"Position": player.global_position}
			)
            # More to come
	
	# Broadcast new state to all clients
	broadcast_new_player_state.rpc(New_Player_State)
	
	# Save player state history (Last 30 ticks)
	collect_player_state_history(New_Player_State)
# In the server's _physics_process function (Ticks 60 times a second)
if not multiplayer.is_server(): return
	
	# Server ticks 60 times a second
	Server_Clock += delta
	get_new_player_state(Connected_Client_Players)

Step 3:

The server then broadcasts the world state to all connected clients. This way, the server is the true game state and authority over all valid actions.

This broadcast function also needs to be in client_manager.gd to pass the checksum and make the function work.

# Server function
func broadcast_new_player_state(New_Player_State) -> void:
	if not multiplayer.is_server(): return
	
	for state in New_Player_State:
		var player = get_node_or_null(state[&"NodePath"])
		if player == null:
			print("null")
			continue
		player.global_position = state[&"Position"]
	pass
# Client function
@rpc("authority","call_remote","reliable",0)
func broadcast_new_player_state(New_Player_State) -> void:
	for state in New_Player_State:
		var player = get_node_or_null(state[&"NodePath"])
		if player == null:
			print("null")
			continue
		player.global_position = state[&"Position"]
	pass

Step 4:

In addition, the server stores the last 30 ticks worth of player states within another array named Player_State_History. The max ticks are controlled by the constant MAX_PLAYER_STATE_HISTORY and is easily adjustable.

The server state history is server side only. However, I plan to send some states to the client in the future for more complex features like:

  • Client-side prediction confirmation/correction
  • Client-side entity interpolation
  • Server-side hit/hurt validation.
func collect_player_state_history(New_Player_State) -> void:
	if not multiplayer.is_server(): return
	
	if Player_State_History.size() == MAX_PLAYER_STATE_HISTORY:
		Player_State_History.push_front(New_Player_State)
		Player_State_History.pop_back()
	else:
		Player_State_History.push_front(New_Player_State)

The Result:

The implementation is laggy and has a few hiccups, but it works! That’s the most important thing.

“You can’t edit a blank page”.

Thanks for reading. I hope some scrap of this information is helpful to future shooter networking enthusiasts.

2 Likes