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
They can help us make our network logic, but before we start doing that, let’s explain what they mean:
Server side signals
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).
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
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.
Invoked when we couldn’t connect to a server after a time out occurs. We should let our user know that connection has failed.
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
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.
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.
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:
- Created a game server and game client
- Handled adding of new players
- Handled the movement synchronization of player Nodes
Thanks for reading. I hope you have learnt something useful.