How to achieve achievements

in Technical

(Almost) no Steam game comes without achievements these days. Achievements are a way to challenge the player in different, sometimes unconventional, ways. Roche Fusion wasn’t any different. For Roche Fusion I wrote a framework to manage achievement in a way that would not be too intrusive in the rest of the code. In this post – loosely based on a dev log I wrote for Roche Fusion – I will explain this framework, and go into some implementation details as well.

Achievements and hardcoding

Achievements are often triggered by a large variety of different events. On first sight, it seems that we have to scatter our achievement code all over our program to deal with this. We could easily put a line of code in the right method that unlocks the achievement or increases a counter somewhere.

There are a few major disadvantages to this approach however. First and foremost: it clutters the source a lot. Maybe one line for unlocking an achievement is fine, but what if we want to link an achievement to performing certain actions in sequence or combination? Then the achievement code would already become a larger block and will slowly fill your entire source code with small segments of unrelated code.

Another disadvantage is that the code becomes heavily decentralised. Is there a bug in an achievement? That means first spending time finding where the achievement is actually defined. Want to change the achievement interface? Changes have to be made all over the source code.

I hope it has become clear how this solution is very suboptimal, so we have to be a bit more smart about this.

Let’s forget about achievement

As the title already says: let’s completely forget about achievements. Remember how I said that achievements are triggered by certain events? Let’s take another look at this aspect of the framework. If there would be a complete newsfeed of everything that happens during a game, we could just look at the feed and when we notice it is our cue, trigger the achievement.

Maybe this gets more clear by using a metaphor: let’s say I want to research how many car fires happen during New Year’s Eve. As the police is busy enough as it is, they won’t just tell me about a car fire whenever they encounter one, but I can listen in on the radio. Most of the events announced on the radio will be completely unrelated, but I can filter these out and thus count the amount of car fires based on that.

The achievement system in Roche Fusion works no different. Achievements subscribe to certain events and change their state based on that.

So how does this solution compare to hardcoding all achievements. The observant readers will immediately tell me that we still need to add code all over the place to actually trigger these events. This is definitely true, but this code is limited to calling an event. The actual logic triggered is completely separated from the game logic. People with knowledge of design patterns will probably have already recognised the observer pattern here.

There are more advantages to this system. By only triggering the event, the game code no longer has any idea or expectations about what is going to happen with that event. This means that we are free to hook up more systems to this newsfeed. In for example Roche Fusion both statistics and managing unlocked content also hook into this newsfeed.

Implementation: the event feed

Since we have the architecture decided upon, we can now look into how the implementation works out. We start by looking at the event feed. This feed is actually a very straight-forward object that contains a long list of events. The C# event system would work great for this, but has one major flaw: you can’t pass (references to) events around easily. To solve this issue, we encapsulated the events in a class. In essence, these classes look like this:

sealed class Event
{
    private event VoidEventHandler handler;

    internal void TryInvoke()
    {
        if (this.handler != null)
            this.handler();
    }

    public void Subscribe(VoidEventHandler sub)
    {
        this.voidHandler += sub;
    }

    public void Unsubscribe(VoidEventHandler sub)
    {
        this.voidHandler -= sub;
    }
}

The actual class we use is slightly more complicated to make sure we don’t count certain events for background games and to allow subscribers to also get a copy of the current game state if they so desire.

Some achievements want some extra information to their events. To allow for this, we also have generic variations of the Event class that look very similar to the class shown before:

sealed class Event
{
    private event GenericEventHandler handler;

    internal void TryInvoke(T t)
    {
        if (this.handler != null)
            this.handler(t);
    }

    public void Subscribe(GenericEventHandler sub)
    {
        this.handler += sub;
    }

    public void Unsubscribe(GenericEventHandler sub)
    {
        this.handler -= sub;
    }
}

It probably doesn’t take a lot of imagination to see how this class looks for even more type parameters.

With these classes and a globally accessible place to store them, we have a central system from which we can send notifications about these events. The events in turn can be passed around by reference, as they are encapsulated by a class.

