Use case sample: Tune game difficulty level for individual player

This sample shows how you can use Triggers to tune your game difficulty level based on player data in Cloud Save and scores in Leaderboards.

Note: This sample uses a Cloud Code module due to the complexity of the use case.

The sample uses the key-saved event emitted by the Cloud Save service, the score-submitted event emitted by the Leaderboards service, and the signed-up event emitted by the Authentication service.

The sample uses the player data in Cloud Save to track the player's progress, and uses the scores in Leaderboards to track the player's skill level. The sample uses the player's progress and skill level to tune the individual game difficulty level for the player.

This sample contains multiple triggers that affect the game difficulty level in different ways:

  • Increase the game difficulty level when the player submits a high score.
  • Increase the game difficulty level when the player has played a certain number of sessions.
  • Increase the game difficulty level when the player has unlocked a certain number of achievements.
  • Increase the game difficulty level when the player has played a certain number of encounters.
  • Increase the game difficulty level when the player has successfully completed a certain number of challenges.
  • Increase the game difficulty level when the player has earned a certain number of experience points.
  • Decrease the game difficulty level when the player has failed a certain number of challenges.

The trigger uses a filter to evaluate the event payload to only trigger the Cloud Code logic when certain Cloud Save keys update.

Note: You can't create triggers with filters through the UGS CLI. This sample uses the Triggers Admin API to create the triggers.

Prerequisites

First, you need to create a service account with the required access roles.

Authenticate using a Service Account

Before you can call the Triggers service, you must authenticate using a Service Account.

  1. Navigate to the Unity Cloud Dashboard.
  2. Select Administration > Service Accounts.
  3. Select the New button and enter a name and description for the Service Account.
  4. Select Create.

Add Product roles and create a key:

  1. Select Manage product roles.
  2. Add the following roles to the Service Account:
    • From the LiveOps dropdown, select Triggers Configuration Editor and Triggers Configuration Viewer.
    • From the Admin dropdown, select Unity Environments Viewer.
  3. Select Save.
  4. Select Add Key.
  5. Encode the Key ID and Secret key using base64 encoding. The format is “key_id:secret_key”. Note this value down.

For more information, refer to Authentication.

Set up Leaderboards

To submit scores, you need to set up a Leaderboard. If you haven't used Leaderboards before, refer to Get started with Leaderboards.

You can set up a Leaderboard using Unity Cloud Dashboard.

  1. Navigate to the Unity Cloud Dashboard.
  2. Select Leaderboards.
  3. Select Add Leaderboard.
  4. Enter a name for the Leaderboard.
  5. Configure the Leaderboard settings.
  6. Select Finish.

Note down the Leaderboard ID.

Examine events

The use case uses the following events:

The events pass the event payload to Cloud Code as parameters.

Cloud Save: Key Saved

The Cloud Save service emits the key-saved event when a key updates in Cloud Save.

The event payload contains the following information:

{
  "id": "7LpyhpsIvEczGkDI1r8J6gHhHezL",
  "idType": "player",
  "key": "LEVEL",
  "value": 1,
  "valueIncluded": true,
  "writeLock": "7b8920a57912509f6b5cbb183eb7fcb0",
  "accessClass": "default",
  "modifiedDate": "2021-03-04T09:00:00Z"
}

For more information, refer to Cloud Save: Key Saved.

Leaderboards: Score Submitted

Leaderboards emits the score-submitted event when a score is submitted to a Leaderboard.

The event payload contains the following information:

{
  "leaderboardId": "leaderboard",
  "updatedTime": "2019-08-24T14:15:22Z",
  "playerId": "5drhidte8XgD4658j2eHtSljIAzd",
  "playerName": "Jane Doe",
  "rank": 42,
  "score": 120.3,
  "tier": "gold"
}

For more information, refer to Leaderboards: Score Submitted.

Authentication: Signed up

The Authentication service emits the signed-up event when a player signs up.

The event payload contains the following information:

{
  "playerId": "string",
  "providerId": "string",
  "createdAt": "string"
}

For more information, refer to Authentication: Signed up.

Initialize Cloud Save data for new players on sign-up

To track player progress, you need to initialize the Cloud Save data for new players. To initialize the data, you can use the Authentication: Signed up event and trigger a Cloud Code module endpoint.

Create a Cloud Code module containing the function that initializes the Cloud Save data for new players:

Define a class to store player data

Create a Config class to store the player's data in Cloud Save.

C#

using Newtonsoft.Json;

namespace TuneGameDifficulty;

/**
 * Config class is used to define the data structure of the player's data in Cloud Save.
 * The PlayerConfig class contains all the player's data. Any new player data should be added to this class.
 * The DifficultySettings class defines predefined difficulty levels and their corresponding settings for global game difficulty.
 *
 * The player is initialized with player data and default difficulty settings on Authentication.
 * However, the player can manually change the difficulty level at any time, and the settings will be updated accordingly, with the player's data reset.
 *
 * When the player's difficulty settings are modified by the use of Triggers, the values will not be accurate to the base settings defined in this class.
 * The default values for each difficulty levels are used as a base to calculate the new values.
 */
