Creating a Unity leaderboard using node.js and redis

I’ve recently added a new node.js based leaderboard system for Unity to Github. I’m going to use something like this in my next game which will feature global leaderboards as a major focus of the game.You can grab the code from Github.

Let start by taking a look at what the repo contains:

Node.js Server

The server side code is written in JavaScript using Node.js with Redis for super fast storage. To use this code you will need a web server that allows you to host your own node.js scripts. There are plenty of super cheap VPS providers around these days so if you go that route take a look at my article on installing node on your own VPS here. You will also need Redis, take a look at my article on installing Redis here.

Once you have everything installed and setup, run main.js located in the Server folder to set the leaderboards server off going:


node main.js

Once running, the server will be listening for connections from Unity on the default port which is set in server.js.

The entire leaderboards service consists of 3 files:

  • server.js – The web server which interacts with the Uniy app
  • leaderboard.js – The leaderboard code which communicates with Redis to store an retrieve persistent data
  • main.js – A main file to create the server and set it off running

The code in server,js is straight forward and much of it was already covered in this article here. The main changes include dealing with data hiding, we now pass the data base64 encoded via the d parameter:

[sourcecode language=”js”]
var vars = body.split("=");
if (vars[0] == "d")
{
var data = Buffer.from(vars[1], "base64");
console.log("Data: " + data);
this.processCommand(data.toString(), res);
}
[/sourcecode]

Here we decode the base 64 string data then pass it on to be processed. This enables me to encrypt the data at the other end then decrypt it when it arrives at this end (not covered in this article), this will allow me to hide what is sent and minimise the chance that someone spams my leaderboards with fake data.

The next major change is to handle commands that are passed to the server, to allow the client to perform different actions such as submit a score or get the users rank etc..

leaderboard.js was added to take care of all actions that are related to dealing with the actual leaderboard data, such as chatting to Redis about what to do with the data. The leaderboard class handles all of this including pushing multiple commands to Redis in one go instead of sending them separately to speed things up. For example sending scores to multiple leaderboards at the same time:

[sourcecode language=”js”]
setUserScores(userName, scores, cb)
{
var multi = this.client.multi();
for (var t = 1; t < this.MAX_BOARDS + 1; t++)
{
var name = this.boardName + ":" + t;
if (scores[t – 1] > 0 && scores[t – 1] < this.MAX_SCORE)
multi.zadd([name, scores[t – 1], userName]);
}
multi.exec((err, replies) =>
{
if (cb) cb(err);
});
}
[/sourcecode]

Here we issue many zadd commands to redis at the same time.

Unity Client

The Unity client code is implemented in Leaderboards.cs located in the Client folder. Lets take a quick look at the code:

[sourcecode language=”csharp”]
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using System;
using System.Text;

