Lets Make An “MMO” (DarkRift 2/Unity/PlayFab) Pt 9.5: Update And Code Changes

So, in the last few posts we covered everything needed to register, sign in, and move around in sync with other players. Post 10 was going to cover data persistence with PlayFab, however about half way through writing it I had the realization that I needed to recode a bunch of stuff to attempt to stop it from growing into a mess.

In that post, I was originally going to show how to use Player Statistics on the PlayFab backend to player position and state data. However this is kind of clunky especially when you add more values, so I switched over to using Objects. Unfortunately the PlayFab API requires separate calls for stats vs objects and I am sure you can see that splitting our data like that would not be ideal. Also, pretty much every other PlayFab tutorial does use that system.

In the process of fixing that, I also ended up re-writing the registration/character loading code. So, In this post I will try to show and explain the new system, the pros and cons, etc. The code on Github will contain these changes and I will be moving forward with our next system using it.

Also, now that we are at this point, it isn’t really feasible to write these posts as I go, so they might take a little more time and have less code snippets that are not directly related. If you have made it this far, chances are you have at least an ok enough grasp on C# to fill in any blanks.

Going forward, I want to try to make my posts more consistent so if you see Server or Client with a capital starting letter I am referring to the project itself and if you see server or client with a lower case start I am referring to the concept of “a server somewhere” or “the client playing the game”.

Before I go over the changes, lets go over the options PlayFab offers us for storing information about a character so you can see if one of the other options fits your own game better:

Objects – Larger sets of JSON you can tie to a key vs a single key/pair value. This is a somewhat newer offering, you can read more about it HERE. It is seen here in the PlayFab backend:

Statistics – A dictionary of string/int keypairs – XP, Gold, Strength, Level, MaxHealth, etc are all good candidates for this. Just remember all of the values are ints. Any values that fit nicely as whole numbers are probably a good fit for this. A fun side-effect of using Statistics is that PlayFab will create a leaderboard for you for each one which can be convenient. Something like “HighScore” would be ideal for this.

Title Data – Various dictionaries of type string/string. This is arbitrary data you set for the character for your game. Title data for characters comes in three flavors:

  1. Character Data – the title-specific custom data for the user which is readable and writable by the client
  2. Internal Data –  the title-specific custom data for the user’s character which cannot be accessed by the client
  3. Read Only Data – the title-specific custom data for the user’s character which can only be read by the client

Character Data is actually a lot like Statistics in terms of use, only there is no leaderboard made and the value is a string vs an int. You can find these in the PlayFab web admin panel for a players characters:

I showed you an example of the Statistics tab in another post, that is where the key/pairs for Level, XP, and Gold live.

If you start typing PlayFabServerAPI. in VS (make sure you have the right namespaces) you can see the types of updates you can make (the ones in the red box correlate to the image above):


And the requests you can makes for those calls:

You might notice there is nothing for Objects here – that is because so far we had been using the Server API and Objects are dealt with using the Data API. The Data API deals with two things: Files and Objects. For now we are not going to deal with Files (but you can read about them HERE and HERE.

If we stored the character data under statistics, we lose the float values and have to do additional juggling to get them to be correct, plus each value is it’s own entry.

If this went under Title Data for the character, we run into the problem of float to string conversions (and again, each value is it’s own entry).

Objects let us define our own structures. When I say Object here, I am talking in the JavaScript send of the word not the C# sense. In JavaScript, Objects are just containers that can hold key/value pairs (and do a few other things) but they are not the result of a class instantiation.

HOWEVER, you can deserialize this back into an object, so if you have a class that matches the data being saved, you can retrieve this information can create a new class instance with that data.

Because posting all of the changes would make this post very long, I am going to show the general code flow and generic code needed to set, save, retrieve, and use your object data.

Here are some oh so pretty diagrams (and code) for login, character creation, and loading characters:

This one is pretty simple, after you login with PlayFab, the client makes a call to the Client API to retrieve existing characters:

public void TryRetrievePlayerCharacters()
{
    ClearPlayerCharacterList(); //Clear current list of existing characters
    ResetCharacterButtons();
    ShowCharacterButtons();
    //Create a request that will return all characters for a given player
    ListUsersCharactersRequest listUsersCharactersRequest = new ListUsersCharactersRequest
    {
        PlayFabId = ConnectionManager.Instance.LocalPlayerPlayFabID
    };

    //All characters contained in single result
    PlayFabClientAPI.GetAllUsersCharacters(listUsersCharactersRequest,
    result =>
    {
        characterResultCount = result.Characters.Count; // how many came back?

        foreach (CharacterResult characterResult in result.Characters)
        {
            GetDataForCharacter(characterResult);
        }
    },
    error =>
    {
        Debug.Log("Error: " + error.ErrorMessage);
    });
}

ClearPlayerCharacterList(); simply clears the current list of stored player characters – PlayFab returns all characters no matter what so there isn’t really any issue with this.

ResetCharacterButtons(); Just tells the UI buttons to untick a bool that says “I already have a character”

ShowCharacterButtons(); Does just that – the code is slightly refactored from the mess I originally had – it still needs some work though.

There is an additional call on the client to refresh the characters list after creation not shown here

Here is an example of how you might approach saving character state (this is in-fact what I had originally before I made a ton of project breaking changes). Don’t bother actually adding this, the codebase does not reflect this code (however it was already typed up and I don’t feel like re-doing ALL of it for an explanation).

At the top of whatever file you are making API calls from, add:

using PlayFab.DataModels;

Then, you could create a new public struct as follows:

public struct PlayFabCharacterStateData
{
    public float WorldPositionX;
    public float WorldPositionY;
    public float WorldPositionZ;

    public PlayFabCharacterStateData(Vector3 WorldPosition)
    {
        WorldPositionX = WorldPosition.x;
        WorldPositionY = WorldPosition.y;
        WorldPositionZ = WorldPosition.z;
    }
}

Create a new method inside PlayFabAPICaller called SaveCharacterStateData that takes PlayFabCharacterStateData as a parameter:

public void SaveCharacterWorldState(string characterID, PlayFabCharacterStateData characterState)
{
            

}

Inside OnClientDisconnected before we call RemovePlayerFromServer create a method call to SaveCharacterState:

if (PlayerManager.Instance.CurrentPlayers.ContainsKey(e.Client.ID))
{
    GameObject character = PlayerManager.Instance.CurrentPlayers[e.Client.ID].gameObject;
    PlayFabCharacterStateData characterState = new PlayFabCharacterStateData(character.transform.position);

    string characterID = ConnectedClients[e.Client.ID].CurrentCharacterData.CharacterID;

    PlayFabAPICaller.Instance.TrySaveCharacterStateData(characterID, characterState);
}

You could have the characterState as one line but I prefer the clarity this gives to the code. You can leave it like this or like I did, extract it into it’s own method.

Now back in PlayFabAPICaller, create a method called SaveCharacterStateData (TrySaveCharacterState will come after):

public void SaveCharacterWorldState(string characterID, PlayFabCharacterStateData characterState)
{

}

First, we create a dictionary holding our state values. In this case it is just the world position of the player character when they disconnect:

var stateData = new Dictionary<string, object>()
{
    {"WorldPositionX", characterState.WorldPositionX},
    {"WorldPositionY", characterState.WorldPositionY},
    {"WorldPositionZ", characterState.WorldPositionZ}
};

Then, we create a list of type SetObject containing the name of our object (CharacterWorldStateData) and the data itself inside DataObject:

var stateDataList = new List<SetObject>()
{
    new SetObject()
    {
        ObjectName = "CharacterWorldStateData",
        DataObject = stateData
    },
};

Next, we create a SetObjectsRequest and pass it the Entity, and a list of objects:

SetObjectsRequest setCharacterStateRequest = new SetObjectsRequest()
{
    Entity = new PlayFab.DataModels.EntityKey { Id = characterID },
    Objects = stateDataList
};

PlayFab currently offers no way (that I can find) to pass in a single object, hence the needs for the List<SetObject> from before. This is similar to how you cannot retrieve a single character but must instead retrieve all the players characters and then find the one you want. This seems to me like an odd design decision but we work with the tools we have…

The Entity refers to what entity the request is acting on. In this case, we are acting on the character so we pass in the Id for that character.

Lastly, we make the actual API call and inform ourselves if we succeed or fail:

PlayFabDataAPI.SetObjects(setCharacterStateRequest,
result =>
{
    Debug.Log($"Character state data saved on disconnect - saved world position: {characterState.WorldPositionX}, {characterState.WorldPositionY}, {characterState.WorldPositionZ}}");
},
error =>
{
    Debug.Log($"Failed to save character state data");
});

Our complete script:

public void SaveCharacterWorldState(string characterID, PlayFabCharacterStateData characterState)
{
    var stateData = new Dictionary<string, object>()
    {
        {"WorldPositionX", characterState.WorldPositionX},
        {"WorldPositionY", characterState.WorldPositionY},
        {"WorldPositionZ", characterState.WorldPositionZ}
    };

    var stateDataList = new List<SetObject>()
    {
        new SetObject()
        {
            ObjectName = "CharacterWorldStateData",
            DataObject = stateData
        },
    };

    SetObjectsRequest setCharacterStateRequest = new SetObjectsRequest()
    {
        Entity = new PlayFab.DataModels.EntityKey { Id = characterID },
        Objects = stateDataList

    };

    PlayFabDataAPI.SetObjects(setCharacterStateRequest,
    result =>
    {
        Debug.Log($"Character state data saved on disconnect - saved world position: {characterState.WorldPositionX}, {characterState.WorldPositionY}, {characterState.WorldPositionZ}");
    },
    error =>
    {
        Debug.Log($"Failed to save character state data");
    });
}

This is obviously a little more involved then our previous request to PlayFab. This code was created by piecing together the quickstart entity guide, the Data API reference, and the built in entity list – after you mess with PlayFab a bit some of these calls to the various APIs will seem pretty obvious but there are plenty of features that simply have little, no, or confusing documentation.

The last thing we need to do is wrap this method inside ANOTHER method as shown (what is called when the client disconnects):

public void TrySaveCharacterStateData(string characterID, PlayFabCharacterStateData characterState)
{
    PlayFabAuthenticationAPI.GetEntityToken(new PlayFab.AuthenticationModels.GetEntityTokenRequest(),
    result =>
    {
        SaveCharacterWorldState(characterID, characterState);
    },
    error =>
    {
        Debug.Log($"Failed to get entity token");
    });
            
}

We need to do this because we cannot set data objects without an Entity Token. On the client, we get this when we log in and can save it there. The Entity Token typically refers to one of the built in Entity Types and you would pass it in as an argument on the Client when making this call.

On the server however, we don’t login as a client, we instead we get a token for the entire title and can do whatever we want with it. You see this in use here:

Entity = new PlayFab.DataModels.EntityKey { Id = characterID, Type = "character" },

This is telling the PlayFab backend we want to run our action on a character with an ID. Because we are calling this server side, if we don’t first GET the authentication token for the title it won’t work and you will get an error about needing to be logged in. It was not suggested *anywhere* in the docs that this was the case by the way, or that you need to call GetEntityKey with no parameters, but I digress.

This is roughly how this works, there are some details missing between the server response and loading the game but you get the idea…

Choose Character uses a similar flow to saving the character, only it also creates and adds a character class for the connected client and tells the player they are good to join the game as said player. Here are the three main methods on the Server used to do this:

#region Character Data Retrieval 
public void TryRetrieveAllPlayerCharacters(ushort clientIdOfCaller, string playfabID, string characterID)
{
    ListUsersCharactersRequest listUsersCharactersRequest = new ListUsersCharactersRequest
    {
        PlayFabId = playfabID
    };

    //Have to get all characters - playfab can't just return a single character
    PlayFabServerAPI.GetAllUsersCharacters(listUsersCharactersRequest,
    result =>
    {
        foreach (CharacterResult character in result.Characters)
        {
            if (character.CharacterId == characterID)
            {
                TryRetrieveCharacterData(clientIdOfCaller, characterID);
            }
        }
    },
    error =>
    {
        Debug.Log("Error retrieving character");
    });
}

void TryRetrieveCharacterData(ushort clientIdOfCaller, string characterID)
{
    GetEntityTokenRequest requestToken = GetTokenForRequest();

    PlayFabAuthenticationAPI.GetEntityToken(requestToken,
    result =>
    {
        Debug.Log("GetEntityToken call for GetCharacterData worked");
        RetrieveCharacterData(clientIdOfCaller, characterID);
    },
    error =>
    {
        Debug.Log($"Failed to get entity token");
    });
}

void RetrieveCharacterData(ushort ConnectedClientID, string characterID)
{
    PlayFab.DataModels.EntityKey characterEntityKey = CreateKeyForEntity(characterID, "character");
    GetObjectsRequest getObjectsRequest = CreateGetCharacterObjectRequestEscaped(characterEntityKey);

    PlayFabDataAPI.GetObjects(getObjectsRequest,
    result =>
    {
        PlayFabCharacterData characterData = PlayFabSimpleJson.DeserializeObject<PlayFabCharacterData>(result.Objects["CharacterData"].EscapedDataObject);

        SetCurrentCharacterDataForConnectedClient(ConnectedClientID, characterData);


    }, error =>
    {
        Debug.Log("Error setting player info from PlayFab result object");
        Debug.Log(error.ErrorMessage);
    });

}
#endregion

Calls to things like GetTokenForRequest() are things that could be done in this method but have been split into utilities I can call in different places.

Now, one of the key things that lets this all work as it does is the following class:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DarkRiftRPG
{
    public class PlayFabCharacterData
    {
        public string PlayFabID { get; set; }
        public string CharacterID { get; set; }
        public string CharacterName { get; set; }
        public int CharacterLevel { get; set; }
        public int CharacterXP { get; set; }
        public int CharacterGold { get; set; }

        public float WorldPositionX { get; set; } 
        public float WorldPositionY { get; set; }
        public float WorldPositionZ { get; set; }
        public Vector3 WorldPosition { get; set; }

        public bool IsInitialCharacterData { get; set; }


        public PlayFabCharacterData(string playfabID, string characterID, string name, int level, int xp, int gold, float x, float y, float z)
        {
            PlayFabID = playfabID;
            CharacterID = characterID;
            CharacterName = name;

            CharacterLevel = level;
            CharacterXP = xp;
            CharacterGold = gold;

            WorldPositionX = x;
            WorldPositionY = y;
            WorldPositionZ = z;

        }
        public PlayFabCharacterData() { }

        public void SetWorldPosition(float x, float y, float z)
        {
            WorldPosition = new Vector3(x, y, z);

        }
    }
}

You can see I am storing more then just positional data, but that the data *I* am currently saving/loading matches and fills out this class. You can deserialize the PlayFab object into this, and then assign this to the client (I have a reference inside ConnectedClient). Any code not shown is just some stuff that deals with setting that reference and letting the player know it’s been set – you can find that in the project. The Client project has a matching class as well for this.

Often refactors happen when you try to implement a feature and you realize you have two options: take the hit now and spend time cleaning/changing your code, or take the much bigger hit later when your various systems become so entangled that change becomes basically impossible. This is what happened in this post.

So ya.. check out the code (by the time you see this it might be 100% different… who knows!). I am going to start working on game play systems and will be posting in more of a devlog style in the future. I hope this series up to this point has at least been enough to get you started.

You can see all of these changes and updates in the codebase HERE.

Leave a Reply

Your email address will not be published. Required fields are marked *