public abstract class Config
{

    // The DifficultySettings class defines the settings for each difficulty level.
    // You can use this class to define your own difficulty levels and their corresponding settings.
    public class DifficultySettings
    {
        [JsonProperty("EnemySpawnRate")] public double? EnemySpawnRate { get; set; }

        [JsonProperty("EnemyHealthMultiplier")]
        public double? EnemyHealthMultiplier { get; set; }

        [JsonProperty("ChallengeRewardsMultiplier")]
        public double? ChallengeRewardsMultiplier { get; set; }

        [JsonProperty("CurrencyRewardsMultiplier")]
        public double? CurrencyRewardsMultiplier { get; set; }

        [JsonProperty("TimeLimitMultiplier")] public double? TimeLimitMultiplier { get; set; }
    }

    // The DifficultyLevel enum defines the available difficulty levels for the game.
    public enum DifficultyLevel
    {
        VeryEasy,
        Easy,
        Medium,
        Hard,
        VeryHard,
        Custom
    }

    // The GlobalSettings dictionary defines the default settings for each difficulty level in the game.
    // The modifiers allow to increase or decrease the difficulty of the game.
    private static readonly Dictionary<DifficultyLevel, DifficultySettings> GlobalSettings = new()
    {
        {
            DifficultyLevel.VeryEasy, new DifficultySettings
            {
                EnemySpawnRate = 0.3,
                EnemyHealthMultiplier = 0.6,
                ChallengeRewardsMultiplier = 0.6,
                CurrencyRewardsMultiplier = 1.5,
                TimeLimitMultiplier = 2
            }
        },
        {
            DifficultyLevel.Easy, new DifficultySettings
            {
                EnemySpawnRate = 0.5,
                EnemyHealthMultiplier = 0.8,
                ChallengeRewardsMultiplier = 0.8,
                CurrencyRewardsMultiplier = 1.2,
                TimeLimitMultiplier = 1.5
            }
        },
        {
            DifficultyLevel.Medium, new DifficultySettings
            {
                EnemySpawnRate = 1,
                EnemyHealthMultiplier = 1,
                ChallengeRewardsMultiplier = 1,
                CurrencyRewardsMultiplier = 1,
                TimeLimitMultiplier = 1
            }
        },
        {
            DifficultyLevel.Hard, new DifficultySettings
            {
                EnemySpawnRate = 1.5,
                EnemyHealthMultiplier = 1.2,
                ChallengeRewardsMultiplier = 1.2,
                CurrencyRewardsMultiplier = 0.8,
                TimeLimitMultiplier = 0.7
            }
        },
        {
            DifficultyLevel.VeryHard, new DifficultySettings()
            {
                EnemySpawnRate = 2,
                EnemyHealthMultiplier = 1.5,
                ChallengeRewardsMultiplier = 1.5,
                CurrencyRewardsMultiplier = 0.5,
                TimeLimitMultiplier = 0.5
            }
        }
    };

    private const DifficultyLevel DefaultDifficulty = DifficultyLevel.VeryEasy;
    private const int DefaultValue = 0;

    // The PlayerConfig class defines the player's data structure in Cloud Save.
    // Any new player data should be added to this class.
    public class PlayerConfig
    {
        public long SessionsPlayed { get; set; } = DefaultValue;
        public long UnlockedAchievements { get; set; } = DefaultValue;
        public long EncounterFrequency { get; set; } = DefaultValue;
        public long CompletedChallenges { get; set; } = DefaultValue;
        public long FailedChallenges { get; set; } = DefaultValue;
        public long ExperiencePoints { get; set; } = DefaultValue;


        [JsonProperty("GameDifficulty")]
        public DifficultySettings GameDifficulty { get; set; }

        // Default constructor with optional parameter
        public PlayerConfig(DifficultyLevel difficulty = DefaultDifficulty)
        {
            GameDifficulty = GlobalSettings[difficulty];
        }

        public PlayerConfig(DifficultySettings difficultySettings)
        {
            GameDifficulty = difficultySettings;
        }
    }

}

The class contains a PlayerConfig class that defines the player's data structure in Cloud Save.

Define a module endpoint to initialize Cloud Save data

Create a TuneGameDifficulty class to initialize the Cloud Save data for new players. The class contains a function InitializeNewPlayer that is triggers when a player signs up. The class relies on the Config class to initialize the player's data in Cloud Save.

C#

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Unity.Services.CloudCode.Core;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Shared;
using Unity.Services.CloudSave.Model;

namespace TuneGameDifficulty
{
    public class TuneGameDifficulty
    {
        private readonly ILogger<TuneGameDifficulty> _logger;

        public TuneGameDifficulty(ILogger<TuneGameDifficulty> logger)
        {
            _logger = logger;
        }

