Leaderboard and saving data

Savior
The purpose of this article would be to explain the different ways to save your data on your device (mobile or not). To do so, we will go through the basic and easiest way first using a basic script and PlayerPrefs, then moving on to try with .NET file system. While this is ok, we should try to make hacker’s work a slight bit harder using binary file instead of text file (this will ont get the toddler hackers away). So how about a bit of encryption.

Some of the information on this article are also explained in the Unity tutorial on how data persistence.

This is the first part of a series. In order to demonstrate further, we will develop a simple local leaderboard.

Using static…

I will start with a way I consider to be wrong, but just for the sake of learning and to get it over with, I will demonstrate it.
You should know that defining a class as static means that you do not need any instance of that class to be able to use it. Actually, you cannot instantiate a static class. It is stored in a different part of the application memory and is never clean by the garbage collector.

So if it never gets cleaned, it means it is always there. Yep, from the moment you make the first call, until the closing of the application. If we need to store some information, that could be a good place. This allows us to keep our data over time and over scene. But hold on, as I said, it is kept in memory until the application is shut off, so it won’t be there next time you start the application.

public static DataStorage 
{
     public static string PlayerName{ get; set; }
     public static int HighScore { get; private set; }
     private static playerScore= 0;
     public static int PlayerScore { get{return playerScore;}
         set
         {
             playerScore = value;
             if(value > HighScore){
                 HighScore = value;
             }
         }
     }
}

Just a few data for the time being, nothing fancy, we just want to see how it works.
Since the class is defined as static, no instance required but also, all members HAVE to be static. It just makes sense since the compiler would not be able to know what instance you are dealing with since you cannot create any. Note that the high score is private and can only be set from within the class. Setting the player value will automatically set the high score if needed.

In our GameManager, we want to set some info:

public class GameManager:MonoBehaviour{
    private void Awake(){
        DataStorage.PlayerName = "Bob Saget";
        DataStorage.PlayerScore = 0;
    }
}

So this is an easy way to keep our value over the lifetime of the game.

Storing on a living object

The other quick way is to use a GameObject that we set as DontDestroyOnLoad. This is one step further since we are able to control the lifetime of the object. If we don’t need it, we destroy it. This was not possible with static class.

public class DataStorage : MonoBehaviour{
     public const string Name = "PlayerName";
     public const string HiScore = "HiScore";
     public const string Score = "PlayerScore"; 
     public string PlayerName{ get; set; }
     public int HighScore { get; private set; }
     private playerScore= 0;
     public int PlayerScore 
     { 
         get{return playerScore;}
         set
         {
             playerScore = value;
             if(value > HighScore)
             {
                 HighScore = value;
             }
         }
     } 
    private void Awake()
    {
        DontDestroyOnLoad(this.gameObject);
    }
}
public class GameManager:MonoBehaviour{
    private DataStorage dataStorage = null;
    private void Awake(){
        this.dataStorage = this.gameObject.GetComponent<DataStorage>();
        this.dataStorage.PlayerName = "Bob Saget";
        this.dataStorage.PlayerScore = 0;
    }
    public void RemoveDataStorage(){
        Destroy(this.dataStorage);
        this.dataStorage = null;
    }
    public void AddDataStorage(){
         this.dataStorage = this.gameObject.AddComponent<DataStorage>();
    }
}

Those methods are just demonstrative of the fact that you can control the object. Again, this is only storing the data as long as the application is running, kill it and you lose it all.

Using PlayerPrefs

We can keep our data but we now want to save it. PlayerPrefs is described on the docs and might be the easiest way to save your achievements. Consider a checkpoint system that saves your progress:

private void OnTriggerEnter(Collider col){
    if(col.transform.gameObject.CompareTag("Player") == true){
        DataStorage ds = FindObjectOfType();
        if(ds == null){ Debug.LogError("missing DataStorage component"); } 
        // Using the const naming prevents typos
        PlayerPrefs.SetString(DataStorage.Name,ds.PlayerName ); 
        PlayerPrefs.SetInt(DataStorage.Score,ds.PlayerScore); 
        PlayerPrefs.SetInt(DataStorage.HiScore,ds.HighScore);        
    }
}

The PlayerPrefs stores the data as key-value pair (kvp) system. For a given key, you get a value if it was previously stored. For this reason, it is necessary to make sure that the kvp exists.
Let’s consider you start the game and want to retrieve some info:

public class GameManager:MonoBehaviour{
    private DataStorage dataStorage = null;
    private void Awake(){
        this.dataStorage = this.gameObject.GetComponent<DataStorage>();
        this.dataStorage.PlayerName = (PlayerPrefs.HasKey(DataStorage.Name) == true)?
            PlayerPrefs.GetString(DataStorage.Name) : "Bob Saget";
        this.dataStorage.PlayerName = (PlayerPrefs.HasKey(DataStorage.Name) == true)?
            PlayerPrefs.GetString(DataStorage.Name) : "Bob Saget";
        this.dataStorage.PlayerScore = (PlayerPrefs.HasKey(DataStorage.HiScore) == true) ? PlayerPrefs.GetInt(DataStorage.HiScore) : 0;
        this.dataStorage.PlayerScore = (PlayerPrefs.HasKey(DataStorage.Score) == true) ? PlayerPrefs.GetInt(DataStorage.Score) : 0;
    }
}

The example might look weird but the high score member has a private setter. So we set the player score to set the high score and reset the player again. I do understand that the player score and high score are likely to be the same, we are just here to see and learn.

With this code, you can switch off the app, I mean totally kill it and put it back on and your data are saved.

So let’s try our leaderboard, we should first define what it should do. We consider a race and at the end, each racer receives points. Those should update the current leaderboard and then save it again.

public class RacerInfo 
{
    public string Name { get; private set; }
    public int Points { get;  set; }
    public string Car { get ; set; }
    public RacerInfo(string name, int points, string car) {
        this.Name = name;
        this.Points = points;
        this.Car = car;
   }
}

The RacerInfo class is not rocket science. But we want to make our leaderboard as flexible as possible, for instance, we may want to reuse is with Turtle object (turtle race game) and in this case, we do not want the car member…mmmm…how do we fix that?
Interface? Yes!!!.

public class interface IRankable
{
    string Name{ get; }
    int Points { get; set;}
}

We can now use any class implementing the IRankable interface. Let’s see a basic LeaderBoard implementation:

public class LeaderBoard 
{ 
    private IRankable[] racerInfos = null;
    public const string data = "LeaderBoardData";
    public LeaderBoard(){}
    public void SetRacers(IRankable[] racerInfos)
    {
        this.racerInfos = racerInfos;
    }
}

We get a default constructor and a SetRacers method. This latter takes an array of IRankable and the point here being that we can pass any type as long as it implements the interface. The data string will be used to save and retrieve in PlayerPrefs.

So let’s save and retrieve

public static class Save 
{
    public const string Data = "LeaderBoardData";
    public static void SaveData<T>(object [] items) where T : class
    {
        BinaryFormatter bf = new BinaryFormatter();
        MemoryStream ms = new MemoryStream();
        bf.Serialize(ms, items as T[]);
        PlayerPrefs.SetString(Data, Convert.ToBase64String(ms.GetBuffer()));
    }

    public static T[] GetDataArray<T>() where T: class
    {
        if (PlayerPrefs.HasKey(Data) == false) { return null; }
        string str = PlayerPrefs.GetString(Data);
        BinaryFormatter bf = new BinaryFormatter();
        MemoryStream ms = new MemoryStream(Convert.FromBase64String(str));
        return bf.Deserialize(ms) as T[];
    }
}

SaveData is generic because we want to be able to save more than the interface content. The constraints make sure that the given type complies with our framework requirements (IRankable) and the method requirement (MemoryStream).

They create a BinaryFormatter and a MemoryStream. The first is used to serialize/deserialize while the second is used as a memory buffer. We can then serialize the buffer as the type that was given. Finally we pass the buffer converted to a string 64 that is requird for the PlayerPrefs method.
The GetDataArray method does the reverted job.

This requires that the passed class has to be serialized.

[System.Serializable] // Added attribute
public class RacerInfo : IRankable
{
    public string Name { get; private set; }
    public int Points { get;  set; }
    public string Car { get; set; }
    public RacerInfo(string name, int points, string car) 
    {
        this.Name = name;
        this.Points = points;
        this.Car = car;
    }
}

One issue to be warned, there is no proper way to check if a class is fully serialized. Also, there is no generic constraint for serialization. For the serialization to work the attribute is needed and it up to the programmer to ensure it. It is possible to check for serialization:

 if(obj.GetType().IsSerializable) { }

