A Framework for Status Effects in Unity


Screenshot of Obelus' status effect inspector.

I’ve had the great pleasure of working with an artist/designer on Obelus. It’s been a blast seeing how they approach problems differently, and how many awesome ideas they have that I’d never have come up with. As the sole programmer on the project, I’ve been putting together a bunch of tools for the designer to use.

While the Unity Editor gives me lots of power to put together tools, as a programmer I still have to define the architecture of how the solution works under the hood. In this article, I’ll outline my solution for putting together status effects, as well as a more general pattern which can be used for solving similar problems.

Note that this article assumes a fair amount of knowledge of C# and Unity, specifically ScriptableObjects.

What’s in a status effect?

I define a status effect to be an effect that applies to a character (player or NPC) which alters their attributes until some condition is met. Another word for this is “buff” or “debuff”. What are some examples of buffs?

  • A strength potion applies a buff which adds 5 strength for 30 seconds.
  • A flamethrower applies a debuff which applies 5 damage every second for 3 seconds.
  • An upgrade shrine applies a buff which multiplies your damage by 2x until you die. (Obelus is a roguelite.)
  • A cursed ring applies a debuff which sets your health to 1 until you kill 5 enemies.

From these, we see that buffs have several properties:

  • Varied behaviors. A buff can do pretty much any modification to a character it wants.
  • Lifetime. The buff is applied for some time, and then wears off some time later.
  • State. This one’s harder to see: inspect the last example carefully. If a curse is applied until 5 enemies are killed, it needs to keep track of how many enemies the player has killed.

And one additional requirement for our buff system, since I’m working with a designer, is that these values should be easily modifiable. We should be able to easily go back and make that strength potion last for 60 seconds, instead.

Possible solutions

ScriptableObjects are perfect for defining data and, in some cases, behavior. However, alone, they are not suitable for defining status effects because they are not easily instanced, and have no concept of lifetime.

How about using GameObjects? You can create a prefab which is instantiated every time a status effect is applied to a player, and destroy it when the status effect expires. This should work, but I’d argue that it’s not the right solution. Status effects don’t need a transform, nor do they need to be placed in the hierarchy.

How can we get the best of both worlds?

The ScriptableObject Factory Pattern

Let’s step back a moment and consider what ScriptableObjects were originally meant to be used for. Unity’s unclear on this in their documentation, but all signs point to this: ScriptableObjects are for storing data to be referenced at runtime.

If we think of ScriptableObjects as merely repositories for data, we can turn this problem on its head. What if we referenced the persistent data contained in a ScriptableObject from a plain c# object? That object can define behavior, lifetime and instanced state.

This line of thought is how I came up with what I call the ScriptableObject factory pattern. It’s characterized by the following:

  • ScriptableObjects are used only to store designer-editable data that does not change at runtime.
  • At runtime, we ask the ScriptableObject for a “fake” instance, which references the ScriptableObject’s persistent data for hints on how it should apply its behavior.

For example, if we wanted to create a status effect for our strength potion, we could do it like so:


public class StrengthPotionBuffFactory: ScriptableObject
{
    public int strengthToAdd = 5;
    public float duration = 60f;

    public StrengthPotionBuff GetBuff(Character target)
    {
        return new StrengthPotionBuff { data = this, target = target };
    }
}

public class StrengthPotionBuff
{
    public StrengthPotionBuffData data;
    public Character target;

    public void Apply()
    {
        target.AddStrength(data.strengthToAdd);
        target.StartCoroutine(UnapplicationCoroutine());
    }

    public IEnumerator UnapplicationCoroutine()
    {
        yield return new WaitForSeconds(data.duration);
        target.RemoveStrength(data.strengthToAdd);
    }
}

Whenever we want to apply a StrengthPotionBuff, all we need is a reference to an instance of the ScriptableObject which contains its data. This would probably be associated with the Strength Potion item (which itself might implement this pattern). Then, we call GetBuff with the target of the buff, and then call Apply. The buff object takes care of the rest.

Polymorphic instances

