Introduction

In GameSparks, Achievements are a pretty simple feature, however there are a few things we need if we want to transition them from GameSparks to another platform.

GameSparks Achievements have the usual short-code, name and description of other features of GameSparks, but they also have the ability to deliver Virtual Goods and Virtual Currency as part of their own delivery. This is important because some of these platforms don't have this feature built in. Where possible, we will show how you can add this feature yourself with existing tools or APIs. Therefore, the next important thing these platforms need is some sort of API for Achievements, or if not, some API for creating static objects which we can add Virtual Goods and Virtual Currency to in order to create our own Achievements.

Most of these platforms do not have Leaderboard Triggers and without an API or callback from the Leaderboard, it would be quite difficult to make an automatic version of these features. However, we will mention where possible an alternative mechanism for Leaderboard Triggers that you could investigate.

Beamable

Beamable does not have an alternative to GameSparks Achievements out-of-the-box. However, they do have a lot of existing features and APIs which we can use to put together our own version of GameSparks Achievements so that is what we are going to cover in this topic.

If we break down what we need into its simplest form, we need to replicate a JSON similar to below. GameSparks config features are just JSON anyway and look something like this.

{
  "shortCode": "completeAllQuests",
  "name": "completeAllQuests",
  "description": "completeAllQuests",
  "vgAward": "diamond_sword",
  "currencyAwards": [
    {
    "type": "XP",
    "amount": 1205
    },
    {
    "type": "GEMS",
    "amount": 15
    }
  ]
}

With Beamable, we can replicate something like this using some custom Content. This process is already described in the Cloud-Code topic here here, so we won't repeat all the steps here but we will cover the setup.

Item Content

We need to be able to deliver this item to a player using existing APIs, so we are going to make this an Item and then let our custom item-type inherit from the regular Beamble Items. That will let us use the Inventory Service to deliver these items.

Note - We’ve already gone through how to deliver the Virtual Currencies here and Virtual Goods here in their own topics, so we won't go through how to set those up again here. Check out their topics for more details.

First we create a new script called GSAchievementContent. We should stick this script in the common GSContent folder mentioned in the Cloud-Code topic so that we can add it to our Microservice as an assembly reference.

[ContentType("achievement")]
[System.Serializable]
public class GSAchievementContent : ItemContent
{
   public string name;
   public string description;
   public string vgAward;
   public GSCurrencyReward[] currencyRewards;
}

[System.Serializable]
public class GSCurrencyReward
{
   public enum CurrencyType
   {
       GEMS,
       COINS,
       XP
   }

   public CurrencyType currencyType;
   public int amount;
}

You’ll notice we got rid of the shortCode field for the achievement. We don't need this because we can use the Id of the content object from the Content Manager. If you go to the Content Manager in the Unity editor you will see this new item-type appear. You can now replicate the details you need for your own achievements.

Before you can proceed, remember to hit the Publish button in order to sync the Content with the backend.

Now that we have the Achievement structure replicated in Beamable, we will take a look at how to deliver this achievement through a Microservice.

If you already have some Microservies created for Virtual Currency, Virtual Goods or custom player functionality, you should add a new function to those existing services, otherwise you can create a new one.

When you create a new Microservice, remember to add the assembly reference for your custom content as described in the section on Referencing Custom Content in Microservices in the Cloud-Code topic here.

Our Microservice function is going to be pretty simple. It needs to deliver the Virtual Good and the Virtual Currency rewards, but it also needs to deliver something to the player’s account to represent itself, i.e. to show that the player has earned the Achievement or owns the Achievement. We can do this by granting the Achievement as an Item using the Inventory Service API.

However, there is another way we could do this using Stats which we will discuss briefly.

Achievements as Stats

Using stats is much the same process as if the Achievement was an Item, though slightly different APIs, but there is a huge advantage to using Stats for Achievements as you can filter and target players using Stats while you cannot do this with a player’s inventory.

What this means is that if you want to target players for campaigns or announcements, based on their Achievements, using Stats is a perfect option, so we will give an example of both options here.

We need to pass the achievement Id and the player Id into the server method. Technically we only need the achievement Id as the player Id is used for saving stats, so if you don't want to do it that way, you can leave this out.

