NAV
Unity iOS Android

GameTune

GameTune enables you to use machine learning to optimize your game’s experience for each player in real time.

It comes with easy to use end-to-end integration and you don’t need to have resources to build and deploy your own ML systems. GameTune does automatic feature discovery, selection, transformations, and trains a model with game specific data and deploys and operates that model.

For making an optimized real time decision for each individual player who is currently playing your game, GameTune uses the user’s current context in your game with historical information. GameTune is driven by your game’s data but is also leveraging Unity’s dataset from 1.5 billion devices, so you get access to utilize Unity data for optimizing your game from the get-go.

GameTune can be used for many kinds of decision problems where the goal is to select best alternative from A, B, C, … for each the user. The best alternative is the one that maximizes retention, revenue, conversion or a custom game-specific reward. For example, game parameters, assets, UI, and settings can be personalized to match individual preferences.

GameTune‚Äôs optimizations are personalized and dynamic for each user. GameTune optimizes for different types of players continuously, in contrast to A/B testing which only finds the best alternative for the average user. It’s easier to setup and maintain than rules based segmentation systems.

How to use GameTune?

GameTune is easy to integrate: just add the SDK into your game and configure your optimization goals in the GameTune dashboard.

Below is a list of example GameTune questions to help you understand how it could be used in your game. These questions have been working well for games using GameTune, but you do not have to limit yourself to these questions and you can still optimize anything specific to your game. For technical implementation of some of these, see the examples section

Optimizing Retention

Level difficulty or other game parameters

Tutorial

Starter pack

Lootbox item or timer

Optimizing Revenue goals

IAP offer

Ad frequency or cool down period

Rewarded ads

Downloads

Unity games

Starting from SDK version 2.2.0 Unity Editor versions supported are 2018.3 and newer. If you are on a lower version (2017.4 and newer) of Unity, consider using SDK v2.0.0 for Unity

To import the GameTune SDK to your project:

GameTune Settings

Download the 2.4.0 GameTune SDK for Unity

non-Unity iOS games

Add UnityGameTune.framework to your project.

In your ViewController interface, import UnityGameTune.h:

#import <UnityGameTune/UnityGameTune.h>

Download the 2.4.0 GameTune SDK for iOS with AdSupport library

Download the 2.4.0 GameTune SDK for iOS without AdSupport library

non-Unity Android games

To import Unity GameTune SDK to your project:

Download the 2.4.0 GameTune SDK for Android

SDK Overview

The GameTune SDK exists to make integration with the GameTune service easier and more reliable. It offers:

Architecture Diagram

Initialization

GameTune SDK should be initialized when user starts the game. In addition to initializing the SDK this reports to GameTune that user started the game in order to track user retention.

GameTune.Initialize(projectId);
[UnityGameTune initialize:projectId];
UnityGameTune.initialize(activity, projectId);

Parameters

Due to legislation, GameTune may require user consent for personalization and tracking. To provide information on the user’s privacy consent status, e.g. for GDPR, create a new InitializeOptions object and call SetPrivacyConsent with either true or false on the created object.

If you haven’t asked for the user’s consent, don’t call SetPrivacyConsent at all. GameTune will then determine the behaviour based on user’s location.

To set privacy consent with Initialize:

InitializeOptions options = new InitializeOptions();
options.SetPrivacyConsent(true);   // if you asked for the user's consent and they provided it
options.SetPrivacyConsent(false);  // if you asked for the user's consent and they denied it
GameTune.Initialize(projectId, options);
UGTInitializeOptions *options = [[UGTInitializeOptions alloc] init];
[options setPrivacyConsent:YES];  // if you asked for the user's consent and they provided it
[options setPrivacyConsent:NO];  // if you asked for the user's consent and they denied it
[UnityGameTune initialize:projectId withOptions:options];
InitializeOptions options = new InitializeOptions();
options.setPrivacyConsent(true);   // if you asked for the user's consent and they provided it
options.setPrivacyConsent(false);  // if you asked for the user's consent and they denied it
UnityGameTune.initialize(activity, projectId, options);

To update privacy consent:

GameTune.SetPrivacyConsent(true);
GameTune.SetPrivacyConsent(false);
[UnityGameTune setPrivacyConsent:YES];
[UnityGameTune setPrivacyConsent:NO];
UnityGameTune.setPrivacyConsent(true);
UnityGameTune.setPrivacyConsent(false);

It is possible to update the privacy consent status after initialization by calling SetPrivacyConsent.

User ID

If userId is included with initialization options, it will be included in the answer signature as the sub field. See more information about the signature.

InitializeOptions options = new InitializeOptions();
options.SetUserId(userId);
GameTune.Initialize(projectId, options);
UGTInitializeOptions *options = [[UGTInitializeOptions alloc] init];
[options setUserId:userId];
[UnityGameTune initialize:projectId withOptions:options];
InitializeOptions options = new InitializeOptions();
options.setUserId(userId);
UnityGameTune.initialize(activity, projectId, options);

GameTune Off

You can exclude a segment of users from exposure to GameTune by setting GameTuneOff flag in initialize options. This may be useful e.g. for A/B testing purposes.

When GameTuneOff is set to true, SDK skips all network calls to GameTune server and returns default answers to questions.

When GameTuneOff is set to false, SDK functions normally.