But this is limited to the class. If you want the RacerInfo class to be fully serializable, not only you need to mark it as such but you also need to make sure the members are serializable too. In our example, we have string and int which are fine, but if we add created a member of another class that is not set as serializable, the method would fail.

We can now finish with our LeaderBoard:

public void SaveData() where T : class, IRankable
{
    Save.SaveData(racerInfos);
}
public void RetrieveData() 
{
    racerInfos = Save.GetDataArray();
}

Those are just wrappers for the Save class. You may wonder why I did not place the Save/Retrieve from Save class directly in this one. In this case, we also want to set the array in order for our leaderboard, fact that the Save class should not care about. Also, the LeaderBoard constrains the generic to IRankable, the Save class should not as it should be reusable for any type of class.

public void RankData() 
{
    Comparison comparer = (a, b) => b.Points.CompareTo(a.Points);
    Array.Sort(racerInfos, comparer);
}
public void SaveData() where T : class, IRankable
{
    RankData();
    Save.SaveData(racerInfos);
}

Now the SaveData method is saving item in order. Notice the comparer delegate, the parameters are (a,b) but it is used b.Points.CompareTo(a.Points). This makes the array in decreasing order instead of increasing as the regular way would.

public IRankable[] GetRacers (){ return racerInfos; }
public IRankable GetRacer(string name) { return Array.Find(racerInfos, r=> r.Name== name); }

Those last two are used to retrieve racer from the array. You could add some more to retrieve racers by points, index or even by car providing some extra info.

Note that none of those classes are MonoBehaviour. They don’t require the MonoBehaviour lifecyle and don’t really use the Inspector. Those are two reasons I consider for non MB candidates

Let’s move on to our LeaderBoardManager, shall we? This is the consumer of our LeaderBoard class. You could consider it a race game manager, it will create racer, assign some points and car, and finally, we will change the points of three random racers.

public class LeaderBoardManager : MonoBehaviour {
    private string [] names = { "John", "Phil", "Terry", "Justin", "Michael", "Steve", "Andrew", "Jeremy" };
    private string [] cars = {"Renault", "Mercedes", "Ferrari", "Bentley"};
    private LeaderBoard leaderBoard = null;
	private void Start ()  {
        leaderBoard = new LeaderBoard();
        if (PlayerPrefs.HasKey(Save.Data) == false) {
            RacerInfo[] infos = new RacerInfo[10];
            for (int i = 0; i <10; i++) {
                infos[i] = new RacerInfo(names[UnityEngine.Random.Range(0, names.Length)], UnityEngine.Random.Range(0, 50),
                    cars[UnityEngine.Random.Range(0, cars.Length)]);
            }
            leaderBoard.SetRacerInfos(infos);
            leaderBoard.SaveData<RacerInfo>();
        } else {
            leaderBoard.RetrieveData();
        }
    }
    private void Update() {
        if (Input.GetKeyDown(KeyCode.A)) {
            foreach (RacerInfo ri in leaderBoard.GetRacers()){
                Debug.Log(ri.Name + " " + ri.Points +" "+ ri.Car);
            }
        }
        if (Input.GetKeyDown(KeyCode.Space)) {
            Debug.Log("----------------------------------");
            SetRacer("Terry",10);
            SetRacer("Andrew",7);
            SetRacer("Phil",5);
            leaderBoard.RankData();
            foreach(RacerInfo ri in leaderBoard.GetRacers()){
                Debug.Log(ri.Name + " " +ri.Points + " " + ri.Car);
            }
            leaderBoard.SaveData();
        }
    }
    private void SetRacer(string name, int points) {
        RacerInfo ri = (RacerInfo)leaderBoard.GetRacer(name);
        if (ri != null) { ri.Points += points; }
    }
}

In the Start, if we do not have any data saved, we create a new board and save it.
In the Update, we look for racer matching the name and add some points. Then we print the new ranking and save it.

Conclusion

This stops here for now, next step we will save look at Xml and Json. For now, this is it and I would like to emphasize on the interface section of the article that clearly shows how flexible the class is. It is easy to reuse in any circumstances and that is only possible using generic and interfaces.

As for our data, they are safe to some extent. Those are saved as binary in a deep section of the device. So not only, it requires a couple of tweaks to access but then it requires a bit of code to revert. But as we did it, it is not impossible to hack, far from it, just a little harder than plain text on the desktop.

Advertisements