Introduction

Feature Parity
Leaderboards
Teams
Currency
Virtual Goods Configurable
Authentication
Achievements Configurable
Push Notifications Configurable
Chat
Matchmaking
Email Configurable
Cloud-Code
Database Access
Analytics
Snapshots
Data Explorer
Player Management

Nakama is a very powerful tool for games that require reliable and scalable backends.

Nakama differs from other services that may be self-service, like GameSparks, where developers log in and configure their backend through an online portal. Nakama is a server runtime environment which can be hosted and deployed anywhere using Docker.

Because developers have access to the environment which Nakam is running on, they are free to augment or modify existing Nakama features or create their own new features as they see fit. The base Nakama environment is Golang but there are Lua and TypeScript environments available also.

For the TypeScript environment GameSparks can provide an example project which includes a translation layer that will allow you to port your existing GameSparks code with minimal refactoring or rewriting. You can request access to this project by raising a ticket with GameSparks support.

While this will save you time and get your game back up and running faster, it is advised that you consider using their base-environments or consider a rewrite of your game post-transition in order to take full advantage of optimized features of the base Golang environment.

Nakama does not require an account in order for you to get set up as Heroic Labs provides an open-source version of Nakama which anyone can download and use. You can check out their Getting Started Guide here.

Something important to note is that while Nakama is an open-source product, Heroic Labs, the developers of Nakama, provide their own hosting service called Heroic Cloud. To ensure the best performance for your new environment Heroic Cloud is advised. Heroic will provide support and dev-ops for your environment and work with you to ensure the best outcome for your transition.

This series starts with the Authentication Basics here You can use the guide there to see how to set up a new Nakama project and authenticate players.

Authentication Basics

Nakama has alternatives to all of the standard authentication routes we mentioned in the introduction. We will cover this in brief in this topic as they are very simple to set up and work similarly to what you would expect from GameSparks.

Script Setup
As this will be the first topic many developers will check for feature parity with GameSparks, we will briefly mention how the Nakama client and SDK are set up in Unity. Before adding this code you will need to have an instance of the Nakama server running somewhere. In the case of this example we are running it locally for testing. You can check out a guide here on how to get the server running locally.

For this example we created a C# script and attached it to a GameObject in Unity. This is going to be our “NakamaManager” script. Unlike GameSparks, all of the attributes of the Nakama SDK are set in this script, rather than through editor settings.

We will go through each of the components needed for this setup below…

public class NakamaManager : MonoBehaviour
{
   /// <summary>
   /// This is either http or https.
   /// https requires TLS setup
   /// </summary>
   private static string commProtocol = "http";

   /// <summary>
   /// The address where the Nakama instance is running (local is 127.0.0.1)
   /// </summary>
   private static string serverIp = "127.0.0.1";

   /// <summary>
   /// This is the port used to create the config backend instance (the portal)
   /// </summary>
   private static int serverPort = 7350;

   /// <summary>
   /// Like the GS "server secret". Can be set from the config portal
   /// </summary>
   private static string serverKey = "defaultkey";

   /// <summary>
   /// This is our Nakama client.
   /// </summary>
   private readonly IClient nakamaClient = new Client(commProtocol, serverIp, serverPort, serverKey);
}

Device Authentication
Device authentication is simple, and like GameSparks, there is a method in the client API specifically for device authentication. However, we do need to remember that we are using asynchronous methods with Nakama so instead of using a callback to determine when the player has been logged in, we will use the “await” command from within an asynchronous method.

As with GameSparks, you can assign a name to a player which is logging in with a device ID. However, this becomes the “userName” of the player and not the “displayName” as you would be familiar with in GameSparks. Usernames must be unique.

/// <summary>
/// Logs the player in using a deviceId
/// </summary>
/// <param name="userName"></param>
private async void DeviceAuthentication(string userName)
{
   Debug.Log("Attempting device auth...");
   Debug.Log($"User Name {userName}...");
   // We'll get the same deviceId that GameSparks uses //
   string deviceId = SystemInfo.deviceUniqueIdentifier.ToString ();
   Debug.Log($"New deviceId: {deviceId}");

   ISession session = await nakamaClient.AuthenticateDeviceAsync(deviceId, userName);
   // Now we can log out the session details //
   Debug.Log($"UserId: {session.UserId}");
   Debug.Log($"AuthToken: {session.AuthToken}");
}

In the Unity console we should see the following logs…

We can confirm the player has been created by going to your Developer Console which can be found at http://127.0.0.1:7351 if you are running the server locally.

Choose the “Accounts” option from the left-hand side menu and you should see your player with the user ID we set from Unity.

You can see here that there is a field for display name if you would like to set it, so let us take a look at how we can set that up. You may need to delete this player so that we can create them again with the display name set.

To set and edit additional player settings we can use the UpdateAccountAsync() method. This method allows us to update different fields on the player account like display name, location, timezone and avatar URL.

ISession session = await nakamaClient.AuthenticateDeviceAsync(deviceId, userName);
// Now we can log out the session details //
Debug.Log($"UserId: {session.UserId}");
Debug.Log($"AuthToken: {session.AuthToken}");
// add displayName to the player's account //
await nakamaClient.UpdateAccountAsync(session, userName, displayName);

And we can check if this added the display name to our player using the accounts window of the Developer Console.

Registration
Next we will take a look at registration. Basic registration and authentication actually use the same method from the Nakama client API, however there are some settings that let this authentication method act like a GameSparks RegistrationRequest.

There is unfortunately no basic-authentication request in Nakama for username and password. To replicate this functionality we are going to modify the AuthenticateEmailAsync() method so that we can log in with a username and password. We will do this by appending an email to the username when we register the account.

This will let it pass the email parameter validation check, and create the account. We can also add the actual username and display name to the user’s account as the username field is different from the email used to login.

This may seem like a round-about way to achieve login but if your game currently uses username/password it will allow you to bring over your accounts and your players won't know they are logging in with a fake email instead of their usernames.

/// <summary>
/// Registers a new user with the given username and password
/// </summary>
/// <param name="userName"></param>
/// <param name="password"></param>
/// <param name="displayName"></param>
private async void UserRegistration(string userName, string password, string displayName)
{
   Debug.Log("Attempting user registration...");
   Debug.Log($"Username: {userName}, password: {password}");

   ISession session = await nakamaClient.AuthenticateEmailAsync(userName+"@mygame.com", password, userName, true);
   // Now we can log out the session details //
   Debug.Log($"UserId: {session.UserId}");
   Debug.Log($"AuthToken: {session.AuthToken}");
   // add displayName to the player's account //
   await nakamaClient.UpdateAccountAsync(session, userName, displayName);
}

The important parameter here is the ‘create’ parameter passed in last to the AuthenticateEmailSync() method.

Setting this to ‘true’ lets us create an account on login, so we can use this same method for login without registration by setting this value to ‘false’.

/// <summary>
///  Logs a new user in given the username and password
/// </summary>
/// <param name="userName"></param>
/// <param name="password"></param>
private async void UserAuthentication(string userName, string password)
{
   Debug.Log("Attempting user authentication...");
   Debug.Log($"Username: {userName}, password: {password}");

   ISession session = await nakamaClient.AuthenticateEmailAsync(userName+"@mygame.com", password, null, false);
   // Now we can log out the session details //
   Debug.Log($"UserId: {session.UserId}");
   Debug.Log($"AuthToken: {session.AuthToken}");
}

We won't cover 3rd Party Authentication such as Facebook, Apple and Google in this topic, but you can check out our topic on those forms of authentication using Nakama here.

Authentication (3rd Party)

Nakama provides social authentication for the three methods listed in the introduction in addition to Steam and GameCenter authentication. There is also the option to integrate a custom authentication route if you need to migrate other options.

We will describe the Apple, GooglePlay and Facebook authentication routes below.

Server Configuration
The Nakama SDK has simple APIs for authentication but there are also some settings that need to be applied to your runtime environment in order for social authentication to work.

These settings are similar to the integration settings you needed to apply to your GameSparks instance but they are not always the same fields that GameSparks required so, in some cases you will have to acquire these parameters from your Google and Apple developer consoles.

Let's first take a look at the simple way we can apply these settings through the Heroic Labs portal.

First, select your project from the Heroic Cloud Developer Console.

Next you need to click on the Configuration tab. Here you will see a section with a large list of configuration settings. These are the settings we will be updating to get our social authentication working.

Once you have changed one of these settings make sure to save them using the button at the bottom of the list. Updating any of these settings will trigger your cluster to redeploy so you may lose access to your Developer Console temporarily.

Although this is simple, it does require you to be registered with the Heroic Cloud and have an instance deployed with them so let us take a look at how to do this with a custom deployment.

To do this we need to modify the runtime enviroment’s config.yaml file directly. We will be following the guide here on how to do this but remember this example is only applicable to the local instance of the server. If you want to test this in a prod environment you will have to remember to set this up for your prod server too.

Using the bare-minimum configuration our new config file looks something like this...

name: nakama-node-1
data_dir: "./data/"

logger:
    stdout: false
    level: "warn"
    file: "/logfile.log"

console:
    port: 7351
    username: "user"
    password: "password1"

social:
  facebook_limited_login:
    app_id: '<app-id-here>'
  apple:
    bundle_id: ‘com.supernimbus.aws-migration-game’

Now you will need to restart your runtime server in order for your server to be updated from your config file. You can check that the change has been applied by going to your Developer Console and clicking on the Configuration tab.

SignInWithAppleConnectRequest
The configuration setting you need to apply for this is the social.bundle_id setting.

This can be found by selecting your app-identifier from the Apple Developer Portal.

For this example we are going to use the Apple-Auth Unity plugin, which is commonly used by Unity developers for the Apple Sign-In authentication route. This package is available here.

What we need here is the Identity Token as you can see from the example provided with the plugin documentation.

Once you have this token you can pass it into the AuthenticateAppleAsync() method to log your player in.

/// <summary>
/// Logs a player into Nakama using AppleSignIn Identity Token
/// </summary>
/// <param name="idToken"></param>
private async void NakamaAppleLogin(string idToken)
{
   Debug.Log($"Attempting Nakama Apple Login...");
   currSession = await nakamaClient.AuthenticateAppleAsync(idToken);
   // Now we can log out the session details //
   Debug.Log($"UserId: {currSession.UserId}");
   Debug.Log($"AuthToken: {currSession.AuthToken}");
}

If you check your player’s account using the Developer Console you will see that their AppleID has been assigned to their account.

FacebookConnectRequest
The configuration setting you need to apply for this is the social.facebook_limited_login.app_id setting.

This is the app-id of your Facebook app, the same as the one you used previously in GameSparks.

You can find this in the Facebook Developer Portal.

Facebook authentication is very easy to migrate. Assuming that you already have the SDK set up and you have been using it up to this point, registration and login can be solved with a couple of lines of code.

We are using the AccessToken.CurrentAccessToken.TokenString value, as you would have used with your previous GameSparks implementation.

We won't cover all of that setup here but all you need to do is make sure the Facebook SDK has initialized and you have logged in your Facebook user. Once that is complete you can get the access token.

Once you have the access token, authentication is just one line of code, very similar to regular device or email authentication.

/// <summary>
/// Uses the Fb auth token to log in your Nakama player
/// </summary>
/// <param name="accessToken"> Fb auth token [AccessToken.CurrentAccessToken.TokenString]</param>
private async void NakamaFacebookAuth(string accessToken)
{
   ISession session = await nakamaClient.AuthenticateFacebookAsync(accessToken);
   // Now we can log out the session details //
   Debug.Log($"UserId: {session.UserId}");
   Debug.Log($"AuthToken: {session.AuthToken}");
}

GooglePlayConnectRequest
GooglePlay does not require any configuration settings in order to work so we can go straight to the Unity example.

GooglePlay authentication is very simple. Assuming that you already have the SDK setup and you have been using it up to this point, registration and login can be solved in a couple of lines of code.

We need the GooglePlay Auth-Token for Nakama so it should be much the same as your existing GameSparks code.

/// <summary>
/// Logs a player into Nakama using Google Auth Token
/// </summary>
/// <param name="googleToken"></param>
private async void NakamGoogleAuth(string googleToken)
{
   Debug.Log("Logging in Nakama with GooglePlay...");
   ISession session = await nakamaClient.AuthenticateGoogleAsync(googleToken);
   // Now we can log out the session details //
   Debug.Log($"UserId: {session.UserId}");
   Debug.Log($"AuthToken: {session.AuthToken}");
}

And that's it for Facebook, Google and Apple. As you can see, the SDK code is very simple but there are some extra steps needed in order to set your configuration settings.

Leaderboard Basics

Leaderboards in Nakama have many of the same functionality as you would expect in GameSparks, however there is still some customization needed to cover all of the GameSparks Leaderboard features.

Luckily, Nakama runtime environment has a very flexible set of APIs so you will see throughout this topic that practically all of the GameSparks Leaderboard features can be rebuilt using Nakama.

Check out Nakama’s full documentation on leaderboards here for more details. We will cover a lot of those examples in this topic.

Porting Leaderboards
Before starting this process it is important to know that unlike GameSparks and many other alternative platforms, leaderboards in Nakama are not set up from the portal, though you can view them from there as we will see later. Instead, you need to set up a runtime server and have those leaderboards created when your Nakama instance starts.

This will be done in the InitModule function of the main.ts script. You can see more details here on how to set your server up and use the InitModule function.

The InitModule function runs only once when your server boots. You can think of it like the GameSparks OnPublished script which you may be familiar with and runs only when a new snapshot is published to the live environment.

You can also import your existing GameSparks Leaderboard configurations and use those to create new Nakama leaderboards if you would like. This is what we will be doing in this topic and there is a guide on how to do that in our topic on Cloud-Code here

To begin with we need to create a new script which we will use for all our Leaderboard functionality. We called this “gsLeaderboards.ts” in our example. In here we need a new function which is going to be called from the InitModule function of the main.ts script. All this function is going to do is load all of our GameSparks Leaderboard config files into the Nakama Storage Engine so we can iterate through them and create new Nakama leaderboards.

GameSparks Leaderboard Config
Before we start writing any code let us take a look at what a Leaderboard configuration object looks like in GameSparks.

{
    "@id": "/~leaderboards/DAILY_RESET",
    "description": "DAILY_RESET",
    "highScoreNotifications": true,
    "name": "DAILY_RESET",
    "propertySet": null,
    "segmentData": [],
    "shortCode": "DAILY_RESET",
    "snapshotFrequency": "DAILY",
    "socialNotifications": false,
    "team": null,
    "topNNotifications": false,
    "topNThreshold": null,
    "updateFrequency": "REALTIME",
    "~fields": [
      {
        "@id": "/~leaderboards/DAILY_RESET/~fields/score-all",
        "calcType": "MAX",
        "collector": {
          "@ref": "/~runningTotals/POST_SCORE/~collectors/score-all"
        },
        "filterType": "*",
        "filterValue": null,
        "sort": "DESC"
      }
    ]
  }

As you can see there are a few things here we will want to port. In this topic we will cover, resetting, sort-order, aggregation, filter values and notifications.

The “~fields” array is going to be important because this is where we use the sort-order and aggregation (calcType) for the new Nakama leaderboards. Basic Nakama leaderboards only allow you to aggregate by a single value however there are some examples of how to customize this in our topic on Partitioned Leaderboards here

We will need to parse the calcType and sort fields into a format that is acceptable for Nakama leaderboards. We will also need to do this with the snapshotFrequency field to replicate our resetting leaderboards.

Resetting Leaderboards
Our code for setting the reset frequency of our leaderboards is going to need to convert the GameSparks reset type (“DAILY”, “WEEKLY”, “MONTHLY”) to a cron-string format.

This is very simple and we’ll see an example of this below.

/**
* Returns the reset schedule string based on the GS config detail for the given LB
* @param {string} gsConfigResetFrequency
* @returns {string}
*/
function parseGSLeaderboardReset(gsConfigResetFrequency: string) : string | null {
   switch(gsConfigResetFrequency){
       case "DAILY" :
           return "0 0 * * *";
       case "WEEKLY" :
           return "0 0 * * 0";
       case "MONTHLY" :
           return "0 0 1 * *";
       default : // case for handling 'NEVER'
           return null;
   }
}

As you can see from the example above, this also opens up the possibility of configuring your own custom Leaderboard reset durations in future if you need to.

Aggregation Types
This is the calcType field in the Leaderboard config JSON.

In Nakama this is called the ‘operator’. Nakama does not offer all the options that GameSparks does for aggregation but it does have the basics. We use the nkRuntime.Operator for this in our Nakama runtime environment code.

The options for aggregation type are:

If you want to replicate the GameSparks “MIN” aggregation type you can use the “BEST” operator value on a Leaderboard which has a descending sort order. This basically flips the Leaderboard around so that the MAX/BEST aggregation type is running on the lowest score.

/**
* Returns the corresponding Nakama LB operator based on the GS leaderboard field config
* @param {any} gsLBFieldDetails - The field in the GS LB config that will become the score value
* @returns {nkruntime.Operator}
*/
function parseGSLeaderboardAggregation(gsLBFieldDetails: any) : nkruntime.Operator {
   switch(gsLBFieldDetails.calcType){
       case "SUM" :
           return nkruntime.Operator.INCREMENTAL;
       case "MAX" :
           return nkruntime.Operator.BEST;
       case "MIN" :
           // reverse the LB order to get a min LB with the 'BEST' operator
           return nkruntime.Operator.BEST;
       case "LAST" :
           return nkruntime.Operator.SET;
       default :
           // for any cases that arent covered just return the normal 'SET' operator
           return nkruntime.Operator.SET;
   }
}