InitializeOptions options = new InitializeOptions();
options.SetGameTunefOff(true);
GameTune.Initialize(projectId, options);
UGTInitializeOptions *options = [[UGTInitializeOptions alloc] init];
[options setGameTunefOff:YES];
[UnityGameTune initialize:projectId withOptions:options];
InitializeOptions options = new InitializeOptions();
options.setGameTuneOff(true);
UnityGameTune.initialize(activity, projectId, options);

Questions

Interaction with GameTune works with the Question -> Answer model. You ask a Question and get a machine learning optimized Answer back. In the Question you define the name for the question, which identifies the optimization point in the game (e.g. level_difficulty). Then you define what the alternatives are (i.e. possible answers) for that question.

Creating questions

Question contains information about a decision problem you are trying to solve with the help of GameTune. It’s an object that consists of these properties:

In order to start receiving ML optimized answers from GameTune, you have to configure your Question and the optimization goal in the GameTune dashboard.

Question buttonText = GameTune.CreateQuestion(
    "button_text",
    new string[]{ "Start", "Begin", "Play" },
    SetButtonText
);

void SetButtonText(Answer answer)
{
    string buttonText = answer.Value;
}

Question levelDifficulty = GameTune.CreateQuestion(
    "level_difficulty",
    new string[]{ "easy", "medium", "hard" },
    AnswerType.ALWAYS_NEW,
    SetLevelDifficulty
);

void SetLevelDifficulty(Answer answer)
{
    string difficulty = answer.Value;
}
UGTQuestion *buttonText = [UnityGameTune createQuestion:@"button_text" alternatives:@[@"Start", @"Begin", @"Play"] handler:^(UGTAnswer *answer) {
    [self setButtonText:answer.chosenAlternative.name];
}];

UGTQuestion *levelDifficulty = [UnityGameTune createQuestion:@"level_difficulty" alternatives:@[@"easy", @"medium", @"hard"] answerType:kUnityGameTuneAnswerTypeAlwaysNew handler:^(UGTAnswer *answer) {
    [self setLevelDifficulty:answer.chosenAlternative.name];
}];
Question buttonText = UnityGameTune.createQuestion(
        "button_text",
        new String[]{"Start", "Begin", "Play"},
        (Answer answer) -> setLevelDifficulty(answer.getValue())
);

Question levelDifficulty = UnityGameTune.createQuestion(
        "level_difficulty",
        new String[]{"easy", "medium", "hard"},
        AnswerType.ALWAYS_NEW,
        (Answer answer) -> setLevelDifficulty(answer.getValue())
);

Treatment groups

GameTune assigns users into different treatment groups. The sizes of these treatment groups can be controlled in the GameTune dashboard.

Default answer

The first alternative (“easy” in this example) is treated as the control alternative - it will be returned by GameTune in the following cases:

Answer Type

Answer type is an optional argument to CreateQuestion that can be either of the two enumerators of AnswerType:

AnswerType.ALWAYS_NEW;
AnswerType.NEW_UNTIL_USED;
UnityGameTuneAnswerType.kUnityGameTuneAnswerTypeAlwaysNew;
UnityGameTuneAnswerType.kUnityGameTuneAnswerTypeNewUntilUsed;
AnswerType.ALWAYS_NEW;
AnswerType.NEW_UNTIL_SED;

Asking questions

Once questions are created, the personalized answer is received by passing your Questions to the AskQuestions method. The GameTune SDK will create an HTTP request to the GameTune service, which will return an Answer back to the SDK, which will invoke callbacks of Questions (calling SetLevelDifficulty in the example above).

The speed of getting answers depends on the network connection, so we recommend asking questions in advance to avoid users waiting for the answer.

AskQuestions supports asking multiple questions at once, e.g. buttonLabel and levelDifficulty in the example are combined into one network request.

GameTune.AskQuestions(levelDifficulty, buttonLabel);
[UnityGameTune askQuestions:levelDifficulty, buttonLabel, nil];
UnityGameTune.askQuestions(levelDifficulty, buttonLabel);

Handling answers

Once an Answer is ready, the Answer object is passed as an argument to your AnswerHandler function.

Answer value

Value is the selected Alternative for the current user, i.e. the name of the alternative.

answer.Value;
answer.chosenAlternative.name;
answer.getValue();

Answer ID

Answer ID a unique ID (UUID) for every Answer.

answer.Id;
answer.identifier;
answer.getId();

Answer’s treatment group

TreatmentGroup contains information about the treatment the user was exposed to when GameTune provided the answer. This can be used e.g. for tagging the user in your analytics in order to measure ML’s performance. TreatmentGroup will be a string with one of the following values:

Value Description
ml The user received an answer optimized by the GameTune ML algorithm.
control The user in this group got the default answer.
exploration The user received a randomly selected answer.
restricted The user has limited ad tracking enabled.
gdpr The user is from EU and is GDPR restricted, and consent is unknown or denied
"” Empty string signals that error occurred and an answer was not picked by GameTune.

Treatment group can be read from the answer:

answer.TreatmentGroup;
answer.treatmentGroup;
answer.getTreatmentGroup();

Answer signature

Every answer is signed by GameTune, so the answer’s authenticity can be checked. The signature verifies that a specific question was answered by GameTune for a specific user at a specific time.

Signatures are JWT tokens signed with GameTune’s public key. In order to verify signatures and read tokens’ contents, retrieve the public key from the GameTune service

See an example of verifying the signature.

answer.Signature;
answer.signature;
answer.getSignature();

