Quest system
The quest system sample demonstrates how to use Cloud Code to implement a quest sample with the Remote Config and Cloud Save services. The sample covers server authoritative anti-cheat, the caching of other UGS service responses, and the modification of players' Cloud Save data. The sample uses Push messages to notify a player when they complete a quest.
Overview
The quest system allows players to call a Cloud Code module to issue them a quest. To provide players with quests, Cloud Code can do the following:
- Stores the quests in Remote Config.
- Retrieves the list of active quests from Remote Config, and selects one at random.
- Assigns the quest to the player, and stores the data in Cloud Save.
The player can perform actions that count toward the quest progress.
Cloud Code checks when the player last made progress, and either permits or denies the action from contributing to their progress dependent on the following limits:
- Progress is restricted to a certain rate per minute.
- Each minute, the player can only perform as many actions as the quest configuration allows.
This acts as a simple anti-cheat method by limiting the rate at which the player can receive quest progress point.
Once the player completes the quest, Cloud Save deletes the quest data and notifies the player via Push messages.
Set up quests
You need to define your quests in the Remote Config service:
- Navigate to Unity Cloud Dashboard.
- Select Products > Remote Config.
- Select the Config tab.
- Select Add a key.
- Enter
QUESTS
as the key name. - Choose JSON as the type.
- Paste the following as the value:
[ { "id": 1, "name": "Chop Trees", "progress_required": 5, "progress_per_minute": 1, "reward": 5 }, { "id": 2, "name": "Mine Gold", "progress_required": 5, "progress_per_minute": 1, "reward": 10 } ]
- Publish the configuration.
The configuration acts as the active quest database.
The quest structure is as follows:
id
: The unique identifier of the quest.name
: The name of the quest.progress_required
: The amount of progress points required to complete the quest.progress_per_minute
: The amount of progress points the player can make per minute.reward
: The reward the player receives upon completing the quest.
For instance, the Chop Trees quest can only be completed in five minutes, assuming the player is performing the action at the end of each one minute cooldown.
Set up Cloud Code
Set up a Cloud Code module called QuestSystem
to handle the quest system.
Restrict access to Cloud Save
To add a layer of security to your Cloud Save data, you can use Access Control to create a resource policy.
To prevent users from cheating by modifying their own Cloud Save data, you need to create a resource policy for UGS.
Create a file called cloud-save-resource-policy.json
and paste the following:
{
"statements": [
{
"Sid": "deny-cloud-save-data-write-access",
"Effect": "Deny",
"Action": ["Write"],
"Principal": "Player",
"Resource": "urn:ugs:cloud-save:/v1/data/projects/*/players/*/items**"
}
]
}
You can deploy it using the UGS CLI:
ugs access upsert-project-policy cloud-save-resource-policy.json
To learn more about resource policies, refer to Access Control.
Set up Quest service
Create a file called QuestService.cs
.
The Quest Service fetches the quests from Remote Config and caches them for a set period of time.
C#
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;
using Unity.Services.CloudCode.Shared;
namespace QuestSystem;
public interface IQuestService
{
List<Quest> GetAvailableQuests(IExecutionContext context, IGameApiClient gameApiClient);
}
public class QuestService : IQuestService
{
private const string QuestKey = "QUESTS";
private readonly ILogger<QuestService> _logger;
public QuestService(ILogger<QuestService> logger)
{
_logger = logger;
}
private DateTime? CacheExpiryTime { get; set; }
// Reminder: cache cannot be guaranteed to be consistent across all requests
private List<Quest>? QuestCache { get; set; }
public List<Quest> GetAvailableQuests(IExecutionContext context, IGameApiClient gameApiClient)
{
if (QuestCache == null || DateTime.Now > CacheExpiryTime)
{
var quests = FetchQuestsFromConfig(context, gameApiClient);
QuestCache = quests;
CacheExpiryTime = DateTime.Now.AddMinutes(5); // data in cache expires after 5 mins
}
return QuestCache;
}
private List<Quest> FetchQuestsFromConfig(IExecutionContext ctx, IGameApiClient gameApiClient)
{
try
{
var result = gameApiClient.RemoteConfigSettings.AssignSettingsGetAsync(ctx, ctx.AccessToken, ctx.ProjectId,
ctx.EnvironmentId, null, new List<string> { "QUESTS" });
var settings = result.Result.Data.Configs.Settings;
return JsonConvert.DeserializeObject<List<Quest>>(settings[QuestKey].ToString());
}
catch (ApiException e)
{
_logger.LogError($"Failed to assign Remote Config settings. Error: {e.Message}");
throw new Exception($"Failed to assign Remote Config settings. Error: {e.Message}");
}
}
}
Data classes
Create a file called DataClasses.cs
to store the data classes. You can use data classes to deserialize the responses that Cloud Code receives from Remote Config and Cloud Save. Create a file called DataClasses.cs
to store the data classes.
C#
using Newtonsoft.Json;
namespace QuestSystem;
public class Quest
{
[JsonProperty("id")]
public int ID { get; set; }
[JsonProperty("name")]
public string? Name { get; set; }
[JsonProperty("reward")]
public int Reward { get; set; }
[JsonProperty("progress_required")]
public int ProgressRequired { get; set; }
[JsonProperty("progress_per_minute")]
public int ProgressPerMinute { get; set; }
}
public class QuestData
{
public QuestData()
{
}
public QuestData(string questName, int reward, int progressLeft, int progressPerMinute, DateTime questStartTime)
{
QuestName = questName;
Reward = reward;
ProgressLeft = progressLeft;
ProgressPerMinute = progressPerMinute;
QuestStartTime = questStartTime;
LastProgressTime = new DateTime();
}
[JsonProperty("quest-name")]
public string? QuestName { get; set; }
[JsonProperty("reward")]
public long Reward { get; set; }
[JsonProperty("progress-left")]
public long ProgressLeft { get; set; }
[JsonProperty("progress-per-minute")]
public long ProgressPerMinute { get; set; }
[JsonProperty("quest-start-time")]
public DateTime QuestStartTime { get; set; }
[JsonProperty("last-progress-time")]
public DateTime LastProgressTime { get; set; }
}
To learn more about custom serialization, refer to Custom serialization.
Dependency setup
To wire up the Cloud Code module, you need to set up the QuestService
, GameApiClient
, and PushClient
:
- The
QuestService
manages and caches the quests. - The
GameApiClient
is required to call the Remote Config and Cloud Save services. - The
PushClient
is required to send push notifications to the player.
The ICloudCodeSetup
interface allows you to manage the dependencies of your modules. Learn more about it in the Dependency Injection documentation.
C#
using Microsoft.Extensions.DependencyInjection;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;
namespace QuestSystem;
public class CloudCodeSetup : ICloudCodeSetup
{
public void Setup(ICloudCodeConfig config)
{
config.Dependencies.AddSingleton<IQuestService, QuestService>();
config.Dependencies.AddSingleton(GameApiClient.Create());
config.Dependencies.AddSingleton(PushClient.Create());
}
}
Quest controller
Create the QuestController
class to act as the main class and an entrypoint of user traffic to the service.
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 QuestSystem;
public class QuestController
{
private const string QuestDataKey = "quest-data";
private const string PlayerHasQuestInProgress = "Player already has a quest in progress!";
private const string PlayerHasNoQuestInProgress = "Player does not have a quest in progress!";
private const string PlayerHasCompletedTheQuest = "Player has already completed their quest!";
private const string PlayerCannotProgress = "Player cannot make quest progress yet!";
private const string PlayerProgressed = "Player made quest progress!";
private const string PlayerHasFinishedTheQuest = "Player has finished the quest!";
private ILogger<QuestController> _logger;
public QuestController(ILogger<QuestController> logger)
{
_logger = logger;
}
[CloudCodeFunction("AssignQuest")]
public async Task<string> AssignQuest(IExecutionContext context, IQuestService questService,
IGameApiClient gameApiClient)
{
var questData = await GetQuestData(context, gameApiClient);
if (questData?.QuestName != null) return PlayerHasQuestInProgress;
var availableQuests = questService.GetAvailableQuests(context, gameApiClient);
var random = new Random();
var index = random.Next(availableQuests.Count);
var quest = availableQuests[index];
questData = new QuestData(quest.Name, quest.Reward, quest.ProgressRequired, quest.ProgressPerMinute,
DateTime.Now);
await SetQuestData(context, gameApiClient, QuestDataKey, JsonConvert.SerializeObject(questData));
return $"Player was assigned quest: {quest.Name}!";
}
[CloudCodeFunction("PerformAction")]
public async Task<string> PerformAction(IExecutionContext context, IGameApiClient gameApiClient, PushClient pushClient)
{
var questData = await GetQuestData(context, gameApiClient);
if (questData?.QuestName == null) return PlayerHasNoQuestInProgress;
if (questData.ProgressLeft == 0) return PlayerHasCompletedTheQuest;
if (DateTime.Now < questData.LastProgressTime.AddSeconds(60 / questData.ProgressPerMinute))
return PlayerCannotProgress;
questData.LastProgressTime = DateTime.Now;
questData.ProgressLeft--;
await SetQuestData(context, gameApiClient, QuestDataKey, JsonConvert.SerializeObject(questData));
if (questData.ProgressLeft <= 0)
{
await HandleQuestCompletion(context, gameApiClient, pushClient);
return PlayerHasFinishedTheQuest;
}
return PlayerProgressed;
}
public async Task HandleQuestCompletion(IExecutionContext context, IGameApiClient gameApiClient, PushClient pushClient)
{
await NotifyPlayer(context, pushClient);
try
{
await gameApiClient.CloudSaveData.DeleteItemAsync(context, context.AccessToken, QuestDataKey,
context.ProjectId, context.PlayerId);
}
catch (ApiException e)
{
_logger.LogError("Failed to delete a quest for player. Error: {Error}", e.Message);
throw new Exception($"Failed to delete a quest for player. Error. Error: {e.Message}");
}
}
private async Task NotifyPlayer(IExecutionContext context, PushClient pushClient)
{
const string message = "Quest completed!";
const string messageType = "Announcement";
try
{
await pushClient.SendPlayerMessageAsync(context, message, messageType, context.PlayerId);
}
catch (ApiException e)
{
_logger.LogError("Failed to send player message. Error: {Error}", e.Message);
throw new Exception($"Failed to send player message. Error: {e.Message}");
}
}
private async Task<QuestData?> GetQuestData(IExecutionContext context, IGameApiClient gameApiClient)
{
try
{
var result = await gameApiClient.CloudSaveData.GetItemsAsync(
context, context.AccessToken, context.ProjectId, context.PlayerId,
new List<string> { QuestDataKey });
if (result.Data.Results.Count == 0) return null;
return JsonConvert.DeserializeObject<QuestData>(result.Data.Results.First().Value.ToString());
}
catch (ApiException e)
{
_logger.LogError($"Failed to retrieve data from Cloud Save. Error: {e.Message}");
throw new Exception($"Failed to retrieve data from Cloud Save. Error: {e.Message}");
}
}
private async Task SetQuestData(IExecutionContext context, IGameApiClient gameApiClient, string key,
string value)
{
try
{
await gameApiClient.CloudSaveData
.SetItemAsync(context, context.ServiceToken, context.ProjectId, context.PlayerId,
new SetItemBody(key, value));
}
catch (ApiException e)
{
_logger.LogError($"Failed to save data in Cloud Save. Error: {e.Message}");
throw new Exception($"Failed to save data in Cloud Save. Error: {e.Message}");
}
}
}
Assign quests to players
You can call the AssignQuest
function to assign a quest to a player. This function:
- Assigns a quest from the Remote Config service to the player.
- Creates a
QuestData
object for the player and stores it in Cloud Save.
If the player already has a quest in progress, the function responds accordingly.
Progress quests
You can call the PerformAction
function to progress a quest. To update the progress, call this function each time the player performs an action that counts towards the quest progress.
- If the cooldown is expired or this is the player's first progress function call, the player is awarded a progress point, and their
progress-left
value in Cloud Save goes down. - If this function is called during the cooldown, Cloud Code responds with
Player cannot make quest progress yet!
. - If the call is successful, Cloud Code responds with
Player made quest progress!
. In Cloud Save, Cloud Code reduces the player'sprogress-left
value and updates theirlast-progress-time
. - If the last successful call left the
progress-left
at zero, Cloud Code notifies the player that they have completed the quest. The quest data deletes from Cloud Save.
Validate the module
To validate the changes to Cloud Save data, you can inspect the player's Cloud Save data in the Unity Cloud Dashboard.
- Navigate to Unity Cloud Dashboard.
- Select Products > Player Management.
- Select Player Management.
- In the search field, enter the player ID and select Find Player.
- Navigate to the Cloud Save > Data section.
- Track the progress of the quest in the
quest-data
key.
To confirm your push notifications work, refer to Push messages.