Skip to content

GameTune integration guide

PavelH edited this page Sep 18, 2019 · 3 revisions

This guide describes how to integrate Unity GameTune into an endless runner game. It walks through reasoning behind selecting optimization points, as well as details of the implementation. Before reading this tutorial, you should read GameTune documentation.

The master branch contains the original game, Unity Ads integration and Unity GameTune package. The gametune-integration branch contains finalized GameTune integration code. You can easily observe the code diff between original version and GameTune integrated version here.

In order to build and run the game on device, you need to set your own Bundle ID in Player Settings, as well as Game ID and Unity Project ID in PackageInitializer of the Start scene.

The project was tested using Unity 2019.2.3f1

Tutorial optimization

Game contains a tutorial, that may feel long and tedious for more experienced players, so why not to make it shorter or even remove it for them? It may reduce the friction of onboarding, hopefully creating a better perception of the game at the very beginning and reduce churn.

First, let’s make a shorter version of the tutorial. It could be just a static image explaining the controls right before they jump into the gameplay for the first time. Scripting-wise it will be just another state between LoadoutState and GameState. In the editor it is a Canvas with a background image under UICamera.

Another version of tutorial could be to not show tutorial at all. Players who are more experienced with endless runner games will appreciate avoiding the unnecessary tutorial.

Now, let’s integrate GameTune into choosing the right tutorial for each individual player. First, we need to initialize GameTune SDK. We can do it in the same place where we initialize Unity Ads, in PackageInitializer. We need to intialize GameTune on every game launch, so GameTune can keep track of player’s retention. Let's add the following code to Awake function:

InitializeOptions options = new InitializeOptions();
options.SetGdprConsent(true); // assuming a user has given a consent
GameTune.Initialize(unityProjectId, options);

GameTune uses Unity Project ID to identify the game project, so ID has to be passed to Initialize method. Unity Project ID is configured in projectId public field in the editor, alongside iOS and Android Game IDs.

InititalizeOptions are optional. Here we indicate to GameTune whether or not user saw a GDPR consent popup and gave the consent. For demo purposes we set the consent to “given”, that is to ensure that GameTune is able to use machine learning for selecting the optimal tutorial also for European. You can read more about it in GameTune documentation.

PackageInitializer is a good place to decide which tutorial to show, as it is attached to a game object in Start scene, which is the first scene to launch. So we are going to create and ask tutorial question right in Start function:

void Start()
{
    Question tutorialQuestion = GameTune.CreateQuestion(
        "tutorial",
        new string[] { "playable", "static", "off" },
        PlayerData.instance.SetTutorial
        );

    GameTune.AskQuestions(tutorialQuestion);
}
  • First parameter to CreateQuestion is the name of the Question.
  • Second parameter is the array of Alternatives, where it is important to place the default one to the first position. The first position defines the “control” alternative, thus it will be selected in case of errors (or no network connection), or if the user has not given the GDPR consent. It is also important from the perspective of setting up the baseline for measuring the performance of GameTune Machine Learning model. In our case, first Alternative is "playable", as it was the default tutorial for every user before GameTune was introduced.
  • The third parameter to CreateQuestion is a custom function that takes care of the Answer that comes from GameTune. We added a new function SetTutorial in the existing class PlayerData. We placed it there because PlayerData is the class that contains all the relevant data about the player’s dynamic state, including data on the tutorial.

Now our PackageInitializer takes care of initializing GameTune SDK on every launch and also asks tutorial question.

When it comes to handling the Answer received from GameTune (PlayerData.SetTutorial), we need to introduce a new data structure TutorialVersion, because the complexity of our tutorial grew with two more versions and one boolean flag (tutorialDone) is not enough to describe it. We read a Value that Answer contains and that is one of the Alternatives we previously passed to CreateQuestion. Our SetTutorial handler could look something like this:

public void SetTutorial(Answer answer)
{
    tutorialAnswer = answer;
    switch (answer.Value)
    {
        case "off":
            tutorialDone = true;
            tutorialVersion = TutorialVersion.Off;
            break;
        case "playable":
            tutorialDone = false;
            break;
        case "static":
            tutorialDone = false;
            tutorialVersion = TutorialVersion.Static;
            break;
        default:
            tutorialDone = true;
            break;
    }
}

See our new additions to PlayerData class: link.

tutorialDone describes whether user has completed the tutorial. We set tutorialDone to true if tutorial is “off” tutorial, and to false for the other two.