        /**
         * InitializeNewPlayer is called when a new player is created.
         * It initializes the player's data in Cloud Save with default values and is called on Authentication sign-up event.
         */
        [CloudCodeFunction("InitializeNewPlayer")]
        public async Task InitializeNewPlayer(IExecutionContext ctx, IGameApiClient gameApiClient, string playerId)
        {
            try
            {
                var response = await gameApiClient.CloudSaveData.GetItemsAsync(ctx, ctx.ServiceToken, ctx.ProjectId, playerId);
                if (response.Data.Results.Count == 0)
                {
                    var dataToSave = new Config.PlayerConfig();
                    var items = dataToSave.GetType().GetProperties().Select(property => new SetItemBody(property.Name, property.GetValue(dataToSave)!)).ToList();
                    await gameApiClient.CloudSaveData.SetItemBatchAsync(ctx, ctx.ServiceToken, ctx.ProjectId, playerId, new SetItemBatchBody(items));

                    _logger.LogInformation("The player {id} has been initialized with default values.", playerId);
                }
            }
            catch (ApiException e)
            {
                _logger.LogError("Failed to initialize player data for player {playerId} in Cloud Save. Error: {Error}", playerId, e);
                throw new Exception($"Failed to initialize {playerId} data in Cloud Save. Error: {e.Message}");
            }
        }

        public class ModuleConfig : ICloudCodeSetup
        {
            public void Setup(ICloudCodeConfig config)
            {
                config.Dependencies.AddSingleton(GameApiClient.Create());
            }
        }
    }
}

Now you have a module that can trigger when a player signs up.

Configure triggers

Now that you have a module to initialize the Cloud Save data for new players, you can configure the triggers.

Create a trigger_configurations.json file to store the trigger configurations for deployment.

Initialize Cloud Save data for new players on sign-up

Add a trigger configuration to the trigger_configurations.json file to trigger the InitializeNewPlayer function when a player signs up:

[
  {
    "name": "initialize-player-data-on-signup",
    "eventType": "com.unity.services.player-auth.signed-up.v1",
    "actionType": "cloud-code",
    "actionUrn": "urn:ugs:cloud-code:TuneGameDifficulty/InitializeNewPlayer"
  }
]

This configuration consumes the Authentication: Signed up event emitted by the Authentication service.

Refer to Use case sample: Initialize Cloud Save data for new players on sign-up for more information.

Tune game difficulty level

To tune the game difficulty level, you need to create multiple triggers that are triggered by different events. The triggers use filters to evaluate the event payload to only trigger the Cloud Code logic when the defined filter criteria are met.

You can customize the filter criteria based on your game design. For instance, you can trigger Cloud Save events only on certain value ranges.

Add the following trigger configurations to the trigger_congigurations.json file:

[
  {
    "name": "increase-difficulty-on-high-skill",
    "eventType": "com.unity.services.leaderboards.score-submitted.v1",
    "actionType": "cloud-code",
    "actionUrn": "urn:ugs:cloud-code:TuneGameDifficulty/TriggerOnHighSkill",
    "filter": "data['score'] > 1000 && data['score'] < 5000"
  },
  {
    "name": "increase-difficulty-on-sessions-played",
    "eventType": "com.unity.services.cloud-save.key-saved.v1",
    "actionType": "cloud-code",
    "actionUrn": "urn:ugs:cloud-code:TuneGameDifficulty/TriggerOnSessionsPlayed",
    "filter": "data['key']=='SessionsPlayed' && data['value'] > 0"
  },
  {
    "name": "increase-difficulty-on-achievement-unlocks",
    "eventType": "com.unity.services.cloud-save.key-saved.v1",
    "actionType": "cloud-code",
    "actionUrn": "urn:ugs:cloud-code:TuneGameDifficulty/TriggerOnAchievements",
    "filter": "data['key']=='UnlockedAchievements' && data['value'] > 0"
  },
  {
    "name": "increase-difficulty-on-encounter-frequency",
    "eventType": "com.unity.services.cloud-save.key-saved.v1",
    "actionType": "cloud-code",
    "actionUrn": "urn:ugs:cloud-code:TuneGameDifficulty/TriggerOnEncounterFrequency",
    "filter": "data['key']=='EncounterFrequency' && data['value'] > 0"
  },
  {
    "name": "increase-difficulty-on-winning-challenges",
    "eventType": "com.unity.services.cloud-save.key-saved.v1",
    "actionType": "cloud-code",
    "actionUrn": "urn:ugs:cloud-code:TuneGameDifficulty/TriggerOnChallengeCompletion",
    "filter": "data['key'] == 'CompletedChallenges' && data['value'] > 0"
  },
  {
    "name": "increase-difficulty-on-experience-points",
    "eventType": "com.unity.services.cloud-save.key-saved.v1",
    "actionType": "cloud-code",
    "actionUrn": "urn:ugs:cloud-code:TuneGameDifficulty/TriggerOnExperiencePoints",
    "filter": "data['key'] == 'ExperiencePoints' && data['value'] > 0"
  },
  {
    "name": "decrease-difficulty-on-failed-challenges",
    "eventType": "com.unity.services.cloud-save.key-saved.v1",
    "actionType": "cloud-code",
    "actionUrn": "urn:ugs:cloud-code:TuneGameDifficulty/TriggerOnChallengeFailure",
    "filter": "data['key'] == 'FailedChallenges' && data['value'] > 0"
  }
]