Sort Order
This is simple as there are only two orders we can choose from, ascending or descending. However, Nakama is using an enumerator for this value so we need to parse that for use with the new Nakama Leaderboards.

/**
* Returns the corresponding Nakama Sort-Order Enum based on the GS leaderboard field config
* @param {any} gsLBFieldDetails - The field in the GS LB config that will become the score value
* @returns {nkruntime.SortOrder}
*/
function parseGSLeaderboardOrder(gsLBFieldDetails: any) : nkruntime.SortOrder {
   return  (gsLBFieldDetails.sort === "DESC")? nkruntime.SortOrder.DESCENDING : nkruntime.SortOrder.ASCENDING;
}

Filter Values
Leaderboards in Nakama can only work with a single score value so any filter values you have assigned to your Leaderboards can only work on one score value. We will need to parse these values as Nakama does not come with this filtering feature out-of-the-box so we will see in the following section how we can use this to create our own custom filtering.

/**
* Created a JSON object about of the current filters in the GS LB config
* @param {any} gsLBFieldDetails - The field in the GS LB config that will become the score value
* @returns {any}
*/
function parseGSLeaderboardFilters(gsLBFieldDetails: any) : any | null {
   // check if there are any filter values set //
   if(gsLBFieldDetails.filterType && gsLBFieldDetails.filterValue){
       return {
           filterType: gsLBFieldDetails.filterType,
           filterValue: gsLBFieldDetails.filterValue
       }
   }
   return null;
}

Now we can start creating our new Nakama Leaderboards.

/**
* Creates new Nakama LB configurations when
* @param logger
* @param nk
*/
function admin_createLeaderboards(logger: nkruntime.Logger, nk: nkruntime.Nakama) {
   logger.info("Creating Nakama LBs from GS Configs...");
   // Get all LB configs //
   const adminUserId   = "00000000-0000-0000-0000-000000000000";
   const collName      = "GSData_Leaderboard";
   let lbConfigCurs: nkruntime.StorageObjectList = nk.storageList(adminUserId, collName);
   logger.info(`Found ${lbConfigCurs.objects?.length} Leaderboard Configs...`);
   // iterate through each LB config //
   if(lbConfigCurs.objects && lbConfigCurs.objects.length > 0){
       lbConfigCurs.objects.forEach(configObj => {
           let lbConfig: any = configObj.value;
           logger.info(`Creating [${lbConfig.shortCode}]`);
           try {
               // setup params for the LB //
               let id : string = lbConfig.shortCode;
               let authoritative : boolean = false;
               // get the sort order of the GS LB //
               let sortOrder =  parseGSLeaderboardOrder(lbConfig['~fields'][0]);
               // get the aggregation type, called the 'operator' in Nakama //
               let operator = parseGSLeaderboardAggregation(lbConfig['~fields'][0]);
               // get the reset frequency and convert it to Nakama reset date //
               let reset = parseGSLeaderboardReset(lbConfig.snapshotFrequency);
               // add any other config or information you might need based on the GS config //
               var metaData: any = {
                   description:        lbConfig.description,
                   name:               lbConfig.name,
                   segmentData:        lbConfig.segmentData,
                   socialNotifications:lbConfig.socialNotifications,
                   topNNotifications:  lbConfig.topNNotifications,
                   topNThreshold:      lbConfig.topNThreshold,
                   // leaderboard score value filtering //
                   filterValue:        parseGSLeaderboardFilters(lbConfig['~fields'][0])
               }
               logger.info(`Id:${id}, auth:${authoritative}, SortOrder:${sortOrder}, Operator:${operator}, Reset:${reset}, MetaData [${JSON.stringify(metaData)}]`);
               nk.leaderboardCreate(id, authoritative, sortOrder, operator, reset, metaData);
               logger.info(`Created [${lbConfig.shortCode}] ...`);
           } catch(error) {
               logger.info(`Error creating LB ${lbConfig.shortCode} ...`);
               logger.error(error.message);
           }
       });   
   }
   else{
       logger.warn("No Leaderboard Configs Found...");
   }
   logger.info("LBs Created...");
}

Before we finish we need to add this function to the InitModule of the main.ts script.

// Server Boot Utils //
admin_createLeaderboards(logger, nk);

Reminder - Remember to add file references to the tsconfig.json file and rebuild and redeploy the server before testing.

Testing
If you have your terminal open when you reboot the server you will see the new logs coming in showing the new leaderboards being created.

You can also check in the Nakama Developer Console to see your leaderboards and the current records. To check your leaderboards click on the “Leaderboards” option on the left-hand side menu or the portal.

If you click on one of your leaderboards you will be able to see the configuration details. You can also view records from here, though you don't have any records in your Leaderboard at this point so we’ll come back to that.

Posting Scores
Now that we have some leaderboards configured let us take a look at how players can post scores to these leaderboards.

/// <summary>
/// Post a score to the given LB for the current player
/// </summary>
/// <param name="session"></param>
/// <param name="lbID"></param>
/// <param name="score"></param>
private async void PostScore(ISession session, string lbID, int score)
{
   IApiLeaderboardRecord postScoreResp = await nakamaClient.WriteLeaderboardRecordAsync(session, lbID, score);
   Debug.Log($"Current Rank [{postScoreResp.Rank}]");
}

If you test out this code you should get the player’s current rank in your Unity console.

We can also check the ranks from the Nakama Developer Console as mentioned before.

Custom RPC
The example above is really basic score submission but what if we wanted to do something more complex?

For this we will need a custom RPC. We will put this RPC function in the same script as the Leaderboard creation code. We are also going to create a class so that we can group all the Leaderboard functionality together.

Let's take a look at a simple example first. All we are going to do is have the RPC post a score on behalf of the player who called it. This is essentially the same as the method we called in Unity above.

class GSLeaderboards {
   /**
    * Posts a score to the leaderboard with the given ID
    * @param logger
    * @param nk
    * @param leaderboardId {string}
    * @param score {number}
    * @returns {string}
    */
   static postScore(leaderboardId: string, score: number, userId: string, userName: string, logger: nkruntime.Logger, nk: nkruntime.Nakama): string {
       logger.info(`Posting Score [${score}] to ${leaderboardId}`);
       // >> post & return rank << //
       let currRecord : nkruntime.LeaderboardRecord = nk.leaderboardRecordWrite(leaderboardId, userId, userName, score);
       logger.info(JSON.stringify(currRecord));
       logger.info(`Current Rank: ${currRecord.rank}`);
       return JSON.stringify({
           rank: currRecord.rank
       });
   }
}

And our RPC function is going to look as follows…

/**
* Posts a score to the leaderboard with the given ID
* @param context
* @param logger
* @param nk
* @param payloadString
*/
function rpcPostScoreCustom(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   let payloadData = JSON.parse(payloadString);
   // grab the request details //
   let lbId    =  payloadData.lbId;
   let score   =  payloadData.score;
   return GSLeaderboards.postScore(lbId, score, context.userId, context.username, logger, nk);
}

And finally we need to add this RPC call to the InitModule function of the main.ts script.

initializer.registerRpc("postScoreCustom", rpcPostScoreCustom);

Reminder - Remember to rebuild and redeploy the server before testing.

Unity Example
Now all we need to do is call this custom function from Unity and let it post scores for our players.

Calling an RPC function to Nakama from Unity is very simple. We just need to know the name of the RPC function and we need to give the RPC function a custom JSON payload.

/// <summary>
/// Post a score to the given LB for the current player
/// </summary>
/// <param name="session"></param>
/// <param name="lbID"></param>
/// <param name="score"></param>
private async void PostScoreCustom(ISession session, string lbId, int score)
{
   string rpcId = "postScoreCustom";
   Dictionary<string, object> requestPayload = new Dictionary<string, object>()
   {
       { "lbId" , lbId },
       { "score" , score }
   };
   var payload = Nakama.TinyJson.JsonWriter.ToJson(requestPayload);
   IApiRpc responseData = await nakamaClient.RpcAsync(session, rpcId, payload);
   Debug.Log(responseData.Payload);
}

If you run this code you will see the rank being returned in the Unity console.

Filter Values
Now that we have a custom Leaderboard posting RPC function lets do something useful with it. The simplest thing we can do is reproduce the GameSparks Leaderboard value filtering.

To do this we can get the Leaderboard config data from the Nakama Storage Engine and then validate the score being posted against the filter value.

You can check what the filter values are for a given Leaderboard by going to the Storage option in the Developer Console.

There are two methods we need to add to our GSLeaderboards class in order to facilitate this. First we are going to have to create a method which will load the config for the leaderboard with the given ID we are posting to. We are going to make this static so we can use it like the Spark.getLeaderboards() function from GameSparks.

/**
 * Returns the leaderboard config for the given shortCode
 * @param {string} lbId
 * @returns {object} lbConfig
 */
static getLeaderboardConfig(lbId: string, nk: nkruntime.Nakama, logger: nkruntime.Logger) : any {
    // first we need to load the Virtual Good config //
    let lbConfigCursor: nkruntime.StorageObject[] = nk.storageRead([{
            collection: "GSData_Leaderboard",
            key: lbId,
            userId: "00000000-0000-0000-0000-000000000000"
    }]);
    if (lbConfigCursor.length > 0) {
           return lbConfigCursor[0].value;
    }
    else{
           // handle errors //
    }
}

Next we will create a private function which we can use to evaluate if the score passess the filter.

/**
* Evaluates if the score being posted passes the filter on the LB
* @param score {number}
* @param filterType {string}
* @param filterValue {number}
* @return {boolean}
*/
private static evaluateScore(score: number, filterType: string, filterValue: number) : boolean {
   // check if there is any filter value at all //
   if(isNaN(filterValue)){
       return true;
   }
   switch(filterType){
       case ">" :
           return (score > filterValue);
       // other cases here for //
       // >=, <=, <, = //
       default :
           return false;
   }
}

And now we can add this check just before where we posted our score to the Leaderboard in the postScore function.

// Check if the LB has any filter values and validate the score being posted //
let lbConfig: any = GSLeaderboards.getLeaderboardConfig(leaderboardId, nk, logger);
// for this example we assume that the score field is the first field in the array //
// you may have to create a function which can get the right filter values for the field used for score //
let filterType = lbConfig["~fields"][0].filterType;
let filterValue = parseInt(lbConfig["~fields"][0].filterValue);
logger.info(`Evaluating Score [${score}], filterType ${filterType}, filterValue ${filterValue}`);
// check if the score value passed the filter //
if(!this.evaluateScore(score, filterType, filterValue)){
    // return an error //
    logger.warn("Validation check failed...");
    throw JSON.stringify({
           "code": 123,
           "error": "validation-failed"
    });
}

You can see below where it fits into the function.

Because we are throwing an error if the filter check does not pass we can test this from Unity to see the error log from the console.

Reminder - Remember to rebuild and redeploy the server before testing.

There is a bit more work to do here to get all your filters working for all your Leaderboard fields but this should give you enough context to continue porting your code and to get things working in Nakama as you expect them to in GameSparks.

Leaderboard Notifications
Let us take a look at how you can handle Leaderboard notifications in Nakama. Out-of-the-box, Nakama does not send notifications when scores are posted to the Leaderboard so we will have to put in some custom code to achieve this.

To keep things simple, we will look at the high score notification feature of GameSparks, but the same code can be adapted for TopN or social notifications (see more details about Nakama’s Friends feature here relating to social Leaderboards).

There are two things to note about this process.

  1. We can send messages to players with Nakama in a similar way to the GameSparks SparkMessage API.
  2. Since the player posting the score is going to get a response back in the client anyway, we can avoid sending a message directly to them and instead, we can return additional information in the response. This is more efficient than sending an additional message asynchronously.

Because there may be cases where you need to send a notification to the player in this postScore method, we will look at how to achieve both these options. Just keep in mind that an additional message might not be efficient if we can just return the data we need in the response.

The first thing we need to do is get the player’s score before they have posted to the Leaderboard so that we can compare it to the score being posted.

You can stick this code just above where you post your score in the postScore method.

// Get player's current score //
let currScore: number;
let isHighScore: boolean = false;
let lbCursor: nkruntime.LeaderboardRecordList = nk.leaderboardRecordsList(leaderboardId, [userId], 1);
if(lbCursor.records && lbCursor.records.length > 0){
   currScore = lbCursor.records[0].score;
   // Now we can compare to see if this is higher than the previous score //
   isHighScore = (currScore < score);
}

Next, we need the current rank of the player so we can add it to the notification we send after we have posted the score.

You are going to model this notification on the GameSparks NewHighScoreMessage. For example…

So our message code will look like this…

// If this is the player's highest score, send a notification //
if(isHighScore){
   let subject = `You just moved up the ${leaderboardId} leaderboard`;
   let content = {
       leaderboardData: {
           userId:     userId,
           score:      score,
           userName:   userName,
       },
       leaderboardName:        leaderboardId,
       leaderboardShortCode:   leaderboardId,
       notification:           true,
       playerId:               userId,
       rankDetails: {
           rank: currRecord.rank,
       },
       summary: `You just moved up the ${leaderboardId} leaderboard`,
   }
   // The number '1' is a unique code for the notification this could be mapped to an enum of something like  your  message shortCode //                                     //6
   nk.notificationSend(userId, subject, content, 1, "00000000-0000-0000-0000-000000000000", true);
}

And then finally, we will return the isHighScore value with the new rank so we can see that in the client.

return JSON.stringify({
   rank: currRecord.rank,
   isHighScore: isHighScore
});

Altogether, your new postScore function will look something like this…

Reminder - Remember to rebuild and redeploy the server before testing.

If we test this in Unity you should see the response from the RPC call showing the isHighScore field, and another log showing the notification has come through.

The above notification flow requires a socket to be opened so that the client can receive asynchronous messages. This is covered in another topic on Achievements here or the Nakama documentation here.

Team Leaderboards
Although Nakama does not have an out-of-the-box Team Leaderboard feature, it is easy to build this feature yourself using the examples above.

The leaderboardRecordWrite() method does not require a userId specifically, you can assign any ID you want to that field.

Therefore, with a custom RPC, you can easily recreate a Team Leaderboard and take advantage of all the Nakama Leaderboard APIs.

let currRecord : nkruntime.LeaderboardRecord = nk.leaderboardRecordWrite(leaderboardId, teamId, teamName, score);

Getting Leaderboard Details
Nakama has similar functionality to GameSparks when it comes to returning and listing Leaderboard records. You can…

There are examples in the links above to Nakama’s docs on how to use these requests so we won't go over them again here.

Leaderboard Partitions

In this topic we will be following on from the examples we work with in the topic on Basic Leaderboards here. For this example we are going to modify one of the Leaderboards we ported over in the previous section in order to make it into a Partitioned Leaderboard.

The JSON config for a partitioned Leaderboard for the example above would look like this…

{
  "@id": "/~leaderboards/WEEKLY_RESET",
  "description": "WEEKLY_RESET",
  "highScoreNotifications": false,
  "name": "WEEKLY_RESET",
  "propertySet": null,
  "segmentData": [],
  "shortCode": "WEEKLY_RESET",
  "snapshotFrequency": "WEEKLY",
  "socialNotifications": false,
  "team": null,
  "topNNotifications": false,
  "topNThreshold": null,
  "updateFrequency": "REALTIME",
  "~fields": [
    {
      "@id": "/~leaderboards/WEEKLY_RESET/~fields/score-all",
      "calcType": "MAX",
      "collector": {
        "@ref": "/~runningTotals/POST_SCORE_PART/~collectors/score-all"
      },
      "filterType": "*",
      "filterValue": null,
      "sort": "DESC"
    },
    {
      "@id": "/~leaderboards/WEEKLY_RESET/~fields/region-all",
      "calcType": "PARTITION",
      "collector": {
        "@ref": "/~runningTotals/POST_SCORE_PART/~collectors/region-all"
      },
      "filterType": "*",
      "filterValue": null,
      "sort": "DESC"
    }
  ]
}

You can see from the example above that we have two fields in the JSON config and we know one of them is the partition field while the other is the score.

We can easily adapt our previous code to check if we are working with a Partitioned Leaderboard and then post to the partition at the same time as the root-Leaderboard. The code is simple but there are a number of steps we need to take.

Partition Name
First we need to get the name of the partition from the JSON config above. Then we need to check if a Leaderboard with that partition has been created (remember that Nakama Leaderboards have to be created first).

If the Leaderboard exists, we can post to both the root-Leaderboard and the partitioned Leaderboard.

We will create a function which will get the name of the partition and let us know if we are posting to a partitioned Leaderboard or not.

/**
* Returns the name of the partition
* @param lbConfig {any}
* @returns {string}
*/
   private static evaluatePartition(lbConfig: any): string  {
   // Here we can loop through the fields array and identify if we have a partitioned LB //
   let partitionName : string = '';
   lbConfig["~fields"].forEach(field => {
       if(field.calcType === "PARTITION"){
           partitionName = field['@id'].split('/')[4].split('-')[0];
       }
   });
   return partitionName;
}

