Managing Gameplay Tag Complexity in ARPGs

Advanced gameplay tag techniques required for complex games like ARPGs
Published on 11/30/2023

ℹ️ This article is mostly based on my experience working at Grimlore Games, with some of the first concepts of literal tags (see below) first developed during my time at Aesir Interactive. All of the code referenced in this article is part of my Open Unreal Utilities plugin.

⚠️ I’m assuming high-level familiarity with Unreal Engine Gameplay Tags.

Introduction

Ever since Epic introduced gameplay tags they are my go to for anything that needs name IDs which can be authored by artists and game designers. The benefit over FNames are quite apparent, but the gameplay tag workflow comes with a few shortcomings that are especially noticeable the bigger a project grows in scope and complexity.

In this article I will show some of the techniques I helped introduce to our workflow at Grimlore that help us with the avalanche of content that comes with building an ARPG like Titan Quest 2.

Defining Core Rules

Before we could make any technical changes, we had to wrap our heads around some core rules for our tag hierarchy that we could aid and enforce. We came up with the following rules that I will also recommend for any future game I will work on:

  1. Global root tags are declared in C++ code and are strictly tied to a plugin or game system that “owns” the tag and all its child tags (exceptions below).
  2. Tags should be added to an allow list if we want art/design/etc to create child tags as child tags beneath them. Purley technical tags should be enforced not to have any child tags.
  3. Clear naming rules for prefixes, plurality, casing, etc
  4. Do reuse tags for the same meaning in multiple systems (e.g. “DamageType.Fire” for spell system, item stat rolling, Niagara parameters, etc)
  5. Do not reuse tags for different meanings, even if in different contexts. If you’re tempted to do this, your tag names might be too generic (e.g. “Player” instead of “ControllGroup.LocalPlayer”, “AggroGroup.Player”)

Enforcing Rules

As soon as you have more than a dozen tags (so VERY soon), you will want to have some automated validation logic that checks if newly created tags do adhere to rules such as the ones outlined above. For this, I created a validator that emits message log entries.

Similar to asset validators, game projects can implement custom validation rules, which is perfect to enforce very project specific rules (such as naming rules, rules that only apply beneath certain root tags, etc).

Some core rules are enforced by default, such as a maximum global tag depth.

Declaring Tags

You might have noticed that one of our most important core rules is to declare all root tags in code, because almost all of our gameplay systems rely on gameplay tags to some degree. However the way Epic implemented native tags was quite limiting to us, because they only serve as a global constant, but there is no built-in information about the relation of two tags. Of course you can get some of that indirectly from the gameplay tags manager, but as any C++ programmer dabbling in soft typed scripting languages, I was desperately missing static type information and compile time checks.

For this purpose, I created literal and typed gameplay tags.

Literal Gameplay Tags

Literal tags are an extension to Epic’s native tags, because they allow you to declare a tree of tags instead of just isolated tags. So e.g. a tag declaration like this…

OUU_DECLARE_GAMEPLAY_TAGS_START(
    OUURUNTIME_API,
    FSampleGameplayTags,
    "OUUTestTags",
    "Description for the root tag")
    OUU_GTAG(Foo, "Foo is a leaf tag that has no children");
    OUU_GTAG_GROUP_START(Bar, "Bar has children")
        OUU_GTAG_GROUP_START(Alpha, "Alpha is the greek letter equivalent of A")
            OUU_GTAG(One, "Number One. Inside Alpha")
            OUU_GTAG(Two, "Number Two. Inside Alpha")
        OUU_GTAG_GROUP_END(Alpha)
        OUU_GTAG(Beta, "Beta is the greek letter equivalent of B. Artists are allowed to create child tags in ini file below this tag.", EFlags::Default & EFlags::AllowContentChildTags)
    OUU_GTAG_GROUP_END(Bar)
OUU_DECLARE_GAMEPLAY_TAGS_END(FSampleGameplayTags)

…declares and registers the following tags

  • OUUTestTags
  • OUUTestTags.Foo
  • OUUTestTags.Bar
  • OUUTestTags.Bar.Alpha
  • OUUTestTags.Bar.Alpha.One
  • OUUTestTags.Bar.Alpha.Two
  • OUUTestTags.Bar.Beta
  • OUUTestTags.Bar.Gamma

You can probably imagine how creating that tag list with many repetitions of the same root tags makes introducing typos super likely.

The macros get rid of all the name repetition and create a nested hierarchy of structs similar to this:

struct FSampleGameplayTags {
    struct Foo {};
    struct Bar {
        struct Alpha {
            struct One {};
            struct Two {};
        }
        struct Beta {};
        struct Gamma {};
    };
};

This means tags can be referenced with the root struct type and scope resolution operators like so:

FGameplayTag NormalTag = FSampleGameplayTags::Bar::Alpha::One::Get();

Each tags also must come with a description and optional flags that denote some meta attributes. E.g. the info whether content tags may be created below this tag is declared here and picked up by the validator system, so we immediately find out whenever someone misuses a tag that was declared as “group” and not meant to be extensible.

There is also an OUU_DECLARE_GAMEPLAY_TAGS_EXTENSION_START tag that allows other plugins / modules to extend a tag structure from a different plugin and retain all the connection to the original declaration. This is crucial for some of the features described below, such as implicit conversion to templated tag and container types.

Typed Tags

So far these tag literals only solve the matter of declaring and registering tags (hence the name “literal”), they are not typesafe tag properties or containers. But we have a solution for that as well: Typed tags can be declared from one or more literal tags to only accept assignment from those tags.

