§10 Add the Final Touches to Your Godot 4 Go MMO
This is the last part of the series we will concern ourselves with adding new features to the game. We just have two more features to add, and then we will move on to polishing everything up and deploying the game over the next two parts.
As always, if do you want to start here without viewing the previous lesson, feel free to download the source code for release v0.09 in the official GitHub repository.
As a reminder, in the last part, we added a hiscores and a leaderboard to the game. In this part, we want to:
- Give players a random chance to drop a spore and lose mass over time, giving them a reason to keep eating as much as possible
- Allow players to search the leaderboard for themselves or their friends
This should be a shorter post than usual, since we should be very familiar with the codebase, tools, and workflow by now. I will also be a bit more brief on explanations where we’ve already covered the topic in detail. Let’s get started!
But first…
Homework solution
At the end of the last part, we noted there is no way to go back to the main menu from the hiscores screen. We will go over a solution to this before we start adding new features. If you already completed the homework, good job! Feel free to skip ahead to the next section, although you may want to skim through the solution to solidify your understanding of the concepts.
Firstly, we will need to make a new packet to signal to the server that the client wants to go back to the Connected
state.
/shared/packets.proto
message FinishedBrowsingHiscoresMessage { }
message Packet {
// ...
oneof msg {
FinishedBrowsingHiscoresMessage finished_browsing_hiscores = 17;
}
}
Next, let’s get the server to listen for this packet in the BrowsingHiscores
state, and transition back to the Connected
state when it receives it.
/server/internal/server/states/browsingHiscores.go
func (b *BrowsingHiscores) HandleMessage(senderId uint64, message packets.Msg) {
switch message := message.(type) {
case *packets.Packet_FinishedBrowsingHiscores:
b.handleFinishedBrowsingHiscores(senderId, message)
}
}
func (b *BrowsingHiscores) handleFinishedBrowsingHiscores(_ uint64, _ *packets.Packet_FinishedBrowsingHiscores) {
b.client.SetState(&Connected{})
}
Finally, we’ll need a way to send this packet from the client. We will add a “Back” button that will send this packet when clicked. To do this, open up Godot, open the scene editor for res://states/browsing_hiscores/browsing_hiscores.tscn
. Now, the way we left the scene in the last part, it will be difficult to position a button nicely, so let’s fix that quickly:
- Add a
VBoxContainer
just under theUI
node, and set its anchor preset to Full Rect - Reposition the existing
Hiscores
node to be a child of theVBoxContainer
- Add a
Button
node to theVBoxContainer
, just above theHiscores
node. Name the buttonBackButton
, and set theText
property to “Back”
Your scene should look like this, which I will admit is not the prettiest, but that’s what the next part is for! We will make everything look nice and pretty eventually, but for now, we just want to get the functionality working.
Now, let’s edit the script for this scene to send the FinishedBrowsingHiscoresMessage
packet when the button is clicked. We will also need to fix up the node path to the Hiscores
node, since we moved it to be a child of the VBoxContainer
.
/client/states/browsing_hiscores/browsing_hiscores.gd
@onready var _back_button := $UI/VBoxContainer/BackButton as Button
@onready var _hiscores := $UI/VBoxContainer/Hiscores as Hiscores
func _ready() -> void:
_back_button.pressed.connect(_on_back_button_pressed)
# ...
func _on_back_button_pressed() -> void:
var packet := packets.Packet.new()
packet.new_finished_browsing_hiscores()
WS.send(packet)
GameManager.set_state(GameManager.State.CONNECTED)
Now, when you run the game, you should be able to go back to the main menu from the hiscores screen. You should also be able to do all the things you could normally do in the connected state, like log in, register, and even go back to the hiscores screen.
Dropping spores
At the moment, our MMO doesn’t incentivize players to keep eating once they reach a certain size. We want to add a mechanic where players have a random chance to drop a spore, which will cause them to lose mass over time. This will give players a reason to keep eating, as to at least maintain their size, if not grow larger. Let’s get started!
There are many ways we could implement this, but the simplest and lowest effort approach is probably to hook in to the existing syncPlayer
method in the InGame
state on the server. We will add a random chance to drop a spore and broadcast it to all clients, plus we’ll strategically update the player’s radius right before we broadcast our update. So we will be injecting this logic right after we update the player’s position, but before we form the updatePacket
:
/server/internal/server/states/ingame.go
func (g *InGame) syncPlayer(delta float64) {
// ...
// Drop a spore
probability := g.player.Radius / float64(server.MaxSpores*5)
if rand.Float64() < probability && g.player.Radius > 10 {
spore := &objects.Spore{
X: g.player.X,
Y: g.player.Y,
Radius: min(5+g.player.Radius/50, 15),
}
sporeId := g.client.SharedGameObjects().Spores.Add(spore)
g.client.Broadcast(packets.NewSpore(sporeId, spore))
go g.client.SocketSend(packets.NewSpore(sporeId, spore))
g.player.Radius = g.nextRadius(-radToMass(spore.Radius))
}
// Broadcast the updated player state
// ...
}
This code will drop a spore with a probability that increases with the player’s size, but only if the player is larger than 10 units. The spore will have a radius between 5 and 15 units, and will be broadcast to all clients. We also send the spore to the client that dropped it, so they can see it immediately. Finally, we reduce the player’s radius by the mass of the spore by using a negative value in the nextRadius
method.
Now, if you run the game, you might notice the dropped spores are appearing on top of the player, but are immediately being consumed again since they are colliding with the player. It might be difficult to spot this at all, since they will only be there for a split second after they spawn. To fix this, we’re going to need to do two things:
- Make the client not consume the spore if it spawns on top of them
- Add a new spore consumption validation check on the server in case a hacked client disables the first point.
Making the client ignore spores that spawn on top of them
We are going to add a new flag to pass into the spore’s instantiate
function, which will tell us whether the spore was dropped by the player, and is still underneath the player. This flag will be turned off if ever the spore exits an actor’s collision shape, and won’t be turned back on ever again.
To achieve this, let’s modify the res://objects/spore/spore.gd
script to allow for this flag:
/client/objects/spore/spore.gd
const Actor := preload("res://objects/actor/actor.gd")
var underneath_player: bool
static func instantiate(spore_id: int, x: float, y: float, radius: float, underneath_player: bool) -> Spore:
# ...
spore.underneath_player = underneath_player
# ...
func _ready() -> void:
if underneath_player:
area_exited.connect(_on_area_exited)
# ...
func _on_area_exited(area: Area2D) -> void:
if area is Actor:
underneath_player = false
Now, we need to pass this flag when we instantiate the spore in the client’s InGame
state:
/client/states/ingame/ingame.gd
func _handle_spore_msg(sender_id: int, spore_msg: packets.SporeMessage) -> void:
# ...
var underneath_player := false
if GameManager.client_id in _players:
var player := _players[GameManager.client_id]
var player_pos := Vector2(player.position.x, player.position.y)
var spore_pos := Vector2(x, y)
underneath_player = player_pos.distance_squared_to(spore_pos) < player.radius * player.radius
if spore_id not in _spores:
var spore := Spore.instantiate(spore_id, x, y, radius, underneath_player)
# ...
We are inserting the logic just after we define the variables from the message, and before we add the spore as a child to the world. For absolute clarity, here’s the full method:
Click to expand
/client/states/ingame/ingame.gd
func _handle_spore_msg(sender_id: int, spore_msg: packets.SporeMessage) -> void:
var spore_id := spore_msg.get_id()
var x := spore_msg.get_x()
var y := spore_msg.get_y()
var radius := spore_msg.get_radius()
var underneath_player := false
if GameManager.client_id in _players:
var player := _players[GameManager.client_id]
var player_pos := Vector2(player.position.x, player.position.y)
var spore_pos := Vector2(x, y)
underneath_player = player_pos.distance_squared_to(spore_pos) < player.radius * player.radius
if spore_id not in _spores:
var spore := Spore.instantiate(spore_id, x, y, radius, underneath_player)
_world.add_child(spore)
_spores[spore_id] = spore
Now we can use this new flag to determine whether the player should consume the spore or not. We can add a check to the _consume_spore
method in the InGame state script:
/client/states/ingame/ingame.gd
func _consume_spore(spore: Spore) -> void:
if spore.underneath_player:
return
# ...
Here, we just exit the method early if the spore is underneath the player. This will prevent the player from consuming the spore if it is underneath them.
So now, in a perfect world, we would be finished with this feature! Here’s a video of the spores dropping from various players, and the players losing mass over time as they consume the spores (I have increased the spore drop rate for demonstration purposes):
It was an absolute nightmare to record this…
The only problem is, we do not live in a perfect world. Rule number one of networking: never trust the client. We need to add a new check on the server to ensure that the client is not consuming spores that are underneath them. Our approach here will be to add a timestamp to the spore object to signify when it was dropped. Then, when the client says they’ve consumed that spore, we will check if enough time has passed since the spore was dropped. If not, we will ignore the consumption request.
Let’s start by adding a timestamp to the spore object:
/server/internal/server/objects/gameObjects.go
import "time"
type Spore struct {
// ...
DroppedBy *Player
DroppedAt time.Time
}
We do not need to worry about setting these new fields unless we are dropping the spore, since the DroppedBy
field will default to nil
, and the DroppedAt
field won’t matter. So let’s modify the spore dropping logic to set these fields:
/server/internal/server/states/ingame.go
func (g *InGame) syncPlayer(delta float64) {
// ...
// Drop a spore
// ...
if rand.Float64() < probability && g.player.Radius > 10 {
spore := &objects.Spore{
// ...
DroppedBy: g.player,
DroppedAt: time.Now(),
}
// ...
}
// ...
}
Now, over to the spore consumption validation logic we implemented in §08. We will add a new check to ensure that the spore wasn’t dropped after the time it takes for the player to travel combined radius of the player and the spore. This works because the minimum distance the player has to travel such that the spore is not underneath them is their own radius, then the spore’s radius. Then, best case scenario, the player turns around instantly and consumes the spore.
We know the player’s speed in units per second, since the server assigns it to the player. Rearranging the simple formula \(\text{distance} = \text{speed} \times \text{time}\), we know that \(\text{time} = \frac{\text{distance}}{\text{speed}}\). So, the minimum time (in seconds) it should take for the player to consume the spore is the sum of the radii of the player and the spore, divided by the player’s speed. We will add this check to the handleSporeConsumed
method:
/server/internal/server/states/ingame.go
func (g *InGame) handleSporeConsumed(senderId uint64, message *packets.Packet_SporeConsumed) {
// First check if the spore exists
// ...
// Next, check if the spore is close enough...
// ...
// Finally, check if the spore wasn't dropped by the player too recently
err = g.validatePlayerDropCooldown(spore, 10)
if err != nil {
g.logger.Println(errMsg + err.Error())
return
}
// If we made it this far, the spore consumption is valid...
}
func (g *InGame) validatePlayerDropCooldown(spore *objects.Spore, buffer float64) error {
minAcceptableDistance := spore.Radius + g.player.Radius - buffer
minAcceptableTime := time.Duration(minAcceptableDistance/g.player.Speed*1000) * time.Millisecond
if spore.DroppedBy == g.player && time.Since(spore.DroppedAt) < minAcceptableTime {
return fmt.Errorf("player dropped the spore too recently (time since drop: %v, min acceptable time: %v)", time.Since(spore.DroppedAt), minAcceptableTime)
}
return nil
}
We also include a buffer distance in this check, to allow for a little of leeway in case the server isn’t synced perfectly with the client. This buffer is set to 10 units, but you can adjust this value to your liking.
Now, if you run the game, you shouldn’t see any difference. The spores will still drop, and the players will still consume them. However, if you were to modify the client to consume spores that are underneath them (i.e. undo the changes we made to the client just above), you’ll see the server complaining about the player dropping the spore too recently, and the player will appear to consume the spore but keep shrinking regardless. Any players witnessing this will see the player shrinking, but the dropped spores will still be there.
Searching the leaderboard
Our leaderboard currently shows the top 10 players, but it would be even more exciting if we could search for a specific player who maybe isn’t in the top 10. This is what we will be going for in this session.
We’re going to need a few things to make this work:
- A new packet to send the search query to the server
- A new query on the database to search for the player
- A new method on the server to handle the search query
- A search bar on the client to send the search query
- A way to highlight the searched player in the leaderboard
Let’s get started and tackle these one by one.
Hiscores search packet
This will be another simple packet, with the only data being the name of the player we want to search for:
/shared/packets.proto
message SearchHiscoreMessage { string name = 1; }
message Packet {
// ...
oneof msg {
SearchHiscoreMessage search_hiscore = 18;
}
}
Hiscores search query
We need a query to retrieve the player’s rank from the database. We will be able to use that to determine the offset to use in the leaderboard results to send back to the client. Let’s go ahead and try to write this query:
/server/internal/db/config/queries.sql
-- name: GetPlayerByName :one
SELECT * FROM players
WHERE name LIKE ?
LIMIT 1;
-- name: GetPlayerRank :one
SELECT COUNT(*) + 1 AS "rank" FROM players
WHERE best_score > (
SELECT best_score FROM players p2
WHERE p2.id = ?
);
The GetPlayerByName
is more of a helper query, used as a stepping stone to get the player’s rank. The only thing to note here is we are using the LIKE
operator to make the search case-insensitive.
The GetPlayerRank
query is a bit more complex than the queries we’ve seen so far, but it is still quite simple. We are using a subquery to get the player’s best score, then counting the number of players with a better score than that player. We add 1 to the count to get something starting from 1, rather than 0.
Hiscores search handler
In our BrowsingHiscores
state handler on the server, we will need to add a new case to handle the search query:
/server/internal/server/states/browsingHiscores.go
func (b *BrowsingHiscores) HandleMessage(senderId uint64, message packets.Msg) {
switch message := message.(type) {
// ...
case *packets.Packet_SearchHiscore:
b.handleSearchHiscore(senderId, message)
}
}
func (b *BrowsingHiscores) handleSearchHiscore(_ uint64, message *packets.Packet_SearchHiscore) {
player, err := b.queries.GetPlayerByName(b.dbCtx, message.SearchHiscore.Name)
if err != nil {
b.logger.Printf("Error getting player %s: %v", message.SearchHiscore.Name, err)
b.client.SocketSend(packets.NewDenyResponse("No player found with that name"))
return
}
playerRank, err := b.queries.GetPlayerRank(b.dbCtx, player.ID)
if err != nil {
b.logger.Printf("Error getting rank for player %s: %v", message.SearchHiscore.Name, err)
b.client.SocketSend(packets.NewDenyResponse("Player is unranked"))
return
}
const limit int64 = 10
offset := playerRank - limit/2
b.sendTopScores(limit, max(0, offset))
}
This code simply retrieves the player’s rank from the database using the queries we wrote above, then sends the 10 players surrounding that player to the client. If the player happens to be in the top 5 players, the offset calculation needs to be adjusted to ensure we don’t send negative offsets to the database.
You will notice we are referring to a function that does not exist yet: b.sendTopScores
. This is because we are going to refactor slightly to extract the OnEnter
logic into a separate method so that we can reuse it for the search query. Let’s do that now. Simply replace the OnEnter
method with the following:
/server/internal/server/states/browsingHiscores.go
func (b *BrowsingHiscores) OnEnter() {
b.sendTopScores(10, 0)
}
Now we can add the sendTopScores
method which will contain the logic that previously was in the OnEnter
method:
/server/internal/server/states/browsingHiscores.go
func (b *BrowsingHiscores) sendTopScores(limit int64, offset int64) {
topScores, err := b.queries.GetTopScores(b.dbCtx, db.GetTopScoresParams{
Limit: limit,
Offset: offset,
})
if err != nil {
b.logger.Printf("Error getting top %d scores from rank %d: %v", limit, offset+1, err)
b.client.SocketSend(packets.NewDenyResponse("Failed to get top scores - please try again later"))
return
}
hiscoreMessages := make([]*packets.HiscoreMessage, 0, limit)
for rank, scoreRow := range topScores {
hiscoreMessage := &packets.HiscoreMessage{
Rank: uint64(rank) + uint64(offset) + 1,
Name: scoreRow.Name,
Score: uint64(scoreRow.BestScore),
}
hiscoreMessages = append(hiscoreMessages, hiscoreMessage)
}
b.client.SocketSend(packets.NewHiscoreBoard(hiscoreMessages))
}
Now, we shouldn’t have any problems running our game and browsing the leaderboard as we did before. We’re done with the server side of things, so let’s move on to the client.
Hiscores search bar
Let’s go ahead and add some more UI to the hiscores scene to allow players to search for a player. We will group the exiting back button, with the new search bar and search button, in a HBoxContainer
which will sit at the top of the VBoxContainer
. Your scene tree should look like this:
- Node - called
BrowsingHiscores
- CanvasLayer - called
UI
- VBoxContainer
- HBoxContainer
- Button - called
BackButton
- LineEdit
- Button - called
SearchButton
- Button - called
- Hiscores
- HBoxContainer
- VBoxContainer
- CanvasLayer - called
The LineEdit node’s Expand property should be enabled underneath the Layout > Container Sizing > Horizontal section of the inspector, or via the sizing settings button at the top of the scene editor.
The SearchButton node should have its Text property set to “Search”.
Now, we will have knocked loose the reference to the BackButton
node in the script, so we need to fix that and add the new search button and line edit nodes:
/client/states/browsing_hiscores/browsing_hiscores.gd
@onready var _back_button := $UI/VBoxContainer/HBoxContainer/BackButton as Button
@onready var _line_edit := $UI/VBoxContainer/HBoxContainer/LineEdit as LineEdit
@onready var _search_button := $UI/VBoxContainer/HBoxContainer/SearchButton as Button
func _ready() -> void:
_line_edit.text_submitted.connect(_on_line_edit_text_submitted)
_search_button.pressed.connect(_on_search_button_pressed)
# ...
func _on_line_edit_text_submitted(_new_text: String) -> void:
_on_search_button_pressed()
func _on_search_button_pressed() -> void:
var packet := packets.Packet.new()
var search_hiscore_msg := packet.new_search_hiscore()
search_hiscore_msg.set_name(_line_edit.text)
WS.send(packet)
This code will send the search query to the server when the search button is pressed, or when the user presses Enter in the line edit. When the server responds, we are already mostly set up to handle the response, since we are listening to HiscoreBoardMessage
s already (although there are some issues). We just need to handle the case where the server responds with a DenyResponse
.
Let’s add a Log (log.gd)
node to the scene, just under the Hiscores
node, and set its Custom Minimum Size’s y value to, say, 100px. This will allow us to display the error message to the player if the server responds with a DenyResponse
.
We will also need to add a new method to handle this response:
/client/states/browsing_hiscores/browsing_hiscores.gd
@onready var _log := $UI/VBoxContainer/Log as Log
func _handle_deny_response(deny_response_msg: packets.DenyResponseMessage) -> void:
_log.error(deny_response_msg.get_reason())
Now, when you run the game, you should have no problem searching for an existent-or-non-existent player, and the server should respond with the appropriate message. The only problem is, the leaderboard kinda just throws new entries in with the old ones, and you can end up in situations where there are more than 10 entries present, not necessarily consecutive. To fix this, we simply need to clear the leaderboard before adding the new entries. We haven’t built a method to do this yet, but it should be quite simple to implement. We will add a new method to the hiscores.gd
script:
/client/classes/hiscores/hiscores.gd
func clear_hiscores() -> void:
_scores.clear()
for entry in _vbox.get_children():
if entry != _entry_template:
entry.free()
Note we are being careful not to remove the template entry, as that’s what we use when creating new entries. Now, we just need to call this method before adding the new entries in the handle_hiscore_board
method:
/client/states/browsing_hiscores/browsing_hiscores.gd
func _handle_hiscore_board_msg(hiscore_board_msg: packets.HiscoreBoardMessage) -> void:
_hiscores.clear_hiscores()
# ...
Now, we should have a fully functioning search feature in our game! It will always return a window of the dataset, centered about our player of interested. But it’s not so obvious that this is the case because it’s not highlighted in any way. Let’s add a highlight to the searched player in the leaderboard.
Highlighting the searched player
We will finally modify the set_hiscore
method in the hiscore_entry.gd
script to optionally highlight the entry. We can then use that to decide whether to highlight the hiscore entry received from the server, based on what we have in the search bar. Let’s add a new parameter to the set_hiscore
method:
/client/classes/hiscores/hiscores.gd
func set_hiscore(name: String, score: int, highlight: bool = false) -> void:
# ...
_add_hiscore(name, score, highlight)
func _add_hiscore(name: String, score: int, highlight: bool) -> void:
# ...
if highlight:
name_label.add_theme_color_override("font_color", Color.YELLOW)
Now, we just need to modify the handle_hiscore_board
method to highlight the searched player (it’s best to refactor a bit here, so I’ll show the full method):
/client/states/browsing_hiscores/browsing_hiscores.gd
func _handle_hiscore_board_msg(hiscore_board_msg: packets.HiscoreBoardMessage) -> void:
_hiscores.clear_hiscores()
for hiscore_msg: packets.HiscoreMessage in hiscore_board_msg.get_hiscores():
var name := hiscore_msg.get_name()
var rank_and_name := "%d. %s" % [hiscore_msg.get_rank(), name]
var score: int = hiscore_msg.get_score()
var highlight := name.to_lower() == _line_edit.text.to_lower()
_hiscores.set_hiscore(rank_and_name, score, highlight)
So now, when you search for a player, the middle entry, the one you searched for, will be highlighted in yellow.
Conclusion
So that ticks off everything we wanted to in this post! We added a new mechanic to keep players engaged, and a search feature to encourage competition and social interaction.
Even though we have a fully functioning game at this point, there is still much to be desired in the looks department. In the next part, we will focus purely on polishing up anything that needs it, and making the game look as good as possible. This will set us up nicely for the final part, where we will deploy the game and make it available for others to play. Until then, good luck with your game development!
If you have any questions or feedback, I’d love to hear from you! Either drop a comment on the YouTube video or join the Discord to chat with me and other game devs following along.