Deploy triggers

Run the following bash script to create the triggers. Ensure the trigger_configurations.json file is in the same directory as the script.

Note: You can't create triggers with filters through the UGS CLI. This sample uses the Triggers Admin API to create the trigger.

Note: You can also send individual requests to the Triggers Admin API to create the triggers. The API doesn't allow you to create multiple triggers in a single request.

Use the base64 encoded value of the key ID and secret key as the value for the Authorization header.

#!/bin/bash

API_URL='https://services.api.unity.com/triggers/v1/projects/<PROJECT_ID>/environments/<ENVIRONMENT_ID>/configs'
HEADERS=(-H "Content-Type: application/json" -H "Authorization: Basic YOUR_KEY_ID:YOUR_SECRET_KEY")

jq -c '.[]' < trigger_configurations.json | while IFS= read -r line; do
  curl -X POST "$API_URL" "${HEADERS[@]}" --data-raw "$line"
done

Note: The script uses the jq command-line JSON processor to iterate through the trigger configurations in the trigger_configurations.json file. Ensure you have jq installed before you run the script.

Define logic to tune game difficulty level

Create a Cloud Code module containing all the functions that increase the game difficulty level for every trigger.

Add a MultplierConfig class to the module. The class defines the default values for the difficulty modifiers. The sample uses the multipliers to calculate the new values for each difficulty level on triggers. You can use this class to customize the difficulty modifiers based on your game design.

C#

namespace TuneGameDifficulty;

/**
 * MultiplierConfig class is used to store the default values for the difficulty modifiers.
 * The multipliers are used to calculate the new values for each difficulty level on triggers.
 */
public static class MultiplierConfig
{
    public enum ConfigurationSetting
    {
        SessionsPlayed,
        UnlockedAchievements,
        EncounterFrequency,
        CompletedChallenges,
        FailedChallenges,
        ExperiencePoints,
    }

    public static readonly Dictionary<ConfigurationSetting, double> DefaultModifiers = new()
    {
        { ConfigurationSetting.SessionsPlayed, 0.1 },
        { ConfigurationSetting.UnlockedAchievements, 0.2 },
        { ConfigurationSetting.EncounterFrequency, 0.2 },
        { ConfigurationSetting.CompletedChallenges, 0.4 },
        { ConfigurationSetting.FailedChallenges, 0.5 },
        { ConfigurationSetting.ExperiencePoints, 0.6 }
    };

}

Add a class DifficultyTriggerHandler to the module to define the logic for each trigger. The class contains a function for each trigger.

  • The function ties to the event that triggers the function. For example, the TriggerOnHighSkill function ties to the score-submitted event.
  • The function takes the event payload as parameters. For example, the TriggerOnHighSkill function takes the score and playerId as parameters from the score-submitted event payload.
  • The function uses the MultiplierConfig class to calculate the default modifiers for the difficulty settings. For example, the TriggerOnHighSkill function uses the default modifier for the SessionsPlayed key to calculate the new value for the EnemySpawnRate difficulty setting.
  • The function uses the GenerateNewValue helper method to calculate the new values for the difficulty settings. This can be customized based on your game design.

C#

using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;
using Unity.Services.CloudCode.Shared;
using Unity.Services.CloudSave.Model;

namespace TuneGameDifficulty;

/**
 * This class contains the Cloud Code functions that are tied to Trigger events.
 * The functions are associated with the following events:
 * - Leaderboards Score Submitted
 * - Cloud Save Key Saved
 *
 * The functions are associated with the following triggers:
 * - TriggerOnHighSkill is associated with the Leaderboards Score Submitted event.
 * - TriggerOnSessionsPlayed is associated with the Cloud Save Key Saved event.
 * - TriggerOnAchievements is associated with the Cloud Save Key Saved event.
 * - TriggerOnEncounterFrequency is associated with the Cloud Save Key Saved event.
 * - TriggerOnChallengeCompletion is associated with the Cloud Save Key Saved event.
 * - TriggerOnChallengeFailure is associated with the Cloud Save Key Saved event.
 * - TriggerOnExperiencePoints is associated with the Cloud Save Key Saved event.
 */
public class DifficultyTriggerHandler
{
    private const string GameDifficultyKey = "GameDifficulty";
    private readonly ILogger<DifficultyTriggerHandler> _logger;
    public DifficultyTriggerHandler(ILogger<DifficultyTriggerHandler> logger)
    {
        _logger = logger;
    }