public class Leaderboards : MonoBehaviour
{
public const int RANK_INVALID = -1; // The rank is invalid

private static string _Url = "http://localhost";
private static string _Port = "8080";

// Submits users score to the server.
//
// @param which Leaderboard index to submit score to
// @param score Score to submit
// @param userName Name of user to submit score for
// @param OnScoreSubmitted Callback to call when operation completes or fails, a bool is passed to the callback which
// is true if an error occurred or false if not
//
public void SubmitScore(int which, int score, string userName, Action<bool> OnScoreSubmitted)
{
StartCoroutine(SubmitScoreToServer(which, score, userName, OnScoreSubmitted));
}

private IEnumerator SubmitScoreToServer(int which, int score, string userName, Action<bool> OnScoreSubmitted)
{
Debug.Log("Submitting score");

// Create a form that will contain our data
WWWForm form = new WWWForm();
StringBuilder sb = new StringBuilder("m=score");
sb.Append("&w=");
sb.Append(which.ToString());
sb.Append("&s=");
sb.Append(score.ToString());
sb.Append("&n=");
sb.Append(userName);
byte[] bytes = Encoding.UTF8.GetBytes(sb.ToString());
form.AddField("d", Convert.ToBase64String(bytes));

// Create a POST web request with our form data
UnityWebRequest www = UnityWebRequest.Post(_Url + ":" + _Port, form);
// Send the request and yield until the send completes
yield return www.Send();

if (www.isError)
{
// There was an error
Debug.Log(www.error);
if (OnScoreSubmitted != null)
OnScoreSubmitted(true);
}
else
{
if (www.responseCode == 200)
{
// Response code 200 signifies that the server had no issues with the data we sent
Debug.Log("Score send complete!");
Debug.Log("Response:" + www.downloadHandler.text);
if (OnScoreSubmitted != null)
OnScoreSubmitted(false);
}
else
{
// Any other response signifies that there was an issue with the data we sent
Debug.Log("Score send error response code:" + www.responseCode.ToString());
if (OnScoreSubmitted != null)
OnScoreSubmitted(true);
}
}
}

// Submits a collection of scores to the server.
//
// @param scores Scores to submit, once score per leaderboard
// @param userName Name of user to submit score for
// @param OnScoresSubmitted Callback to call when operation completes or fails, a bool is passed to the callback which
// is true if an error occurred or false if not
//
public void SubmitScores(int[] scores, string userName, Action<bool> OnScoresSubmitted)
{
StartCoroutine(SubmitScoresToServer(scores, userName, OnScoresSubmitted));
}

private IEnumerator SubmitScoresToServer(int[] scores, string userName, Action<bool> OnScoresSubmitted)
{
Debug.Log("Submitting scores");

WWWForm form = new WWWForm();
StringBuilder sb = new StringBuilder("m=scores");
sb.Append("&a=");
for (int t = 0; t < scores.Length; t++)
{
sb.Append(scores[t].ToString());
if (t < scores.Length – 1)
sb.Append(",");
}
sb.Append("&n=");
sb.Append(userName);
byte[] bytes = Encoding.UTF8.GetBytes(sb.ToString());
form.AddField("d", Convert.ToBase64String(bytes));

UnityWebRequest www = UnityWebRequest.Post(_Url + ":" + _Port, form);
yield return www.Send();

if (www.isError)
{
Debug.Log(www.error);
if (OnScoresSubmitted != null)
OnScoresSubmitted(true);
}
else
{
if (www.responseCode == 200)
{
Debug.Log("Scores sent complete!");
Debug.Log("Response:" + www.downloadHandler.text);
if (OnScoresSubmitted != null)
OnScoresSubmitted(false);
}
else
{
Debug.Log("Scores sent error response code:" + www.responseCode.ToString());
if (OnScoresSubmitted != null)
OnScoresSubmitted(true);
}
}
}

// Gets the user rank from the server.
//
// @param which Leaderboard index to submit score to
// @param score Score to submit
// @param userName Name of user to submit score for
// @param OnScoreSubmitted Callback to call when operation completes or fails, an int is passed to the callback which
// represents the users rank
//
public void GetRank(int which, string userName, Action<int> OnRankRetrieved)
{
StartCoroutine(GetRankFromServer(which, userName, OnRankRetrieved));
}

private IEnumerator GetRankFromServer(int which, string userName, Action<int> OnRankRetrieved)
{
Debug.Log("Getting rank");

WWWForm form = new WWWForm();
StringBuilder sb = new StringBuilder("m=rank");
sb.Append("&w=");
sb.Append(which.ToString());
sb.Append("&n=");
sb.Append(userName);
byte[] bytes = Encoding.UTF8.GetBytes(sb.ToString());
form.AddField("d", Convert.ToBase64String(bytes));

UnityWebRequest www = UnityWebRequest.Post(_Url + ":" + _Port, form);
yield return www.Send();

if (www.isError)
{
Debug.Log(www.error);
if (OnRankRetrieved != null)
OnRankRetrieved(RANK_INVALID);
}
else
{
if (www.responseCode == 200)
{
Debug.Log("Get rank complete!");
Debug.Log("Response:" + www.downloadHandler.text);
if (OnRankRetrieved != null)
{
int rank = RANK_INVALID;
int.TryParse(www.downloadHandler.text, out rank);
OnRankRetrieved(rank);
}
}
else
{
Debug.Log("Get rank error response code:" + www.responseCode.ToString());
if (OnRankRetrieved != null)
OnRankRetrieved(RANK_INVALID);
}
}
}

// Gets the users ranks from the server.
//
// @param userName Name of user to submit score for
// @param OnRanksRetrieved Callback to call when operation completes or fails, callback has an array of ints, with
// each element representing a leaerboard rank
//
public void GetRanks(string userName, Action<int[]> OnRanksRetrieved)
{
StartCoroutine(GetRanksFromServer(userName, OnRanksRetrieved));
}

private IEnumerator GetRanksFromServer(string userName, Action<int[]> OnRanksRetrieved)
{
Debug.Log("Getting ranks");

WWWForm form = new WWWForm();
StringBuilder sb = new StringBuilder("m=ranks");
sb.Append("&n=");
sb.Append(userName);
byte[] bytes = Encoding.UTF8.GetBytes(sb.ToString());
form.AddField("d", Convert.ToBase64String(bytes));

UnityWebRequest www = UnityWebRequest.Post(_Url + ":" + _Port, form);
yield return www.Send();

if (www.isError)
{
Debug.Log(www.error);
if (OnRanksRetrieved != null)
OnRanksRetrieved(null);
}
else
{
if (www.responseCode == 200)
{
Debug.Log("Get ranks complete!");
Debug.Log("Response:" + www.downloadHandler.text);
if (OnRanksRetrieved != null)
{
string[] sranks = www.downloadHandler.text.Split(‘,’);
int[] ranks = new int[sranks.Length];
int i = 0;
foreach (string s in sranks)
{
ranks[i] = RANK_INVALID;
int.TryParse(sranks[i], out ranks[i]);
i++;
}
OnRanksRetrieved(ranks);
}
}
else
{
Debug.Log("Get ranks error response code:" + www.responseCode.ToString());
if (OnRanksRetrieved != null)
OnRanksRetrieved(null);
}
}
}
}
[/sourcecode]

This class contains four methods at present:

  • SubmitScore – Submits a score to the server
  • SubmitScores – Submits a collection of scores to the server
  • GetRank – Gets the users rank from the server
  • GetRanks – Gets all of the users ranks from the server

Internally each request to the server is called via a coroutine and the supplied callback that we pass is called when a response from the server comes in, e.g.:

[sourcecode language=”csharp”]
Leaderboards lbds = GetComponent<Leaderboards>();
lbds.GetRank(which, (int rank) =>
{
// Do something with the result
});
[/sourcecode]

Its also worth mentioning that when we send data to the server we make some effort to hide it which makes it a little harder for someone to spam the server with false scores, e.g.:

[sourcecode language=”csharp”]
// Create a form that will contain our data
WWWForm form = new WWWForm();
StringBuilder sb = new StringBuilder("m=score");
sb.Append("&w=");
sb.Append(which.ToString());
sb.Append("&s=");
sb.Append(score.ToString());
sb.Append("&n=");
sb.Append(userName);
byte[] bytes = Encoding.UTF8.GetBytes(sb.ToString());
form.AddField("d", Convert.ToBase64String(bytes));
[/sourcecode]

Note how we firstly encode the data to bytes then convert it to a base 64 string, this enables us to add a step between, where we can encrypt the byte data using whatever means we like.

Whats coming next….

I plan to extend the server to allow queries of the data, so I can for example view all of the leaderboards from a website and possibly on device.