Keeping players in sync

Read how I approach synchronizing player transforms

The goal of multiplayer games more often than not is to synchronize state between all peers where, preferably, the server is authoritative

A small introduction

As some of you may know, I am the developer of Cubash. Cubash was an online game where users could interact with each-other and customise their character to their likings and make new friends.

Cubash had a game client in development but unfortunately, never came to see the light of day.

However, as of recent I became interested to play around with the client, and I started rebuilding the client from the ground up.

One of the subjects I find fascinating throughout all the projects I have ever worked on is networking and that’s exactly what we’re going to be talking about today.

This post will aim to cover the bare basics and will leave out a lot and focus only on the networking side of things.

Listening for client connections

For this project, I have settled with using the Godot Engine which uses the ENet networking library that offers a high level interface for using the UDP and TCP protocols.

Before we can start sending RPCs to peers, we need a server for clients to connect to:

extends Node

var peer:NetworkedMultiplayerENet

# Simple method to start a server using ENet
func _host(max_clients:int = 16, port:int = 22000, in_bandwidth:int = 2457600, out_bandwidth:int = 2457600):

    # We need the SceneTree singleton so we can register signals and register the network peer
    var tree = get_tree()
    
    # Register signals
    tree.connect("network_peer_connected", self, "_network_peer_connected")
    tree.connect("network_peer_disconnected", self, "_network_peer_disconnected")
    
    # Create a new instance of NetworkedMultiplayerENet so we can start listening for connections
    self.peer = NetworkedMultiplayerENet.new()
    self.peer.compression_mode = NetworkedMultiplayerENet.COMPRESS_ZLIB
    self.peer.create_server(port, max_clients, in_bandwidth, out_bandwidth)
    
    # Finally we attach the network peer to the SceneTree singleton
    tree.set_network_peer(self.peer)
    tree.set_meta("network_peer", self.peer)

# Method that is called once the script is ready
func _ready():

    # Start server
    _host()

Once our script is ready, we invoke the _host() method that will initialize a NetworkedMultiplayerENet instance that we can use for hosting our game server.

Now we have a game server ready to happily accept connections from clients!

Connecting to the server

What would a server be without a client to serve? Let’s make our client connect to our server. For that, we’ll need to create a script that handles all the networking for the client.

It will look quite similar to the script we have just made for creating a server except now instead of creating a server, we connect to (hopefully) a listening server.

extends Node

var peer:NetworkedMultiplayerENet

func _connect(address:String = "127.0.0.1", port:int = 22000):

    # Just like before, we need the SceneTree singleton for the exact same purpose
    var tree = get_tree()
    
    # Register the relevant signals
    tree.connect("connected_to_server", self, "_connected_to_server")
    tree.connect("connection_failed", self, "_connection_failed")
    tree.connect("server_disconnected", self, "_server_disconnected")
    
    # Attempt to connect to the server. Similar to before, we create a new instance of NetworkedMultiplayerENet
    self.peer = NetworkedMultiplayerENet.new()
	self.peer.compression_mode = NetworkedMultiplayerENet.COMPRESS_ZLIB
	self.peer.create_client(address, port) # Note: create_client() instead of create_server()
	
	# Just like before, we do the attaching of the network peer to our SceneTree singleton
	tree.set_network_peer(self.peer)
    tree.set_meta("network_peer", self.peer)
    
    
func _ready():

    # Connect to the server once we're ready to do so
    _connect()

A word on signals

Remember the signals we registered with tree.connect()?

They can help us make our network logic, but before we start doing that, let’s explain what they mean:

Server side signals

network_peer_connected
Invoked when a new client peer connects to our server.
We can use this signal to register network ownership of the Player node (or better known as their character, the only thing they should be in control terms of security).

network_peer_disconnected
Invoked when a client loses connection to our server or otherwise closes the socket. We should handle removal of client information once this is called.

Client side signals

connected_to_server
Invoked when we have successfully connected to a game server.
We could use this signal to claim ownership of our local Player and to get the game going.

connection_failed
Invoked when we couldn’t connect to a server after a time out occurs. We should let our user know that connection has failed.

server_disconnected
Invoked when the server disconnects you for any reason, like cheating or when the server closes. We should let the user know that the connection has closed down.

Making network code

Now that we know what every signal means and what it should do, let’s start writing some basic network code.

Creating a new player on the server side

Once our player has connected to our server, we need to create a new Player node for them. This will be their character that they can move around. Let’s go back to the script we wrote to host a server and implement that logic:

func _network_peer_connected(peer_id:int):
    
    # Create a new instance of the player Node
    var player = load("res://scenes/player.tscn").instance()
    
    # Set player name
    player.set_name(String(peer_id))
    
    # Give the client ownership of the player Node
    player.set_network_master(peer_id)
    
    # Insert the new Player Node to our game Node
    $"/root/Game/Players".add_child(player)
    
    # Let the client(s) know about this. I'll explain this very soon!
    rpc("new_player", peer_id)