    /**
     * Generates a new value for particular difficulty setting based on the default modifier and the new value of the affecting key.
     * - newAffecting value is the new value of the affecting key, for example the new value of SessionsPlayed key when the player plays a new session.
     * - defaultModifierForAffectingValue is the default modifier for the affecting value, for example the default modifier for SessionsPlayed key found in MultiplierConfig.
     * - lastDifficultyValue is the last value of the difficulty setting, for example the last value of EnemySpawnRate.
     * - increaseValue is a boolean that indicates whether the new value should be added or subtracted from the last value.
     *
     * The new value is calculated as follows:
     * - if newAffectingValue for the SessionsPlayed key is 2, the new value for EnemySpawnRate is calculated by adding 2 * 0.1 to the last value of EnemySpawnRate.
     */
    private static Task<double> GenerateNewValue(double newAffectingValue, double defaultModifierForAffectingValue, double lastDifficultyValue, bool increaseValue = true)
    {
        // Ensure the new value is not less than 0.1
        const double minValue = 0.1;
        double scalingFactor = 0.1;  // Adjust this value to control the rate of reduction

        double newValue = increaseValue
            ? lastDifficultyValue + newAffectingValue * defaultModifierForAffectingValue
            : lastDifficultyValue - scalingFactor * newAffectingValue * Math.Abs(defaultModifierForAffectingValue);

        return Task.FromResult(Math.Max(minValue, newValue));
    }

    /**
     * TriggerOnHighSkill function is associated with the Leaderboards Score Submitted event.
     * The default filter for the trigger is set to trigger the function when the player's score 1000 > x > 5000.
     */
    [CloudCodeFunction("TriggerOnHighSkill")]
    public async Task TriggerOnHighSkill(IExecutionContext ctx, IGameApiClient gameApiClient, double score, string playerId)
    {
        var scoreDifficultyMapping = new Dictionary<double, int>()
        {
            {1000, 1},
            {2000, 2},
            {3000, 3},
            {4000, 4},
            {5000, 5}
        };

        var scoreLevel = scoreDifficultyMapping.First(x => x.Key > score).Value;
        var currentDifficultyForPlayer = await ReadCurrentPlayerData(ctx, gameApiClient, playerId);

        var newEnemySpawnRate = await GenerateNewValue(scoreLevel, 0.3, (double)currentDifficultyForPlayer.EnemySpawnRate!);
        var newEnemyHealthMultiplier = await GenerateNewValue(scoreLevel, 0.2, (double)currentDifficultyForPlayer.EnemyHealthMultiplier!);

        await UpdatePlayerGameConfiguration(ctx, gameApiClient, currentDifficultyForPlayer, new Config.DifficultySettings
        {
            EnemySpawnRate = newEnemySpawnRate,
            EnemyHealthMultiplier = newEnemyHealthMultiplier
        }, playerId);
    }


    /**
     * TriggerOnSessionsPlayed function is associated with Cloud Save Key Saved event.
     * The default filter for the trigger is set to trigger the function when the player's SessionsPlayed key is updated.
     */
    [CloudCodeFunction("TriggerOnSessionsPlayed")]
    public async Task TriggerOnSessionsPlayed(IExecutionContext ctx, IGameApiClient gameApiClient, double value, string id)
    {
        await TriggerOnConfiguration(ctx, gameApiClient, value, id, new List<(MultiplierConfig.ConfigurationSetting, Func<Config.DifficultySettings, double?>, Action<Config.DifficultySettings, double>, bool)>
        {
            (MultiplierConfig.ConfigurationSetting.SessionsPlayed, ds => ds.EnemySpawnRate, (ds, newMultiplier) => ds.EnemySpawnRate = newMultiplier, true)
        });
    }



    /**
     * TriggerOnAchievements function is associated with the Cloud Save Key Saved event.
     * The default filter for the trigger is set to trigger the function when the player's UnlockedAchievements key is updated.
    */
    [CloudCodeFunction("TriggerOnAchievements")]
    public async Task TriggerOnAchievements(IExecutionContext ctx, IGameApiClient gameApiClient, double value, string id)
    {
        await TriggerOnConfiguration(ctx, gameApiClient, value, id, new List<(MultiplierConfig.ConfigurationSetting, Func<Config.DifficultySettings, double?>, Action<Config.DifficultySettings, double>, bool)>
        {
            (MultiplierConfig.ConfigurationSetting.UnlockedAchievements, ds => ds.ChallengeRewardsMultiplier, (ds, newMultiplier) => ds.ChallengeRewardsMultiplier = newMultiplier, true)
        });
    }