Ask questions timeout

You may pass a timeout for AskQuestions in the initialize options, which will guarantee a response within that timeout. The timeout is set in milliseconds. If the response takes longer than that e.g. in bad network conditions, control Answer will be returned to the user.

Default timeout is 5000 milliseconds.

InitializeOptions options = new InitializeOptions();
options.SetAskQuestionsTimeout(2000);
UGTInitializeOptions *options = [[UGTInitializeOptions alloc] init];
[options setAskQuestionsTimeout:2000];
InitializeOptions options = new InitializeOptions();
options.setAskQuestionsTimeout(2000);

Test mode

With test mode, data sent to the backend will be omitted when training the model. This is useful if you are running e.g. tests in your CI which calls the GameTune service, so the data generated by these tests won’t affect the model.

Test mode is disabled by default. GameTune returns control Alternative (first in the array) in test mode.

InitializeOptions options = new InitializeOptions();
options.SetTestMode(true);
UGTInitializeOptions *options = [[UGTInitializeOptions alloc] init];
[options setTestMode:YES];
InitializeOptions options = new InitializeOptions();
options.setTestMode(true);

Alternative attributes

Alternative attributes are optional but may be useful for GameTune to learn underlying similarities and other associations between the alternatives. These could include e.g. item price, weapon damage, or item alignment.

You can set the attributes by creating Alternative objects and pass them as an array to the CreateQuestion method.

For example, your game is deciding which weapon to show to the player: Demonbane, Excalibur, Grimtooth, or Orcrist (or one of the 20 other weapons in the game). The first two are “lawful”, the last two are “chaotic”; and mean damages are 23, 17, 12.5, and 26, respectively. The machine learning model can then learn correlations between damage and reward. Similarly, there are only a few alignments such as lawful or chaotic, but there can be dozens of weapons, so the model sees more data per alignment than per weapon. Thus alignment preferences can be learnt faster and more accurately than individual weapon preferences.

var easy = new Alternative("easy", new Dictionary<string, object>()
{
    { "grunt_count", 10 },
    { "ogre_count", "1" },
    { "special_items_allowed", true }
});

var medium = new Alternative("medium", new Dictionary<string, object>()
{
    { "grunt_count", 15 },
    { "ogre_count", "2" },
    { "special_items_allowed", true }
});

var hard = new Alternative("hard", new Dictionary<string, object>()
{
    { "grunt_count", 20 },
    { "ogre_count", "3" },
    { "special_items_allowed", false }
});

Question levelDifficulty = GameTune.CreateQuestion(
    "level_difficulty",
    new Alternative[] { easy, medium, hard },
    SetLevelDifficulty
);
UGTAlternative *easy = [[UGTAlternative alloc] initWithName:@"easy" andAttributes:@{ @"grunt_count": [NSNumber numberWithInteger:10], @"ogre_count": [NSNumber numberWithInteger:1], @"special_items_allowed": @"true" }];
UGTAlternative *medium = [[UGTAlternative alloc] initWithName:@"medium" andAttributes:@{ @"grunt_count": [NSNumber numberWithInteger:15], @"ogre_count": [NSNumber numberWithInteger:2], @"special_items_allowed": @"true" }];
UGTAlternative *hard = [[UGTAlternative alloc] initWithName:@"hard" andAttributes:@{ @"grunt_count": [NSNumber numberWithInteger:20], @"ogre_count": [NSNumber numberWithInteger:3], @"special_items_allowed": @"false" }];

UGTQuestion *levelDifficulty = [UnityGameTune createQuestion:@"level_difficulty" alternatives:@[easy, medium, hard] handler:^(UGTAnswer *answer) {
    [self setLevelDifficulty:answer.chosenAlternative.name];
}];
Map<String, Object> easyDiffAttributes = new HashMap<>();
tutorialEasyAttributes.put("grunt_count", 10);
tutorialEasyAttributes.put("ogre_count", "1");
tutorialEasyAttributes.put("special_items_allowed", true);
Alternative easy = new Alternative("easy", easyDiffAttributes);

Map<String, Object> mediumDiffAttributes = new HashMap<>();
tutorialMediumAttributes.put("grunt_count", 15);
tutorialMediumAttributes.put("ogre_count", "2");
tutorialMediumAttributes.put("special_items_allowed", true);
Alternative medium = new Alternative("medium", mediumDiffAttributes);

Map<String, Object> hardDiffAttributes = new HashMap<>();
tutorialMediumAttributes.put("grunt_count", 20);
tutorialMediumAttributes.put("ogre_count", "3");
tutorialMediumAttributes.put("special_items_allowed", false);
Alternative hard = new Alternative("hard", hardDiffAttributes);

Question levelDifficulty = UnityGameTune.createQuestion(
        "level_difficulty",
        new Alternative[]{easy, medium, hard},
        (Answer answer) -> setLevelDifficulty(answer.getValue())
);

Events

In order to understand player’s behaviour related to the chosen alternative for a Question, GameTune requires sending Use and Reward events at appropriate times.

These events are required in correct order to have a functional feedback loop:

Feedback loop

When enough of these events related to your Question are collected, GameTune is able to train a model. You can see the status of the events in the GameTune dashboard. As more events flow through the system, the model is trained again with more data and it becomes more accurate. For model to be trained, a minimum number of 1000 used Questions and 100 Rewards is required.

Use

