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 propertyplayer.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
Get Camp Fire Craft
Camp Fire Craft
Multiplayer survival game where teams compete to earn badges while camping
Status | In development |
Authors | GomaGames, kellishouts |
Genre | Survival |
Tags | Crafting, Godot, Multiplayer, Team-Based, Top-Down |
More posts
- Multiplayer LobbyMar 06, 2022
- 🚀 build-220226bFeb 27, 2022
- SinglePlayer Level Design - Process time lapse videoFeb 24, 2022
- Game Title and Sketches for brandingFeb 20, 2022
- 📘 Scouts Handbook UI 📘Feb 19, 2022
- 🦊 ⛺️ Foxes can destroy tents ⛺️ 🦊Feb 19, 2022
- 🔨📦 New Animations 📦🔨Feb 19, 2022
- Updated Animations 🪓 ⛏️Feb 19, 2022
- Netcode Update - InventoryFeb 19, 2022
Leave a comment
Log in with itch.io to leave a comment.