Now we are going to create another function which will post to the root-Leaderboard and the Partitioned Leaderboard.

/**
* Checks if the LB is partitioned and posts the score to both the partition and the main LB
* @param lbConfig {any}
* @param userId {string}
* @param userName {string}
* @param score {number}
* @return {number}
*/
   private static postToLB(lbConfig: any, userId : string, userName: string, score: number, partKey: string, nk: nkruntime.Nakama, logger: nkruntime.Logger) : number {
   // First we check if the LB is partitioned //
   let partitionName : string = this.evaluatePartition(lbConfig);
   if(partitionName !== ''){
       // check if an LB with that partition exists //
       let partitionLBName: string =  lbConfig.name+"."+partitionName+"."+partKey;
       let lbList: nkruntime.Leaderboard[] = nk.leaderboardsGetId([partitionLBName]);
       // check if the LB exists //
       // if not, then we will create it //
       if(!lbList || lbList.length === 0){
           lbConfig.shortCode = partitionLBName;
           logger.info(`Creating [${lbConfig.shortCode}]`);
           try {
               // setup params for the LB //
               let id : string = lbConfig.shortCode;
               let authoritative : boolean = false;
               // get the sort order of the GS LB //
               let sortOrder =  parseGSLeaderboardOrder(lbConfig['~fields'][0]);
               // get the aggregation type, called the 'operator' in Nakama //
               let operator = parseGSLeaderboardAggregation(lbConfig['~fields'][0]);
               // get the reset frequency and convert it to Nakama reset date //
               let reset = parseGSLeaderboardReset(lbConfig.snapshotFrequency);
               // add any other config or information you might need based on the GS config //
               var metaData: any = {
                   description:        lbConfig.description,
                   name:               lbConfig.name,
                   segmentData:        lbConfig.segmentData,
                   socialNotifications:lbConfig.socialNotifications,
                   topNNotifications:  lbConfig.topNNotifications,
                   topNThreshold:      lbConfig.topNThreshold,
                   // leaderboard score value filtering //
                   filterValue:        parseGSLeaderboardFilters(lbConfig['~fields'][0])
               }
               logger.info(`Id:${id}, auth:${authoritative}, SortOrder:${sortOrder}, Operator:${operator}, Reset:${reset}, MetaData [${JSON.stringify(metaData)}]`);
               nk.leaderboardCreate(id, authoritative, sortOrder, operator, reset, metaData);
               logger.info(`Created [${lbConfig.shortCode}] ...`);
           } catch(error) {
               logger.info(`Error creating LB ${lbConfig.shortCode} ...`);
               logger.error(error.message);
           }
           // post to the partition //
           nk.leaderboardRecordWrite(partitionLBName, userId, userName, score);
       }  
   }
   // post to the regular leaderboard //
   let lbRecord: nkruntime.LeaderboardRecord = nk.leaderboardRecordWrite(lbConfig.shortCode, userId, userName, score);
   return lbRecord.rank;
}

You can see from the example above that we first check to see if the Leaderboard exists, and if it does not, then we create it.

And now we can clean up the postScore function to call this new function. We can include this at the bottom of the postScore function.

/ >> post & return rank << //
let currRank : number = this.postToLB(lbConfig, userId, userName, score, partitionKey, nk, logger);
// check if we successfully posted to the LB //
if(currRank === 0){
   // handle errors //
}
return JSON.stringify({
   rank: currRank,
   isHighScore: isHighScore
});

And we also need to make sure the RPC call is getting the partition key and sending it to the postScore function.

/**
* Posts a score to the leaderboard with the given ID
* @param context
* @param logger
* @param nk
* @param payloadString
*/
function rpcPostScoreCustom(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   let payloadData = JSON.parse(payloadString);
   // grab the request details //
   let lbId            =  payloadData.lbId;
   let score           =  payloadData.score;
   let partitionKey    =  payloadData.partitionKey;
   return GSLeaderboards.postScore(lbId, score, partitionKey, context.userId, context.username, logger, nk);
}

From the previous Leaderboards topic you will have seen how to add parameters to this RPC request in Unity. We will have to add the partitionKey parameter to the Dictionary too.

/// <summary>
/// Post a score to the given LB for the current player
/// </summary>
/// <param name="session"></param>
/// <param name="lbID"></param>
/// <param name="partKey"></param>
/// <param name="score"></param>
private async void PostScoreCustom(ISession session, string lbId, string partKey, int score)
{
   Debug.Log($"Posting Score [{score}] to {lbId}...");
   string rpcId = "postScoreCustom";
   Dictionary<string, object> requestPayload = new Dictionary<string, object>()
   {
       { "lbId" , lbId },
       { "score" , score },
       { "partitionKey", partKey }
   };
   var payload = Nakama.TinyJson.JsonWriter.ToJson(requestPayload);
   IApiRpc responseData = await nakamaClient.RpcAsync(session, rpcId, payload);
   Debug.Log(responseData.Payload);
}

And that's all you need to get some basic Partitioned Leaderboards working in Nakama!

Something to note about the example above is the dynamic creation of Partitioned Leaderboards. This happens automatically in GameSparks, but it is not automatic with Nakama and in the example above we check if the partition exists before we post to it. This allows us to create a new Leaderboard on-the-fly based on the partition key.

This makes the code somewhat inefficient as we always have to perform this check before posting our scores every time.

If you know the keys for all your partitioned Leaderboards it would be more efficient to create those Leaderboards when the runtime server boots, as we did in the Basic Leaderboards topic. That way you know the Leaderboards always exist before posting to them and you can save on the check to see if the Leaderboard exists before posting.

Virtual Currency

As is the case with GameSparks, Virtual Currency in Nakama is tied to the player’s account. The APIs for crediting and debiting the player are not available through the client and instead are accessed from Nakama’s version of Cloud-Code called the runtime server.

Getting Player Wallet
The first thing we are going to look at is how to get the player’s wallet balance in Unity so we can display the player’s current balance.

As this is the first time the player has encountered Virtual Currency there will be no currencies included in the player’s wallet.

We will add those later and you can also check out the section below on sign-up bonuses. For now we are going to manually add some currencies to the player’s wallet so we have something to display in Unity in this example.

You will need to find a registered player’s account in the Developer Portal.

To do this you will need to have a Nakama instance running. In our case we have the instance running locally on http://127.0.0.1:7351. Check out the guide here on how to get an instance running locally.

Click on the “Accounts” option on the left-hand side menu of the Developer Portal and then click on the player you want to select.

Next, you need to click on the “Wallet” tab.

We want to update the JSON here in order to give this player some currency we can get back from the server in Unity. We will include non-currency parameters such as level and xp, as these parameters often benefit from using the same APIs as regular Virtual Currency.

Remember to hit the save button to make sure your changes are updated. Now let us go back into Unity and see how we can get this balance back.

You can do this with just a few lines of code. Remember that these API calls need to be made from within asynchronous methods in your C# code.