This has one big problem, and it’s that StrengthPotionBuffFactory is not polymorphic. If we have a generic Potion item that can apply any number of buffs, we’d really want to make a system which allows us to drag any BuffData instance into its inspector, rather than defining each potion individually.

To implement this, we turn to generics. BuffFactory, above, is just a container for some data (which can be defined in a separate type) and a reference to a buff type (above, StrengthPotionBuff) which defines the behavior:

public abstract class BuffFactory: ScriptableObject
{
    public abstract Buff GetBuff(Character target);
}

public class BuffFactory<DataType, BuffType>: BuffFactory
    where BuffType: new, Buff<DataType>
{
    public DataType data;

    public Buff GetBuff(Character target)
    {
        return new BuffType { data = this.data, target = target };
    }
}

public abstract class Buff
{
    public abstract void Apply();
}

public abstract class Buff<DataType>: Buff
{
    public DataType data;
    public Character target;
}

There’s a couple of weird things I’m doing here. First off, we make a non-generic base class for BuffFactory because, otherwise, we won’t get the desired polymorphic behavior. We subclass the non-generic version into a generic version so that we achieve better typesafety.

The Buff class is acting like an interface here, defining how buffs can be used. Our buffs can only be applied to a character, but more abstract methods can be defined if we want our status effects to support more operations.

Reimplmenting our strength potion using our new framework, we get:

[CreateAssetMenu(menuName = "Custom/Data/Buff/Strength Potion")]
public class StrengthPotionBuffFactory: BuffFactory<StrengthPotionBuffData, StrengthPotionBuff> { }

public class StrengthPotionBuffData
{
    public int strengthToAdd = 5;
    public float duration = 60f;
}

public class StrengthPotionBuff: Buff<StrengthPotionBuffData>
{
    public void Apply()
    {
        target.AddStrength(data.strengthToAdd);
        target.StartCoroutine(UnapplicationCoroutine());
    }

    public IEnumerator UnapplicationCoroutine()
    {
        yield return new WaitForSeconds(data.duration);
        target.RemoveStrength(data.strengthToAdd);
    }
}

There’s a unity-specific hack above. We must create an empty concrete subclass of the generic BuffFactory, StrengthPotionBuffFactory, so that Unity can serialize it. Unity does not know how to serialize generic types.

With this, we’ve opened the door to create all sorts of different status effects. We can implement a burning debuff like so:

[CreateAssetMenu(menuName = "Custom/Data/Debuff/Burn")]
public class BurningDebuffFactory: BuffFactory<BurnBuffData, BurnBuff> { }

public class BurnBuffData
{
    public float damagePerTick = 5f;
    public float tickTime = 0.5f;
    public float duration = 3f;
}

public class BurnBuff: Buff<BurnBuffData>
{
    public void Apply()
    {
        target.StartCoroutine(BurnCoroutine());
    }

    public IEnumerator BurnCoroutine()
    {
        float startTime = Time.time;
        while (Time.time - startTime <= data.duration)
        {
            yield return new WaitForSeconds(data.tickTime);
            target.DealDamage(data.damagePerTick);
        }
    }
}

And we can easily interchange these buffs. Not only can we create potions that apply any type of status effect, but we can also apply any status effect using a zone, a hitbox, or whatever else you can think of.

But that’s not all

Note that I called the above a pattern. I’ve found that this ScriptableObject factory pattern can apply to more than just status effects. It can apply to any objects that have an editor-time data component, but which also must be instanced at runtime.

For example, I’ve also put together a trigger/effect system in Obelus for attaching effects to Unity animation events. This system uses essentially the same pattern as the status effect system. It’s a little complicated to get into here, but I’ll tease you with a screenshot of the system so you have an idea of what it looks like:

Screenshot of the node graph which we use for defining weapons.

Remember, however, that this pattern should only be applied to objects which must have per-instance data and lifetime. Already, I’ve mistakenly applied this pattern to objects which do not meet these criteria, which simply leads to a lot of boilerplate for no gain.

I hope you found this useful. Let me know if you find any other applications for this pattern!