Implementing Basic Features
In this chapter, we will cover some of the features Nakama has to offer. We will show you code samples of currently discussed features as well as provide links to the documentation if there are more functionalities we decided to omit.
Multiple Authentication Sources and Device Migration
To ensure an easy way for users to log into their accounts, Nakama offers multiple ways of account authentication, as mentioned in the chapter Session authentication. We are also able to link multiple external accounts, such as Google, Steam or Facebook into a single Nakama account, which allows us to log in while using different credentials. We could, for example, log into our account by using username and password on PC, but use Google on mobile – in both cases we expect to get the same Nakama account. Linking and unlinking different ways of authentication is really simple in Nakama
await Client.LinkDeviceAsync(Session, id); await Client.LinkEmailAsync(Session, email, password); await Client.LinkFacebookAsync(Session, facebookToken); await Client.LinkGoogleAsync(Session, googleToken); await Client.LinkSteamAsync(Session, steamToken); await Client.UnlinkDeviceAsync(Session, id); await Client.UnlinkEmailAsync(Session, email, password); await Client.UnlinkFacebookAsync(Session, facebookToken); await Client.UnlinkGoogleAsync(Session, googleToken); await Client.UnlinkSteamAsync(Session, steamToken);
There are, however, some rules for linking and unlinking accounts:
- There must be at least one authentication method linked to an account – when we create an account, it is done by logging into Nakama by using one of the allowed methods, which will be the only way to access our account at that time. Unlinking this method is forbidden until we link another account/device, in which case we will be able to unlink the first method.
- A single device/external account can be linked to only one Nakama account at a time – we can’t link our device to another Nakama account unless we unlink it from the previous one. Same goes for Facebook, Google, Game Center, Steam and traditional email and password authentication methods. This will ensure there is no ambiguity during session authentication.
Both of the rules were created to make sure there is always a way to log into our account. Nowadays, however, users expect to get to the pure gameplay as soon as possible – they get discouraged and annoyed when they have to go through all the gameplay unrelated processes, like account creation and authentication. That’s why developers tend to perform these actions in the background, let players experience their game and later on, when users are hooked on, ask them for more information, like username or Facebook integration (most of the time some game functionality is blocked until we finish our registration, or we get some kind of reward for doing it). That’s why it might be a good idea to create an account when the user logs in for the first time while using an unknown device (its ID is not linked to any existing Nakama account) and let them play the game.
Unfortunately, this approach might cause some problems. Take a look at this scenario:
- Start the game on the device A – a new account is created, we play a few levels and win some rewards we really like.
- Connect our existing account to Facebook – linking Facebook allows us to share our high score to our friends as well as give us a small boost in rewards.
- Start the game on device B – we got a new phone and wanted to play our game on the said phone. The first run of the program will create a new account with no rewards or high scores.
In this case, we cannot link our new device to our old account, because this would mean that device B is linked to two accounts at the same time, which violates the second rule. We also can’t unlink device B from the newly created account, because there would be no authentication methods left linked to that account, which would violate the first rule.
There are two ways to solve this problem:
- Delete the new account, erasing all of its progress and link device B’s ID to the old one.
- Create a “dummy” device, link it to the new account, unlink device B and re-link it to the old Nakama account.
There are advantages and disadvantages of both methods. Deleting an existing account ensures help with removal if the unused accounts from the database, which limits the amount of the used storage. We will, however, lose all of our progress on such account, meaning if we would ever want to return to this account, there is nothing that even database admins can do. If we merged our accounts this way by mistake, there is no way of recovering our progress.
The second method, which was used in Jolly Rogers demo, might require more storage space in the database because the first run of the game will always create a new account, even if we start the game with connecting to our old account in mind. In contrast to the first method, depending on how we create the “dummy” device, there is a possibility of restoring our old account.
//NakamaSessionManager.cs /// <summary> /// Transfers this Device Id to an already existing user account linked with Facebook. /// This will leave current account floating, with no real device linked to it. /// </summary> public async Task<bool> MigrateDeviceIdAsync(string facebookToken) { try { Debug.Log("Starting account migration"); string dummyGuid = _deviceId + "-"; await Client.LinkDeviceAsync(Session, dummyGuid); Debug.Log("Dummy id linked"); ISession activatedSession = await Client.AuthenticateFacebookAsync(facebookToken, null, false); Debug.Log("Facebook authenticated"); await Client.UnlinkDeviceAsync(Session, _deviceId); Debug.Log("Local id unlinked"); await Client.LinkDeviceAsync(activatedSession, _deviceId); Debug.Log("Local id linked. Migration successfull"); Session = activatedSession; StoreSessionToken(); ... return true; } catch (Exception e) { Debug.LogWarning("An error has occured while linking dummy guid to local account: " + e); return false; } }
In the sample code shown above, the dummy device is created by adding a dash ‘-‘ symbol at the end of our device’s ID. A device id must consist of alphanumeric characters and range from 10 to 60 bytes. Unity allows us to retrieve our unique ID by calling SystemInfo.deviceUniqueIdentifier, which after adding a dash at the end will create an entirely new ID. We can then link such id to the new account and unlink our actual device.
Groups and Clans
Multiplayer games almost always have some social aspects to them. Nakama gives developers an opportunity for creating groups or clans, which allows users to create teams to fight that one tough boss together, chat with each other or simply belong to a bigger society.
Group management in Nakama is pretty straightforward – there are a handful of operations you may perform related to groups:
- create/delete,
- join/leave,
- set basic info – name, description, avatar and whether the group is public or not,
- invite/kick users,
- promote members to higher ranks,
- get a list of members, as well as get all groups the user belongs to.
Upon entering a group (whether by creating it or joining an existing one) you are assigned one of the existing ranks:
- Superadmin,
- Admin,
- Member,
- Join Request
The highest rank given to owners of the group is Superadmin. There must be at least one Superadmin in any given group and only they can delete the group. They can also kick other members or promote them to higher ranks, as well as accept newcomers. The next rank is Admin, which has mostly the same privileges as Superadmin, however, they cannot delete their group, kick Superadmins or promote to the Superadmin rank. Every other user who belongs to the clan will be assigned Member rank. They are unable to promote, kick or manage the group.
If group accessibility is set to public, users can freely join the group without the need of having permission. This allows for creating open groups where everyone can hop in and out whenever they please. This might, however, allow unwanted users to join and disturb other members – that’s where the private groups might be a better option. Each user willing to join the group is first assigned Join Request rank and their request can be accepted or rejected by Admins or Superadmins.
A game can have both types of groups, however, for demonstration purposes, Jolly Rogers demo allows for public clans only. All of the code used for managing and displaying groups can be found under /Assets/DemoGame/Scripts/Clans/ folder. Below you can see an example of the clan creation method used in the project.
//ClanManager.cs /// <summary> /// Creates clan on Nakama server with given <paramref name="name"/>. /// Fails when the name is already taken. /// Returns <see cref="IApiGroup"/> on success. /// </summary> public static async Task<IApiGroup> CreateClanAsync(Client client, ISession session, string name, string avatarUrl) { try { IApiGroup group = await client.CreateGroupAsync(session, name, "", avatarUrl); return group; } catch (ApiResponseException e) { if (e.StatusCode == System.Net.HttpStatusCode.InternalServerError) { Debug.LogWarning("Clan name \"" + name + "\" already in use"); return null; } else { Debug.LogWarning("An exception has occured when creating clan with code " + e.StatusCode + ": " + e); return null; } } catch (Exception e) { Debug.LogWarning("An internal exception has occured when creating clan: " + e); return null; } }
Matchmaking
Although not suitable for every type of multiplayer game, matchmaking mechanism provided by Nakama greatly reduces the amount of work needed to create and manage matches. Its flexibility allows to determine who any given player should play against depending on a given set of parameters. We could, for example, match players with similar levels or from the same country (to reduce ping difference). It also notifies all matched users when the match is ready and waits until all of them connect before starting the game.
Users can join any number of matchmaker pools, each time receiving a matchmaker ticket, which then can be used to cancel a particular entry or determine which matchmaker queue has found opponents for you. While we are in the pool, we can bind our own business while the server is looking for the best matches for us, and when each matching player has been found, notifications are about the game starting soon are being sent to participants. For demo purposes, we allowed for only one matchmaking queue.
More information about parameters used in matchmaking can be found here. The sample code in Jolly Rogers regarding matchmaking can be found under /Assets/DemoGame/Scripts/Matchmaking/ folder.
Host Selection and Real-time Multiplayer
In most multiplayer games we choose to move all the computing logic to a single device, called host or server. This helps with synchronization because then all important systems run on a single machine and only the inputs and outputs are sent between the server and clients. Making the server do the math also reduces the number of cheaters, because clients can only send their input signals to the server.
Nakama allows for two types of a real-time multiplayer:
- Client-authoritative, also known as the relayed multiplayer, where one of the clients is the host who takes care of all the game logic of all players,
- Server-authoritative, where the computation takes place on the main server.
With client-authoritative multiplayer, the game logic is handled by one of the clients, whereas with server-authoritative multiplayer it’s the Nakama itself that’s responsible for it. We should use the latter one when we have a powerful server machine, which can handle emulating multiple matches at the same type or our game is not match-based. This type of multiplayer, requires a lot of custom logic, designed especially for the server. On the other hand, client-authoritative multiplayer has the advantage of not requiring too much computing power, because it’s the host, chosen from one of the players, who handles all the calculation and game logic management. It is also faster and sometimes easier to write it because we don’t have to write distinct logic for the server and clients, but rather mix them up together.
For demonstration purposes, Jolly Rogers uses client-authoritative real-time multiplayer with a bit of custom server logic to ease the things up. After a match is found, it’s time to choose the host, who will handle user input and send the output back. There are two ways of host selection:
- Non-deterministic – the server generates some random number from 1 (or most likely 0 in C#) to the number of joined players, then the player with the index equal to the generated number is selected as the host,
- Deterministic – we take the session ID of each player, sort them lexicographically (an alphabetical sort) and the player who’s session ID comes up as the first in our sorted list will be the host.
The non-deterministic way is commonly used in programming to select one object from a list at random, however, this requires custom server logic. The latter approach allows us to determine who’s the host without communicating with the server or other users because on joining a match we are given the list of all joined users out of the box. Jolly Rogers uses the deterministic way of choosing the host.
//MatchCommunicationManager.cs /// <summary> /// Chooses host in deterministic way /// </summary> private void ChooseHost(IMatchmakerMatched matched) { // Add the session id of all users connected to the match List<string> userSessionIds = new List<string>(); foreach (IMatchmakerUser user in matched.Users) { userSessionIds.Add(user.Presence.SessionId); } // Perform a lexicographical sort on list of user session ids userSessionIds.Sort(); // First user from the sorted list will be the host of current match string hostSessionId = userSessionIds.First(); // Get the user id from session id IMatchmakerUser hostUser = matched.Users.First(x => x.Presence.SessionId == hostSessionId); HostId = hostUser.Presence.UserId; }
Real-time Multiplayer in Jolly Rogers
All players have joined the match, the host has been chosen, the gameplay scene has been loaded and initialized – now it’s time to finally play the game!
To communicate with each other and the server, clients must establish a connection with Nakama socket through which they will send their state messages. Each message contains all the information needed for the receiver to determine what to do with it:
- User Presence – informs us who has sent this message,
- Match ID – the ID of the match this message comes from,
- State – message data,
- OpCode – integer used to help determine how we should process received message.
User Presence and Match ID are pretty much self-explanatory. They were made to clearly define where the given message comes from. OpCode is used (most likely in a switch statement or as an index in a list) to determine which method this message is supposed to be processed by. State is a byte array, most of the times a serialized JSON string, which contains all the data required to call the method determined by OpCode.
With this system, we are able to handle client communication with ease, without a need of a server to process the data, because messages are sent to, processed by and send back from the host. The server here is used just to connect all clients together.
//MatchCommunicationManager.cs /// <summary> /// Decodes match state message json from byte form of matchState.State and then sends it /// to ReceiveMatchStateHandle for further reading and handling /// </summary> private void ReceiveMatchStateMessage(IMatchState matchState) { string messageJson = System.Text.Encoding.UTF8.GetString(matchState.State); if (string.IsNullOrEmpty(messageJson)) { return; } ReceiveMatchStateHandle(matchState.OpCode, messageJson); } . . . /// <summary> /// Reads match messages sent by other players, and fires locally events basing on opCode. /// </summary> public void ReceiveMatchStateHandle(long opCode, string messageJson) { if (GameStarted == false) { _incommingMessages.Enqueue(new IncommingMessageState(opCode, messageJson)); return; } //Choosing which event should be invoked basing on opcode //then parsing json to MatchMessage class and firing event switch ((MatchMessageType)opCode) { //GAME case MatchMessageType.MatchEnded: MatchMessageGameEnded matchMessageGameEnded = MatchMessageGameEnded.Parse(messageJson); OnGameEnded?.Invoke(matchMessageGameEnded); break; //UNITS case MatchMessageType.UnitSpawned: MatchMessageUnitSpawned matchMessageUnitSpawned = MatchMessageUnitSpawned.Parse(messageJson); OnUnitSpawned?.Invoke(matchMessageUnitSpawned); break; . . . } }
We send messages in a similar way: serialize our message to JSON string and send a message with specific OpCode to other players.
Custom Server Logic
Although Nakama covers many of the most commonly used features, sometimes we come across a really specific problem, which might only occur in particular cases. For Jolly Rogers, it was the card management. Because this demo is based on constructing your own deck with cards you buy for gold, we had to design our custom server logic to manage user’s wallet and their card collection.
Nakama doesn’t allow for managing wallet client side for obvious reasons (if users could change their wallet content by themselves, some of them would certainly try to cheat the game). In this case, we need to write our own custom server logic, which will handle buying the cards and manage our collection itself.
Custom scripts for Nakama server are written in Lua. All .lua files stored in the bound folder (information about which can be found in chapter the Quickstart) are processed by the server upon its start and all handlers are added to underlying events. Nakama then invokes listeners whenever an event occurs. There are a handful of events we can subscribe to (called hooks), a list of which can be found here. Below is an example method used for debugging to add a random card to the user’s deck.
//initializer.lua ... nk.register_rpc(db.debug_add_random_card, "debug_add_random_card") ... //deck_building.lua function db.debug_add_random_card(context, payload) if w.update_wallet(context.user_id, -db.buy_cost, metadata, true) == false then return nk.json_encode({ response = false, message = "insufficient funds" }) end local random_type = math.random(db.card_type_first, db.card_type_count - 1) local unused_cards = get_cards(context.user_id, "unused_cards") add_card(unused_cards, tostring(random_type), tostring(1)) store_deck(context.user_id, "unused_cards", unused_cards) local metadata = { source = "random_card_bought" } return nk.json_encode({ response = true, message = "" }) end
Summary
If you are looking for an open-source server for your game, with the ability to write your own custom logic, allowing for server- as well as client-authoritative, real-time multiplayer, Nakama is the way to go. It is easy to get started and straightforward to use. It handles multiple multiplayer related features, like chat or matchmaking right out of the box, we just have to wrap it up in UI. However, if you would ever have any problems regarding the usage of Nakama, people from The Knights of Unity and Heroic Labs are always ready to answer your questions, so feel free to ask.