/// <summary>
/// Retrieves the player data from the Nakama server based on the player's session
/// </summary>
/// <param name="session">The player's current Nakama session</param>
private async void GetPlayerData(ISession session)
{
   Debug.Log("Fetching Player Data...");
   IApiAccount account = await nakamaClient.GetAccountAsync(session);
   IApiUser user = account.User;
   Debug.LogFormat($"User wallet: '{account.Wallet}');
}

The “session” parameter for this method is taken from the authentication response. Check out the topic on Authentication here for more information.

If you test this method out you should see your player’s wallet balance displayed in the console.

The player wallet object is a string as you can see from the code above. You will need to parse this into a something usable in C# like Dictionary<string, int>.

The Nakama SDK comes with TinyJSON installed so we can use that to parse our responses.

Dictionary<string, int> wallet = Nakama.TinyJson.JsonParser.FromJson<Dictionary<string, int>>(account.Wallet);
foreach (var currency in wallet)
{
   Debug.Log($"{currency.Key}:{currency.Value}");
}

Player Wallet - Server API
As is the case for GameSparks, there is no way to credit and debit players directly from the client in Nakama.

In most cases you would never want to allow this in order to prevent a player from taking advantage of the service if they hacked the client.

Instead, you would do this through a request to the server, or let the server credit and debit the player based on some server-authoritative action like an Achievement event.

For this example we are going to look at an easy way to test crediting and debiting of players through an RPC function.

RPC functions are Nakama’s equivalent of a GameSparks LogEventRequests.

We are going to need a runtime server for this step so check out the guide on Cloud-Code in Nakama here for more details on how to set up a runtime server and get some Cloud-Code working.

We will create a new TypeScript file for this example called “rpcCreditPlayer.ts”. We will then add the following function.

/**
* Credits the player a given about of currency and returns the player's new balance
* @param context
* @param logger
* @param nk
* @param payloadString
* @returns {string} - current balance
*/
function rpcCreditPlayer(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   logger.info("Crediting Player...");
   // parse the payload to JSON so we can work with it //
   var payloadData = JSON.parse(payloadString);
   // get the current balance so we can credit it //
   var currWallet = nk.accountGetId(context.userId).wallet;
   // credit the player //
   for (var currencyKey in currWallet) {
       logger.info(currencyKey);
       currWallet[currencyKey] += payloadData[currencyKey];
   }
   // save the balance back //
   for (var currencyKey in currWallet) {
       logger.info(`Updated ${currencyKey},  Value: ${currWallet[currencyKey]}`);
       nk.walletUpdate(context.userId, { [currencyKey]: currWallet[currencyKey] });
   }
   logger.info(`Updated Balance ${JSON.stringify(currWallet)}`);
   // return the new balance to the player //
   return JSON.stringify(currWallet);
}

This is all the code we need to update the player’s balance. You can see from the example above that all we are doing is getting the current balance from the player’s account, crediting the wallet, and saving it back to the player account. It is a flow that you should recognise from GameSparks scriptData and privateData APIs if you use those in your game.

You can see more information about these wallet API calls here.

Reminder - Remember to build and redeploy your server instance for these changes to take effect.

Once your server has been updated you can check that the new RPC function was added by going to the “Runtime Modules” panel of the administrator portal. You will be able to see your new RPC function listed there.

Next we will add some code in Unity which will be able to hit this RPC with a payload to update the player’s balance.

Note - This is just an example. In a real case you would not want to allow the player to update their balance from the client directly.

Calling The Update From The Client
In order to send a request to the RPC we need to construct a payload and then convert that payload to a JSON string. Unlike GameSparks, we will need to use an await command to call this RPC so make sure you stick these calls in an asynchronous method.

Below is an example of how you can call the “creditPlayer” RPC function we created earlier from Unity.

/// <summary>
/// Updates the player's balance using the "creditPlayer" RPC
/// </summary>
/// <param name="session">Taken from the currently authenticated player's session</param>
/// <param name="coins"></param>
/// <param name="gems"></param>
/// <param name="level"></param>
/// <param name="xp"></param>
private async void UpdatePlayerWallet(ISession session, int coins, int gems, int level, int xp)
{
   Debug.Log($"Coins:{coins}, Gems:{gems}, Level:{level}, XP:{xp}");
   // construct request //
   string rpcId = "creditplayer";
   Dictionary<string, int> updatedWallet = new Dictionary<string, int>()
   {
       { "COINS", coins },
       { "GEMS", gems },
       { "LEVEL", level  },
       { "XP", xp }
   };
   var payload = Nakama.TinyJson.JsonWriter.ToJson(updatedWallet);
   // Send request to RPC //
   IApiRpc responseData = await nakamaClient.RpcAsync(session, rpcId, payload);
   // Now we can parse the response payload back to a dictionary //
   Debug.Log(responseData.Payload);
   Dictionary<string, int>  wallet = Nakama.TinyJson.JsonParser.FromJson<Dictionary<string, int>>(responseData.Payload);
   foreach (var currency in wallet)
   {
       Debug.Log($"{currency.Key}:{currency.Value}");
   }
}

Executing this RPC will get you a response object of the type IApiRpc.

There are a lot of details about the response parameters in that object but what we need is the Payload parameter.

This is a JSON string, so you can see in the example above that we use the TinyJSON API here again to parse the current balance back to a C# Dictionary.

We can see the output logged in the Unity editor console.

You can double-check your player’s balance was updated by opening the Developer Console and looking at your player’s wallet balance again.

If you have your terminal open and connected to your Nakama instance, or you are looking at the logs for your instance in Docker-Hub you will be able to see the logs we put in the RPC function there too.

SignUp Bonuses
We have looked at how you might credit and debit the player using the runtime server. The next thing would be to look at how you might replicate sign-up bonuses for your new players the same way as you have set up in GameSparks.

To do this, we are going to use some of the Nakama runtime server hooks. These are similar to the “Request” and “Response” scripts you are familiar with from GameSparks.

These hooks are raised before or after out-of-the-box server events; in our case after authentication.

Nakama does not have a hook for after-registration, so we are going to use the after-authentication hook instead.

We will add a flag to the player’s account to indicate that the player has been set up and will not go through the process on subsequent authentications.

OnAuthentication Script
Create a new TypeScript file in your runtime server project. For this example we called our file “onAuthentication”. As this is a hook and not an RPC like we created before, the entry function takes a slightly different set of parameters. Let's take a look at the code and then we will go through it step-by-step.

/**
* This function is raised when a player authenticates
* @param context
* @param logger
* @param nk
* @param data
*/
function onAuthentication(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, data: nkruntime.Session) {

   // First we need to check if this player has been set up already //
   var playerMetaData = nk.accountGetId(context.userId);
   var isAccountSetup = playerMetaData.user.metadata['isAccountSetup'];
   if (!isAccountSetup) {
       // Add the sign-up bonus to the player's wallet //
       var signUpBonus: any = {
           "COINS":    350,
           "GEMS":     5,
           "LEVEL":    0,
           "XP":       0
       };
       // Loop through each of the sign-up currencies and deliver them to the player //
       for (var currencyKey in signUpBonus) {
           logger.info(`Adding Sign-Up bonus ${currencyKey}, ${signUpBonus[currencyKey]}`);
           nk.walletUpdate(context.userId, { [currencyKey]: signUpBonus[currencyKey] });
       }
       var metaData = { "isAccountSetup": true };
       var playerData = nk.accountUpdateId(context.userId, null, null, null, null, null, null, metaData);
       logger.info("Player Initialized...");
   }
}
  1. First we need to get the flag from the account so that we know if this account has been set up or not. We are going to store this flag in the player’s metadata for this example.
  2. If there is no flag on the player’s account we will give the player their starting balance. To keep this example simple we are going to use a JSON object

Working With JSON in TypeScript
You’ll notice the JSON object is declared using the any data-type. This allows you to work with JSON as you are familiar with from JavaScript and GameSparks Cloud-Code, however, remember that when using TypeScript we should be declaring strict types when possible.

A better way to handle this JSON might be to declare an interface for your currency rewards and declare the signupBonus object as that data-type instead.

Public Metadata
All player metadata is public. Therefore it is accessible by other players who are loading your account by ID.

There is also a limit of 16K on player metadata. Coupled with the fact that this data is public, it might not always be the best place to store player data so keep this in mind.

Metadata or Storage Engine?
For this example we added the flag to the player’s metadata as it is a quick and easy way to set a flag on the player account. For more complicated player data you might want to use the Nakama Storage Engine which is similar to GameSparks’ GDS or runtime collections. There is more information on the Nakama Storage Engine included in the Cloud-Code topic here.

Registering The Hook
The next step is to register the script in the InitModule function of the main.ts script.

You can put this code underneath where you declared the “creditPlayer” RPC.

   // Set up hooks.
   initializer.registerAfterAuthenticateDevice(onAuthentication);
   initializer.registerAfterAuthenticateEmail(onAuthentication);
   // register steam, fb, apple, etc

You can see that there are a lot of authentication hooks available, so you might want to apply the same script to multiple hooks depending on what authentication routes you are using in your game.

Reminder - Remember to build and redeploy your server instance for these changes to take effect.

Now let's test our code.

From the last example you have seen that you can inspect a player’s wallet through the Developer Console, but you can also see the player’s metadata from there too. This would be set in the “Profile” tab of the player's account.

And to double-check you can also check the player’s wallet.

Now that we have Virtual Currency set up for our user, let's look at how we can use it to buy some Virtual Goods in the next topic here.

Virtual Goods

Nakama does not have a comparable feature to GameSparks Virtual Goods, however there are several ways we can replicate this feature ourselves which we will discuss in this topic.

Because Nakama does not provide a way to configure Virtual Goods through a portal or something similar, we will need to create our own configuration files to replace your existing GameSparks Virtual Goods.

We are going to port some simple Virtual Goods examples which we will be using throughout these tutorials.

These Virtual Goods are inventory items which cost a certain amount of Virtual Currency (coins and gems for this example) each.

They have no other special properties but you will see through the following examples that you can easily extend these Virtual Goods to work as bundles or credit currency instead of debiting, etc, so we won't cover all those use-cases here.

The structure of a Virtual Good on GameSparks looks something like this…

{
    "@id": "/~virtualGoods/diamond_armor",
    "W8StoreProductId": null,
    "WP8StoreProductId": null,
    "amazonStoreProductId": null,
    "bundledShortCodes": null,
    "currencyCostMap": {
      "COINS": 1300
    },
    "description": "diamond_armor",
    "disabled": false,
    "googlePlayProductId": null,
    "iosAppStoreProductId": null,
    "maxQuantity": null,
    "name": "diamond_armor",
    "propertySet": null,
    "psnStoreProductId": null,
    "segmentData": [],
    "shortCode": "diamond_armor",
    "steamStoreProductId": null,
    "tags": null,
    "type": "VGOOD",
    "xboxOneStoreProductId": null
  }

You can find a list of your own Virtual Goods configurations using the GameSparks REST APIs.

There is a guide in our Cloud-Code topic here on how to import these configuration JSONs into Nakama so we can use them from the Nakama Storage Engine.

For this tutorial we are not going to repeat the process for importing these files here so we advise you to check out the section on Migrating GameSparks Configuration in the Cloud-Code topic here.

After importing our Virtual Goods into Nakama our collection looks something like this…

Now we are ready to work with some code examples.

Server Functionality
The first thing you will need to do is create a new TypeScript file.

For this example we will just be adding Virtual Goods, but in future you may need to group similar functionality for Virtual Goods together so in this topic we will put all the Virtual Goods functionality into a single file.

We are going to create a class which can be accessed from other scripts and add our first function to replicate the GameSparks API “buyVirtualGood”.

This function is simple. It needs to load the Virtual Good configuration with the given short code, debit the player the required cost and then deliver the Virtual Good.

class GSVirtualGoods {

   /**
    * Adds a new Virtual Good to the player's account and debits the balance
    * @param shortCode
    * @param userId
    * @param logger
    * @param nk
    * @returns {string} - result
    */
   static buyVirtualGood(shortCode: string, userId: string, logger: nkruntime.Logger, nk: nkruntime.Nakama): string {
       logger.info(`Granting ${userId}, Virtual Good: ${shortCode}`);
       // first we need to load the Virtual Good config //
       let vgsCursor: nkruntime.StorageObject[] = nk.storageRead([{
           collection: "GSData_VirtualGood",
           key: shortCode,
           userId: "00000000-0000-0000-0000-000000000000"
       }]);
       if (vgsCursor.length > 0) {
           let vg: any = vgsCursor[0].value;
           // debit the player the cost of the VG //
           for (var currencyKey in vg.currencyCostMap) {
               logger.info(`Debiting ${vg.currencyCostMap[currencyKey]} ${currencyKey}`);
               try {
                   nk.walletUpdate(userId, { [currencyKey]: -vg.currencyCostMap[currencyKey] });
               }
               catch (e) {
                   // Handle the error response if the player does not have enough balance //
                   logger.error(e.message);
                   return JSON.stringify({ "error": e.message });
               }
           }
           // grant the player the VG //
           // we need to read the player's account and update the vgs array //
           let playerAccount: nkruntime.Account = nk.accountGetId(userId);
           let playerMetaData: any = playerAccount.user.metadata;
           // Init the VG if it hasnt been added yet //
           if (!playerMetaData['vgs']) {
               playerMetaData['vgs'] = {};
           }
           if (!playerMetaData['vgs'][shortCode]) {
               playerMetaData['vgs'][shortCode] = 0;
           }
           playerMetaData['vgs'][shortCode]++;
           nk.accountUpdateId(userId, null, null, null, null, null, null, playerMetaData);
           logger.info(`Granted ${userId}, Virtual Good: ${shortCode}`);
           // handle success response //
           return JSON.stringify({ "success": true });
       }
       // handle error response //
       return JSON.stringify({ "error": `shortCode ${shortCode} not found` });
   }
}

Next, we need to create an RPC request which can call this function.

We will put this request in the same script as our GSVirtualGood class.

/**
* Grants the player the amount for the given Virtual Good and debits them the cost
* @param context
* @param logger
* @param nk
* @param payloadString
*/
function rpcBuyVirtualGood(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   const payloadData: any = JSON.parse(payloadString);
   const vgShortCode: string = payloadData.shortCode;
   return GSVirtualGoods.buyVirtualGood(vgShortCode, context.userId, logger, nk);
}

And finally, you will need to declare this RPC in the main.ts script’s InitModule function.

   initializer.registerRpc("buyVirtualGood", rpcBuyVirtualGood);

Reminder - Remember to add file references to the tsconfig.json file and rebuild and redeploy the server before testing.

Testing
In the next section we will take a look at how to call this function from Unity but we don't need to build our client code in order to test this. Once your server is redeployed, you can test this from the Developer Console.

To do this, select the “API Explorer” option from the left-hand side menu of the Developer Console. Select the “buyvirtualgood” API and enter a user ID for one of your players.

Remember you will need to enter the short code of your Virtual Good in the request body field.

If everything is set up correctly you should see a successful response.

And we can double-check the player received the Virtual Good by checking their account from the “Accounts” window of the administrator portal.

Now that we have the buyVirtualGood() function working we would like to point out that you will need another function called “addVirtualGood” which will be mostly the same but it will not debit the player for the cost of the Virtual Good.

We will need this for other features like Achievements covered in the topic here where you want to add a Virtual Good to the player’s account but it does not consume currency but is instead awarded to the player.

/**
* Delivers the VG to the player but does not debit them
* @param shortCode
* @param userId
* @param logger
* @param nk
*/
static addVirtualGood(shortCode: string, userId: string, logger: nkruntime.Logger, nk: nkruntime.Nakama): string {
   logger.info(`Granting ${userId}, Virtual Good: ${shortCode}`);
   // first we need to load the Virtual Good config //
   let vgsCursor: nkruntime.StorageObject[] = nk.storageRead([{
       collection: "GSData_VirtualGood",
       key: shortCode,
       userId: "00000000-0000-0000-0000-000000000000"
   }]);
   if (vgsCursor.length > 0) {
       // grant the player the VG //
       // we need to read the player's account and update the vgs array //
       let playerAccount: nkruntime.Account = nk.accountGetId(userId);
       let playerMetaData: any = playerAccount.user.metadata;
       // Init the VG if it hasnt been added yet //
       if (!playerMetaData['vgs']) {
           playerMetaData['vgs'] = {};
       }
       if (!playerMetaData['vgs'][shortCode]) {
           playerMetaData['vgs'][shortCode] = 0;
       }
       playerMetaData['vgs'][shortCode]++;
       nk.accountUpdateId(userId, null, null, null, null, null, null, playerMetaData);
       logger.info(`Granted ${userId}, Virtual Good: ${shortCode}`);
       // handle success response //
       return JSON.stringify({ "success": true });
   }
   // handle error response //
   logger.error(`VG ${shortCode} no found...`);
   return JSON.stringify({ "error": `shortCode ${shortCode} not found` });
}

Alternative Server Approach
In the examples above we are storing Virtual Goods on the player’s metadata. There is a size limit of 16kb for player metadata so if your game needs something more complex than GameSparks Virtual Goods or you would prefer to create something more like an inventory system we should look at using the Nakama Storage Engine instead of player metadata.

This is very simple so let us take a look at how we might approach this in Nakama.

First we need to read the player’s current inventory object for the item with the given short-code and then we can update it and save it back.

// update inventory engine //
let playerVG: any = {};
let vgCursor: nkruntime.StorageObject[] = nk.storageRead([{
   collection: "Player_VirtualGood",
   key: shortCode,
   userId: userId
}]);
// init the player's item if it doesn't exist //
if (vgCursor.length === 0) {
   playerVG = { shortCode: shortCode, amount: 0, lastUpdated: Date.now() }
} else {
   playerVG = vgCursor[0].value;
}
playerVG.amount++;
// save this item back for the player //
nk.storageWrite([{
   collection: "Player_VirtualGood",
   key: shortCode,
   userId: userId,
   value: playerVG,
   permissionRead: 1, // owner read
   permissionWrite: 1 // owner write
}]);

All we are really doing here is updating the count and the lastUpdated field. You won examples might be different but this will work fine for a basic custom inventory.

If you test this code example using the “addVirtualGood” RPC function you will see the "Player_VirtualGood" Collection appear in the Storage list of the Developer Portal.

And we can see the data stored by clicking on the object.

Complex Storage
In the case where neither of these above options will work for you or you need something that allows for complex queries you can also check out the Nakama SQL API. You can also reach out to the Nakama team directly and they will work with you to help make sure you are getting the best solution and performance for your needs.

Unity Example
We have covered some examples of calling RPC requests from Unity in other topics.

It is a very simple call but the two things to remember are that the request payload must be formed manually using a C# Dictionary, and that the request method is asynchronous so it should be called from an async method.

/// <summary>
/// Purchases a Virtual Good for this player and debits them the cost
/// </summary>
/// <param name="session"></param>
/// <param name="shortCode"></param>
private async void BuyVirtualGood(ISession session, string shortCode)
{
   Debug.Log($"Buying Virtual Good {shortCode}");

   // construct request //
   string rpcId = "buyVirtualGood";
   Dictionary<string, string> updatedWallet = new Dictionary<string, string>()
   {
       { "shortCode" , shortCode }
   };
   var payload = Nakama.TinyJson.JsonWriter.ToJson(updatedWallet);
   IApiRpc responseData = await nakamaClient.RpcAsync(session, rpcId, payload);
   Debug.Log(responseData.Payload); // << response codes
}

Testing this code, you should be able to see the RPC’s response payload printed out to the console as a JSON string.

Using this basic recreation of GameSparks Virtual Goods in Nakama you should be able to follow on from the examples shown in this topic and replicate all the functionality you need.

Something that is not included in this topic is how Nakama handles In-App Purchases (IAPs).

For more information on IAPs you can check out our topic on 3rd Party Virtual Goods here.

Achievements

Nakama does not have Achievements out-of-the-box as you are familiar with in GameSparks. This means we are going to have to create or import your existing GameSparks configuration files.

The GameSparks Achievement structure is a simple JSON structure. An example of an Achievement with some currency and Virtual Goods rewards looks something like this.

{
    "@id": "/~achievements/didTheCoolThing",
    "currencyAwards": {
      "gems": 5,
      "xp": 333
    },
    "description": "didTheCoolThing",
    "leaderboard": null,
    "name": "didTheCoolThing",
    "propertySet": null,
    "repeatable": false,
    "segmentData": [],
    "shortCode": "didTheCoolThing",
    "virtualGoodAward": {
      "@ref": "/~virtualGoods/diamond_sword"
    },
    "~triggers": []
  }

You can see what your own Achievements look like or get a copy of their configuration for migration using the GameSparks REST API.

There is a guide in our Cloud-Code topic here on how to import these configuration JSONs into Nakama so we can use them from the Nakama Storage Engine.

For this tutorial we are not going to repeat the process for importing these files here so we advise you to check out the section on Migrating GameSparks Configuration in the Cloud-Code topic linked above.

Delivering Achievements
Once you have your Achievement config imported the next step is to create some functionality which we can use to deliver Achievements to your players from the server.

We are going to add this functionality to a class so you can group similar Achievement functionality together across all scripts on your server in future.

Our “addAchievement” function is going to have the following steps:

  1. Load the Achievement config from the storage engine
  2. Validate the short-code is correct
  3. Reward Virtual Currencies
  4. Reward Virtual Good
  5. Update the Achievement on the player account
  6. Send a notification.

For delivering the Virtual Good we are going to use a function we created in the Virtual Goods topic here.

class GSAchievements {

   /**
    * Delivers the achievement with the given shortCode to the player
    * @param shortCode
    * @param userId
    * @param nk
    * @param logger
    */
   static addAchievement(shortCode: string, userId: string, nk: nkruntime.Nakama, logger: nkruntime.Logger): string {
       logger.info(`Adding Ach: ${shortCode}, to player: ${userId}...`);
       // [1] - load the achievement config list and find the right config object for this achievement code //
       var achConfigData: any = {};
       let achCursor = nk.storageRead([{
           collection: "GSData_Achievement",
           key: shortCode,
           userId: "00000000-0000-0000-0000-000000000000"
       }]);
       // [2] - Now we can check what VC and VG need to be delivered with this
       if (achCursor.length > 0) {
           achConfigData = achCursor[0].value;
           logger.info(`Processing ${achConfigData.shortCode} Rewards...`);
           // [3] - Reward the VCs //
           let vcRewards = achConfigData.currencyAwards;
           for (let currencyKey in vcRewards) {
               logger.info(`Reward Curr Key: ${currencyKey}, Amount: ${vcRewards[currencyKey]}`);
               nk.walletUpdate(userId, { [currencyKey]: vcRewards[currencyKey] })
           }
           // [4] - Reward the VG //
           let vgShortCode = achConfigData.virtualGoodAward;
           vgShortCode = vgShortCode["@ref"].split('/')[2];
           logger.info(`Virtual Good Code: ${vgShortCode}`);
           GSVirtualGoods.addVirtualGood(vgShortCode, userId, logger, nk);

           // [5] - Set the Achievement on the player account //
           // we need to read the player's account and update the achievements array //
           let playerAccount: nkruntime.Account = nk.accountGetId(userId);
           let playerMetaData: any = playerAccount.user.metadata;
           // Init the achievement if it hasn't been added yet //
           if (!playerMetaData['achievement']) {
               playerMetaData['achievement'] = {};
           }
           if (!playerMetaData['achievement'][shortCode]) {
               playerMetaData['achievement'][shortCode] = 0;
           }
           playerMetaData['achievement'][shortCode]++;
           nk.accountUpdateId(userId, null, null, null, null, null, null, playerMetaData);
           logger.info(`Granted ${userId}, Achievement: ${shortCode}`);

           // [6] - Send notification //
           let subject = `You just earned ${achConfigData.name}`;
           let content = {
               achievementName: achConfigData.name,
               achievementShortCode: achConfigData.shortCode,
               currencyAwards: vcRewards,
               summary: achConfigData.description,
               virtualGoodEarned: vgShortCode
           }
           // this is a unique code for the notification this could be mapped to an enum of something like  your  message shortCode //                                     //6
           nk.notificationSend(userId, subject, content, 1, "00000000-0000-0000-0000-000000000000", true);

           return JSON.stringify({ "success": true });
       }
       logger.error(`Achievement ${shortCode} no found...`);
       return JSON.stringify({ "error": `shortCode ${shortCode} not found` });
   }
}

Next, we will need to create an RPC request so we can test this function.

In many cases you will not want the player to be able to award Achievements from the client but in our case we at least want to quickly test everything is working.

/**
* Awards and Achievement to the player and processes the rewards
* @param context
* @param logger
* @param nk
* @param payloadString
*/
function rpcAddAchievement(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   let shortCode = JSON.parse(payloadString).shortCode;
   return GSAchievements.addAchievement(shortCode, context.userId, nk, logger);
}

And finally, you will need to declare this RPC request in the main.ts script’s InitModule function.

initializer.registerRpc("addAchievement", rpcAddAchievement);

Reminder - Remember to add file references to the tsconfig.json file and rebuild and redeploy the server before testing.

Testing Before we go into Unity and see some examples of how to call this RPC, we can test it from the Developer Console by going to the “API Explorer” option on the left-hand side menu.

You can select your RPC from the drop-down menu and give the function a userId for context.

You will also need to set the short-code of the Achievement you want to deliver in the request body.

If everything is set up correctly you will see a successful response from your RPC function.

And we can double-check the Achievement and Virtual Good was rewarded by looking at the player's account from the Developer Console.

Unity Example
Next we are going to take a look at how to grant this Achievement from the client by calling the RPC request we created.

It is a very simple call but two things to remember is that the request payload must be formed manually using a C# Dictionary and that the request method is asynchronous so it should be called from an async method.

/// <summary>
/// Delivers an Achievement to the player
/// </summary>
/// <param name="shortCode"></param>
private async void AddAchievement(ISession session, string shortCode)
{
   string rpcId = "addAchievement";
   Dictionary<string, string> payloadDic = new Dictionary<string, string>()
   {
       { "shortCode", shortCode }
   };
   var payload = Nakama.TinyJson.JsonWriter.ToJson(payloadDic);
   IApiRpc responseData = await nakamaClient.RpcAsync(session, rpcId, payload);
   Debug.Log(responseData.Payload);
}

If you test this out you should get a successful response in the Unity console.

Achievement Notifications
We have demonstrated how you can reproduce Achievements in Unity and from the runtime server. However, there is a piece missing from our replicated GameSparks Achievements functionality which we need to look at next.

Back in our runtime server code we added a section which would send a notification to the player when the Achievement was delivered. This was intended to replicate the GameSparks notifications shown below.

We can reproduce this notification in Nakama using sockets. There is already a socket callback in the Unity SDK which we can use for this. You can get more information on socket callbacks here.

In order for us to register a socket we need a valid player session first. Therefore, in this example we will go back to the Device Authentication example we created in the Basic Authentication topic and expand upon that example in order to open the socket we need when the player authenticates.

We are going to do this using a callback function which will allow you to add additional functionality outside of your authentication functions.

/// <summary>
/// Callback raised after authentication
/// </summary>
/// <param name="session"></param>
private async void onAuth(ISession session)
{
   // [1] - create a new socket //
   var socket = nakamaClient.NewSocket();
   // [2] - (optional) log out a message when the socket is open //
   socket.Connected += () =>
   {
       Debug.Log("Client Socket Connected...");
   };
   // [3] - Handle notifications  //
   socket.ReceivedNotification += (notificationData) =>
   {
       Debug.Log("Notification Received...");
       Debug.Log($"Code: {notificationData.Code}, Id: {notificationData.Id}, Subject: {notificationData.Subject}");
       Debug.Log(notificationData.Content);
   };
   // Assign socket to player session //
   await socket.ConnectAsync(session);
}

And now you can assign this to your device authentication function.


/// <summary>
/// Logs the player in using a deviceId
/// </summary>
/// <param name="userName"></param>
private async void DeviceAuthentication(OnAuthentication onAuth, string userName, string displayName)
{
   Debug.Log("Attempting device auth...");
   Debug.Log($"User Name {userName}...");
   // We'll get the same deviceId that GameSparks uses //
   string deviceId = SystemInfo.deviceUniqueIdentifier.ToString ();
   Debug.Log($"New deviceId: {deviceId}");
   ISession session = await nakamaClient.AuthenticateDeviceAsync(deviceId, userName);
   // Now we can log out the session details //
   Debug.Log($"UserId: {session.UserId}");
   Debug.Log($"AuthToken: {session.AuthToken}");
   onAuth(session);
   // add displayName to the player's account //
   await nakamaClient.UpdateAccountAsync(session, userName, displayName);
}

If you test this out in Unity you should see the notification come in just after you deliver the Achievement.

Remember that you need to be logging-in in order for the notification to come through as your sockets are opened upon authentication in the example above just as they are with GameSparks.

Leaderboard Triggers
Triggering Achievements from a Leaderboard is not an out-of-the-box feature of Nakama, however it is possible with some custom code.

For this example, we will follow on from the examples shown in our topic on Basic Leaderboards here.

We will keep this example simple and use a very basic Leaderboard trigger which will look for global ranks above 100.

Before we set up our custom code we need to get an example of an Achievement with Leaderboard triggers.

The configuration JSON for these triggers will look something like this…

{
  "~triggers": [
    {
      "@id": "/~achievements/didTheCoolThing/~triggers/globalRank>100",
      "filterType": ">",
      "filterValue": "100",
      "leaderboardField": null,
      "nonLeaderboardAttribute": "globalRank"
    }
  ],
  "leaderboard": {
    "@ref": "/~leaderboards/DAILY_RESET"
  }
}

However, if we were to validate this Achievement when posting to a Leaderboard it would require going through every Achievement to find out if any Achivements are associated with the Leaderboard we want to post to.

The more efficient way to do this would be to add this trigger array to the Leaderboard config directly in the Nakama Storage Engine, so this is what we are going to do.

If we follow on from the example shown in the Basic Leaderboards topic we can get this “triggers” array when we load the Leaderboard config in the postScore method.

You can add this code just below where we send Leaderboard notifications.

// Validate Achievements //
if(lbConfig["~triggers"]){
   // you can do your own validation on which triggers you want to choose //
   // for this example we only have one so we get the first element //
   let triggerDetails: any = lbConfig["~triggers"][0];
   let triggerFilterValue: number = parseInt(triggerDetails.filterValue);
   let triggerFilterType: string = triggerDetails.filterType;
   if(this.evaluateTrigger(currRecord.rank, filterType, filterValue)){
       // return an error //
       let achShortCode = triggerDetails['@id'].split('/')[2];
       GSAchievements.addAchievement(achShortCode, userId, nk, logger);
   }
}

Reminder - Remember to rebuild and redeploy the server before testing.

If you now try to test this in Unity, you should see the notification coming in after you post to your Leaderboard.

You can also see your logs in the Nakama console processing the whole event; from posting to the Leaderboard to delivering the Achievement to the player.

Hopefully from the example above you will have what you need to continue migrating your Achievements to Nakama.

Virtual Goods (3rd Party)

Nakama does provide support for Google and Apple IAPs however there is some configuration needed to get these purchases working. Check out the guide on how to change configuration settings in the Cloud-Code tutorial here

Store Configuration Settings
As with social authentication, the first thing we need to do is add the configuration settings Nakama needs to perform validation.

Google
For Google purchases you will need to add two settings: iap.google.client_email and iap.google.private_key there may be some additional setup required through the Google Developer Console in order for you to get these settings. Nakama has a guide here on how to set this up.

Apple
For Apple purchases you need to add the iap.apple.shared_password setting. Nakama also has a guide on where to get this setting here.

Once you have this done you should be able to see these settings in the Developer Console.

Unity Example
We won't be covering how to set up IAPs in Unity as we are assuming this is something you already have configured. Something to note however is that these examples are using UnityIAP and therefore get the receipt object from the Unity Product class. This object has a number of fields but the one we need for Nakama to work with Google and Apple IAPs is the “Product” field.

We will need to get that field out of the object and pass it into the Nakama receipt validation methods.

Google

/// <summary>
/// Validates a receipt string from Google and delivers the IAP to tne Nakama player
/// </summary>
/// <param name="session"></param>
/// <param name="receipt"></param>
private async void NakamaGoogleIAPPurchaseValidation(ISession session, string receipt)
{
   Debug.Log("Validating Purchase Receipt...");
   // we need to get the "Payload" object out of the receipt string //
   Dictionary<string, string> receiptJSON = Nakama.TinyJson.JsonParser.FromJson<Dictionary<string, string>>(receipt);
   string payload = receiptJSON["Payload"];
   IApiValidatePurchaseResponse response = await nakamaClient.ValidatePurchaseGoogleAsync(session, payload);
   foreach (var validatedPurchase in response.ValidatedPurchases)
   {
       Debug.Log("Validated purchase: " + validatedPurchase);
       transactionValidTxt.text = "Purchase Valid...";
   }
}

Apple

/// <summary>
/// Validates a receipt string from Apple and delivers the IAP to tne Nakama player
/// </summary>
/// <param name="session"></param>
/// <param name="receipt"></param>
private async void NakamaAppleIAPPurchaseValidation(ISession session, string receipt)
{
   Debug.Log("Validating Purchase Receipt...");
   // we need to get the "Payload" object out of the receipt string //
   Dictionary<string, string> receiptJSON = Nakama.TinyJson.JsonParser.FromJson<Dictionary<string, string>>(receipt);
   string payload = receiptJSON["Payload"];
   IApiValidatePurchaseResponse response = await nakamaClient.ValidatePurchaseAppleAsync(session, payload);
   foreach (var validatedPurchase in response.ValidatedPurchases)
   {
       Debug.Log("Validated purchase: " + validatedPurchase);
       transactionValidTxt.text = "Purchase Valid...";
   }
}

We can't test this code in Unity because the app needs to be deployed on a device. Remember that the app won't be able to communicate with your local instance so in order to test this you will need to deploy your server somewhere.

Once you have tested on your device you can confirm purchases are working by going to the Account page of the Developer Console. You should see these IAPs under the Purchases tab.

Delivering Virtual Goods
At the moment we have IAP purchases working but they are not delivering any Virtual Goods. The next step is to link the Virtual Goods server code we created in the Virtual Goods topic to these IAP purchases. Nakama already has hooks for Google and Apple purchases so this is going to be easy for us.

First we will need to add the productId for these items to your stored Virtual Goods objects. You can edit these directly in the Nakama Developer Console for now, but it would be more efficient to add them when migrating your Virtual Goods automatically as we showed in the previous topic on Virtual Goods.

Next we need to set up some hooks so that when an IAP is processed, we can grant the player some Virtual Goods based on the Virtual Goods APIs that we created in the previous tutorial.

We are going to need two functions: One which can find the right Virtual Good based on the productId of the item purchased, and the other will validate the purchase details and grant the item to the player.

The first one is simple. It will get your Virtual Goods from the storage engine and look for matching productIds.

/**
* Returns the VG details from the storage engine based on the productId
* @param productId {string}
* @param store {ValidatedPurchaseStore} - enum [0 = Apple, 1 = Google]
* @param nk
*/
function getVGFromProductId(productId: string, store: nkruntime.ValidatedPurchaseStore, nk: nkruntime.Nakama): any | null{
   let vgCursor: nkruntime.StorageObjectList = nk.storageList("00000000-0000-0000-0000-000000000000", "GSData_VirtualGood");
   if(vgCursor.objects && vgCursor.objects.length){
       let returnValue: any | null = null;
       vgCursor.objects.forEach(vgDoc => {
           switch(+store){
               case 0:
                   if(String(vgDoc.value.iosAppStoreProductId) === productId){
                       returnValue = vgDoc.value;
                       break;
                   }
               case 1:
                   if(String(vgDoc.value.googlePlayProductId) === productId){
                       returnValue = vgDoc.value;
                       break;
                   }
           }
       });
       return returnValue;
   }
   return null;
}

The runtime environment already has an enumerator called ValidatePurchaseStore which we can use to check which store the purchase was made on and therefore which productId field to check in the stored Virtual Good object.

You can see that we are returning the Virtual Good details using the any type, but we are also allowing the function to return null in case we don't find a Virtual Good.

The next function is going to be our hook so it will have a special field, ValidatePurchaseResponse.

/**
* Processes an IAP purchase for the player and delivers their VGs
* @param context
* @param logger
* @param nk
* @param purchaseResp
*/
function onIAPPurchase(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, purchaseResp: nkruntime.ValidatePurchaseResponse){
   logger.info("Processing Purchase...")
   // We need to get the productId out of the purchase response //
   if(purchaseResp.validatedPurchases){
       purchaseResp.validatedPurchases.forEach(function(purchaseDetails: nkruntime.ValidatedPurchase) {
           logger.info(`Processing [${purchaseDetails.productId}] on [${purchaseDetails.store?.toString()}], for player [${context.userId}]`);
           if(purchaseDetails.productId && purchaseDetails.store){
               // get the VG details for this productId //
               let vgDetails: any | null = getVGFromProductId(purchaseDetails.productId, purchaseDetails.store, nk);
               if(vgDetails !== null){
                   GSVirtualGoods.addVirtualGood(vgDetails.shortCode, context.userId, logger, nk);
               }
               else{
                   // handle error //
               }
           }
           else{
               // handle error //
           }
       });
   }
}

Now, in the InitModule function of the main.ts script we can register these hooks.

initializer.registerAfterValidatePurchaseApple(onIAPPurchase);
initializer.registerAfterValidatePurchaseGoogle(onIAPPurchase);

It will be the same function for both Apple and Google purchases because the store enum will let us get the correct Virtual Good for either store.

We won't show an example of how to test here because you will need a device and an IAP already set up for your game. You can confirm Virtual Goods have been delivered by checking the player’s Storage tab in the Accounts section of the Developer Console.

Teams

The Teams feature in Nakama is called Groups. It has many of the same features that you would expect from GameSparks Teams, plus a few extra features to manage members and their permissions within a team.

As with GameSparks, Groups have the following functionality:

In addition to the functionality above, Nakama also offer the following functionality for Groups out-of-the-box with no additional configuration or custom code:

We recommend taking a look through Nakama’s documentation on Groups here as these APIs are relatively simple and there are plenty of examples on how to use the APIs listed above on Nakama’s documentation site.

Finding & Listing Groups
If you are using the out-of-the-box ListTeamsRequest in GameSparks to get a list of your teams you will find that there is a similar API in Nakama. However, in Nakama you are also able to do partial string searches by including the “%” wildcard character at the end of the search string.

groupNameSearch += "%";
IApiGroupList groupListResp = await nakamaClient.ListGroupsAsync(session, groupNameSearch, 100);
Debug.Log($"Groups Found [{groupListResp.Groups.Count()}]...");
foreach (var g in groupListResp.Groups)
{
   Debug.Log($"Group name '{g.Name}' count '{g.EdgeCount}'");
}

We created a Group called “Awesome Group For Cool People” and you can see below that we can find that group by adding the wildcard character to the name search parameter. We don't need to include the full Group name in order to find the Group.

Listing Player Groups
This would be something like the GameSparks GetMyTeamsRequest which allows players to get a list of their own Groups. This is a very simple API call and you can see an example of this call with Unity on Nakama’s documentation site here so we don't cover this for this topic.

Listing Group Members
In both examples above we get some basic information about the Group but we don't get a list of the members and their details like we can from GameSparks.

There is however, a request to get a list of member details using Nakama. You can check out more details about that request here. You can get a lot of information about group members from this request so it is very useful.

Joining Groups
Similar to the GameSparks JoinTeamRequest, joining a Group in Nakama requires the group ID.

await client.JoinGroupAsync(session, groupId);

However, if the Group is not open, the player will not be able to join.

Instead, a message will be sent to the Group’s super-admin. By default this is the player who created the Group.

They can use the user ID of the member seeking to join the Group to accept that member’s request. When that action is performed the member seeking to join will also receive a message that their request has been accepted.

You can see an example of those APIs here.

In order to set up notifications you will need to assign a listener to the ReceivedNotification handlers.

You can see a short guide on this here or you can check out our topic on Achievements here for more details on how to create sockets and set up notification listeners.

Group Permissions
Group permissions are not a feature that is available from GameSparks but it is a very common case for modification of the GameSparks Teams feature using Cloud-Code.

Permissions are applied to members of a Group to allow them different privileges within the Group, such as being able to accept new member requests.

There is more information on these permissions and how to use them here.

Group Data
Team-Data is a common feature created by GameSparks developers in order to add additional information to their Team to track Achievements or give their Team a balance they can use for buying their members certain in-game items.

In GameSparks this must be done with a custom collection but with Nakama’s groups there is an out-of-the-box option.

As you may have noticed from some other Nakama topics, many out-of-the-box components of the Nakama’s system have a “metaData” parameter where you can store custom JSON data.

Remember that this data is always public. This doesn't mean that other players can edit the data but it does mean that it is available through public APIs like searching for Groups. This could be very useful in a case where you want to show a team’s level, balance, icon, etc in the list of Teams.

MetaData can only be set from the runtime server so for this example we are going to create a new RPC function which will set this MetaData at the same time as we create a new Group which is a common GameSparks example.

We created a new TypeScript file called “gsGroups.ts” and we are going to create a new class which we can use in future for all Group functionality.

class GSGroups {
   /**
    * Creates a new group with the details provided
    * @param userId
    * @param groupName
    * @param isOpen - if 'false' group is private
    * @param metaData
    * @param maxPlayers
    * @param logger
    * @param nk
    * @returns {string} groupId
    */
   static createGroup(userId: string, groupName: string, isOpen: boolean, metaData: any, maxPlayers: number, logger: nkruntime.Logger, nk: nkruntime.Nakama): string {
       try{
           // Create new group with metadata //
           var groupDetails: nkruntime.Group = nk.groupCreate(userId, groupName, userId, null, null, null, isOpen, metaData, maxPlayers);
           // return new group's Id //
           return JSON.stringify({
               "groupId": groupDetails.id
           });
       }
       catch(e: any){
           logger.error(e.message);
           throw JSON.stringify({
               "code": 123,
               "error": e.message
           });
       }
   }
}

You can see from the example above that we can handle errors by throwing an exception. This will allow us to handle the response we get back in the event of an error server-side.

An example of this during normal gameplay might be a duplicate group-name being created. The Nakama runtime server will not allow this and will throw an error instead so we need to handle that error ourselves.

Next we are going to create a new RPC for this function. This will be simple; all we need to do here is parse the data we would pass in from the client to make sure it is in the correct format for the groupCreate() function.

/**
* Creates a new group for the user with the given parameters
* @param context
* @param logger
* @param nk
* @param payloadString
*/
function rpcCreateNewGroup(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   const payloadData: any = JSON.parse(payloadString);
   // parse params //
   let groupName: string = payloadData.groupName;
   let isOpen: boolean = (payloadData.isOpen === "True")? true : false;
   let maxPlayers: number = parseInt(payloadData.maxPlayers);
   let metaData: any = JSON.parse(payloadData.metaData);
   // and we can get the userId from the context //
   let userId = context.userId;
   // create the group and return the new group Id //
   return GSGroups.createGroup(userId, groupName, isOpen, metaData, maxPlayers, logger, nk);
}

And lastly we will need to register this RPC function in the InitModule function of the main.ts script.

initializer.registerRpc("createNewGroup", rpcCreateNewGroup);

Reminder - Remember to add file references to the tsconfig.json file and rebuild and redeploy the server before testing.

Note - You can also update Group details using the groupUpdate() function. This takes many of the same parameters as the groupCreate() function so we won't cover this in this topic.

Now that we have the server code prepared, we need to create some C# code so we can create our groups from Unity.

// create metaData //
Dictionary<string, string> metaData = new Dictionary<string, string>()
{
   { "COINS" , "123" },
   { "GEMS" , "55" },
   { "LEVEL" , "10" },
};
// construct request //
string rpcId = "createNewGroup";
Dictionary<string, string> requestPayload = new Dictionary<string, string>()
{
   { "groupName" , newGroupName },
   { "isOpen" , isOpen.ToString() },
   { "maxPlayers" , maxPlayers.ToString() },
   { "metaData" , Nakama.TinyJson.JsonWriter.ToJson(metaData) },
};
var payload = Nakama.TinyJson.JsonWriter.ToJson(requestPayload);
try
{
   IApiRpc responseData = await nakamaClient.RpcAsync(session, rpcId, payload);
   Debug.Log(responseData.Payload); // << response string
}
catch (Exception e)
{
   // handle error
   Debug.LogError(e);
}

This code is simple but it may appear complicated as it contains a couple of C# Dictionaries and a try-catch. So let's break down what is happening in this example.

  1. We are creating a Dictionary which will represent the metaData JSON object which we want to attach to our Group.
  2. We are creating the request payload which includes this metaData, but also the group name and other parameters. The metaData object needs to be parsed to a string so that the request payload can be parsed back on the server.
  3. We will try to send the request and if all the parameters are correct we can parse a response payload string.
  4. If there was an error, we can catch this using the try-catch and handle the error.

Below you can see examples of successful and unsuccessful attempts to create a Group using this method.

Success
We get the new Group ID back in a JSON string.

Error
We get some context about the error from the Nakama runtime server when the request failed.

Getting Group MetaData
Group metaData can be retrieved when you search for, or list groups. The metaData object is a JSON string so you will need to parse this data before you can use it in C#.

IApiGroupList groupListResp = await nakamaClient.ListGroupsAsync(session, groupNameSearch, 100);
foreach (var g in groupListResp.Groups)
{
   Debug.Log($"Group name '{g.Name}' count '{g.EdgeCount}'");
   Debug.Log("Parsing MetaData...");
   Dictionary<string, string>  metaData = Nakama.TinyJson.JsonParser.FromJson<Dictionary<string, string>>(g.Metadata);
   foreach (var key in metaData)
   {
       Debug.Log($"{key.Key}:{key.Value}");
   }
}

Custom Group Data
The above example shows how you can attach data to a Group using metaData, but there are cases where you want to have more complex data, for example a larger data-set than metaData can provide (max 16kb) or you want the Group data to be private. In these cases you can do something similar to what you would have done in GameSparks.

When you create a new Group in Cloud-Code, you can get the new Group ID and then create a document for that Group using the Nakama Storage engine.

Let us take a look at creating this new document using our previous code example…

// Create new group with metadata //
var groupDetails: nkruntime.Group = nk.groupCreate(userId, groupName, userId, null, null, null, isOpen, null, maxPlayers);
// create a new doc for this group //
var customGroupDoc: any = {
    wallet: {
        COINS: 123,
        GEMS: 5,
        LEVEL: 10
    },
    items: [{ "diamond_sword" : 2, "diamond_shield" : 1 }]
};
// Save this doc into the Nakama storage engine //
nk.storageWrite([{
    collection: "Custom_Group",
    key: groupDetails.id,
    userId: userId,
    value: customGroupDoc,
    permissionRead: 0, // no public read
    permissionWrite: 0, // no write
}]);

You will notice that we added no read or write permissions to this doc. This doesn't mean that we can never edit the doc, it just means that from the client, no players can edit it, not even the owner. This restricts all edit permissions of the document to the server only.

You can double-check your custom group document was created by going to the Developer Console and clicking on the “Storage” option on the left-hand side menu.

You will be able to see your Group’s data by clicking on the document.

Now we need an example of how to read this data back. This is a simple process. We will create a new function in our GSGroups class, and a new RPC so we can call the RPC from the client.

   /**
    * Returns the group data from the storage engine
    * @param groupId
    * @param logger
    * @param nk {JSON}
    */
   static getGroupData(groupId: string, logger: nkruntime.Logger, nk: nkruntime.Nakama): any {
       let groupDataCurs = nk.storageRead([{
           collection: "Custom_Group",
           key: groupId,
           userId: "00000000-0000-0000-0000-000000000000"
       }]);
       if (groupDataCurs.length > 0) {
           return JSON.stringify(groupDataCurs[0].value);
       }
       // handle errors //
       throw JSON.stringify({
           "code": 123,
           "error": "Group not found"
       });
   }

And our RPC is as follows…

/**
* Returns custom group data for the given groupId
* @param context
* @param logger
* @param nk
* @param payloadString
*/
function rpcGetGroupMetaData(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   const payloadData: any = JSON.parse(payloadString);
   let groupId: string = payloadData.groupId;
   let groupDetails = GSGroups.getGroupDetails(groupId, logger, nk);
   return JSON.stringify(groupDetails);
}

And lastly we will need to register this RPC function in the InitModule function of the main.ts script.

initializer.registerRpc("getGroupCustomData", rpcGetGroupMetaData);

Reminder - Remember to rebuild and redeploy your server before testing.

We will check out an example of how to test this from the Nakama administrator portal. Since only the server has permission to access the Group’s custom data we don't need a client to test that this is working. Instead we can call this from the API Explorer window of the portal.

Team Leaderboards
Team Leaderboards are possible with Nakama however they are not an out-of-the-box feature and will require some custom code. We have covered how you might approach Team Leaderboard in our Leaderboards topic here.

Player Manager

Nakama provides a player management screen out-of-the-box. You will find this in the Developer Console of your Nakama instance.

For most of the examples in this topic we are running our instance locally which would be at 127.0.0.1:7351. You can find the player manager by clicking on the Accounts option on the left-hand side menu of the Developer Console

From this screen you can search for players by ID, user-name or partial username. You can click on a player to view their account.

From here you can see the tab on the top of the window shows the other options of the player account you can view.

The player profile tab shows some basic player information along with the player metadata at the bottom. We covered metadata in the Authentication topic here.

You can also edit and update metadata from here.

Authentication

Here you can see any authentication details about your players. In the example above we have a player using Facebook authentication, so we can see their Facebook user ID linked to this account. Similarly, if the player was using device-auth or email, you would be able to view this here too.

Groups
This window shows you what groups/teams the player is a member of. You can drop the player from these groups from here.

Wallet
The wallet tab shows the player’s current balance as a JSON object which can be edited from this view. Check out our topic here to see how we set these wallets up for our players automatically when they create accounts.

You can see below the JSON editor window that there is also a list of changes to the player's wallet available in this tab. You can use this to audit player transactions.

Purchases
In the purchases tab you can see any IAPs purchased by the player. The information displayed here depends on the store being used, for example…

GooglePlay

App Store

Storage
The storage tab is a very useful feature of the player manager. This tab shows you all the documents in all the collections in the Nakama Storage Engine which are currently associated with the player.

There are various examples in these migration topics where we use the Nakama storage engine to store custom objects or objects modeled on GameSparks features. The example below shows how we can store virtual goods as custom objects to create something like an inventory feature out of the standard GameSparks Virtual Goods feature.

Chat Messaging

Nakama approaches chat differently to GameSparks Team Chat, however you can achieve the same functionality as GameSparks Team Chat using Nakama’s chat system.

Nakama also offers several chat features which are not available in GameSparks which you may find useful.

Chat Rooms
The first thing we will look at is chat rooms. These use the same system as Nakama’s Group Chat feature which is similar to GameSparks’ Team Chat, however anyone can make or join a chat room while Group Chat is only for members of a specific Group.

Chat rooms can also be used for global chat or for chat rooms that you have setup per country or region.

Once chat rooms are created there is no API for getting a list of these rooms so that users can choose which rooms to join. So if this functionality is needed you should consider creating an entry in the Nakama Storage Engine which you can query later. Check out the topic on Cloud-Code here for more details on how to achieve that.

The client calls for joining a chat room and sending messages are very simple and are covered here in Nakama’s documentation so we wont show them again here but there is a modified example of this request in the Unity Example section below.

Remember to set up the socket.ReceivedChannelMessage callback so you will start to receive messages over the socket once your rooms are created.

Group Chat
Group chat in Nakama is the closest thing to GameSpark Team Chat. Groups are another name for the Teams feature in Nakama.

The same features we discussed above on chat-rooms apply to Group chat, however Group chat is private. Only other members of the Group will be able to communicate with each other.

The APIs for Group chat are similar to rooms mentioned above and are covered in Nakama’s documentation here.

The Channel ID for any Group chat messages picked up by the ReceivedChannelMessage listener will be the Group ID.

Private Chat
With Nakama it is also possible to send private messages directly to players. This works the same as chat rooms but with the player’s ID in place of the channel ID.

This means that you need to use the JoinChatAsync() method before being able to send or receive messages. This will therefore mean some refactoring of your GameSparks code as the registration and callback need to be set up first.

You can check out some examples in Nakama’s documentation page on Chat here.

Inbox & Notifications
Nakama stores all chat history provided you set the ‘persistence’ parameter to ‘true’ when joining a channel.

This allows you to get the channel's history and sync it with the client if needed. There are some examples of how to work with message history here.

However, this might not be suitable for something like an inbox system where the player needs a history of all messages sent to them from direct messages to in-app rewards or event announcements.

For this you can use notifications instead of the chat system. Nakama has excellent documentation and examples on this feature here so we won't cover it again in detail in this topic.

There is an example on how to use this feature to send Achievement notifications in our topic on Achievements here.

Server-Side Message Validation
A common use-case for GameSparks Cloud-Code is to use server-side validation on chat messages before sending them onto players so let's take a look at how we can achieve that using some message hooks.

What we will do is create a function which will be called whenever a message is posted to a channel. We will extract our message data, validate it, and then allow it to be passed into the channel or stop it from being passed on and return an error to the client.

There is already an example in the Cloud-Code topic here on how to reach out to a 3rd party service using Nakama’s HTTP API, so in this example we will instead do a random coin-flip so we quickly cover how to intercept messages, pass on valid messages and return custom errors to the client, without adding the complexity of the HTTP request. Check out the Cloud-Code topic for an example on profanity validation.

/**
* This hook is raised whenever a message is sent to a channel
* @param context
* @param logger
* @param nk
* @param envelope - message payload //envelope.channelMessageSend.content//
* @returns {Envelope}
*/
function onChannelMessageSend(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, envelope: any): nkruntime.Envelope {
   var messageData = JSON.parse(envelope.channelMessageSend.content);
   logger.info(`Validating message [${messageData.message}]`);

   // << VALIDATE MESSAGE HERE >> //
   // https://heroiclabs.com/docs/nakama/server-framework/function-reference/#http //

   // for this example we just want to show the successful case or the error case //
   // so we'll do something simple like a coin toss //
   let isValid = (Math.floor(Math.random() * 2) == 0);
   if (isValid) {
       // pass on the envelope to allow the message through //
       logger.info("Validation check passed...");
       return envelope;
   } else {
       // throw an error to stop the message from being sent //
       logger.info("Validation check passed...");
       throw JSON.stringify({
           "code": 123,
           "error": "validation-failed"
       });
   }
}

You can see here, if we want to pass through the message we return the envelope parameter and if we want to return an error instead we can throw an error. We will take a look at how to detect that error on the client later.

The next thing we need to do is register this callback in the main.ts script’s InitModule function.

   // Set message hooks //
   initializer.registerRtBefore("ChannelMessageSend", onChannelMessageSend);

Reminder - Remember to add file references to the tsconfig.json file and rebuild and redeploy the server before testing.

You can see from the docs here that there are a number of reserved message hooks and instead of registering them directly like we would for one of the before and after hooks, we need to give the specific name of the hook in order to register them.

Keep this list in mind as it will be a useful reference for porting other hooks later in your migration.

Unity Example
We can pick these errors up using a simple try-catch. Because we sent back a JSON string for the error you will be able to parse that to handle all the different server error cases you need.

/// <summary>
/// Send a chat message to the given channel
/// </summary>
/// <param name="channelID"></param>
/// <param name="message"></param>
private async void SendChatRoomMessage(string channelID, string message)
{
   Debug.Log($"Sending Message {message} Chat Room {channelID}");
   var content = new Dictionary<string, string> {{"message", message}};
   var payload = Nakama.TinyJson.JsonWriter.ToJson(content);
   try
   {
       var sendMessage = await sessionSocket.WriteChatMessageAsync(channelID, payload);
   }
   catch (Exception e)
   {
       Debug.Log(e.Message);
       // Handle error how you wish //
   }
}

Downloadables

Nakama does not come with an out-of-the-box feature comparable to GameSparks Downloadables or Uploadables. However, because the Nakama runtime environment is so flexible it is possible to put together your own system for Downloadables and Uploadables.

Nakama has a guide on how to achieve this with Unity here.

This guide presumes that you are already familiar with the AWS S3 service. For those who are unfamiliar we will use this topic to explain some basic setup and key points you need to know in order to set up your own S3 bucket with the Nakama guide linked above.

AWS S3 is a storage solution for files hosted by AWS. It is an infinitely scalable service which means that you can put whatever files you need into your bucket and forget about them.

Something to note about this approach and how it differs from GameSparks is that we don't have to keep to any GameSparks file-limitations with a custom S3 bucket, though there is a 5TB file limit which should be enough for most developers.

Before starting anything you will need to set up a new AWS account. We won't cover this here but it is a simple process. Just go to aws.amazon.com to sign-up.

S3 Setup
First we need to create a new S3 bucket for our game. In the AWS portal, navigate to the S3 service and click on the “Create Bucket” button.

There are two options at the top for the bucket name, and the region. Go ahead and fill those out. Choose a region that is closest to the majority of your players.

You’ll notice a section titled “Block Public Access settings for this bucket”. At the moment we want to leave that as it is. We only want access from Nakama for now but this is going to be granted in a different way, so we want no public access for now. For more information on bucket access policies there is a guide on that here.

Bucket names must be globally unique. This means that the name you choose has to be unique across all buckets in every AWS account and across all regions. In other words something like “test1” or “myGameBucket” is not going to be allowed. The best thing to do is use something like a domain name unique to your company and your project.

There are other details and settings available when you create an S3 bucket but we are going to skip those for simplicity. There is a guide here on best-practices with S3 which you can check out for more tips. One thing you could do is give your bucket some tags. Tags let you group services and instances by a common tag. This makes it easier to search for certain features and see what is connected to what, but also allows you to see which applications your bills are coming from and how much your application is costing you so we highly recommend this.

Take note of your bucket name and region of your bucket. You will need those later for your Nakama set up.

You will also need the ARN (Amazon Resource Number) of your bucket. You can access the ARN by going back to S3 and clicking on the properties tab. You will see it at the top of the page.

Uploading Files
The next thing you will need to do is upload some files.

We have a section at the bottom of this topic on exporting downloadables so the flow here would be manually uploading your GameSparks downloadables to S3. There are other ways of doing this but this method requires no additional setup.

Go ahead and upload your file. You don't need to worry about any parameters or options available when uploading the file for the moment.

Something to note is that you’ll want to use the same “shortCodes'' as you used for your GameSparks downloadables. These will become your file names in the bucket so make sure to change them if the Downloadable short-code is different to the file name so you don't have to change your code later.

Once your file is uploaded it will bring you back to your bucket menu.

Other Options
There are, of course, a lot more features to S3 than what we just covered. Updating and deleting files are things you’ll do a lot while importing your files to the new bucket. We won't cover those here as they are simple.

For a walkthrough of more complicated options and features you can check out a guide here.

Bucket Access Management
In the Nakama example here you can see a number of parameters are needed in order for you to communicate with your new bucket:

We got the region and bucket (the bucket name in this case) from the setup steps above so the next thing to do is to get our access key and secret. We get these values by creating a user with specific permissions to access our bucket.

IAM: Roles & Access Policy
Before we create a user we first need to create a Role and a new Access Policy.

For those unfamiliar to AWS services this might seem like extra steps but what we are doing with IAM and creating this user is defining what services a user can access. We can create policies which define the specific terms of use of a group of resources or, in our case, narrow it to a specific bucket. We can group multiple policies into a Role. Once a Role is applied to a user we know exactly what that user can access in our AWS account.

We do this to make our account as secure as possible when granting permissions to external tools like Nakama.

We will therefore be restricting our policies to only what Nakama’s example requires which are get (returning the URL for the file) and put (uploading a file) permissions.

Create Role & Policy
To create a new Role you will need to find the “IAM” service (Identity & Access Management) in your AWS dashboard.

From the menu on the left-hand side of the page click on “Roles” and create a new Role. Click on “S3” from the list of services presented and then on the “Next” button at the bottom of the page.

On the next page, click on the “Create Policy” button. There is a visual editor here but for our case we already know the permissions we need so we are going to add the following JSON.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject"
            ],
            Resource": "arn:aws:s3:::<the-arn-of-your-bucket>"
        }
    ]
}