Use must be called once the Answer is changing the game. This means that the selected Answer was actually displayed to the user and Reward(s) is be expected for it. Use enables GameTune to learn if user has actually seen the part of the game that was changed as a result for asking the Question. The purpose of the Use is to include only data from users that were affected by Question into model training set.

answer.Use();
[answer use];
answer.use();

For example if asking a level difficulty Question, call Use when user started the level i.e. the Answer changed the difficulty of the level. If the game is not affected by the answer, Use should not be called. E.g. if you ask a question about button color when the game starts, but the player never sees the button.

If it depends on Question’s alternatives when Use should be called, Answer should be used at the earliest moment when game has possibility to change. For example if a Question is about when to show a reward to user, after 2, 3 or 5 levels played, Use should be called after user has played 2 levels regardless of which Answer the user got.

If you don’t have a reference to the answer you are going to use at any given time, you can call the Use method on the question itself, providing the chosen Alternative that you expose to your user, so the SDK can internally map it to the latest matching answer.

Question levelDifficulty = GameTune.CreateQuestion(
    "level_difficulty",
    new string[]{ "easy", "medium", "hard" },
    AnswerType.ALWAYS_NEW,
    SetLevelDifficulty
);

levelDifficulty.Use("easy");
UGTQuestion *levelDifficulty = [UnityGameTune createQuestion:@"level_difficulty" alternatives:@[@"easy", @"medium", @"hard"] answerType:kUnityGameTuneAnswerTypeAlwaysNew handler:^(UGTAnswer *answer) {
    [self setLevelDifficulty:answer.chosenAlternative.name];
}];
[levelDifficulty use:@"easy"];
Question levelDifficulty = UnityGameTune.createQuestion(
        "level_difficulty",
        new String[]{"easy", "medium", "hard"},
        AnswerType.ALWAYS_NEW,
        (Answer answer) -> setLevelDifficulty(answer.getValue())
);

levelDifficulty.use("easy");

Reward

Reward event is used for reporting that user did the desired action that is the optimization goal of the question. GameTune learns to maximize your optimization goal using data from Rewards and their attributes. For retention optimization reward is collected automatically when Initialize is called, so sending a Reward is not required. The reward is connected to a Question in the GameTune dashboard.

For some use cases, it’s expected that a user does a positive action (reward) multiple times during a period of time. This time period is called reward window, and it starts when a Question is used. The goal of the model is to maximize the sum of rewards over the duration of the reward window.

Example 1

For example if you want GameTune to optimize the probability of the user clicking a button, then call a Reward Event for a button click.

GameTune.RewardEvent("button_clicked");
[UnityGameTune rewardEvent:@"button_clicked"];
UnityGameTune.rewardEvent("button_clicked");

Example 2

This second example is about optimizing which item to display to the user, to optimize for revenue. In this case your target metric might be the total dollar value of IAP purchases. When user sees the offer, call Use and add the following two reward events.

Every time when the user clicks the offer (small reward when plannit to buy)

GameTune.RewardEvent("offer_clicked", attributes);
[UnityGameTune rewardEvent:@"offer_clicked" withAttributes:attributes];
UnityGameTune.rewardEvent("offer_clicked", attributes);

Once a successful purchase was made, report large reward

GameTune.RewardEvent("offer_purchased", attributes);
[UnityGameTune rewardEvent:@"offer_purchased" withAttributes:attributes];
UnityGameTune.rewardEvent("offer_purchased", attributes);

Optimization targets

Optimization target for each question is defined in the GameTune dashboard. When you create a new question, select one of the optimization target presets.

Question type selection

Custom questions

For more detailed control of the optimization target you can select the custom question type. First select if you want to optimize for retention, or reward (such as revenue).

Retention optimization

For retention optimization you can select which day you want the user to come back and what is the window i.e. if you select 3 for the day and window is 2, positive reward is calculated for users that came back on day 3, 4 or 5.

Revenue optimization strategies

Select revenue as your optimization target if you want to maximize revenue or other numeric value, e.g., number of ads watched, or in-game currency spent.

Configure reward events

Click on “Add reward” and make sure that reward name matches to what you have in your game code. Depending on your optimization strategy, you may need to add attribute to the reward that describes its value. For example in a case where your reward includes a price:

var attributes = new Dictionary<string, object>() { { "price", 0.99 } };
GameTune.RewardEvent("purchase", attributes);

You should configure the reward event in the dashboard like this:

Reward configuration

User attributes

The GameTune SDK automatically collects information about the player’s context: platform, device, language, country, time of the day, etc. In addition, you can provide information of what the user has done in your game through user attributes, and with that data the GameTune ML algorithm can make better decisions.

User attributes are describing the player’s current state in game and what they have done in the past. It should have information that may help the GameTune machine learning model to differentiate players from each other and that may be relevant for the question asked.

User attributes are a collection of key-value pairs, where values may be numbers, booleans or strings. Calling SetUserAttributes will override values for the existing attributes. The GameTune SDK attaches the stored user attributes to every event, so set user attributes right before asking questions, sending use and reward events so they reflect the current state of the user.

Examples of useful user attributes

describe the player, how they’ve played

in-game resources

describe how good the player is, how they play

what you know about the player from other sources & social

User data that GameTune automatically collects

Set all attributes

Dictionary<string, object> userAttributes = new Dictionary<string, object>()
{
    { "coins", 3500 },
    { "kills", 323 },
    { "deaths", 257 },
    { "win_lose_ratio", 1.23 }
};