[ClientCallable]
public async void GrantAchievement(string achId, long playerId)
{
  Debug.Log($"Granting Achievement [{achId}]...");
  // First we are going to load the achievement definition //
  GSAchievementContent achDef;
  try
  {
     achDef = (GSAchievementContent) await Services.Content.GetContent(achId);
     Debug.Log("Description: "+achDef.description); // << just to test it loaded correctly
  }
  catch (Exception e)
  {
     Debug.LogError(e.Message);
     return; // you could return an error code here //
  }
  // Now we can iterate through the currency rewards and deliver those //
  foreach (GSCurrencyReward currencyReward in achDef.currencyRewards)
  {
     await Services.Inventory.AddCurrency("currency."+currencyReward.currencyType, currencyReward.amount);
     Debug.Log($"Awarded {currencyReward.amount}, {currencyReward.currencyType}");
  }
  // Now we can add the virtual good //
  await Services.Inventory.AddItem("items.virtual_goods."+achDef.vgAward);
  Debug.Log($"Awarded {achDef.vgAward}");
  // And lastly we can add this achievement. we'll show an example as a stat and as an item //
  // Item example //
  await Services.Inventory.AddItem(achDef.Id);
  Debug.Log($"Added Item {achDef.Id}");
  // Stat example //
  Dictionary<string, string> stats = new Dictionary<string, string>();
  stats.Add(achDef.Id, "1");
  await Services.Stats.SetStats("game", "public", "player", playerId, stats);
  Debug.Log($"Added Stat {achDef.Id}");
}

And then we can call this server-method using a simple call to the Microservice client object as we’ve shown in other examples.

string achId = "items.achievement.completeAllQuests";
AchievementsServiceClient _achServiceClient = new AchievementsServiceClient();
_achServiceClient.GrantAchievement(achId, beamableAPI.User.id);

To confirm your player received their achievement you can check the Inventory section of their player account through the portal.

We can also see the Stat set from the stats section.

Leaderboard Triggers

Beamable does not have an equivalent to Leaderboard Triggers, however, because the Leaderboard API is exposed, you could create a server function for posting to your Leaderboards. From there you could add conditions to your GSAchievementContent objects which define what Leaderboard conditions would qualify for the Achievement and then deliver them.

The same process is also possible on the client, though less secure.

AccelByte

AccelByte provides a few similar features to GameSparks Achievements, however the main difference is how we deliver and trigger Achievements.

To create an Achievement through the Admin portal, you should select Achievements under the Game Management section as shown below.

You can find the Add Configuration button on the achievements page above. If we click on that option, a small window will appear to configure Achievements as shown below.

Achievements have the same Code, Name, and Description you are used to from GameSparks.

Using the Hidden option the Achievement will only appear once they get unlocked. This is a handy feature that GameSparks does not have.

We can also set two kinds of Achievements namely Incremental and Non-Incremental. Incremental, Non-incremental Achievements are set by the client and do not need a backend trigger to be delivered.

Incremental achievements are goal-based achievements and you should tie to specific Stats from the Statistics service. The state of those Stats delivers the Achievement to the player. From the AccelByte SDK you cannot reward incremental-Achievements directly, this is very important.

Incremental Achievements have to be delivered to the player through other mechanisms such as stats or other event triggers. We will therefore show a simple example for non-incremental usage below using the Unity client SDK.

Client Example

We can unlock non-incremental Achievements from the game client with a few lines of code, as below. All we need is the Achievement Code which is the “Code” param from when we set up the Achievement config earlier.

string achievementCode = "unlock-bullets-pack";
AccelBytePlugin.GetAchievement().UnlockAchievement(achievementCode, OnUnlockAchievement);

Unfortunately you can't see these Achievements from the Users section of the Admin portal so we can try to retrieve it using the API if you want to double-check.

We can retrieve any single Achievement with the following code

AccelBytePlugin.GetAchievement().GetAchievement(achievementCode, OnGetAchievement);

We will get achievement information from the backend as shown below.

We can retrieve all achievement-details that includes both Unlocked and InProgress as shown below.

AccelBytePlugin.GetAchievement().QueryUserAchievements(AchievementSortBy.LISTORDER, OnQueryAchievements);

*Please note that achievements are like events, when they happen, it will trigger Rewards service to award Virtual Goods or Currency awards to the player automatically.**

Leaderboard Triggers

As discussed above, one of the main differences between Accelbyte and GameSparks is how Achievements are triggered.

In GameSparks, we can trigger Achievements either through a Leaderboard or from a Cloud-Code script. In AccelByte, we have to trigger the Achievement in conjunction with the Statistics service. This is because the Leaderboard service is dependent on the Statistics service in AccelByte and you can find the Leaderboard tutorial about it here.

Nakama

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 transitioning 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.

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.

Note - 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.

From the example above you will have what you need to continue transitioning your Achievements to Nakama.