You will need to use the ARN of your bucket as we mentioned earlier. Now you can click the “Next” button at the bottom and give your Policy some tags if you want.

Give your Policy a name and description and create the Policy.

Now you can go back to the tab where we left off creating our Role. If you refresh the policy list you should find your new policy. Select that and add it to the Role. Add some tags if you wish, give your Role a name and then create it.

New User
Next we need to create a new user and assign this Role to them. You can create a new user by going to the Users option on the left-hand menu of the IAM page.

Click on the “Add users” button on the top-right of the page.

Give your user a name and make sure you click on the “Programmatic Access” Type. This is where we get the key and secret from. Other users created in IAM would give access to your AWS account and services through the same portal use are using to set this user up, but this user only has access via Nakama.

Click on the “Next” button at the bottom of the page. Now we need to assign this user the Policy we created. To do this, click on the tab to attach existing policies directly. Now you can search for your Nakama policy and add it.

Now continue to create the new user.

The last screen will show you that your user has been created successfully. Take note of the access key ID and the secret for your key.

Now you have everything you need to get the Nakama guide working for your own game.

Exporting & Importing Files
Depending on the volume of files you need to import into your new bucket, this task could be as simple as downloading files from the GameSparks portal and then re-uploading them to your new S3 bucket. A large number of files or larger files might make this a lengthy process.