    /**
    * TriggerOnEncounterFrequency function is associated with the Cloud Save Key Saved event.
    * The default filter for the trigger is set to trigger the function when the player's EncounterFrequency key is updated.
    */
    [CloudCodeFunction("TriggerOnEncounterFrequency")]
    public async Task TriggerOnEncounterFrequency(IExecutionContext ctx, IGameApiClient gameApiClient, double value, string id)
    {
        await TriggerOnConfiguration(ctx, gameApiClient, value, id, new List<(MultiplierConfig.ConfigurationSetting, Func<Config.DifficultySettings, double?>, Action<Config.DifficultySettings, double>, bool)>
        {
            (MultiplierConfig.ConfigurationSetting.EncounterFrequency, ds => ds.EnemySpawnRate, (ds, newMultiplier) => ds.EnemySpawnRate = newMultiplier, true),
            (MultiplierConfig.ConfigurationSetting.EncounterFrequency, ds => ds.TimeLimitMultiplier, (ds, newMultiplier) => ds.TimeLimitMultiplier = newMultiplier, false)
        });
    }

    /**
     * TriggerOnChallengeCompletion function is associated with the Cloud Save Key Saved event.
     * The default filter for the trigger is set to trigger the function when the player's CompletedChallenges key is updated.
     */
    [CloudCodeFunction("TriggerOnChallengeCompletion")]
    public async Task TriggerOnChallengeCompletion(IExecutionContext ctx, IGameApiClient gameApiClient, double value, string id)
    {
        await TriggerOnConfiguration(ctx, gameApiClient, value, id, new List<(MultiplierConfig.ConfigurationSetting, Func<Config.DifficultySettings, double?>, Action<Config.DifficultySettings, double>, bool)>
        {
            (MultiplierConfig.ConfigurationSetting.CompletedChallenges, ds => ds.CurrencyRewardsMultiplier, (ds, newMultiplier) => ds.CurrencyRewardsMultiplier = newMultiplier, true),
            (MultiplierConfig.ConfigurationSetting.CompletedChallenges, ds => ds.ChallengeRewardsMultiplier, (ds, newMultiplier) => ds.ChallengeRewardsMultiplier = newMultiplier, true)
        });
    }

    /**
     * TriggerOnChallengeFailure function is associated with the Cloud Save Key Saved event.
     * The default filter for the trigger is set to trigger the function when the player's FailedChallenges key is updated.
     */
    [CloudCodeFunction("TriggerOnChallengeFailure")]
    public async Task TriggerOnChallengeFailure(IExecutionContext ctx, IGameApiClient gameApiClient, double value, string id)
    {
        await TriggerOnConfiguration(ctx, gameApiClient, value, id, new List<(MultiplierConfig.ConfigurationSetting, Func<Config.DifficultySettings, double?>, Action<Config.DifficultySettings, double>, bool)>
        {
            (MultiplierConfig.ConfigurationSetting.FailedChallenges, ds => ds.EnemyHealthMultiplier, (ds, newMultiplier) => ds.EnemyHealthMultiplier = newMultiplier, false),
            (MultiplierConfig.ConfigurationSetting.FailedChallenges, ds => ds.EnemySpawnRate, (ds, newMultiplier) => ds.EnemySpawnRate = newMultiplier, false),
            (MultiplierConfig.ConfigurationSetting.FailedChallenges, ds => ds.TimeLimitMultiplier, (ds, newMultiplier) => ds.TimeLimitMultiplier = newMultiplier, true),
        });
    }


    /**
     * TriggerOnExperiencePoints function is associated with the Cloud Save Key Saved event.
     * The default filter for the trigger is set to trigger the function when the player's ExperiencePoints key is updated.
     */
    [CloudCodeFunction("TriggerOnExperiencePoints")]
    public async Task TriggerOnExperiencePoints(IExecutionContext ctx, IGameApiClient gameApiClient, double value, string id)
    {
        await TriggerOnConfiguration(ctx, gameApiClient, value, id, new List<(MultiplierConfig.ConfigurationSetting, Func<Config.DifficultySettings, double?>, Action<Config.DifficultySettings, double>, bool)>
        {
            (MultiplierConfig.ConfigurationSetting.ExperiencePoints, ds => ds.TimeLimitMultiplier, (ds, newMultiplier) => ds.TimeLimitMultiplier = newMultiplier, false),
            (MultiplierConfig.ConfigurationSetting.ExperiencePoints, ds => ds.EnemyHealthMultiplier, (ds, newMultiplier) => ds.EnemyHealthMultiplier = newMultiplier, true),
        });
    }

    /**
     * TriggerOnConfiguration is a helper method that updates the player's game difficulty settings in Cloud Save.
     * The method takes a list of tuples that contain the following:
     * - The configuration setting that is being updated
     * - A function that returns the last value of the configuration setting
     * - An action that sets the new value of the configuration setting
     * - A boolean that indicates whether the new value should be added or subtracted from the last value
     * The method loops through the list and updates the configuration settings accordingly.
     */
    private async Task TriggerOnConfiguration(IExecutionContext ctx, IGameApiClient gameApiClient, double value, string id, List<(MultiplierConfig.ConfigurationSetting, Func<Config.DifficultySettings, double?>, Action<Config.DifficultySettings, double>, bool)> settings)
    {
        var currentDifficultyForPlayer = await ReadCurrentPlayerData(ctx, gameApiClient, id);
        var newConfigSettings = new Config.DifficultySettings();

        // for each, append the new value to the new config settings object
        foreach (var (configurationSetting, getLastValue, setNewValue, increaseValue) in settings)
        {
            var multiplier = MultiplierConfig.DefaultModifiers[configurationSetting];
            var lastValue = getLastValue(currentDifficultyForPlayer);

            var newValue = await GenerateNewValue(value, multiplier, (double)lastValue!, increaseValue);
            setNewValue(newConfigSettings, newValue);
        }

        await UpdatePlayerGameConfiguration(ctx, gameApiClient, currentDifficultyForPlayer, newConfigSettings, id);
    }


