Managing and reporting player achievements

When implementing goals such as achievements I like to keep the detection logic separate from the main game logic wherever possible. Otherwise it is easy to end up with spaghetti where achievements are being reported all over the place. One way to separate this cross cutting concern is to have each goal monitor the game’s state to detect success or even failure.

Let’s take the example of an endless game where the player has to endure the game’s challenges for as long as possible. When the player eventually succumbs to failure a screen appears showing their score and time. There are lots of opportunities for recording achievements.

A general purpose goal type can be defined to observe and report basic score accolades such as “Score X points in a single run”:

[CreateAssetMenu(menuName = "Project/Goals/Score Goal")]
public sealed class ScoreGoalAsset : ScriptableGoal
{
    [Inject]
    private IPlayerProfile playerProfile = null;


    [SerializeField]
    private int score = 0;


    protected override void OnActivated()
    {
        this.playerProfile.GameRecorded += this.PlayerProfile_GameRecorded;
    }

    protected override void OnDeactivated()
    {
        this.playerProfile.GameRecorded -= this.PlayerProfile_GameRecorded;
    }


    private void PlayerProfile_GameRecorded()
    {
        if (this.playerProfile.LastScore >= this.score) {
            this.Status = GoalStatus.Completed;
        }
    }
}

Note - The [Inject] attribute in the above example is a part of the Zenject dependency injection framework which I highly recommend for Unity projects.

When the ScoreGoalAsset changes it’s status to GoalStatus.Completed the goal manager will trigger a goal completed event allowing for things like rewards, achievements, etc to be handled. The goal asset’s only purpose is to define and monitor the progress of a goal.

So at this point you might be wondering; okay, so how are achievements actually reported? Well there are a few ways that this logic can be implemented. It could be implemented as a rewarder or as a more general service. I chose to implement a general purpose service which monitors for goal completion and then reports achievement progress if a platform specific achievement identifier is associated with that goal name. In a nutshell:

public sealed class GoalAchievementReporting
{
    private readonly IGoalManager goalManager;
    private readonly IAchievementReporter achievementReporter;
    private readonly IGoalAchievementNameResolver goalAchievementNameResolver;

    private bool isRunning = false;


    public GoalAchievementReporting(
        IGoalManager goalManager,
        IAchievementReporter achievementReporter,
        IGoalAchievementNameResolver goalAchievementNameResolver
    ) {
        this.goalManager = goalManager;
        this.achievementReporter = achievementReporter;
        this.goalAchievementNameResolver = goalAchievementNameResolver;
    }


    private void GoalManager_GoalProgressed(IGoal goal)
    {
        var achievementName = this.goalAchievementNameResolver.ResolveAchievementName(goal);
        if (!string.IsNullOrEmpty(achievementName)) {
            // A goal can of course have sub-goals; in such cases partial completion will
            // be reported rather than simply unlocking the achievement.
            this.achievementReporter.ReportAchievementProgress(achievementName, goal.Progress);
        }
    }


    public void Start()
    {
        if (!this.isRunning) {
            this.isRunning = true;
            this.goalManager.GoalProgressed += this.GoalManager_GoalProgressed;
        }
    }

    public void Stop()
    {
        if (this.isRunning) {
            this.isRunning = false;
            this.goalManager.GoalProgressed -= this.GoalManager_GoalProgressed;
        }
    }
}

For a second example let’s look at how we might handle consecutive scores:

[CreateAssetMenu(menuName = "Project/Goals/Consecutive Score Goal")]
public sealed class ConsecutiveScoreGoalAsset : ScriptableGoal
{
    [Inject]
    private IPlayerProfile playerProfile = null;


    [SerializeField]
    private int score = 0;
    [SerializeField]
    private int consecutiveCount = 1;


    protected override void OnActivated()
    {
        this.playerProfile.GameRecorded += this.PlayerProfile_GameRecorded;
    }

    protected override void OnDeactivated()
    {
        this.playerProfile.GameRecorded -= this.PlayerProfile_GameRecorded;
    }


    private void PlayerProfile_GameRecorded()
    {
        var recentScores = this.playerProfile.RecentScores
            .Take(this.consecutiveCount)
            .ToArray();
        
        if (recentScores.Length < this.consecutiveCount) {
            return;
        }

        if (recentScores.All(recentScore => recentScore >= this.score)) {
            this.Status = GoalStatus.Completed;
        }
    }
}

The keen eye might notice that the ConsecutiveScoreGoalAsset goal from above can be used to define the same goals that could be defined using ScoreGoalAsset from the first example.

Each goal asset can make reference to a series of ScriptableGoalObserver assets which can initiate custom logic for various goal events… such as completion or failure. For example, you might define a GoalOutfitRewarder which rewards the player with some fashion for scoring highly:

[CreateAssetMenu("Project/Goals/Rewarders/Outfit Rewarder")]
public sealed class GoalOutfitRewarder : ScriptableGoalObserver
{
    [SerializeField]
    private string outfitName = "";


    [Inject]
    private IPlayerProfile playerProfile = null;


    public override void OnGoalStatusChanged(IGoal goal)
    {
        if (goal.Status == GoalStatus.Completed) {
            this.playerProfile.UnlockOutfit(this.outfitName);
        }
    }
}

Goals can be managed however desired and for more advanced games they can pop in and out of existence based upon the player’s progress in the game. The same mechanism can thus be used to manage achievements, quests, challanges, etc.