GameTune.SetUserAttributes(userAttributes);
NSMutableDictionary *userAttributes = [[NSMutableDictionary alloc] init];
[userAttributes setValue:[NSNumber numberWithInteger:3500] forKey:@"coins"];
[userAttributes setValue:[NSNumber numberWithInteger:323] forKey:@"kills"];
[userAttributes setValue:[NSNumber numberWithInteger:257] forKey:@"deaths"];
[userAttributes setValue:[NSNumber numberWithDouble:1.23] forKey:@"win_lose_ratio"];
[UnityGameTune setUserAttributes:userAttributes];
Map<String, Object> userAttributes = new HashMap<String, Object>();
userAttributes.put("coins", 3500);
userAttributes.put("kills", 323);
userAttributes.put("deaths", 257);
userAttributes.put("win_lose_ration", 1.3);

UnityGameTune.setUserAttributes(userAttributes);

Or add each Key-Value pair separately

GameTune.SetUserAttribute("total_xp", 24550);
GameTune.SetUserAttribute("control_scheme", "tactical");
GameTune.SetUserAttribute("fb_login", false);
[UnityGameTune setUserAttribute:[NSNumber numberWithInteger:24550] forKey:@"total_xp"];
[UnityGameTune setUserAttribute:@"control_scheme" forKey:@"tactical"];
[UnityGameTune setUserAttribute:@"false" forKey:@"fb_login"];
UnityGameTune.setUserAttribute("total_xp", 24550);
UnityGameTune.setUserAttribute("control_scheme", "tactical");
UnityGameTune.setUserAttribute("fb_login", false);

Analytics

Not every GameTune API call leads to an actual network request. Device may be offline, the API call can be redundant (in case of consecutive calls, for example) or the service is temporary unavailable. In order to keep events in sync and avoid potential discrepancies in data, you can implement GameTune Event Listener and log events in an analytics service of your choice.

public interface IEventListener
{
    void OnAppStartEvent(AppStartEvent appStartEvent);
    void OnUseEvent(UseEvent useEvent);
    void OnRewardEvent(RewardEvent rewardEvent);
    void OnQuestionEvent(QuestionEvent questionEvent);
}
@protocol UGTEventListener<NSObject>

- (void)onAppStartEvent:(UGTAppStartEvent *)appStartEvent;

- (void)onUseEvent:(UGTUseEvent *)useEvent;

- (void)onRewardEvent:(UGTRewardEvent *)rewardEvent;

- (void)onQuestionEvent:(UGTQuestionEvent *)questionEvent;

@end
public interface IUnityGameTuneEventListener {

    void onAppStartEvent(AppStartEvent appStartEvent);
    void onUseEvent(UseEvent useEvent);
    void onRewardEvent(RewardEvent rewardEvent);
    void onQuestionEvent(QuestionEvent questionEvent);
}

Event Listener callback methods are invoked when the given event has passed validation and reached GameTune server. It contains the precise information that GameTune records and uses in it’s predictions. There are four types of events.

AppStartEvent

AppStartEvent is sent when GameTune SDK has been successfully initialized. It can happen only once per game session.

Properties:

appStartEvent.UnityProjectId;
[appStartEvent unityProjectId];
appStartEvent.getUnityProjectId();

UseEvent

UseEvent is sent when Use is called and:

Properties:

useEvent.UnityProjectId;
useEvent.AnswerId;
useEvent.QuestionName;
useEvent.TreatmentGroup;
useEvent.AnswerSignature;
useEvent.ChosenAlternative;
[useEvent unityProjectId];
[useEvent answerId];
[useEvent questionName];
[useEvent treatmentGroup];
[useEvent answerSignature];
[useEvent chosenAlternative];
useEvent.getUnityProjectId();
useEvent.getAnswerId();
useEvent.getQuestionName();
useEvent.getTreatmentGroup();
useEvent.getAnswerSignature();
useEvent.getChosenAlternative();

RewardEvent

RewardEvent is sent every time the RewardEvent method is called and event reached GameTune.

Properties:

rewardEvent.UnityProjectId;
rewardEvent.Name;
Dictionary<string, object> attributes = rewardEvent.Attributes;
[rewardEvent unityProjectId];
[rewardEvent name];
NSDictionary *attributes = [rewardEvent attributes];
rewardEvent.getUnityProjectId();
rewardEvent.getName();
Map<String, Object> attributes = rewardEvent.getAttributes();

QuestionEvent

QuestionEvent is sent if the Answer to the given Question wasn’t yet used in case of NEW_UNTIL_USED or every time in case ALWAYS_NEW.

Properties:

questionEvent.UnityProjectId;
questionEvent.Name;
Alternative[] alternatives = questionEvent.Alternatives;
[questionEvent unityProjectId];
[questionEvent name];
NSArray *alternatives = [questionEvent alternatives];
questionEvent.getUnityProjectId();
questionEvent.getName();
Alternative[] attributes = questionEvent.getAlternatives();

Examples

Game speed

A game designer has noticed that many users are churning during the game’s first level which is currently using medium speed configuration. At the same time they’ve received feedback that game is too easy in the beginning for players that have played similar games in the genre.

The current version of game speed settings is called medium. The designer creates a new variant fast, which is meant for players that are familiar with the genre. They also created a slow settings which make the game play really easy and slow.