Now, there’s a new Player node reserved for the client that just connected to our game.

Creating a new player on the client side

We have successfully created a new player for our client, but the client is not aware of this at all yet!

We need a way to tell all clients (including the one who just connected) about the creation of a new player. One way that I think is good is to have an RPC that sends over the client peer ID to all the peers, so that the clients can repeat what we just did.

Let’s go back to the script we made to connect to a server and implement the new_player RPC:

remotesync func new_player(peer_id:int):

    # Like on the server, we create a new instance of the player Node
    var player = load("res://scenes/player.tscn").instance()
    
    # Set player name
    player.set_name(String(peer_id))
    
    # Give network ownership to the peer we have received
    player.set_network_master(peer_id)
    
    # Finally, we add the player to the game Node
    $"/root/Game/Players".add_child(player)

That’s it! Our clients are now automatically aware of any client that joins the server and creates a character for the player, including themselves.

Synchronizing player movement

Inside the player Node movement script, we have to add additional logic, so we can send the Body velocity over the network and thus, let all other peers (including the server) be aware of where we are.

Why the server you ask? We want our movement to be secure, so we simulate the movement on the server side to check if the client is doing anything that’s not supposed to happen.

Clientside

I assume you have already written your player movement logic script. If not, why are you adding networking to it now?

Moving to the player movement logic script, we add additional logic after we have calculated the velocity that we would be applying to our own player to make movement happen.

We also want to add a check to the Input handler that ensures that we only control our own player Node:

onready var game = $"/root".get_node("Game")

func _physics_process(delta:float = 0):

    # This variable contains the central force vector that will be applied to the Node
    var force = Vector3()
    
    # This variable contains the direction vector that is calculated by the Input handler
    var direction = Vector3()
    
    # Check if we are in control of this player.
    # Otherwise do no calculation here.
    if is_network_master():
    
        # Handle your input here using Input.is_action_pressed() - I leave that up to you.
        direction = Vector3()
        
    
        # This assumes you are using Vector3 and add_central_force to move your player around.
        # We populate this with the direction you calculated using the Input handler.
        # Again, this is up to you and heavily depends on the game you are making.
        force = Vector3()
        
    add_central_force(force)
    
    # Send our transform and direction over the network
    if is_network_master():
    
        # We use the script that's attached to the Game node to perform the RPC call
        game.rpc_unreliable_id(1, "move", direction, get_transform())

Now the direction and transform of our player Node is sent every physics process tick. By default, that’s 60 times a second.

Serverside

The client is now sending us movement data. All we have to do is process the movement ourselves and send the result of that to all clients.

Let’s look at the script we made to start a server again and add new logic:

remote func move(direction, transform):
    
    # Obtain the RPC sender ID so we can find the relevant player
    var rpc_id = get_rpc_sender_id()
    
    # Find the player node
    var player = $"/root/Game/Players".get_node(String(rpc_id))
    
    # Call movement function
    player.move(direction, transform)

Let’s also implement the move method that we are calling. In the player Node script:

onready var game = $"/root".get_node("Game")
onready var direction = Vector3()

func move(direction:Vector3, transform:Transform):
    
    # Update player state with the state received from the client
    direction = direction
    client_transform = transform # explicitly calling it client_transform here
    
func _physics_process(delta:float = 0):
    
    # Like the client, we have a force and a direction vector. We just run the same calculation here.
    # I still leave that up to you to do. It's your game and I do not know what you're making. :)
    var force = Vector3()
    
    add_central_force(force)
    
    # Send the transform that we, as server have calculated to all the clients
    game.rpc_unreliable(get_name(), "move", get_transform())

You may have noticed I am not using the client_transform variable here.

That’s because you may choose to implement a way to allow slight differences between the client and server transform (there will be differences, no getting around that!) - but that is out of the scope of this post.

The server is now sending the transform of the player!

Processing server calculated transforms

We are almost finished. Let’s go back to the script we created to connect to servers and listen for the move RPC call:

remote func move(peer_id:int, transform:Transform):
    
    # Get player node
    player = $"/root/Game/Players".get_node(String(peer_id))
    player.move(transform)

Lastly, we handle the transform data through the move method inside our client player Node script:

func move(transform:Transform):

    # We can ignore the packet if we are the network owner of the player.
    # We already know our own state!
    if is_network_master():
        return
    
    # Set serversided calculated transform. You may want to use Tweening to make it look smoother.
    set_transform(transform)

You made it!

In this post, we have successfully:

  1. Created a game server and game client
  2. Handled adding of new players
  3. Handled the movement synchronization of player Nodes

Thanks for reading. I hope you have learnt something useful.