In a case where you have a large volume of files to migrate you might consider using the GameSparks REST API to extract the files you need and then automatically upload them to S3 using the AWS S3 CLI (command line interface). The AWS CLI provides a set of APIs that allow you to control files in your buckets.

Cloud Code

Cloud-Code with Nakama is a very large topic as Nakama offers many of the same features as GameSparks does, such as being able to create custom events, access the database and write custom code which can be applied to hooks like on-authentication or after IAP purchase validation.

However, Nakama’s approach to Cloud-Code is very different from GameSparks. Where GameSparks allows you to write your code from the GameSparks portal IDE, in Nakama you develop your code locally and push it to your server instance.

In this topic we will take a look at how to replicate your GameSparks code within the Nakama runtime server.

Server Setup
The first thing you need to do is set up a new server runtime.

Nakama offers 3 languages you can choose to develop your server code in: Golang, Lua and TypeScript.

For this example we are going to use TypeScript as it may be more familiar to you and your team due to your existing GameSparks code being currently in JavaScript. Using TypeScript will also mean that your code will be more portable and therefore save you some time.

It is important to note that TypeScript is not the same as JavaScript so there will be some constraints to consider when porting your code. You may need to find workarounds or rewrite some of your project architecture.

Note - Nakama recommends Golang over the other options as the runtime environment is built on Golang. You therefore get more features and efficiency by running your server on the native environment. Consider porting your code to Golang to take advantage of this.