We need to separate the logic for “playable” and “static” tutorials. Playable tutorial (which is also the default one) is implemented a special case of GameState, whereas static tutorial is a separate state (see above). Players are getting subjected to these two tutorials at different times in the game. Playable tutorial triggers a tutorial prompt in LoadoutState, while static tutorial is shown to a player once they click Run button. In order to handle playable tutorial, we need to add an additional check in GameState.

When it comes to handling static tutorial, we do a check in LoadoutState and switch to StaticTutorialState from there.

Next we need to notify GameTune that the answer it suggested has been taken into use and exposed to the user. It’s important to place Answer.Use call exactly where tutorial was shown to user, because every user from that point onwards will be affected by the experience they had at that time.

So if playable tutorial starts with a prompt right in the LoadoutState

We need to call Use there (or a few frames before). Let’s add the call to StartButton, which leads to LoadoutState, while skipping "static" tutorial:

if (PlayerData.instance.tutorialAnswer.Value != "static")
{
    PlayerData.instance.tutorialAnswer.Use();
}

Same place can be used to call “Use” for “Off” tutorial, but static tutorial is only exposed to the player once they click Run. We have already added a script that handles static tutorial. By design it shows the image with the instructions about game controls for 5 seconds and then jumps into the game. So let’s add Use event just before the game starts.

Optimizing game balance

Now we are all done with our all new personalized tutorial! It’s time to think about other optimizations to our game. One thing that comes to mind when playing the game - is the balance right? Should it be same for all players? In the end, we are trying to design game play for all the players to enjoy, so should we account for people’s differences? Common issue is that some players may find the game too difficult and get a bad experience, while others will find it boring and demand more challenge. What if we try to satisfy both user types?

Two major parameters contribute to this game’s balance: difficulty and premium currency frequency. The more difficult the game is to the player, the slower they will collect premium currency. Premium currency is used for buying cosmetic items and in-game power ups.

Collecting cosmetics can be the goal for some users, while others may be more oriented towards achieving better results. For both groups premium currency is important. Slower accumulation of the premium currency results in a more difficult/challenging experience. So we can say that difficulty and premium currency are interconnected, and both affect the game balance. Getting this balance right for each individual user is our goal.

Game difficulty can be easily adjusted e.g. by tweaking game speed and the power up probability. So let’s first create tiers of values that we are going to apply. We create them in form of dictionaries, where the keys are also Alternatives for corresponding GameTune questions:

public static Dictionary<string, float[]> speedTiers = new Dictionary<string, float[]>()
{
    { "medium", new[] { 10.0f, 30.0f } },
    { "slow", new[] { 7.5f, 25.0f } },
    { "fast", new[] { 12.5f, 35.0f } },
    { "very_fast", new[] { 15f, 40.0f } },
};

public static Dictionary<string, float> powerUpChanceTier = new Dictionary<string, float>()
{
    { "normal", 0.5f * 0.001f },
    { "rare", 0.25f * 0.001f },
    { "often", 0.75f * 0.001f },
    { "very_often", 1f * 0.001f }
};

public static Dictionary<string, float> premiumChanceTier = new Dictionary<string, float>()
{
    { "normal", 0.5f * 0.0001f },
    { "rare", 0.25f * 0.0001f },
    { "often", 0.75f * 0.0001f},
    { "very_often", 1f * 0.0001f }
};

Values follow a certain pattern, for example in speedTiers the step is 25% from default minimum speed and roughly 16% for the maximum speed, whereas for powerUpChanceTier and premiumChanceTier the step is 50%. Tiers should feel right for the game designer but if they don’t work for end-users, the GameTune will figure that out and stops selecting them for anyone. We are going to create three more handler functions and apply answer values to change minSpeed, maxSpeed, powerUpChanceCoefficient and premiumChanceCoefficient:

public static void SetMinMaxSpeed(Answer answer)
{
    pgeAnswers[answer.Name] = answer;

    if (speedTiers.ContainsKey(answer.Value))
    {
        float[] speedTiersArr = speedTiers[answer.Value];
        minSpeed = speedTiersArr[0];
        maxSpeed = speedTiersArr[1];
    }
}

public static void SetPowerUpChanceCoefficient(Answer answer)
{
    pgeAnswers[answer.Name] = answer;

    if (powerUpChanceTier.ContainsKey(answer.Value))
    {
        powerUpChanceCoefficient = powerUpChanceTier[answer.Value];
    }
}

