Synchronizing Multiplayer Game State - ⛺️ 🏃‍♂️


Synchronizing game state in multiplayer is challenging.

I wanted to design a pattern that applies the least amount of cognitive load.
What do I mean by that?

Let’s start with a simple state change, “Player enters tent”.
We expect:

  • you see your character hide()
  • you see your tent door close
  • all other connected players see your character hide()
  • all other connected players see your tent’s door closed

Single Player code

# Tent.gd

func _on_player_enter_tent(player):
    player.hide()
    door_closed_sprite.show()

func _on_player_exit_tent(player):
    player.show()
    door_closed_sprite.hide()

First, what would the MOST ideal way, in your grandest dreams, to write game logic and net code ?

What if Multiplayer code looked like this?

# Tent.gd

func _on_player_enter_tent(player):
    player.hide()
    door_closed_sprite.show()

func _on_player_exit_tent(player):
    player.show()
    door_closed_sprite.hide()

Right? Wouldn’t just be the best most amazing developer experience for the net code to be wired such that you pretty much just write the same way you would for single player, and the Net module just synchronizes all game state between all players?

This is our goal: to get as close as we can to this 👆, which would be the minimum amount of increased cognitive load.

Alas, I have not reached this holy grail of netcode abstraction that scales to “any” game state object. I was able to do this for the slightly more complex Inventory mixin, more details in previous devlog NetCode Inventory Update.

Further, some of the ways I’ve coded some interactions for Single Player ends up causing a cognitive spaghetti tornado in my mind.

One thing that caused this is the use of signals.
I love signals! They enable a beautiful decoupling between event emitters and event listeners.
However, although it’s still possible to use them the way I did, I decided to rip some out for the sole purpose of making the series of events that has to occur for states to get synced over the server easier to follow.

I’ve also encountered another complication, infinite loops. Although this is an avoidable pitfall, I wanted to make it easy to avoid, instead of being easy to fall into.

# Player.gd

var in_tent := false setget set_in_tent

func set_in_tent(p_in_tent:bool):
	in_tent = p_in_tent
	if in_tent:
		hide()
	else:
		show()

	# send new state for server to sync to other players
	Network.send_update_player_props({in_tent = in_tent})
# Tent.gd

func _on_player_enter_tent(player):
	player.in_tent = true
	door_closed_sprite.show()

func _on_player_exit_tent(player):
	player.in_tent = false
	door_closed_sprite.hide()

Makes sense, when you set Player.in_tent = true, also send a message to the server to broadcast to other players.
Network.send_update_player_props will send a message with OpCode UPDATE_PLAYER_PROPS,
the server routes the message by OpCode and continues to broadcast the message to all players
{ player_id = "the_player_id", { in_tent = true } }

Now let’s look at the message received from other players, OpCodes.UPDATE_PLAYER_PROPS

# Network.gd

func _on_socket_received_message(message) -> void:
  var op_code := message.op_code
  var payload := message.payload
  match op_code:
    OpCodes.UPDATE_PLAYER_PROPS:
      var player_id: String = payload.player_id
      var props: Dictionary = payload.props
      var player = get_player_by_id(player_id)
      for prop in props:
        player[prop] = props[prop]
      return

player[prop] = props[prop] evaluates to player["in_tent"] = true

Do you see the danger ahead?

When the other players receive this message,

  • _on_socket_received_message sets the property player.in_tent = true
  • which triggers the player.set_in_tent() setter
  • which cleverly sends the updated state back to the server
  • uh oh 🦶🔫
  • the setter is going to send the message to the server, to be broadcasted to everyone ad infinitum

Consider different ways to mitigate this issue. Ordered from ‘worst’ to ‘meh’:

Check if Player is Me

We can add a check to see if the Player instance is ‘me’ in which case, send the message to the server, or if the Player instance is someone else, in which case don’t re-broadcast the message.

# Player.gd

var in_tent := false setget set_in_tent

func set_in_tent(p_in_tent:bool):
	in_tent = p_in_tent
	if in_tent:
		hide()
	else:
		show()

	# send new state for server to sync to other players 
	# only if this player is 'me'
	if Network.my_user_id == self.user_id:
		Network.send_update_player_props(
			{in_tent = in_tent}
		)
	else: # don't rebroadcast this message
		pass