We won't go through every step for setting up this server as much of it is already covered on the Nakama documentation site. Make sure you have Docker-Hub installed and running before you start, then you can follow the guide available here for the TS server runtime.

Main.ts
Using the flow from the guide linked above you should have your server setup and running.

You can always double-check that your instance is running locally by going to the address http://127.0.0.1:7351 which should show you your Developer Console.

If you have followed the steps in the setup tutorial linked above, your main.ts script should look something like below…

/**
* This is our main function and entry point to the server when it starts
* @param ctx           - server information & environments
* @param logger        - used to add logging to the server logs
* @param nk            - Nakama related functions
* @param initializer   - used to assign RPCs and hooks
*/
let InitModule: nkruntime.InitModule = function(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
   logger.info("Hello World!");
}

This main function is the entry point to your server so all your initialization code will go here.

To launch you server locally, follow these steps:

  1. Open your terminal and cd into the folder where your runtime server is located.
  2. Build your docker container using the command “docker-compose build”. Make sure the folder you are currently in contains the docker-compose.yml file.
  3. Once the build is complete you can start the server using the command “docker-compose up”

You should start seeing logs from Nakama in your terminal indicating that your server is being deployed.

The final log should show the message “Startup done” but somewhere in the middle of those logs you will see your “Hello World” message too.

You can think of the InitModule function like the GameSparks OnPublished script which you may be familiar with and runs only when a new snapshot is published to the live environment.

Therefore, any code you put in the InitModule function will run when your server boots, including anytime you redeploy the server instance.

Creating RPCs/Events
For the next example let us take a look at how we can create some custom events. In Nakama, these are called RPC functions.

We will start by creating a new TypeScript file in our project. All TypeScript files are imported as modules so any parameters or functions we put into them are accessible everywhere in the project. For porting our GameSparks code this means that we don't need to use the require() statement to import modules.

For our example we called this script “exampleModule.ts”.

Before we can start creating our RPC function we need to list this file in the tsconfig.json file.

{
 "files": [
   "./src/main.ts",
   "./src/exampleModule.ts"
 ],
 "compilerOptions": {
   "typeRoots": [
     "./node_modules"
   ],
   "outFile": "./build/index.js",
   "target": "es5",
   "strict": true,
   "esModuleInterop": true,
   "skipLibCheck": true,
   "forceConsistentCasingInFileNames": true
 }
}

Now we can create the RPC in the exampleModule script. We are going to do something very simple for this example; we will create a function that will take a userId and return some basic information about that user.

/**
* Returns the player's basic details
* @param context
* @param logger
* @param nk
* @param payloadString
*/
function rpc_getPlayerDetails(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   const payloadData: any = JSON.parse(payloadString);
   let userId = payloadData.userId;
   let playerData: any = {};
   // Get the player's account details //
   let playerAccount: nkruntime.Account = nk.accountGetId(userId);
   // construct the response data //
   playerData.userName = playerAccount.user.username;
   playerData.displayName = playerAccount.user.displayName;
   playerData.userId = playerAccount.user.userId;
   // stringify the data before returning it //
   return JSON.stringify(playerData);
}

If we break down what this function is doing, you can see that we are first converting the payload string to JSON. You will need to do this anytime you send data to an RPC function.

We are then loading the player’s account. This would be something similar to Spark.loadPlayer() in GameSparks. We take the fields we need from the account to create a custom object.

Finally, we return the data from the RPC function which will return that payload to the client. You will notice that we parse the data back to a string before returning it. This is because all data returned from an RPC function needs to be a string.

Now we need to register this RPC request function in the main.ts script.

let InitModule: nkruntime.InitModule = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {

   initializer.registerRpc("GetPlayerDetails", rpc_getPlayerDetails);

   logger.info("hello world");
}

Reminder - Remember to rebuild and redeploy your server before continuing to test this example.

If you log into the Developer Console you should see your new RPC function appear in the Runtime Modules page.

Testing RPCs
Nakama does not have a Test-Harness like GameSparks does but you can still test RPC functions from the Developer Console’s API Explorer page.

Testing From Unity
Calling an RPC function to Nakama from Unity is very simple. We need to know the name of the RPC function and we need to give the RPC function a custom JSON payload using a C# Dictionary.

Below is an example from the Leaderboard topic showing how you can post data to a custom Leaderboard RPC.

/// <summary>
/// Post a score to the given LB for the current player
/// </summary>
/// <param name="session"></param>
/// <param name="lbID"></param>
/// <param name="score"></param>
private async void PostScoreCustom(ISession session, string lbId, int score)
{
   string rpcId = "postScoreCustom";
   Dictionary<string, object> requestPayload = new Dictionary<string, object>()
   {
       { "lbId" , lbId },
       { "score" , score }
   };
   var payload = Nakama.TinyJson.JsonWriter.ToJson(requestPayload);
   IApiRpc responseData = await nakamaClient.RpcAsync(session, rpcId, payload);
   Debug.Log(responseData.Payload);
}

RPC Context
In the example we just covered we were showing how you can send in a userId and get that user’s details back. As with GameSparks you could also get back the details of the user that called the RPC. You can get this information from the RPC context.

   let userId: string = payloadData.userId;
   let playerAccount: nkruntime.Account;
   // if there is no userId, then we can get the current user's Id //
   if(!userId){
       logger.info("Fetching current user's data...");
       playerAccount = nk.accountGetId(context.userId);

   }else{
       logger.info(`Fetching data for user [${userId}]...`);
       playerAccount = nk.accountGetId(userId);
   }

For more information on context parameters check out this guide here.

Request & Response Scripts
Similar to GameSparks, Nakama has hooks which allow you to run code before or after common out-of-the-box server events.

An example frequently used by GameSparks developers is to run some code after registration which will give the player some starting currency or items. Check out our topic on Virtual Currency here to see an example of how this can be done.

You can see more details on how to register these hooks here. There is also another type of hook you can use in Nakama which is called Messages. They work similarly to the before and after (request and response) hooks, but they are used for notifications such as chat or matchmaking.

You register these in the InitModule function as you would the other hooks and RPC, however, you need to know the name of the message if you want to register it. For example, if you want some custom code to run when you send a chat message you would use the “ChannelMessageSend” name.

initializer.registerRtBefore("ChannelMessageSend", onChannelMessageSend);

Working With TypeScript
Something to point out if you are not familiar with TypeScript is that you are required to maintain strict typing throughout your code. This might make porting GameSparks JavaScript code tricky as everything is treated like an object in JS.

The main thing to keep in mind is the response type of your functions and whether or not they can return null or undefined types.

let myString: string | null = returnString();
function returnString(): string | null {
   return "hello-world";
}

Any data that can be modeled should use an interface outlining that data. For example, in the GetPlayerDetails RPC example we could create an interface something like this…

interface PlayerData {
   userName: string,
   displayName: string,
   userId: string
}

We can then use its interface instead of a JSON object…

let playerData: PlayerData = {
   userName:       playerAccount.user.username,
   displayName:    playerAccount.user.displayName,
   userId:         playerAccount.user.userId
}

However, there are cases where strict typing will not work for you. The flexibility of using JS objects in GameSparks means that you could have cases where only JSON objects will work.

An example of this is where you have a function which could return different types of data depending on what is needed. For these cases you can use the any type. This might get you unblocked in many cases while migrating your code but you should aim to apply strict typing as much as possible.

function returnAny() : any {
   return {
       some: "messy",
       json: "stuff"
   }
}

Deploying
We’ve already talked about how to build and deploy your local Nakama instance. But you will eventually have to deploy somewhere else so that other developers and players can access the instance.

Because Nakama uses docker you are free to choose where you deploy your instance, however, for this topic we will focus on the deployment options Nakama provides called the Heroic Cloud.

Heroic Cloud
Heroic Cloud is a hosting platform that Heroic Labs provides for Nakama. Deploying on the Heroic Cloud means that they take care of all the infrastructure for you and you can focus on development.

You can see a link to the Heroic Cloud from the Developer Portal of your local instance.

Using the Heroic Cloud you can manage and monitor your server, change configuration settings and you can also hook up your server to a repository so that you can update and redeploy your live instance.

Builders
Builders in your Heroic Cloud account allow you to easily create new server-images and deploy them to your projects. Builders are easy to set up. There is a guide here on how to create a builder and link it to your repo.

Something important to note is that the folder layout for TypeScript is different than for Golang and Lua. Instead of using the root folder of your server runtime project, you will need the compiled JavaScript index file. This is usually found in the “build” folder of your project.

This is compiled when you build your project, but you can also trigger it to recompile by using the command “npx tsc” in the terminal.

There is one more step you will need in order for your configuration settings to be updated from your repo while using TypeScript. Your config.yml file needs to be renamed to the same name as your project.

For this example, my project was called “gsmigrationtutorials” so my config file is called “gsmigrationtutorials.yml”.

My repo folder therefore looks like this…

Once you have created a new builder image, you can deploy that image by going to your project, clicking on the Configuration tab and selecting an image to update from. This will trigger a redeployment so the service may be offline for a couple of minutes.

You can check that your server is ready by checking out the logs in the Logs tab.

Migrating GameSparks Configuration
Once you have your server structure set up, the next thing to think about is how to get your GameSparks configuration into Nakama. In this section we are going to look at a quick and easy way to migrate this data.

By GameSparks configuration here, we are talking about your Leaderboards, Virtual Goods, Achievements, etc. These could be migrated by hand, one by one, or they could be synced via REST automatically. Something to note is that Nakama does not have all of these features out-of-the-box with exactly the same functionality as GameSparks so there will be some custom code required.

We are going to take a look at something in-between both of these approaches (manual and automatic).

You can find the configuration details for all your GameSparks features using the REST API here. For this example, we are going to look at migrating Achievements.

From the Swagger page you need to fill in your authentication details at the top of the page and then select the Achievements configuration. We are going to use the “GET” method.

You will need to supply the API Key for your game and then hit “Try it out”.

The response should be an array of JSON data containing all your configured Achievements.

We are going to take this array and copy it into a new script in your Nakama runtime server.

const gsData_Achievements: any = [
   {
       "@id": "/~achievements/didTheCoolThing",
       "currencyAwards": {
           "gems": 5,
           "xp": 333
       },
       "description": "didTheCoolThing",
       "leaderboard": null,
       "name": "didTheCoolThing",
       "propertySet": null,
       "repeatable": false,
       "segmentData": [],
       "shortCode": "didTheCoolThing",
       "virtualGoodAward": {
           "@ref": "/~virtualGoods/diamond_sword"
       },
       "~triggers": []
   }
];

Next, you will need to store this config data somewhere so you can reference it from other scripts.

Our function for loading data is straightforward. It is going to use the Nakama Storage Engine to save each config document as an object. You will then be able to get those config objects in your scripts using the object “key”, which in this case will be the shortCode of the Achievement.

This process will allow you to write your own wrapper module which will be able to return the relevant configuration object based on its short-code, just as you are familiar with in GameSparks.

/**
* Loads GameSparks config data into the Nakama storage engine
* @param nk {nkruntime.Nakama}
* @param logger {nkruntime.Logger}
* @param gsDataType {string}
* @param gsDataList {object} a JSON array containing GS config data
*/
function loadGSDataToNakamaStorage(logger: nkruntime.Logger, nk: nkruntime.Nakama, gsDataType: string, gsDataList: any) {
   logger.info(`Loading GS Config Data [${gsDataType}] to Nakama Storage...`);
   // We will go through the gsData and convert each object into a format that can be stored in the //
   // Nakama storage engine //
   var storageList = gsDataList.map(function (gsDataObj: any) {
       logger.info(`Loading ${gsDataType}, Id: ${gsDataObj.shortCode}`);
       return {
           collection: "GSData_" + gsDataType,
           key: gsDataObj.shortCode,
           userId: "00000000-0000-0000-0000-000000000000",
           value: gsDataObj,
           permissionRead: 1,
           permissionWrite: 0
       }
   });
   nk.storageWrite(storageList);
   logger.info(`${gsDataType}'s uploaded to Nakama Storage...`);
}

As you can see, we are using a map function to convert the GameSparks config data array into a form that the Nakama storage engine requires.

We will create a collection based on the GameSparks config type. The key is the object short-code as already mentioned. The user ID is the default user. This is important because we must have a user ID associated with objects in the storage engine and we also want to control access to this object so that it is only accessible from runtime server scripts.

The permissions also help with this. permissionRead (1) allows only the owner to read the object. permissionWrite (0) allows no one to write/update the doc once it is created.

The overall plan is to design something similar to how MetaCollections work on GameSparks. This would also be a suitable method for migrating your MetaCollections provided you aren't using complex queries. Property Sets would also be suitable for this method.

Now we can test this code using your RPC function.

Reminder - Remember to declare the RPC in the InitModule function of the main.ts script and rebuild and redeploy the server.

function test_syncAchievements(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string) {
   loadGSDataToNakamaStorage(logger, nk, "Achievement", gsData_Achievements);
}

We can check that this object was uploaded by going to the Developer Console and clicking on the “Storage” option on the left-hand side menu.

You will be able to see your GameSparks configuration objects listed here. If you click on one of your objects you can inspect the data to ensure it was uploaded correctly.

Reading Storage Data
The next thing we need to cover is reading this storage data back into your scripts. This is also covered in the links above, but for this example you can see how easy it is from the code example.

var achConfigList: any[] = [];
let achCursor: nkruntime.StorageObject[] = nk.storageRead([{
    collection: "GSData_Achievement",
    key: "didTheCoolThing",
    userId: "00000000-0000-0000-0000-000000000000"
}]);
if (achCursor.length > 0) {
    achCursor.forEach(gsDoc => {
        achConfigList.push(gsDoc.value);
    });
}

The method above will work for any kind of custom collections you have in your current GameSparks implementation providing you are using queries such as { playerId, itemId } or just playerId to get these objects back.

If you require more complex queries then you can check out Nakama’s SQL API which allows you to make direct calls to the database.

However, we would encourage you to attempt to convert your calls to use the Storage Engine as this will keep your runtime server operating as efficiently as possible.

HTTP Requests
Let us take a look at how to use HTTP requests to reach out to other services using Nakama.

In this example we show how you can use Nakam to reach out to a GameSparks endpoint. This will come in useful if you are performing a passive transition. Check out our topic on data transition here

Creating GameSparks Endpoint
Before we start with our Nakama example we will need an endpoint in GameSparks which we can hit to retrieve data. For this example we are going to pass a player ID to this endpoint. We can use this player ID to get all our player’s data and return it to Nakama to finish setting up our player.

To create a new endpoint in GameSparks we need to go to the Credentials option in the configurator menu.

Next we are going to create a new credential which we called “nakama_player_export” in our example. We want to allow this endpoint to be hit over REST, but to keep it secure we do not want this credential to be able to be used to run any other events.

Take note of the secret generated once the endpoint has been created.

We can now go to the script for this endpoint and stick in some placeholder code.

We won't add a full example of how to extract this code for this topic as your needs will likely differ depending on how much player data you need to migrate, along with how many runtime or GDS collections, teams, friends, etc, you have to migrate too.

You will find the script in the Cloud-Code IDE inside the Callbacks folder. We will perform some simple validation on the data that comes into the endpoint and then we will return some basic player data as an example…

//https://<apikey>.<stage>.gamesparks.net/callback/<apikey>/nakama_player_export/<secret>?playerId=test
var rawData = Spark.getData();
Spark.getLog().debug("NakamaPlayerMigration: "+JSON.stringify(rawData)+"}");
if(!rawData || Object.keys(rawData).length === 0){
    Spark.getLog().error("NakamaPlayerMigration: No data...");
    Spark.setScriptError("error", "no-data");
    Spark.exit();
}
// get the player id //
var playerId = rawData.playerId;
if(!playerId){
    Spark.getLog().error("NakamaPlayerMigration: No playerId...");
    Spark.setScriptError("error", "no-player-id");
    Spark.exit();
}
// load the player to validate they exist //
var currPlayer = Spark.loadPlayer(playerId);
if(!currPlayer){
    Spark.getLog().error("NakamaPlayerMigration: Player ["+playerId+"] not found...");
    Spark.setScriptError("error", "invalid-player-id");
    Spark.exit();
}
// >>>> PLAYER MIGRATION CODE HERE <<<<<< //
var playerData = {
    playerId:       playerId,
    userName:       currPlayer.getUserName(),
    displayName:    currPlayer.getDisplayName(),
    // ... etc ... //
}
Spark.setScriptData("playerData", playerData);