By using GameTune with Unity data about the new player, the hypothesis is that we can select the optimal first time experience for each user. The ultimate goal for this experiment is to increase the average day one retention, meaning that more players should come back to the game next day. The optimization goal is set in the GameTune dashboard by selecting “retention, day 1” as the optimization target.

Initialize GameTune

The first thing you do is to initialize GameTune when the game starts:

using UnityEngine.GameTune;
GameTune.Initialize(projectId);

This initialization call also signals to GameTune that the player played the game, so GameTune gets user retention information.

Ask speed question

Next the different variants of the tutorial are defined:

  Question speed = GameTune.CreateQuestion (
      "speed",
      new string[]{ "medium", "fast", "slow" },
      MyAnswerHandler
  );

Notice that here “medium” was listed first. That means that “medium” is the default experience player gets e.g. if they don’t have network or have opted out for personalization. After the question is defined, the question which speed to use for the current player is asked:

GameTune.AskQuestions(speed);

Handle answer

Next MyAnswerHandler is implemented, which contains the actual logic for selecting either the ‘fast’, ‘medium’ or ‘slow’ speed for the first level:

  private void MyAnswerHandler(Answer answer)
  {
      // do something with the answer.Value
  }

Use answer

It’s also important to inform GameTune when the player starts the tutorial, so that GameTune knows that the answer it provided now actually changed the game experience. This is done by “using” the answer:

answer.Use();

Release the game

After the game with GameTune integration is released, GameTune doesn’t yet have any data about how different speeds work. Until enough data is collected, GameTune randomly selects which speed to use - this is called exploration. Once enough data is collected, a machine learning model is trained automatically and the optimization starts.

Certain group of users will always get the default experience, which is medium in this case. This is called the control treatment group, and it can be used to measure the performance of users in this group vs. those that are in the ML optimized group. As more users play the game, the ML model is continuously trained with more data, and the model becomes more accurate and can select the optimal level difficulty for users with similar attributes.

In-app Purchase offer

The game has coins that players can use to buy in-game items. There’s three available special deals: the user can purchase either 100 coins for $0.99, 500 coins for $2.99 or 1000 coins for $4.99. There’s a modal that is displayed once per day to promote one of these deals. But which one of these offers should be shown to the user? We can use GameTune to maximize the revenue, by selecting the optimal offer that leads to more dollars per user.

GameTune was already initialized when the game added the tutorial optimization, so the first thing to do is to create new experience in the GameTune dashboard, with optimization target set to “revenue”.

Ask offer question

Next the different variants of the offer are defined:

var smallOffer = new Alternative("small", new Dictionary<string, object>()
{
    { "price", 0.99 },
    { "coins", 100 },
});
var midOffer = new Alternative("mid", new Dictionary<string, object>()
{
    { "price", 2.99 },
    { "coins", 500 },
});
var largeOffer = new Alternative("large", new Dictionary<string, object>()
{
    { "price", 4.99 },
    { "coins", 1000 },
});
Question offer = GameTune.CreateQuestion (
    "iap_offer",
    new Alternative[] { smallOffer, midOffer, largeOffer },
    OfferAnswerHandler
);

After this, the question which offer to show to the user is asked before the offer modal is about to be shown:

GameTune.AskQuestions(offer);

Handle offer answer

OfferAnswerHandler should be implemented similarly:

  private void OfferAnswerHandler(Answer answer)
  {
    // Offer to be shown should be equal to answer.Value and show the modal.
    // After modal is shown, call answer.Use().
  }

Send reward

If the user purchases the offer, this information should be sent to GameTune via a Reward event:

var attributes = new Dictionary<string, object>() { { "price", 0.99 } };
GameTune.RewardEvent("purchase", attributes);

This way GameTune can learn which offer is optimal for each user to maximize “price” per user.

Configure the optimization target

Go to the GameTune dashboard and add the question. Select revenue as the goal for optimization. In the next step select IAP offer.

Maximizing ad impressions

GameTune can be utilized to maximize ad impressions the user is exposed to while balancing churn. This could be done for example by choosing how often an interstitial advertisement is shown.

Ask ad frequency questions

In this example we choose how often the ad is displayed, every means that ad is shown after every level, every_3rd means every 3rd level and every_5th after every 5th level.

  Question adFrequency = GameTune.CreateQuestion (
      "ad_frequency",
      new string[]{ "every", "every_3rd", "every_5th" },
      MyAnswerHandler
  );

Ad frequency question is asked every time the user starts the game. answer.Use() should be called before the user sees the first ad.

Send rewards

Once the user watches an ad, send the reward ad_watched. This way GameTune learns to select the alternative that maximizes the number of ad watches over 7 days. GameTune also learns to minimize churn, as a user churning would lead to no more rewards from that user.

GameTune.RewardEvent("ad_watched");

Configure the optimization target

Go to the GameTune dashboard and add the question. Select revenue as the goal for optimization. In the next step select Ad impressions.

Customize UI

One of the designers has made different variations of the in-game notification promoting a new game feature, and wants to optimize for conversion of clicking the notification. The optimization goal is selected as conversion in the dashboard.

Ask notification question

There’s different combinations of the copy text and positioning of the notification. The different alternatives of the offer are defined:

var trNewCopy = new Alternative("tr_new", new Dictionary<string, object>()
{
    { "position", "top-right" },
    { "copy", "new" },
});
var trOldCopy = new Alternative("tr_old", new Dictionary<string, object>()
{
    { "position", "top-right" },
    { "copy", "old" },
});
var brNewCopy = new Alternative("br_new", new Dictionary<string, object>()
{
    { "position", "bottom-right" },
    { "copy", "new" },
});
var brOldCopy = new Alternative("br_old", new Dictionary<string, object>()
{
    { "position", "bottom-right" },
    { "copy", "old" },
});

Question notification = GameTune.CreateQuestion (
    "notification",
    new Alternative[] { trOldCopy, trNewCopy, brNewCopy, brOldCopy },
    MyAnswerHandler
);

The question of which notification to show to the user is asked when the game starts. Thus it can be combined with the difficulty question:

GameTune.AskQuestions(difficulty, notification);

Handle answer

MyAnswerHandler should be implemented similarly:

private void MyAnswerHandler(Answer answer)
{
  // Notification to be shown should be equal to answer.Value, for example "br_new".
}

Use answer

When the notification is shown to the user, call answer.Use(), so GameTune knows that the answer was actually displayed to the user.

answer.Use();

Send reward

If the user clicks on the notification, a reward should be sent:

GameTune.RewardEvent("notification_clicked");

This creates a feedback loop and GameTune learns to show the optimal notification for each user to maximize the conversion of opening the notification.

Full example code

Full example on how to use GameTune

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using UnityEngine.GameTune;

public class LevelManager : MonoBehaviour {

    private const string UnityProjectId = "Your unity project id here";
    private Question _levelDifficultyQuestion;
    private Question _buttonLabelQuestion;
    private Answer _levelDifficultyAnswer;
    private Answer _buttonLabelAnswer;
    private string _levelDifficulty;

    private int _levelsStarted;

    // button that executes StartLevel()
    public Button PlayButton;
    public Text PlayButtonText;

    void Start ()
    {
        PlayButton.interactable = false;

        _levelDifficultyQuestion = GameTune.CreateQuestion(
            "levelDifficulty",
            new string[]{ "easy", "medium", "hard" },
            LevelDifficultyAnswerHandler
        );

        _buttonLabelQuestion = GameTune.CreateQuestion(
            "buttonLabel",
            new string[]{ "Start", "Begin", "Play" },
            ButtonLabelAnswerHandler
        );

        Dictionary<string, object> userAttributes = new Dictionary<string, object>()
        {
            { "coins", 3500 },
            { "kills", 323 },
            { "deaths", 257 },
            { "win_lose_ratio", 1.23 },
            { "fb_login", false },
            { "control_scheme", "tactical" }
        };

        GameTune.SetUserAttributes(userAttributes);

        InitializeOptions initOptions = new InitializeOptions();
        initOptions.SetPrivacyConsent(true);
        initOptions.SetUserId("123G-A67D-FY8G");

        GameTune.Initialize(UnityProjectId, initOptions);
        GameTune.AskQuestions(_levelDifficultyQuestion, _buttonLabelQuestion);
    }

    private void LevelDifficultyAnswerHandler(Answer answer)
    {
        string lvlDifficultySignature = answer.Signature;
        _levelDifficultyAnswer = answer;
        _levelDifficulty = "difficulty_" + _levelDifficultyAnswer.Value;
    }

    private void ButtonLabelAnswerHandler(Answer answer)
    {
        string btnLabelSignature = answer.Signature;
        _buttonLabelAnswer = answer;

        PlayButtonText.text = _buttonLabelAnswer.Value;

        // button label changed according to the answer, notifying GameTune
        _buttonLabelAnswer.Use();
        PlayButton.interactable = true;
    }

    public void StartLevel()
    {
        // update levels started attribute. Will be used when asking questions next time.
        _levelsStarted += 1;
        GameTune.SetUserAttribute("levels_started", _levelsStarted);

        if (_levelDifficultyAnswer != null)
        {
            // set level difficulty
            // ...
            // level difficulty set according to the answer, notifying GameTune
            _levelDifficultyAnswer.Use();
        }

        SceneManager.LoadScene("level10");
    }
}
// .h file
#import <UIKit/UIKit.h>
#import <UnityGameTune/UnityGameTune.h>

@interface ViewController : UIViewController

@end
// .m file
#import "ViewController.h"

@interface ViewController ()

@property (nonatomic, strong) UGTQuestion *buttonLabel;
@property (nonatomic, strong) UGTQuestion *levelDifficulty;
@property (nonatomic, strong) UGTAnswer *buttonLabelAnswer;
@property (nonatomic, strong) UGTAnswer *levelDifficultyAnswer;

@property (weak, nonatomic) IBOutlet UIButton *startButton;

@end

