This is a guest blog post written by Fedor Logachev who worked on the Fish Game tutorial.
Fish Game is a 2-4 player online game built in the Macroquad game engine and the Rust programming language. The game was created as a demonstration of Nakama, an open-source scalable game server.
As you can see, Fish Game is a frenetic platformer arena starring murderous fish - the last fish standing wins! The game design is heavily inspired by the excellent Duck Game.
The game is playable online on itch.io and the Windows/Linux/Mac native version may be built from the source.
In this tutorial, we’re going to walk through each part of the code that interacts with Nakama to cover all the principles and APIs that you need to know to create your own online multiplayer game with Macroquad and Nakama.
We’ll touch on the following Nakama features:
This tutorial will briefly cover how to create a game with Macroquad. Afterward, it will focus on Nakama integration.
This part of the tutorial will explain how to create a single-player platformer game in Rust, starting with setting up macroquad. By the end of this section, we will have created a simple but fully functional platformer.
Start an empty Rust project:
|
|
Run it:
|
|
Open Cargo.toml
and add macroquad as a dependency:
|
|
Open src/main.rs
and add some macroquad drawing code to check that everything works (took this from macroquad examples):
src/main.rs
:
|
|
> cargo run
If we got a window with some shapes: All good, the project is set up correctly. Now it’s time to draw more shapes to make it look like a game.
If instead of a window, we have some errors: Maybe some native dependency is missing. Most likely, it is one of these:
|
|
For more details, check the build instructions.
The easiest way to do some 2D-level design with macroquad is with the free tiles editor Tiled.
Tiled with Fish Game’s level
We have a crate for reading tiled data in macroquad.
Our Cargo.toml
:
|
|
Then load the Tiled map to macroquad and draw a level:
src/main.rs
, all assets are in the Fish Game repo:
|
|
Now we have a level background. For a more polished game, it would be reasonable to invest some time into a more complicated camera, window handling, etc. All of those are implemented in the final Fish Game but will be skipped in the tutorial to get started with Nakama faster.
Add a character into the level from the previous chapter:
|
|
Now we have a character and a level. The next step is to allow the user to move the character.
|
|
There are lots hard-coded corner cases to take into account, but putting all of this together gives us a complete platformer character mechanic.
There is a crate with platformer physics implementation based on the brilliant article on Celeste and Towerfall physics: macroquad-platformer.
Cargo.toml
:
|
|
With the new crate involved, the code will look like the platformer example.
|
|
Now the fish can jump!
Now we have enough of a game to start adding Nakama networking. Just one final piece missing.
So far, the game code looks like this:
|
|
This may be good enough for a quick prototype. But for the whole Fish Game, we are going to have quite a few things in those variables:
Macroquad does not force any specific way to store data or game objects. Macroquad does have some embedded ways for organizing scenes and is friendly for any third-party ECS-like crates.
For Fish Game, we are going to use macroquad’s scenes.
|
|
It may not look like a big improvement over the previous approach, but scenes allow building complicated node relationships and applying iteration strategies over scene nodes. We are going to use it a lot more in the upcoming sections.
The “nakama-rs” crate is a pure Rust implementation of the Nakama protocol.
It allows working with Nakama in three different styles.
For Fish Game, we are going to use that high-level client. That client works as a giant state machine - the user makes non-blocking calls, and the client may change some internal state based on those calls.
Than in the main loop game pull changes from the ApiClient and may react accordingly.
nakama-rs’ ApiClient will be used as a singleton. Once a global object is created, it is persisted throughout the game lifetime and is globally accessible.
In macroquad, we can use the node system for this. It will still be a singleton, but the access and relationship graph with our new Nakama node will be easily traceable and visible.
So we will create a scene node with ApiClient and pass a reference to this node to all nodes communicating with Nakama.
|
|
Macroquad uses the immediate mode gui concept for UI. Here we will skip GUI style setup (it may be found here), and we will proceed to the UI logic instead.
Most UI windows interacting with Nakama are built in the same way:
|
|
The real-time multiplayer engine makes it easy for users to set up and join matches where they can rapidly exchange data with opponents. Any user can participate in matches with other users. Users can create, join, and leave matches with messages sent from clients. A match exists on the server until its last participant has left. Any data sent through a match is immediately routed to all other participants. The matches are kept in memory and can be persisted as needed. source
Match window code is very similar to the authentication window, as well as all other windows in Fish Game:
|
|
Now let’s focus on how Nakama’s real-time matches work.
To create a match:
|
|
This will start a process that on success will give some id in nakama.match_id()
.
This ID may be shared to friends to join this exact match later:
|
|
We have two problems here:
Match discoverability. Sharing a match id with a friend works for private games, but we need a better solution for public games. This will be addressed in the Matchmaker section.
Nakama considers a match started right after the create_match
call, and anyone can join at any moment. While the rules of Fish Game won’t let players join in the middle, and the game starts only when all of the players have pressed the “ready” button.
This will be addressed in the Ready window section.
You can use the Matchmaker to find other players. It is possible to match them using properties and a query that specifies the values the other players’ properties should hold. In nakama-rs
the Matchmaker is a struct
and can be created using Matchmaker::new()
. There are two types of properties, string properties and numeric properties that can be added with matchmaker.add_string_property("name", "value")
and matchmaker.add_numeric_property("rank", 1000.0)
respectively. Names of properties should be unique across both types.
The query is a space-separated string using the Bleve Query-String-Query Syntax. It is possible to add queries manually using matchmaker.add_query_item("properties.region:Europe")
but nakama-rs
provides a helper to construct the query string using the builder pattern. For now, terms, numeric ranges, required, optional and exclusion are supported. See examples/matchmaker.rs for more examples.
// By default query items are optional. The Matchmaker will prefer
// players holding the value, but will also match players without it.
// Prefer players from Europe.
let query_item = QueryItemBuilder::new("region")
.term("Europe")
.build();
// Only match with players from Europe
let query_item = QueryItemBuilder::new("region")
.term("Europe")
.required()
.build();
// Only match with players not from Europe
let query_item = QueryItemBuilder::new("region")
.term("Europe")
.excluded()
.build();
let query_item = QueryItemBuilder::new("rank")
.lt(10) // or .gt(10), .leq(10) and .geq(10)
.build();
In addition, it is possible to specify the minimum and the maximum number of players using matchmaker.min(2)
and matchmaker.max(100)
. The default values are 2 and 100, respectively.
Fish Game only specifies one property called "engine"
with the value macroquad_engine
. The query only specifies that we also match players holding the same value for that property, allowing us to avoid matchmaking players running a different game on the same Nakama server.
The full matchmaker setup can be seen below:
widgets::InputText::new(hash!())
.ratio(1. / 4.)
.filter_numbers()
.label("Minimum players")
.ui(ui, &mut minimum_players);
widgets::InputText::new(hash!())
.ratio(1. / 4.)
.filter_numbers()
.label("Maximum players")
.ui(ui, &mut maximum_players);
if ui.button(None, "Start matchmaking") {
let mut matchmaker = Matchmaker::new();
matchmaker
.min(minimum_players.parse::<u32>().unwrap())
.max(maximum_players.parse::<u32>().unwrap())
.add_string_property("engine", "macroquad_matchmaking")
.add_query_item(
&QueryItemBuilder::new("engine")
.required()
.term("macroquad_matchmaking")
.build(),
);
nakama.api_client.socket_add_matchmaker(&matchmaker);
next_scene = Some(Scene::WaitingForMatchmaking { private: false });
);
You can add the Matchmaker by calling nakama.socket_add_matchmaker(&matchmaker)
, adding the user to the server’s matchmaking pool. The user will stay in the pool until matched as long as they are online or until you remove them manually. It is possible to add multiple matchmakers with different queries simultaneously to look for different types of matches.
When the server matches the user, the ApiClient will handle the event and set nakama.matchmaker_token
to Some(token)
. The token is a short-lived entry ticket that you can use to join a match with the other matched players by calling nakama.socket_join_match_by_token(token)
. The server creates the match as soon as the first player tries to join it and sends an event once the player joined successfully. In the client, nakama.match_id()
will then contain a value. It is now possible to send match data between players.
For additional details, check out the Matchmaker documentation.
nakama-rs’ApiClient
provides two API calls to communicate between each other:
fn socket_send<T: SerBin>(&self, opcode: u32, data: T)
socket_send
will binary serialize given message and broadcast it to each player in the room. Opcode here acts as a tag or discriminant in rust enum - a small descriptor specifiyng what kind of data is being sent.
fn try_recv(&self) -> Option<Event>
try_recv
will give an Event if someone sent a message or joined/left match since last try_recv
call.
A convenient Rust representation of an opcode
/data
pair may look like this:
|
|
Then, in order to send some message:
|
|
To recieve such a message on other client:
|
|
Event may be either a message from other client or a system even like joined/leaved player. This will be explained in details in Player state syncronization section.
Fish Game uses a relayed network synchronization model. Each player simulates its physics and sends its state to other players.
One of the players is called “host” and is responsible for global events and conflict resolution.
Nakama node from previous chapters:
|
|
Now it will also take responsibility for state synchronization.
|
|
In the final game code, the State
structure is slightly more optimized.
Also, in the real game, network fps are different from the rendering fps - with networking lag sending state 60 times per second is too much, and ~15-20 should be enough. Fish Game works on 15.
But the idea is the same - The Nakama
node packs all the important parts of the player state and sends it over the network to all the other players in the room.
Next step - receive other players’ data and draw other players.
|
|
That is the bare-bones implementation - for each frame, we receive all the Nakama events and apply changes to the scene.
We need to have a list of remote_players
to track the scene nodes of remote players and add/remove players on game joins or leaves. And we need to apply the received messages to those remote players.
|
|
This will keep the list of active remote players accurate, and for each, we will have a RemotePlayer node.
The RemotePlayer node may be very similar to the Player node but with very different logic.
Then continue the event handling match
to deal with a data message:
|
|
src/nodes/nakama/nakama_realtime_game.rs
While Fish Game has good enough network performance to be fully functional, it is kept intentionally simplistic for demonstration purposes.
Nakama’s real-time Multiplayer API sends data over WebSockets, which means TCP. TCP is reliable (you know if the message you sent arrived or not), but it’s slower.
Fast-paced games commonly use UDP to send synchronization information: it’s unreliable (your message can get lost) but faster. Using unreliable UDP would allow for some network optimizations that we can’t do here. For example, with some kinds of game states, only the most recent message is valid (all older messages are immediately invalid once a newer one arrives), so we could use the faster UDP packets. It’s OK if some get lost, we’d just take the newest one that arrives.
This game is fast-paced enough that it ideally should be using “input prediction and rollback” rather than “input prediction and correction”. When using rollback, rather than applying the corrections you received to the current state of the game, you roll back the local game state to the time when the remote state was generated, apply it, and then roll the game state forward to the current time. This can help eliminate lag and avoid situations where one player sees themselves hitting another player, but it doesn’t register as a hit. However, rollback is a lot more complicated to implement than correction. Fish Game has enough in common with arcade-style Fighting Games that the most optimal networking technique for it might be the GGPO technique.
This would merit further investigation if this was a commercial game.
In Fish Game, we have some global events. Good example: Spawn process of pickable weapon.
There are two ways to simulate such a process:
With the deterministic approach, we are going to have a problem with conflicts - if two players correctly simulated that they picked the weapon at the very same time, who is right?
With the “host” approach it is way easier - the host may be responsible for such decisions.
While all the players have unique IDs and everyone knows each other’s ID, we can just sort the list of remote_players
IDs and whoever’s first is the host.
Now we can create a special node, GlobalEvents
:
|
|
One of our unsolved problems: in Fish Game the game starts only when all the players joined the match, can see each other and has opted in as ready.
After this point, it is impossible to join a match and only one last standing fish will win.
All this logic may be implemented on top of Nakama’s messages.
We can add a flag to our Nakama node:
|
|
And introduce a special message:
|
|
Now it is the game’s responsibility to figure when to start a match. In Fish Game rules are quite simple - before everyone pushed “Ready” and the host confirmed by pushing “Start game” - no loot is spawning and noone is allowed to move. Implementation is in nakama node
But this logic may be totally different depending on game design, and the goal of this little chapter - demonstrate where nakama’s area of responsibility in the matchmaker/match logic ends and game should roll its own logic.
There’s one last Nakama feature we’re going to look at before ending this tutorial: Leaderboards.
Leaderboards need to be created on the server before your game can write data to them. This is done in Fish Game by adding a small server-side Lua module in nakama/data/modules/fish_game.lua
:
|
|
This runs on the Nakama server at startup and creates a leaderboard called “fishgamewins”, which we’re going to use to track the total number of wins that each player has gotten.
It’s a non-authoritative leaderboard (the false
in the 2nd argument), which means that the game clients can modify the leaderboard themselves, rather than requiring server-side logic to do it. It’s sorted in descending order (the “desc” in the 3rd argument) and is updated by incrementing the score (the “incr” in the 4th argument).
Note: Nakama modules can be written in Lua, Go or (in Nakama 3) JavaScript.
When only one fish is alive and the game has ended, the winner may update the leaderboard record.
|
|
We may want to wait for the result status to display some error and retry if we got a network error.
The leaderboard window is not much different from the authentication/matchmaking window.
First, make the Nakama request:
|
|
And then on success we will have something in api_client.leaderboard_records
|
|