We can test this in Postman to make sure it will work for the Nakama code we are about to write.

Now we can start on our Nakama code. This will be a function which you can call from an RPC for testing, or from a hook after authentication if you want this to happen automatically after the player logs in for the first time.

All we need is the URL for our endpoint which will look like this…

https://<apikey>.<stage>.gamesparks.net/callback/<apikey>/nakama_player_export/<secret>?playerId=test

As an RPC, our code would look something like this...

/**
* Reaches out to the GameSparks instance to get all information for the player with the given playerId
* @param context
* @param logger
* @param nk
* @param payloadString
*/
function rpcSyncGSAccount(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   const playerId = JSON.parse(payloadString).playerId;

   try {
       let method: nkruntime.RequestMethod = 'get';
       let response : nkruntime.HttpResponse = nk.httpRequest(`https://<apikey>.<stage>.gamesparks.net/callback/<apikey>/nakama_player_export/<secret>?playerId=${playerId}`, method);
       // get the response and check for errors //
       var resp: any = JSON.parse(response.body);
       logger.info(JSON.stringify(resp));
       if(resp.errors){
           logger.error(`GS Sync Error: ${JSON.stringify(resp)}`);
           // handle error //
           return JSON.stringify({
               "errorCode" : 122,
               "errorMessage" : resp.errors
           });
       }
       else{
           // >>> player migration code <<<< //
           logger.info(`Player [${playerId}] Migrated...`)
           return JSON.stringify({
               "playerId" : playerId,
               "success" : true
           });
       }
   }
   catch(error){
       logger.error("Error fetching player data from GS...");
       return JSON.stringify({
           "errorCode" : 123,
           "errorMessage" : "error-syncing-player"
       });
   }
}

And we can test this from the Developer Console to prove it works…

Hopefully this example will save you some time when it comes to migrating your GameSparks player data and also shows how you can migrate some of your SparkHTTP requests.

You can also set a body and headers for these HTTP requests. For more details on how to do that check out the documentation here.

Callbacks: Endpoints
As with GameSparks, you can also create scripts which can be hit via HTTP requests. These scripts are the same as the regular RPC functions we created in this topic. Let's take a look at a simple “echo” function that will return whatever we send to it.

function rpc_Echo(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   return payloadString;
}

Remember we have to register this RPC in the InitModule function.

initializer.registerRpc("echo", rpc_Echo);

To hit this RPC via REST we need the http key. You can find this in the Configuration settings list in the Developer Console. For this example we are still using the default key but we will explain how to change these settings in the next section.

If you are testing your local instance the URL will look like this…

http://127.0.0.1:7350/v2/rpc/echo?http_key=defaulthttpkey

You will then need to pass a JSON string in for any data you wish to place in the body of the request.

We can use Postman to test this.

For more details on server-to-server calls from Nakama, check out the documentation here.

Configuration Settings
As with GameSparks, there are some configuration settings that you need to apply to the server in order to get features like social authentication and IAP working. In Nakama however, there are quite a few more settings that you can set so let us take a look at how we can set these.

There are more details and a full list of these settings available here

Let's first take a look at the simple way we can apply these settings through the Heroic Labs portal.

First, select your project from the Heroic Cloud portal.

Next you need to click on the Configuration tab. Here you will see a section with a large list of configuration settings. These are the settings we will be updating to get our social authentication working.

Once you have changed one of these settings make sure to save them using the button at the bottom of the list. Updating any of these settings will require your cluster to be redeployed before the new settings are updated. Remember that triggering a redeployment mean you will lose access to your Developer Console temporarily.

Although this is simple, it does require you to be registered with the Heroic Cloud and have an instance deployed with them so let us take a look at how to do this with a custom deployment.

To do this we need to modify the runtime environement’s config.yaml file directly. We will be following the guide here on how to do this but remember, this example is only applicable to the local instance of the server. If you want to test this in a prod environment you will have to remember to set this up for your prod server too.

Using the bare-minimum configuration our new config file looks something like this...

name: nakama-node-1
data_dir: "./data/"

logger:
    stdout: false
    level: "warn"
    file: "/logfile.log"

console:
    port: 7351
    username: "user"
    password: "password1"

social:
  facebook_limited_login:
    app_id: '<app-id-here>'
  apple:
    bundle_id: ‘com.supernimbus.aws-migration-game’

Now you will need to restart your runtime server in order for your server to be updated from your config file. You can check that the change has been applied by going to your Developer Console and clicking on the Configuration tab.

Note on TypeScript Environments
We mentioned in the section on Deploying that there is a special flow for deploying builds to TypeScript on the Heroic Cloud. Check that section out again to see how you can update settings from a yml file if you are hosting on the Heroic Cloud. For self-hosting options the above method will work.

Missing Features
Although Cloud-Code with Nakama’s runtime environments is very flexible, there are some components that you will not be able to migrate.

Bulk-jobs
Bulk Jobs are not supported by Nakama. However, the Nakama team will work with you to see if they can help you achieve what you need by some other means.

Schedulers Schedulers are not an out-of-the-box feature of Nakama however, it is possible to recreate this feature in Golang but not with TypeScript.

Every-Minute, Every-Day, Every Hour Scripts
Nakama does not provide these scripts out-of-the-box for their runtime environments. It would be possible to recreate this functionality using the Golang environment as you could make these timed events run based on the current timestamp. This is not possible in TypeScript however.

Another alternative would be to use a Cron-Job using AWS CloudWatch. There is a tutorial on how to achieve this here.

Example : Daily Login Reward
For some of the other platforms we took a look at throughout these migration guides we would wrap up the Cloud-Code section with a simple example showing how we can piece all these components together to create a daily reward system. In this case Nakama already has a daily reward example on their site along with an accompanying video. Check that tutorial out here for more information.

Emails

While Nakama does not list SendGrid on their feature list, because of the flexibility of the runtime servers, you can easily get the SendGrid API working on Nakama using the Nakama HTTPRequest API.

Nakama has a complete example of the SendGrid API working on their platform here.

Matchmaking

Nakama has a very flexible matchmaking system which allows you to make complex queries on your matchmaking parameters. This begins with a set of matchmaking Properties which are sent to the server using an equivalent to the GameSparks MatchmakingRequest.

Lets take a look at a GameSparks Match configuration. You can get a copy of your existing match configurations using the GameSparks REST API.

{
  "@id": "/~matches/bomber_man",
  "description": "bomber_man",
  "dontAutoJoinMatch": false,
  "dropInDropOut": false,
  "dropInDropOutExpireSeconds": null,
  "maxPlayers": 4,
  "minPlayers": 4,
  "name": "bomber_man",
  "playerDisconnectThreshold": null,
  "realtime": false,
  "realtimeScript": null,
  "script": null,
  "shortCode": "bomber_man",
  "~thresholds": [
    {
      "@id": "/~matches/bomber_man/thresholds/10",
      "acceptMinPlayers": false,
      "max": 1,
      "min": 10,
      "period": 10,
      "type": "ABSOLUTE"
    }
  ]
}

In the portal, this match would look something like this…

Let us take a look at how we could recreate this match config using Nakama’s Match Properties.

In GameSparks we create a match config through the portal and then use the match short code to let the server know the settings we want for the match. We then submit a matchmaking request along with the “skill” value.

In Nakama, the match config (Match Properties) are defined in the client and submitted in a matchmaking request.

The GameSparks example above in Nakama would look like this in Unity...

/// <summary>
/// Submits a matchmaking request to Nakama with the given values
/// </summary>
/// <param name="minPlayers"></param>
/// <param name="maxPlayers"></param>
/// <param name="skill"></param>
private async void SubmitSimpleMatchRequest(int minPlayers, int maxPlayers, int skill)
{
   Debug.Log($"Submitting Matchmaking Request | min:{minPlayers}, max:{maxPlayers}, skill:{skill}");
   string query = "+properties.skill:>=1 +properties.skill:<=10";
   Debug.Log($"Query: [{query}]");
     var numericProperties = new Dictionary<string, double>() {{ "skill", skill }};
   IMatchmakerTicket matchTicket = await sessionSocket.AddMatchmakerAsync(query, minPlayers, maxPlayers, null, numericProperties);
   Debug.Log($"MatchTicket: {matchTicket.Ticket}...");
}

This code requires a socket to be created from your session. We will not cover that here as it is already covered in a number of other topics. Check out our topic on Achievements here for an example, or you can look at some examples in the Nakama documentation here.

Let us break down the example above…

Match Query
Match queries allow you to control what kind of matches you want for your player.

As you can see from our match query…

"+properties.skill:>=1 +properties.skill:<=10"

We are setting the absolute value of the skill level to between 1 and 10, just like in the GameSparks example.

For this to work we need to add properties to our request which will include our skill. You can see that being set using the C# Dictionary in the above example.

Nakama uses the Bleve search and indexing engine so you can check that out for more examples of the kinds of queries you can use here.

Match Properties
You can see from the example above that we have added a Dictionary called “numericProperties” in which we have included our player’s skill value. You can add multiple properties to this field depending on your requirements and they are referenced from the query by adding the “+properties.” prefix.

We can also add string properties to the matchmaking request. We’ll see an example of how to do that later in this topic.

Before we can test this matchmaking request we need to create a listener for our matchmaking messages and assign it to the socket created from our session.

For this example we will just print the details of the successful match message to the console.

sessionSocket.ReceivedMatchmakerMatched += matched =>
{
   Debug.LogFormat("Received: {0}", matched);
   foreach (IMatchmakerUser user in matched.Users)
   {
       Debug.Log($" UserName: {user.Presence.Username}, Id: {user.Presence.UserId}" );
   }
};

Note - We are using a minimum of 2 players, a maximum of 4 players and both players have a skill value of 5 for this example.

Note - Matchmaking with Nakama can take up to 30 seconds before you get a result so be patient and wait for the logs to appear to confirm your code is working correctly.

This example covers very basic matchmaking with the skill parameter, but what if we need to migrate some more complex matchmaking from GameSparks like Thresholds or Participant Data?

Participant Data
We’ve already touched on how you can replicate this in Nakama using match properties but let's take a look at a simple example you might have in GameSparks where you want to match players via skill level but also by region or country.

In GameSparks, the MatchMakingRequest would look something like this…

{
 "@class": ".MatchmakingRequest",
 "customQuery": {"players.participantData.countryCode":"US"},
 "participantData": {"countryCode":"US"},
 "matchShortCode": "4v4",
 "skill": 5
}

With Nakama we can add string properties to the matchmaking request and include the country code to the matchmaking query.

string query = "+properties.skill:>=1 +properties.skill:<=10";
// add country property to query //
query += " +properties.country:" + countryCode;
Debug.Log($"Query: [{query}]");
var numericProperties = new Dictionary<string, double>() {{ "skill", skill }};
var stringProperties = new Dictionary<string, string>() {{ "country", countryCode }};
IMatchmakerTicket matchTicket = await sessionSocket.AddMatchmakerAsync(query, minPlayers, maxPlayers, stringProperties, numericProperties);

Remember that you can include multiple numeric and string properties to your request and add them to your query to construct more complex matchmaking.

Thresholds
Because matchmaking in Nakama is initiated from the client, in order to create thresholds which change matchmaking parameters over time, we need to use something like a Coroutine in Unity.

We will need to loop through a list of thresholds and create a new matchmaking request after each threshold has timed out. This is simple in Unity but it does require some preparation.

To begin with we are going to create a GSMatchConfig class. To keep this example simple, this class will have a min and max player attribute, along with an array of thresholds.

Thresholds will be a struct with a duration attribute and a string which will represent a Nakama matchmaking query.

public class GSMatchConfig
{
   public int maxPlayers { get; set; }
   public int minPlayers { get; set; }
   public Threshold[] thresholdQueries { get; set; }
   public struct Threshold
   {
       public Threshold(int _duration, string _query)
       {
           duration = _duration;
           query = _query;
       }
       public int duration;
       public string query;
   }
}

And now we can create an instance of this class and add our threshold details.

GSMatchConfig thresholdMatchConfig = new GSMatchConfig();
thresholdMatchConfig.minPlayers = minPlayers;
thresholdMatchConfig.maxPlayers = maxPlayers;
thresholdMatchConfig.thresholdQueries = new GSMatchConfig.Threshold[]
{
   new GSMatchConfig.Threshold(20, "+properties.skill:>=1 +properties.skill:<=10"),
   new GSMatchConfig.Threshold(20, "+properties.skill:>=1 +properties.skill:<=50"),
   new GSMatchConfig.Threshold(20, "+properties.skill:>=1 +properties.skill:<=100")
};

For this example we are just broadening the skill range over time but you can add more complex query strings using Bleve search parameters.

Next we need to create the Coroutine method which will actually run through each threshold. We want the process to wait until the threshold duration has passed before starting the next matchmaking request so this is why we are using Coroutine.

This will be started from the matchmaking request method where our GSMatchConfig object is defined.

/// <summary>
/// Iterates over an array of thresholds and creates new matchmaking requests for each threshold
/// </summary>
/// <param name="matchConfig"></param>
/// <param name="sessionSocket"></param>
/// <param name="skill"></param>
/// <returns></returns>
IEnumerator StartMatchmakingThresholds(GSMatchConfig matchConfig, ISocket sessionSocket, int skill)
{
   Task<IMatchmakerTicket> matchTicket = null;
   for (int i = 0; i < matchConfig.thresholdQueries.Length; i++)
   {
       var threshold = matchConfig.thresholdQueries[i];
       int duration = threshold.duration;
       string query = threshold.query;
       if (matchFound)
       {
           break;
       }
       if (i > 0)
       {
           Debug.Log($"Cancelling preview matchmaking request {matchTicket.Result.Ticket}");
           sessionSocket.RemoveMatchmakerAsync(matchTicket.Result);
       }
       Debug.Log($"Sending matchmaking request...");
       Debug.Log($"Query [{query}]");
       var numericProperties = new Dictionary<string, double>() {{ "skill", skill }};
       matchTicket = sessionSocket.AddMatchmakerAsync(query, matchConfig.minPlayers, matchConfig.maxPlayers, null, numericProperties);
       yield return new WaitForSeconds(duration);
   }

   if (!matchFound)
   {
       Debug.LogWarning("Match not found...");
       sessionSocket.RemoveMatchmakerAsync(matchTicket.Result);
   }
   else
   {
       Debug.Log("Match Found...");
   }
}

Let us break down what is happening in the flow above:

  1. Before we start the matchmaking request, we check to see if a match has been found. If so, we can break out of the loop. You could also stop the Coroutine when the matchmaking notification is picked up by the ReceivedMatchmakerMatched listener but this has to be done within the main thread. A Coroutine handler could achieve this but for the sake of simplicity we are just going to use the matchFound bool.
  2. If this is not the first threshold we can cancel the matchmaking request for the previous threshold.
  3. Now we can create our matchmaking request. You can see from the above example that we are using the min and max players from the matchConfig object, the query for the given threshold, and the same numeric properties we used in the previous examples to assign the player’s skill.
  4. If no match was found over the duration of all thresholds, we cancel matchmaking.

Now we can start this Coroutine from our matchmaking method.

/// <summary>
/// Starts the matchmaking process when using matchmaking thresholds
/// </summary>
/// <param name="minPlayers"></param>
/// <param name="maxPlayers"></param>
/// <param name="skill"></param>
private async void SubmitThresholdMatchRequest(int minPlayers, int maxPlayers, int skill)
{
   GSMatchConfig thresholdMatchConfig = new GSMatchConfig();
   thresholdMatchConfig.minPlayers = minPlayers;
   thresholdMatchConfig.maxPlayers = maxPlayers;
   thresholdMatchConfig.thresholdQueries = new GSMatchConfig.Threshold[]
   {
       new GSMatchConfig.Threshold(60, "+properties.skill:>=1 +properties.skill:<=10"),
       new GSMatchConfig.Threshold( 60, "+properties.skill:>=1 +properties.skill:<=50"),
       new GSMatchConfig.Threshold( 60, "+properties.skill:>=1 +properties.skill:<=100")
   };
   StartCoroutine(StartMatchmakingThresholds(thresholdMatchConfig, sessionSocket, skill));
}

You can run this code with a single player just to check the process is working. You should see each step in the console logs.

Note - remember to set the matchFound bool to ‘true’ in the ReceivedMatchmakerMatched listener.

There are several things to note about this process as it relates to Nakama.

As mentioned before, Nakama matchmaking can take much longer than what you expect from GameSparks matchmaking. Therefore short threshold durations may not be useful and you may need to increase your threshold durations in order for your matchmaking to be effective.

You can also choose not to cancel match-tickets at the start of each new threshold. This would keep your match ticket in the pool throughout all thresholds so that you can still get potential matches at lower thresholds before the duration of all thresholds has passed.

As you can see from what we have covered in this topic, Nakama’s matchmaking feature is very flexible and should be able to adapt to the vast majority of gameSparks matchmaking configurations.

For more information consult Nakama’s documentation on matchmaking here.