This if/else can be abstracted to be a bit cleaner, though it still leaves the huge pitfall there to be narrowly in each instance.

Net specific setters

Alternatively, we can define two separate methods, one that is triggered from the current player, and one that is triggered from a different player over the Net.

# Player.gd

var in_tent := false setget set_in_tent
var net_in_tent := false setget set_net_in_tent

# only set this as the current player!
func set_in_tent(p_in_tent:bool):
	assert(Network.my_user_id == self.user_id, "Player.set_in_tent was triggered over network. This should not happen")


	# send new state for server to sync to other players if this player is 'me'
	if Network.my_user_id == self.user_id:
		Network.send_update_player_props(
			{net_in_tent = p_in_tent} # <//===
		) 
	else: # don't rebroadcast this message
		pass

# set only by network
func set_net_in_tent(p_in_tent:bool):
	in_tent = p_in_tent
	if in_tent:
		hide()
	else:
		show()

This approach is less bad than the previous idea. Notice that if the current Player is me, and i do player.in_tent = true, we don’t actually set in_tent to true yet. Invoking this method will send a message to the server that broadcasts net_in_tent = true to all players, including myself. When I finally receive this message, the actual in_tent property is changed and i see the tent’s door closed.

It’s still not ideal, let’s measure some of the additional cognitive load.

  • I have to create a secondary net_some_prop property for each state and for every object 🤮
  • I have to also create a matching set_net_some_prop setter method for each of these 🤮🤮

Chosen Approach

Here’s the approach I ended up liking more than the others. It’s not perfect, though it is one that was easiest to wrap my head around when migrating single player code to synchronized state in multiplayer. I chose this approach because it’s the easiest for me to understand what is going to happen at a glance, without having to trace through the entire Net flow to validate what I expect to happen.

Design Decision One, No Magic Setters

Nothing should change about how “traditional” single player GDScript works. I shouldn’t have to “remember” that magic is happening.

If I read player.in_tent = true, no translations need to occur, I know that if there’s a setter method, it will get called, and that no Network calls will be made here.

Following this rule is important to prevent any recursive network calls and re-broadcasts.

Design Decision Two, Scalable Net Interface

I don’t have a ‘ton’ of game objects that need to be synced over the net, realistically I have Players, CampFires, Tents, Trees, Bushes, Stones, Items laying about, it’s not that much right now. Though I am lazy enough to set a rule to not have to add additional Net sync specific code to each object. This also results in me not having to remember to write Net sync props and methods for each new object -> less cognitive load.

The approach here is, any player can change any prop of any world object. If I introduce a new Object or add a new prop, our server code doesn’t have to change.

Furthermore, any player can invoke any method of any world object.

Here’s how that works:

# Net.gd

func update_props(node_path:String, props:Dictionary):
	if Network.is_session_connected():
		Network.send_update_node(node_path, props)
	else:
		var node = get_tree().get_node(node_path)
		for prop in props:
			node[prop] = props[prop]

# Network.gd

func send_update_node(node_path:String, props:Dictionary):
	var payload := {node_path = node_path, props = props}
	socket.broadcast(OpCodes.UPDATE_NODE, payload)


Receiving the message

# Network.gd

func _on_socket_received_message(message) -> void:
  var op_code := message.op_code
  var payload := message.payload

  match op_code:
    OpCodes.UPDATE_NODE:
      var node_path: String = payload.node_path
      var props: Dictionary = payload.props

      var node = get_tree().get_node(node_path)
      for prop in props:
        node[prop] = props[prop]

Putting it all together

This net code pattern is best illustrated through an example of migrating Single Player code to Multiplayer code.

Single Player (Before)

# Tent.gd

func _on_player_enter_tent(player):
	self.occupied = true
	player.in_tent = true

Multiplayer (After)

# Tent.gd

func _on_player_enter_tent(player):
	Net.update_props(get_path(), { occupied = true })
	Net.update_props(player.get_path(), { in_tent = true })

If you review the Net.update_props() method, it also handles the abstraction for single player offline mode.

Now, I simply write GDScript the way Godot intended, and if there’s any state change that I want broadcasted, then I simply use the Net module.

Demos

Net synchronized methods 2022-03-18 14-39

Net sychronized animation state 2022-03-18 14-44

Get Camp Fire Craft

Leave a comment

Log in with itch.io to leave a comment.