static int _levelsStarted;

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Create questions. Do this only once for each question.
    self.buttonLabel = [UnityGameTune createQuestion:@"button_label" alternatives:@[@"Start", @"Begin", @"Play"] handler:^(UGTAnswer *answer) {
        self.buttonLabelAnswer = answer;
        // set the button label, and send use event
        [self.startButton setTitle:answer.chosenAlternative.name forState:UIControlStateNormal];
        [self.buttonLabelAnswer use];
    }];
    self.levelDifficulty = [UnityGameTune createQuestion:@"level_difficulty" alternatives:@[@"easy", @"medium", @"hard"] handler:^(UGTAnswer *answer) {
        // Save answer for later use
        self.levelDifficultyAnswer = answer;
    }];

    // Set user attributes
    NSMutableDictionary *userAttributes = [[NSMutableDictionary alloc] init];
    [userAttributes setValue:[NSNumber numberWithInteger:3500] forKey:@"coins"];
    [userAttributes setValue:[NSNumber numberWithInteger:323] forKey:@"kills"];
    [userAttributes setValue:[NSNumber numberWithInteger:257] forKey:@"deaths"];
    [userAttributes setValue:[NSNumber numberWithDouble:1.23] forKey:@"win_lose_ratio"];
    [userAttributes setValue:[NSNumber numberWithBool:NO] forKey:@"fb_login"];
    [userAttributes setValue:@"tactical" forKey:@"control_scheme"];
    [UnityGameTune setUserAttributes:userAttributes];

    // Initialize
    [UnityGameTune initialize:@"your unity project id here"];

    // Ask questions. Will get answers once GameTune is initialized.
    [UnityGameTune askQuestions:self.buttonLabel, self.levelDifficulty, nil];
}

- (IBAction)buttonTapped:(id)sender {
    // Use levelDifficultyAnswer to start level
    [self startLevel:self.levelDifficultyAnswer.chosenAlternative.name];
}

- (void)startLevel:(NSString *)difficulty {
    // Update levels started attribute
    _levelsStarted += 1;
    [UnityGameTune setUserAttribute:[NSNumber numberWithInteger:_levelsStarted] forKey:@"levels_started"];

    // Send use event for levelDifficultyAnswer
    [self.levelDifficultyAnswer use];

    // start the level with difficulty
}

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}

@end
package com.unity3d.gametune.app;

import com.unity3d.gametune.Answer;
import com.unity3d.gametune.Question;
import com.unity3d.gametune.UnityGameTune;
import com.unity3d.gametune.misc.Utilities;

import android.app.Activity;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.view.View;
import android.widget.Button;

public class UnityGameTuneApp extends Activity {
    final private String unityProjectId = "your unity project id here";

    private Answer levelDifficultyAnswer;
    private Answer buttonLabelAnswer;

    // setting Alternatives for Questions
    final private String[] levelDifficultyAlternatives = new String[] {
        "short",
        "medium",
        "hard"
    };

    final private String[] buttonLabelAlternatives = new String[] {
        "Start",
        "Begin",
        "Play"
    };

    // setting Questions
    private Question levelDifficulty = UnityGameTune.createQuestion("levelDifficulty",
        levelDifficultyAlternatives, (Answer answer) -> handleDifficulty(answer));

    private Question buttonLabel = UnityGameTune.createQuestion("buttonLabel",
        buttonLabelAlternatives, (Answer answer) -> handleButton(answer));

    // setting Answer handlers for Questions
    private void handleDifficulty(Answer answer) {
        String difficultySignature = answer.getSignature();
        levelDifficultyAnswer = answer;
    }

    private void handleButton(Answer answer) {
        String buttonLabelSignature = answer.getSignature();
        buttonLabelAnswer = answer;
        Button levelStartButton = (Button) findViewById(R.id.unity_start_button);

        // setting button label according to the answer from GameTune
        levelStartButton.setText(buttonLabelAnswer.getValue());

        Utilities.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                levelStartButton.setEnabled(true);
            }
        });

        // button label changed according to the answer, notifying GameTune
        buttonLabelAnswer.use();

        levelStartButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                // user clicked the button, sending reward event
                UnityGameTune.rewardEvent("button_clicked");
                startLevel(levelDifficultyAnswer);
            }
        });
    }

    private void startLevel(Answer difficultyAnswer) {
        // checking if answer actually exists
        if (difficultyAnswer != null) {
            // do something that determines level difficulty
            // ...
            difficultyAnswer.use();
        } else {
            // do something that determines default level difficulty
            // ...
        }
        // start level and execute after it completed:
        // Map<String, Object> attributes = new HashMap<>();
        // attributes.put("number", 5);
        // attributes.put("moves", 10);
        // attributes.put("completion_time", 24);
        // UnityGameTune.rewardEvent("level_completed", attributes);
    }

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.unity_gametune_test_layout);

        InitializeOptions initOptions = new InitializeOptions();
        initOptions.setPrivacyConsent(true);

        // your internal User ID
        initOptions.setUserId("2345-34-5353");

        // initializing GameTune SDK and asking Questions
        UnityGameTune.initialize(this, unityProjectId, initOptions);

        Map<String, Object> userAttributes = new HashMap<String, Object>();
        userAttributes.put("coins", 3500);
        userAttributes.put("kills", 323);
        userAttributes.put("deaths", 257);
        userAttributes.put("win_lose_ration", 1.3);
        userAttributes.put("fb_login", false);
        userAttributes.put("control_scheme", "tactical");
        UnityGameTune.setUserAttributes(userAttributes);

        UnityGameTune.askQuestions(levelDifficulty, buttonLabel);
    }

    @Override
    protected void onResume() {
        super.onResume();
    }
}

Unity example game

Modified version of the familiar EndlessRunnerSampleGame and the guide on how GameTune can be integrated into a game like this.

Example game - a GitHub repository containing the game’s source code

Integration guide - a wiki page containing the iformation about the approach and technical details of GameTune integration

Release notes

v2.4.0

v2.3.1

v2.3.0

v2.2.0

v2.1.2

v2.0.0

v1.9.4

v1.9.0

v1.8.2

v1.8.1

v1.8.0

v1.7.1

v1.7.0