public static void SetPremiumChanceCoefficient(Answer answer)
{
    pgeAnswers[answer.Name] = answer;

    if (premiumChanceTier.ContainsKey(answer.Value))
    {
        premiumChanceCoefficient = premiumChanceTier[answer.Value];
    }
}

See how nicely it blends into TrackManager.

The appropriate place for asking these three questions would be when LoadoutState is entered. As you can see, premium chance coefficient question has a third parameter AnswerType.ALWAYS_NEW. As the name suggests, the answer to this questions will always be new, so it may change every time this question is asked.

Speed question and power up probability question will keep returning the first used answer every time when asked. It is done to keep the game experience consistent. Player shouldn’t get confused by sudden leaps in speed, from slow to fast and back to slow. They want to play and get better by improving their skills and beating their own high scores. The game shouldn’t disrupt this process, but merely help them in the beginning for picking the right starting difficulty. In some other types of games which have level based progression system, it would be perfectly fine to keep adjusting difficulty for every level, as each level would be a unique, non-recurring experience.

Premium currency probability coefficient can be chosen once or it may change over time. There is a small possibility that it can sabotage someone’s gameplay experience, but at the same time it may lift game’s LTV, so it could be a good candidate for a continuously adapting value.

We are going to Use these answer values once the character starts moving after verifying that the current is not a playable tutorial.

Reward events

GameTune can be set to optimize for retention and in that case there is nothing else to do and our integration is complete. GameTune will track retention based on Initialize event (that should be called every game launch for every user, see PackageInitializer). There is also a possibility to optimize for custom targets with the help of Reward Events.

Reward Events are meant to signal to GameTune how good the answer was, so GameTune can learn to pick better answers for the next user. Check for more information on Reward Events in GameTune documentation. Player plays an essential role here by signaling that GameTune did a good (or bad) job providing a good experience for them through their actions in the game. So we need to analyze our game and its optimization points to figure out associated rewards.

When optimizing the game difficulty (speed and power up probabilities), we want to measure lift of GameTune decisions for engagement and retention. GameTune measures retention automatically, but we need to figure out what would be indication of player engagement? Opening a leaderboard could be a sign of a player’s engagement by being curious about their skill, so let’s put our first reward event to Leaderboard buttons in LoadoutState and GameOverState. We can do it by simply providing a name of the event:

GameTune.RewardEvent("opened_leaderboard");

Or we can as well add some more information about, like the location Leaderboard was open from:

GameTune.RewardEvent("opened_leaderboard", new Dictionary<string, object>()
{
    { "location", "loadout" }
});

Watching an ad to get another life would mean that player doesn’t want to give up, another indication of engagement. It also has a direct monetary implication, clearly a valuable thing to track and optimize. Attach it to AdForLifeButton.

We also optimize for premium currency probability. Player collects premium currency during their runs and we control the frequency of that, therefore we shouldn’t reward GameTune brain for something it is already doing. Instead, we should reward it for the player’s spend of premium currency, because it may show how engaged the player is. So let’s add a reward event to each item bought in the store:

We are also going to add a reward event when the player uses premium currency to get another life during a run.

Missions is a game feature that we should track for two reasons. Completing and claiming a mission to get premium currency could mean the player is interested in getting more currency to purchase more items, or it could indicate that the player is a completionist and thus heavily engaged. So adding reward even there too.

User attributes

GameTune can benefit from getting more information about user state at the moment of the event or when asking a question. In GameTune SDK the user state can be sent via User Attributes. Conveniently, our game has a PlayerData class that already stores some useful characteristics about a player. Let’s add a helper function that compiles player data to a dictionary:

public Dictionary<string, object> GetUserAttributesForPge()
{
    return new Dictionary<string, object>()
            {
                { "first_time_user_experience_lvl", ftueLevel },
                { "rank", rank },
                { "coins", coins },
                { "premium", premium },
                { "missions_completed", missions.Count },
                { "characters_owned", characters.Count },
                { "themes_owned", themes.Count },
                { "accessories_owned", characterAccessories.Count },
                { "used_character", usedCharacter },
                { "used_accessory", usedAccessory },
                { "used_theme", usedTheme },
                { "tutorial_completed", tutorialDone }
            };
}

You have probably already noticed GameTune.SetUserAttributes before every reward event and ask questions call, like here or here.

SetUserAttributes doesn't do a call to GameTune itself, but stores those attributes internally and attaches to every event automatically. That's why it is wise to set them before every other GameTune call.

Our integration is complete!

Clone this wiki locally
You can’t perform that action at this time.