    // ReadCurrentPlayerData is a method that reads the player's configuration settings from Cloud Save, including the game difficulty settings as a PlayerConfig object.
    private async Task<Config.DifficultySettings> ReadCurrentPlayerData(IExecutionContext ctx, IGameApiClient gameApiClient,
        string playerId)
    {
        try
        {
            var response = await gameApiClient.CloudSaveData.GetItemsAsync(ctx, ctx.ServiceToken, ctx.ProjectId, playerId);
            var gameDifficulty = JsonConvert.DeserializeObject<Config.DifficultySettings>(response.Data.Results.First(x => x.Key == GameDifficultyKey).Value.ToString());
            return gameDifficulty!;
        }
        catch (ApiException e)
        {
            _logger.LogError("Failed to read player {playerId} game difficulty settings in Cloud Save. Error: {Error}", playerId, e.Message);
            throw new Exception($"Failed to initialize {playerId} data in Cloud Save. Error: {e.Message}");
        }
    }

    // UpdatePlayerGameConfiguration is a method that updates the player's game difficulty settings in Cloud Save.
    private async Task UpdatePlayerGameConfiguration(IExecutionContext ctx, IGameApiClient gameApiClient, Config.DifficultySettings currentSettings, Config.DifficultySettings newSettings,
        string playerId)
    {
        try
        {
            var mergedSettings = MergeSettings(currentSettings, newSettings);
            await gameApiClient.CloudSaveData.SetItemAsync(ctx, ctx.ServiceToken, ctx.ProjectId, playerId, new SetItemBody(GameDifficultyKey, mergedSettings));
        }
        catch (ApiException e)
        {
            _logger.LogError("Failed to update player {playerId} game difficulty settings in Cloud Save. Error: {Error}", playerId, e.Message);
            throw new Exception($"Failed to initialize {playerId} data in Cloud Save. Error: {e.Message}");
        }
    }

    // MergeSettings is a helper method that merges the current settings with the new settings. Only the new settings that are not null are updated.
    private static Config.DifficultySettings MergeSettings(Config.DifficultySettings currentSettings, Config.DifficultySettings newSettings)
    {
        var mergedSettings = new Config.DifficultySettings();
        var properties = typeof(Config.DifficultySettings).GetProperties();

        foreach (var property in properties)
        {
            var currentValue = (double?)property.GetValue(currentSettings);
            var newValue = (double?)property.GetValue(newSettings);

            // Update only if the new value is not null
            property.SetValue(mergedSettings, newValue ??
                                              // If new value is null, retain the current value
                                              currentValue);
        }

        return mergedSettings;
    }

}

Now the module contains all the functions that increase the game difficulty level for every trigger.

Configure player config management

Define a class PlayerConfigManagement to add helper methods.

You can use the helper methods to manipulate the player's data in Cloud Save and fire events. You can also use the helper method to submit a score to the leaderboard.

You can use the ChangeDifficulty method to reset the player's data in Cloud Save to default values when the player requests a manual difficulty change. Note that this method is not associated with a trigger. You can call this method from your game client.

Note: You can use Access Control to restrict access to the ChangeDifficulty and ensure that only authorized users can call the method.

C#

using Microsoft.Extensions.Logging;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;
using Unity.Services.CloudCode.Shared;
using Unity.Services.CloudSave.Model;
using Unity.Services.Leaderboards.Model;

namespace TuneGameDifficulty;

/**
 * PlayerConfigManagement class is used to manipulate the player's data in Cloud Save and fire Triggers.
 * It contains helper methods to update the player's data and submit scores to leaderboards to activate Triggers.
 */
public class PlayerConfigManagement
{
    private readonly ILogger<PlayerConfigManagement> _logger;
    public PlayerConfigManagement(ILogger<PlayerConfigManagement> logger)
    {
        _logger = logger;
    }