Implementation: the ‘chievs

One of the first achievements to be implemented in Roche Fusion was the achievement that triggers once you activated the phoenix, a state the player can activate a few minutes into a session. In Roche Fusion all achievements are defined in a central place and the line that defines the above achievement reads as follows.

Achievement.Define("ach_ulti_beard")
    .AchieveOn(dispatcher.UltimateActivated, (p, ulti) => ulti is Phoenix);

There is quite a lot going on in this line of code, so we will disect it into parts:

  • Achievement.Define("ach_ulti_beard"): Achievement is (unsurprisingly) the type that contains all achievement logic. Using the static Define method creates a new instance of the Achievement class. We give it the id of the Steam achievement, so it knows what achievement to unlock. This piece of code is actually equivalent to calling new Achievement("ach_ulti_beard"), but this approach was chosen for aesthetic reasons and it allows us to insert more logic easily if necessary.
    The static method returns the Achievement it created, so we can do some stuff with it.
  • .AchieveOn: as opposed to Define, AchieveOn is actually an instance method. What it does is it takes an event and optionally a condition, and it will take care of unlocking the achievement when the event is called with arguments that fulfil the given condition.
    The AchieveOn method is a chaining method. This means that it will make some changes to the instance it is called on, and then return the same instance. Therefore we could continue writing code to make the achievement more complex. I will give an example of that below.
  • dispatcher.UltimateActivated: the dispatcher is the news feed we have been talking about earlier. UltimateActivated is one of the events listed in the dispatcher. In this case it is an instance of Event<Player, Ultimate>.
  • (p, ulti) => ulti is Phoenix: what you see here is a so-called lambda expression. This expression is equivalent to the following method:
bool isUltiPhoenix(Player p, Ultimate ulti)
{
    return ulti is Phoenix;
}

Using a lambda expression saves us defining the method explicitly and referring to it, making the code more concise and also make it immediately obvious what the condition encompasses.

The implementation of the AchieveOn method looks like this:

public Achievement AchieveOn(Event @event, EventCondition condition)
{
    if (!this.achieved)
        @event.Subscribe((t1, t2) => this.achieveIf(condition, t1, t2));
    return this;
}

We can see how it accepts an event and a condition (the latter is optional, there is also an overload without a condition parameter). We subscribe to the event (note how we use another lambda-expression here) so that once the event is fired, the achieveIf method is called. If this method detects that the condition is fulfilled, the achievement is awarded to the player.

Chaining

Before checking out, I would like to revisit the concept of chaining. As is clear from the implementation of AchieveOn the instance is returned by the method. This allows for the chaining of methods. If we want to for example award an achievement for reaching an ultimate or dying, the definition could look like this:

Achievement.Define("ach_nonsense")
    .AchieveOn(dispatcher.UltimateActivated)
    .AchieveOn(dispatcher.PlayerDied);

In addition to the AchieveOn methods, there are several other methods that allow for more complex conditions to trigger achievements. Consider the (hypothetical) achievement of not losing a life before levelling up. In code, this could be achieved (no pun intended) as follows:

Achievement.Define("ach_more_nonsense")
    .AchieveOn(dispatcher.PlayerLevelledUp)
    .FailOn(dispatcher.PlayerDied)
    .ResetOn(dispatcher.GameStarted);

The FailOn allows the definition of events that will mark the achievement as failed. Even if the player levels up later on, the achievement will not be awarded, as it is flagged as failed. To make sure that the player gets to try another time in the next game, the ResetOn method completely resets the state of the achievement.

These are just two examples of additional methods, but there are even more complex conditions that I have implemented. As the framework as described here is very flexible, the possibilities for making more complex achievements are very numerous. I have omitted many implementation details to limit the length of this post. I hope that based on the information given, you can get a general idea about the structures and design patterns I have used to make the achievement system as flexible and least ugly as possible.

If you have any questions, feel free to ask them in the comments and I will do my best to get back to you as soon as possible.

Comments

Place comment

Your email address will not be published. Required fields are marked *