-->
Attention: This tutorial was written for Godot 3. Not only the information here contains several flaws, networking has changed significantly in Godot 4.
To wrap this tutorial we will work on some features that are a lot easier to implement than synchronizing the game simulation, yet both are commonly desireable to have in multiplayer games. The features in question are chat system and player kicking. While I will not fully describe the implementation of player banning, I will mention how to do that and why I didn't include the implementation in the tutorial.
In this topic we will work on a popup menu that will be shown when right clicking a player entry in the HUD. Back in part 2 we created the ui_player_list_entry.tscn
control meant to display players in our HUD, which is based on the MenuButton
. This choice was done just so we can easily add a popup menu which, in this case, should provide kick, ban and whisper options. Obviously we could even add more things, like teleport to or report or... you get the point.
When an option in the popup menu is clicked, it will emit a signal sending the identification of the clicked entry. This identification can be set when populating the menu itself. To make things simpler for us, let's create an enumeration with those IDs:
# Popup menu entry IDs
enum PopID { whisper = 1, kick, ban }
We now want to populate the popup menu with the relevant entries. Now comes one important thing. The ability to kick/ban a player is desireable only for the player running the server. OK, it would be possible to incorporate some credentials system which would allow other players to also kick/ban but that is beyond the scope of this tutorial. Anyway, the idea here is to create a whisper option regardless of which player the code is running on and then add a separator plus the kick and ban options only if on the server.
To add an option into a popup menu all we have to do is use one of the various add_*()
functions provided by the popup object. Since our control is deriving from the MenuButton
class, we have access to a popup object by calling get_popup()
function. Once the menu gets populated we then connect a function to the id_pressed
signal, which is emitted whenever an option in the menu gets clicked. We will use the _ready()
function to add the options into the menu:
func _ready():
var pop = get_popup() # Just a shortcut
pop.add_item("Whisper", PopID.whisper)
if (get_tree().is_network_server()):
pop.add_separator()
pop.add_item("Kick", PopID.kick)
pop.add_item("Ban", PopID.ban)
pop.connect("id_pressed", self, "_on_popup_id_pressed")
Of course, we have to create the function that is being connected to the id_pressed
signal. For now it will be just an "skeleton" to the code we will add later:
func _on_popup_id_pressed(id):
match id:
PopID.whisper:
print("Whispering")
PopID.kick:
print("Kicking")
PopID.ban:
print("Banning")
As mentioned early, the intention is to obtain this menu through right clicking at an entry in the player list. By default the MenuButton
is setup to bring the popup menu when clicking with the left mouse button. It's rather easy to change this behavior, though. Open the ui_player_list_entry.tscn
scene and then click on the PlayerEntryRoot
. Now, in the properties, find the Button Mask
and disable Mouse Left
while enabling Mouse Right
.
Before we begin working on the control that will hold our chat window, there is one last thing to add to this ui_player_list_entry
control. When we kick/ban a player, maybe we will want to write a reason for that. This can also serve as a confirmation box. We can do this through a dialog window or through a panel that appears bellow the player entry. In this tutorial I will show the panel route.
That said, add a Panel
into the PlayerEntryRoot
. Resize as desired and place maybe somewhere bellow the player name label. Rename the panel to pnlKickBan
and then add a few controls into it:
txtReason
and then change the Placeholder
property to Reason...
btCancel
and place it at the bottom right section of the panel, bellow the line edit. Change its text to Cancel
.btKickBan
and place it besides the btCancel
button. The text will be changed dynamicallyBecause we don't want the pnlKickBan
panel to be normally visible, we disable its Visible
property, which can be done from the Inspector
panel or clicking the eye icon in the Scene
hierarchy.
Now we work on the visual aspect of the control that will hold our chat. Bear in mind that we will work on a very basic functionality, with focus on the networking aspect, so don't expect advanced automatic opening/closing of the chat window, as well as the possibility to add images, emojis and so on, which is not the topic of this tutorial anyway.
To make things simpler we will create yet another scene that will be instanced within the game_world.tscn
. That said, create one and choose Control
as the root type. Rename the root to ChatRoot
. Selecting ChatRoot
in the editor will display a surrounding box sort of showing the "limits" of our control, so keep that in mind when I mention "bottom section" or "entire width" of the control. On those cases I will be referring to those dimensions.
That said, at a LineEdit
at the bottom section of the control, naming it txtChatInput
. On its right side add a somewhat square-ish button named btShowChat
. The line edit will be used as input for new chat entries where the button will be used to show/hide the chat box. Occupying the rest of the chat box add one ScrollContainer
naming it pnlChat
. Inside this new container add one VBoxContainer
naming it ChatLines
.
The scroll container will bring scroll bars whenever we fill the allocated vertical space with chat lines, while the ChatLines
will be filled with the chat entries. Now, in order for the scroll container to correctly work we have to change the minimum size of the ChatLines
. To better see which values to enter, select the pnlChat
and its size within the Rect
property. In my case the shown values are x = 250; y = 266
. So, with that information select ChatLines
and change the Min Size
(within the Rect
property) and change to match the values (x = 250; y = 266
).
By default the ScrollContainer
control will have absolutely no background, which may not be desireable for an area meant to display text. So, select the pnlChat
and expand the Custom Styles
property category. Enable the Bg
property and then select New StyleBoxFlat
from the drop down. This will add a light grey background to the scroll container. For my taste it's a bit too light so I have also changed the Self Modulate
color to a near black one (RGB = 50, 50, 50
).
We probably don't want horizontal bars within the chat box, so expand the Scroll
category (still with the pnlChat
selected) and disable the Horizontal Enable
property. By default we don't want the chat panel to be visible, so disable its visibility. Later on we will change it from code. Finally save the scene naming it ui_chat.tscn
.
Open the game_world.tscn
and add one instance of the ui_chat.tscn
scene into the HUD
layer. Position the box so the line edit stays somewhat close to the bottom left corner of the scene.
Now that we have the basic user interface in place, we can now start work on the code to incorporate the features that we want. The idea here is that once the Kick
option in the popup menu is (right) clicked we show the pnlKickBan
automatically giving focus to the txtReason
line edit. Within that panel, if the btCancel
is clicked we just hide this panel, otherwise we effectively call a networking function to kick the player. That means we have to work on the networking functionality. So, let's do that first then we come back to the UI code.
The kicking function will first call a remote function that will send the reason the player has been kicked. After that, we call one function named disconnect_peer()
, which will effectively remove the specified (through network ID) player from the server. The function:
func kick_player(net_id, reason):
# First remote call the function that will give the reason to the kicked player
rpc_id(net_id, "kicked", reason)
# Then disconnect that player from the server
get_tree().network_peer.disconnect_peer(net_id)
As you can see, this is remote calling a function named kicked()
, which we have to work on now. It expects an argument indicating the kick reason. Ideally this function should display a dialog box or something with the reason and an "OK" button, so the player know he/she was being a douche. But, for simplicity in this tutorial just a print with the reason will be presented. Of course, because it's a remote function we add the fake latency simulation code:
remote func kicked(reason):
if (fake_latency > 0):
yield(get_tree().create_timer(fake_latency / 1000), "timeout")
print("You have been kicked from the server, reason: ", reason)
From the networking that's all! So, let's go back to the UI that will use that. Probably you have noticed that we need the network ID of the player to be kicked and our ui_player_list_entry
, which is the control that will call the kick_player()
function, does not contain this data. We will change this by first declaring a variable meant to hold this:
# Network ID of the player associated with this object
var net_id = 0
Shortly we will properly set this value, but for now let's continue the script of the UI control. We already have the skeleton code that deals with the options in the popup menu being selected. In the case of the kick we want to change the text of the btKickBan
button to Kick
. The panel itself has to be shown and the line edit has to get focus. We will take advantage and add similar logic but for the banning:
func _on_popup_id_pressed(id):
match id:
PopID.whisper:
print("Whispering")
PopID.kick:
$pnlKickBan/btKickBan.text = "Kick"
$pnlKickBan.show()
$pnlKickBan/txtReason.grab_focus()
PopID.ban:
$pnlKickBan/btKickBan.text = "Ban"
$pnlKickBan.show()
$pnlKickBan/txtReason.grab_focus()
Next we have to connect functions to the pressed
signal of the btKickBan
and btCancel
within the control. For the cancel button we just hide the pnlKickBan
panel. As for the confirmation button we have to grab the reason text and initialize with something if nothing has been entered (if preferred it's possible to disable the button if the reason is empty - which won't be done in this tutorial). Once the reason is obtained it's necessary to find out if the intention is to kick or ban the player. For that we could have used a boolean variable but instead of that we just check the value of the text in the btKickBan
. With that information the correct network's functionality can be called and then we can hide the pnlKickBan
panel again:
func _on_btCancel_pressed():
$pnlKickBan.hide()
func _on_btKickBan_pressed():
var reason = $pnlKickBan/txtReason.text
if (reason.empty()):
reason = "No reason"
if ($pnlKickBan/btKickBan.text == "Kick"):
print("Kicking player with unique ID ", net_id)
network.kick_player(net_id, reason)
else:
print("Banning not implemented yet")
$pnlKickBan.hide()
Of course we have to properly set the net_id
variable. That will be done whenever the ui_player_list_entry
dynamic instance is created, within the game_world.gd
class in the _on_player_list_changed()
function:
func _on_player_list_changed():
... # Previous code
# Now iterate through the player list creating a new entry into the boxList
for p in network.players:
if (p != gamestate.player_info.net_id):
var nentry = entry_class.instance()
nentry.net_id = p
nentry.set_info(network.players[p].name, network.players[p].char_color)
$HUD/PanelPlayerList/boxList.add_child(nentry)
player_row[network.players[p].net_id] = nentry
We could have changed the set_info()
function within the ui_player_list_entry.gd
class in order to accept the network id, which would be even better. But, for simplicity sake we just directly edit its property and that's it. And now, it's possible to test the game and its kicking ability!
As mentioned I will not present any code showing the banning functionality, however I did promise to describe how to implement it. The main reason for that is because we need means to correctly identify the banned player other than the network ID (which changes at each connection attempt). The IP number or some credentials like login/password are typically used to do this identification. Because of that, testing the functionality becomes rather impractical and I don't want to dump untested code in here. In order to further describe what is needed I will refer to this identification as player_id
.
If you want to retrieve the IP number of a given peer, you can use the following line:
var peer_ip = get_tree().network_peer.get_peer_address(network_id)
Regardless, the basic idea relies on holding an internal list of blocked player_id
value. Within the network._on_player_connected()
event handler, the server can, before anything else, build the proper player_id
identification (other than the network ID) and then try to see if there is an entry in the banned database matching the player_id
of the newly connected player. If so, refuse the connection by directly calling
get_tree().network_peer.disconnect_peer(id)
Remember, the id
is the argument of the _on_player_list_connected()
function itself. To make things clear to the player it would be a good idea to create another remote function like banned()
that would be called right before "refusing" the connection. That function would display "you are banned" to the player or something like that.
Then, from the Ban
option within the popup menu, we would call one function named ban_player()
implemented in the network.gd
class. This function should add the player_id
into the banned database. After that, call the remote banned()
function and forcefully disconnect that player, just like we did with the kicking.
For management sake, it's a good idea to implement means to remove players from the banned list. If you want to incorporate temporary banning, then within the player_id
in the banned database it should be added the timestamp of the real life time indicating when the banning happened, maybe something like:
var ban_time = OS.get_system_time_secs()
Quoting from the documentation of this function:
Returns the epoch time of the operating system in seconds.
So, periodically (perhaps every second) we can then iterate through the list of temporary bans and check if the total amount of seconds has been elapsed by comparing with the stored ban time. Perhaps this can even run on a separate thread in order to not bog down the game simulation when performing this check.
As you can see, the overall logic of banning players is not exactly complicated, it only require means to properly identify them, which haven't been incorporated within our current system. Of course, this system doesn't exactly prevent a player from trying to bring revenge and hammering or even attacking the server with some other methods to take it down. But then again, that kind of protection is usually better done elsewhere.
The basic idea of the chat system will rely on a remote function named send_message()
. While we can obtain the network ID of the peer calling a remote function we will add this as an argument. The reason for that is because whenever a player sends a message, it will be sent to the server, which will then broadcast into the other players. We will use this ID to perform some minor local formating to the generated line of text. As usual we separate the function in two major sections, one that is run only on the server and the other that will run regardless.
The server only section first have to check if the message is meant to be broadcast (remember, we will allow whispering too). If the message is meant to be broadcast we then iterate through the connected players while relaying the message to them, using rpc_id()
. In this case we skip the player that originally sent the message because that one will handle the text in an slightly different way. Then we check if source is the server itself and, if so we bail from the function, mostly because the player sending the message will handle the generated text differently. As for the general section, we will perform some very basic formatting of the text, by prefixing the message with "[name_of_the_sender] - " string. Locally (that is, the one sending the message) will see the own generated text without this prefix. The text itself will be added into the ui_chat
instance within the game_world.tscn
, but that will be through a signal that will be emitted from this remote function.
All that said, let's first declare the signal:
... # Previous signals
signal chat_message_received(msg) # Whenever a chat message has been received - the msg argument is correctly formatted
And this is the code for the send_message()
function:
remote func send_message(src_id, msg, broadcast):
if (fake_latency > 0):
yield(get_tree().create_timer(fake_latency / 1000), "timeout")
if (get_tree().is_network_server()):
if (broadcast):
for id in players:
# Skip sender because it already handles the message, which has different formatting
# Skip the server because it will handle itself after exiting the server-only section
if (id != src_id && id != 1):
rpc_id(id, "send_message", src_id, msg, broadcast)
# Now that the message has been broadcast, check if the source is the server. If so, skip the rest
# otherwise the mesage will appear twice on the chat box. One with the "everyone" formatting and the
# other with the local formatting
if (src_id == 1):
return
# Everyone section. First, format the message. Prefix it with the name of the sender
var final_msg = "[" + players[src_id].name + "]: " + msg
emit_signal("chat_message_received", final_msg)
From the networking point of view, we have finished! Now we go back to the UI that will display the messages. In this case, the ui_chat
control. First, attach a new empty script to the ChatRoot
, naming the file ui_chat.gd
. At this point it should contain a single line, extends Control
.
Let's begin working on this script by adding a property to it, which should serve as a limit of how many messages will be shown within the message box. By default we will set it to 50
. We will also declare it using the export
feature, which should allow us to edit it from the editor in any scene instancing this control:
# Maximum number of chat lines within the box container
export(int) var max_chat_lines = 50
Next we work on a function that will be used to create new entries in the chat box. The main idea of this function is to first check if the maximum amount of text has been reached and, if so, remove the oldest message. Then create a new instance of Label
control, setting it to auto wrap so the text doesn't go beyond the horizontal limits. Of course we set the text property to be the message itself. Finally, we add this new instance into the ChatLines
container:
func add_chat_line(msg):
if ($pnlChat/ChatLines.get_child_count() >= max_chat_lines):
$pnlChat/ChatLines.get_child(0).queue_free()
var chatline = Label.new()
chatline.autowrap = true
chatline.text = msg
$pnlChat/ChatLines.add_child(chatline)
The next step is to take a received message and add to the box using this function. Remember that we have created a signal in the network.gd
class exactly to tell that a new message has arrived. The interesting thing here is the fact that our signal sends a single argument, the message that we want to add to the UI. Because of that, we can directly connect add_chat_lin()
to that signal! So, within the _ready()
function we perform this connection:
func _ready():
network.connect("chat_message_received", self, "add_chat_line")
One of the desired features of this chat system is the ability to whisper, that is, direct a message to one of the connected players, and only to that player. When entering the text in the input box we need means to differentiate whisper messages from the ones that must be broadcast. So we will add a very basic string parsing in the code, where text prefixed with @player_name
will be set to whisper. After "extracting" the player name from the string we will need to obtain the network ID of that player so we can properly direct the message using rpc_id()
. Because of that we create a function that will take the player name and return the network ID:
func get_player_id(player_name):
for id in players:
if (players[id].name == player_name):
return id
# If here the player has not been found. Return an invalid ID
return 0
The LineEdit
control emits a signal whenever the enter key is pressed while it also has input focus. This signal is named text_entered
and it gives the new text as argument. We will connect a new function to this signal. In it we first have to cleanup the text by stripping out any leading and trailing spaces, by using the strip_edges()
function. After that we locally add the cleaned up message into the chat box by directly calling add_chat_line()
. Then we perform the very basic string parsing, by checking if the string begins with a "@" or not. The logic follows two major paths now:
send_message()
function directing to the obtained network ID.send_message()
, which will then broadcast the message. Otherwise remote call send_message()
directing the call to the server, which will take care of broadcasting the message to the other players.At the end of the function we make the txtChatInput
control empty so we can easily write new messages. The function looks like this:
func _on_txtChatInput_text_entered(new_text):
# Cleanup the text
var m = new_text.strip_edges()
# Local chat box without any formatting
add_chat_line(m)
# Check if whisper or broadcast
if (m.begins_with("@")):
var t = m.split(" ", true, 1)
# It must be checked that we actually have a message after the name. For simplicity sake
# ignoring this check for the tutorial
var player_name = t[0].trim_prefix("@")
var dest_id = network.get_player_id(player_name)
if (dest_id == 0):
# Unable to locate the destination player for whispering
add_chat_line("ERROR: Cannot locate player " + player_name)
else:
# Player found. Remote call directly to that player
network.rpc_id(dest_id, "send_message", get_tree().get_network_unique_id(), t[1], false)
else:
# Send cleaned message for broadcast
if (get_tree().is_network_server()):
network.send_message(1, m, true)
else:
network.rpc_id(1, "send_message", get_tree().get_network_unique_id(), m, true)
# Cleanup the input box so new messages can be entered without having to delete the previous one
$txtChatInput.text = ""
In theory the messages should already be correctly distributed across the players and added into the chat box. The problem is that we haven't added any means to unhide it in order to test the chat system! Let's fix that by connecting a new function to the pressed
signal of the btShowChat
button. In this function we first check if the panel is actually visible or not because we will toggle the visibility. If the panel is visible we hide it and release focus from the button. Otherwise, we show the panel and give focus to the txtChatInput
, so we can directly enter a text message when that button is clicked:
func _on_btShowChat_pressed():
if ($pnlChat.is_visible_in_tree()):
$pnlChat.hide()
$btShowChat.release_focus()
else:
$pnlChat.show()
$txtChatInput.grab_focus()
At this point messages are indeed being sent to all players while whispering is correctly working, sending the message only to the destination player. However we have created a drop down menu containing a whisper option that is doing absolutely nothing up to this point (other than printing a message). The idea here is that once this option is clicked it should automatically fill the name of the player in the txtChatInput
with the correct formatting. But here comes the thing, the popup option selection is an event happening in the ui_player_list_entry
control, while the text input is in the ui_chat
control. Moreover, the first control is dynamically spawned in the scene. How would we tie the things together? Signal, of course! The idea is to emit it whenever the whisper option is clicked and we send as argument an string containing the name of the selected player prefixed by the "@". So. let's first declare it:
signal whisper_clicked(msg_prefix) # Given when the whisper option in the pop menu is clicked
Then we update the _on_popup_id_pressed()
function to properly deal with this event. In the case of the PopID.whisper
all we have to do is emit the signal with the built string. Notice that I have appended an empty space at the end of the generated string. When filling the input box control, that space will be an interesting addition to serve as the separation between the destination of the text and the message itself:
func _on_popup_id_pressed(id):
match id:
PopID.whisper:
emit_signal("whisper_clicked", "@" + network.players[net_id].name + " ")
PopID.kick:
$pnlKickBan/btKickBan.text = "Kick"
$pnlKickBan.show()
$pnlKickBan/txtReason.grab_focus()
PopID.ban:
$pnlKickBan/btKickBan.text = "Ban"
$pnlKickBan.show()
$pnlKickBan/txtReason.grab_focus()
Obviously we have to connect a function to this signal. This connection will be done right after creating the ui_player_list_entry
instance. But one question remains, where should the function handling the signal should go? Here comes the thing, we are spawning player list entries within the game_world.gd
code but we want the function handling the signal to fill a control that is part of the ui_chat
widget. Because of that we end up with some options, most notably:
ui_chat.gd
class.game_wold.gd
itself and then access the "inner control" inside the instance of ui_chat
that is placed in the scene.Both options are valid and will work, however the first option is a bit cleaner in the sense of what each class should do. Based on that, we will go with that. Then, first we create the function itself, which will just take the prefix received from the signal, fill that into the input box, give focus to the control and move the cursor to the end of the string:
func _on_whisper(msg_prefix):
$txtChatInput.text = msg_prefix
$txtChatInput.grab_focus()
$txtChatInput.caret_position = msg_prefix.length()
Because of the space appended to the prefix when generating the signal, the resulting behavior is that the user will be able to directly type the desired message rather than first having to add an space! Anyway, all that is left to do now is connect this function to the signal:
func _on_player_list_changed():
... # Previous code
# Now iterate through the player list creating a new entry into the boxList
for p in network.players:
if (p != gamestate.player_info.net_id):
var nentry = entry_class.instance()
nentry.net_id = p
nentry.set_info(network.players[p].name, network.players[p].char_color)
nentry.connect("whisper_clicked", $HUD/ChatRoot, "_on_whisper")
$HUD/PanelPlayerList/boxList.add_child(nentry)
player_row[network.players[p].net_id] = nentry
Ok, before wrapping this tutorial, there is an small polishing that I want to mention. Right now, whenever we start typing a message within the input box, the avatar is still moving according to the input keys. We have to disable player input whenever the chat input box has focus and re-enable it when the focus is removed from that box. To do that we will have to connect functions to two specific signals of the LineEdit
control within our ui_chat.tscn
. Those signals are focus_entered
and focus_exited
. Before working on those functions, let's first work on the mechanism. The idea is to change a flag within the gamestate.gd
that will enable or disable the input gathering. So, let's declare this flag:
# If this is true, then player input will be gathered and processed. Otherwise "ignored"
var accept_player_input = true
Now, in the player.gd
class we have to check both this flag and if the object is the network master before calling the gather_input()
function:
func _process(delta):
... # Previous code
if (is_network_master() && gamestate.accept_player_input):
gather_input()
Now we can work on the functions connected to the mentioned signals of the LineEdit
in the ui_chat.tscn
. In the focus_entered
we will take advantage and make sure the chat box is visible. Anyway, all we have to do is change the gamestate.accept_player_input
flag, accordingly:
func _on_txtChatInput_focus_entered():
# Disable player input
gamestate.accept_player_input = false
# And show the chat box
$pnlChat.show()
func _on_txtChatInput_focus_exited():
# Re-enable player input
gamestate.accept_player_input = true
And that's it, the chatting system is fully working! Ok, it can be vastly improved on many aspects, but then it would make this tutorial a lot complicated and the idea is to keep as simple as possible. Nevertheless, some of the points that can be done:
Label
, create a custom control that allows images mixed with formatted text, which would also allow colors to be added as well as custom emojis. Or even icons of items that were dropped in the game. Custom mouse interaction could even be added in this case, allowing further actions to be done.Yes, the feature list could go endlessly, so it's better to stop here!
And so, we have reached the conclusion of this advanced networking tutorial! While we did create a client/server system, where the server has full control over the simulation of the game, thus closing some doors to cheaters, we have only scratched the surface of network game synchronization! In the provided system I haven't incorporated any means to effectively hide the unavoidable lag of networked games, albeit I did mention some possible ways to do that and pointed into a bunch of online material that could help with this task. It all depends on the type of game you are working on, really! A turn based game (chess, card, tabletop, tactical strategy etc) can very well get away with many of the techniques shown here and be done with solely the bare-bones authoritative server shown in the part 1.
While Godot does not offer, out of the box, means to measure the latency (or even simulate it), we still can work around this and perform those ourselves, purely from GDScript!
Kicking players from a server is a somewhat desireable feature in a multiplayer game and luckily it's very easy to implement it with Godot. Banning requires a bit more work, while still not that complicated.
And sending chat messages is also relatively simple, specially when the concept of remote procedure calls is understood. The interesting thing of a chat system is that we can either hijack a virtual console to display the network messages or hijack the network messaging widget to also work as a virtual console. That's up to you!
At this point I truly hope you have an starting point for you own project! I also hope I didn't forget anything that was originally planned for the tutorial, like I did last time!
Happy forging!