USTRUCT(BlueprintType)
struct OUURUNTIME_API FOUUSampleBarTag : public FTypedGameplayTag_Base
{
    GENERATED_BODY()

    // This macro adds implicit conversion / assignment possible from the specified tag literals and their child tags.
    IMPLEMENT_TYPED_GAMEPLAY_TAG(FOUUSampleBarTag, FSampleGameplayTags::Bar::Alpha, FSampleGameplayTags::Bar::Beta)
};

Usage:

// Compiles
FOUUSampleBarTag TypedTag = FSampleGameplayTags::Bar::Alpha::One::Get();

// Doesn't compile, because tags are unrelated
FOUUSampleBarTag TypedTag = FSampleGameplayTags::Foo::Get();

// Doesn't compile, because we can't statically enforce tag type safety
FOUUSampleBarTag TypedTag = FGameplayTag::RequestGameplayTag(TEXT("OUUTestTags.Bar.Alpha.One"));

// Does compile, but now it's explicit that this conversion may fail at runtime. If it does fail, the tag is empty.
FOUUSampleBarTag TypedTag = FOUUSampleBarTag::TryConvert(FGameplayTag::RequestGameplayTag(TEXT("OUUTestTags.Bar.Alpha.One")));

These tags can be easily used in Blueprint as well, but you need to define conversion operators to and from regular gameplay tags in Blueprint Function Libraries for each new type.

Typed Tag Containers

Containers are more tricky, because of their increased complexity and number of supported operations compared to single tags.

Containers in C++ Code

For C++ code, the typed gameplay tag implementation macros implicitly declare two aliased template types:

  • FOUUSampleBarTags_Value is a value type container that enforces type safety
  • FOUUSampleBarTags_Ref is a reference type container that limits type safe access to an existing gameplay tag container (e.g. a regular FGameplayTagContainer uproperty)

These can also be created from regular tag containers and we regularly use them in our code to filter tags from other sources for the relevant tags for the current context:

FGameplayTagContainer EquipmentTags;
FAnimationTags_Value AnimationRelevantTags = FAnimationTags_Value::CreateFiltered(EquipmentTags);

Containers in Blueprints

For Blueprints it’s more trouble than it’s worth it to actually create new container types with all the reflection boilerplate every time you add a new typed tag (maybe once UHT extensibility is possible more easily this can be achieved). So instead I opted to go a more runtime-oriented approach for the few cases where it is necessary:

FTypedGameplayTagContainer is a generic type that allows declaring typed gameplay tag containers that use the dynamic filtering but don’t have any of the compile time checks available for the template containers described above. Instead, they have a type name property that can be set via dropdown in the editor and only be edited on the first blueprint or C++ class. Derived Blueprint classes can only edit the contained tags, but do not allow changing the type tag / filter anymore.

Configurability

In equipment/animation example above we were switching contexts, but expect some of the tags to be relevant in both contexts. So which of these systems “owns” the tags? Can we have a tag in both systems as a typed tag?

The answer is a clear YES, but you also have to be mindful about where to declare which tags. Continuing with the same sample, we actually have a super similar setup in Titan Quest 2:

  • Each weapon is assigned a tag for what item type it is (“Item.Weapon.Sword”, “Item.Weapon.Shield”, etc)
  • Those item types can only go into the equipment slots (“Item.Slot.MainHand”, “Item.Slot.OffHand”, etc) in certain combinations and produce an equipment set tag (e.g., “Item.EquipSet.SwordDualWield”)
  • The animation system doesn’t care about the individual weapons, but about the Item.EquipSet tag

We decided that all of those tags originally belong to the equipment/item system, but that animation should be influenced by it.

Sometimes we already know this in code, but we also want artists and designers to have the freedom to pull in new tags from other systems on demand. So our animators could add Item.EquipSet to a list of animation relevant tags in the project settings and now we can actually do this in code:

FEquipmentTags_Ref EquipmentTags; // contains ("Item.Weapon.Sword", "Item.Weapon.Shield", "Item.EquipSet.SwordAndShield")
FAnimationTags_Value AnimationRelevantTags = FAnimationTags_Value::CreateFiltered(EquipmentTags);
// AnimationRelevantTags now contains ("Item.EquipSet.SwordAndShield")

If they later decide that they actually DO care about the individual item tags, they can also change that setting, but our code is still valid and concise.

The same globally configurable type filter can be used for non-typed properties as well, by the way - the OpenUnrealUtilities plugin introduces the following tag filter syntax in meta attributes:

UPROPERTY(EditAnywhere, meta = (Categories = "TypedTag{AnimationTag}"))
FGameplayTagContainer AnimationTags;

This applies the same contextual tag filter to any property and narrows the global tag list to just the tags relevant for that game context. So even if a new context doesn’t introduce any new tags, it’s often beneficial to declare a new “typed tag”, just so you can have a reusable filter. The Blueprint typed tag containers described above use this same mechanism internally.

Future Work

I’d love to investigate how rules for splitting content tags up into multiple sources (ini files, uassets) and codifying those rules could be added to the C++ tag declarations and validators. Since we added the tag validators and good comments for most of our tags, we had far less tag misuse, so I wasn’t too fond of arbitrarily limiting tag access via restricted tags or some similar mechanism. But it might be worth re-investigating. In their current implementation you cannot declare restricted tags in C++ code at all without engine modifications.