    // This method is called when the player requests a manual difficulty change.
    // All the current configuration is  to default values alongside the player data and the new difficulty is applied.
    [CloudCodeFunction("ChangeDifficulty")]
    public async Task ChangeDifficulty(IExecutionContext ctx, IGameApiClient gameApiClient, Config.DifficultyLevel newDifficulty)
    {
        try
        {
            var defaultPlayerConfig = new Config.PlayerConfig(newDifficulty);

            var items = defaultPlayerConfig.GetType().GetProperties().Select(property => new SetItemBody(property.Name, property.GetValue(defaultPlayerConfig)!)).ToList();
            await gameApiClient.CloudSaveData.SetItemBatchAsync(ctx, ctx.ServiceToken, ctx.ProjectId, ctx.PlayerId, new SetItemBatchBody(items));

            _logger.LogInformation("The player has reset their difficulty to {newDifficulty}. They progress has been reset.", newDifficulty);

        }
        catch (ApiException e)
        {
            _logger.LogError("Failed to reset player data in Cloud Save. Error: {Error}", e.Message);
            throw new Exception($"Failed to reset player data in Cloud Save. Error: {e.Message}");
        }
    }

    // Use this helper method to manipulate the player's data in Cloud Save and fire Triggers.
    [CloudCodeFunction("UpdatePlayerStats")]
    public async Task<string> UpdatePlayerStats(IExecutionContext ctx, IGameApiClient gameApiClient, string playerId, string key,
        double newValue)
    {
        try
        {
            await gameApiClient.CloudSaveData.SetItemAsync(ctx, ctx.ServiceToken, ctx.ProjectId, playerId, new SetItemBody(key, newValue));
            _logger.LogInformation("The player's {key} has been updated to {newValue}", key, newValue);
            return $"The player's {playerId} {key} has been updated to {newValue}";

        }
        catch (ApiException e)
        {
            _logger.LogError("Failed to update player data for player {playerId} in Cloud Save. Error: {Error}", playerId, e.Message);
            throw new Exception($"Failed to update player data for player {playerId} in Cloud Save. Error: {e.Message}");
        }
    }

    // Use this helper method to submit a score to the leaderboard.
    // Note: requires a leaderboard to be created first.
    [CloudCodeFunction("SubmitScore")]
    public async Task<string> SubmitScore(IExecutionContext ctx, IGameApiClient gameApiClient, string playerId, string leaderboardId, double score)
    {
        try
        {
            await gameApiClient.Leaderboards.AddLeaderboardPlayerScoreAsync(ctx, ctx.ServiceToken, new Guid(ctx.ProjectId), leaderboardId, playerId, new LeaderboardScore(score));
            _logger.LogInformation("The player's {playerId} score of {score} has been submitted to {leaderboardId}", playerId, score, leaderboardId);
            return $"The player's {playerId} score of {score} has been submitted to {leaderboardId}";

        }
        catch (ApiException e)
        {
            _logger.LogError("Failed to submit score to leaderboard {leaderboardId}. Error: {Error}", e.Message, leaderboardId);
            throw new Exception($"Failed to submit score to leaderboard {leaderboardId}. Error: {e.Message}");
        }
    }
}

Deploy the Cloud Code module

Deploy the Cloud Code module containing the functions that increase the game difficulty level for every trigger. Refer to Deploying Hello World to learn how to deploy a module.

Note: If you want to deploy the module with the same Service Account, don't forget to add the additional Service Account role of Cloud Code Editor.

Test the use case

To test the use case you can trigger the events that are associated with the triggers.

For API requests, you need to use the Bearer token as the value for the Authorization header. Refer to Authentication to authenticate as a player or a trusted client using a service-account or a stateless token. Use the received token as a bearer token for HTTP authentication in the request header.

For instance, you can use the cURL command below to submit a score to the leaderboard to trigger the TriggerOnHighSkill function:

curl -XPOST -H 'Authorization: Bearer <BEARER_TOKEN>' -H "Content-type: application/json" --data-raw '{
  "params": {
    "playerId": "<PLAYER-ID>",
    "leaderboardId": "<LEADERBOARD_ID>",
    "score": 2000
   }
}' 'https://cloud-code.services.api.unity.com/v1/projects/<PROJECT_ID>/modules/TuneGameDifficulty/SubmitScore'

You can also update the player's data in Cloud Save to trigger other functions. For instance, to trigger the TriggerOnSessionsPlayed function, call the UpdatePlayerStats function with the SessionsPlayed key:

curl -XPOST -H 'Authorization: Bearer <BEARER_TOKEN>' -H "Content-type: application/json" --data-raw '{
  "params": {
      "key": "SessionsPlayed",
       "newValue": 10
   }
}' 'https://cloud-code.services.api.unity.com/v1/projects/<PROJECT_ID>/modules/TuneGameDifficulty/UpdatePlayerStats'

Note: If you are testing the Cloud Save events through a Cloud Code script, you must define the parameter type as number. With no specific type defined, the parameter is passed as a string and the filter evaluation fails.

Note: In a real game scenario, to trigger the functions, you call the endpoints from your game client based on the player's actions. For more information on how to run the functions, refer to Run modules.

To validate the changes to Cloud Save data, you can inspect the player's Cloud Save data in the Unity Cloud Dashboard.

  1. Navigate to Unity Cloud Dashboard.
  2. Select Player Management.
  3. In the search field, enter the player ID and select Find Player.
  4. Navigate to the Cloud Save > Data section.
  5. Track the progress of data changes as you trigger the events.