A major aspect of ARPGs is the abilities of the playable characters and of their enemies. A warrior spinning both blades around themselves pushing back enemies, a mage firing a series of explosions forward damaging friend and foe alike, a boss striking the floor in rage causing the whole environment to shake.

There is usually a lot of these abilities, their effects wide ranging and they're subject to a lot of iteration and tweaking, so if you're making an ARPG you're going to ideally want a system that makes it easy to: specify many types of effects, create new abilities quickly, tweak existing abilities at run time, all with minimal code duplication and easy to extend with new features.

The basis of this system in Seekers is a set of scriptable objects of different types which specify the various aspects of an ability. Scriptable objects fulfil the "as many as you want", the "easy to create" and the "easy to tweak" requirements. The create asset menu attribute allows you create assets to represent your abilities (or their sub-elements) from scratch, being unity asset files you can serialize and link them from character definitions or scripts and you can duplicate existing abilities to make similar abilities or variations. You can also change their values at run time and see changes immediately without a recompile.

The "minimal code duplication" and "easy to extend" requirements can only be solved by making good choices when designing this configuration data. Well designed data structures where you don't need to duplicate information naturally leads to implementation code that is self-contained and modular, and if you're careful about your semantics (i.e. making sure your data structures accurately represent the problem they're trying to solve) extensibility tends to take care of itself.

So the focus of this post is not how to code the particulars of implementation but how to structure the data by which you specify the abilities, as modular specification naturally leads to modular code. Now that's all very high level so lets actually get into some specifics, I'm going to present some data structure classes and then explain the reasoning behind them and how they're intended to work. The implementation of game code to support these definitions is left as an exercise for the reader ;).

Ability Definition

[CreateAssetMenu(menuName = "Definitions/Ability")]
public class AbilityDefinition : ScriptableObject
{
  public float Duration;
  public float Cooldown;
  public Outcome[] Outcomes;
}

[System.Serializable]
public class Outcome
{
  public string Id;
  public Trigger Trigger;
  public Targeting Targeting;
  public GameAction[] Effects;
}

An ability in Seekers is essentially a timeline, a sequence of outcomes, which are comprised of a trigger, targeting information and effects. I like to think of it as each section answers a question we need to know in order to figure out which of our bits of self contained code needs to run and the context which it needs to execute.

  • Trigger - when does this happen?
  • Targeting - who is it happening to?
  • Effects - what happens?

Triggers

This is the most straight forward one; the most basic trigger is an execution time; that is after a certain amount time after the ability is triggered the specified effects should happen.

[System.Serializable]
public class Trigger
{
  public float ExecutionTime;
  public bool IsNormalizedTime;
}

Allowing the time to be specified as a normalized time or an absolute time gives the option to easily speed up or slow down the entire ability without having to re-enter the execution time of every outcome. It also allows easy lining up effects to points in animations, presuming you start playing an animation at the start of the ability (as it's easy to preview an animation in Unity, find the point you want something to happen, note the normalized execution time and put it in as execution time).

This could be extended to allow for specifications such as "1 second after a previous outcome starts", "immediately after a previous outcome finishes" or something more conditional such as "when the ability executor takes damage". I've not felt the need to add these options yet in Seekers but it bodes well for the desire for extensibility that you could add them without changing the concept.

Targeting

Generally things in games happen either directly to characters, or at a position relative to them, so we need to know who the effects are going to either happen to, or happen relative to. So for example, you might want to damage everyone within 5 units of a point 3 units in front of the character executing the ability, you might want to spawn a VFX object 3 units in front of the ability executor, or you might want to target the targets of a previous outcome.

public enum TargetingType
{
  Self,
  TargetFilter,
  TargetsOfPreviousOutcome,
}

[System.Serializable]
public class Targeting
{
  public TargetingType Type;
  public TargetFilter TargetFilter;
  public string PreviousOutcomeId;
}

This structure gives three ways to pick targets; 'self' a quick short hand for the character executing the ability, a number of targets as specified by a 'target filter', and by using the targets of a previous outcome. The last one saving us from recalculating the same target filter multiple times, and also allowing us a way to target characters based on the game state at previous point during the ability, rather than only being able to target characters based on where they are when an outcome executes.

Where a target filter is a way of specifying a set of characters by taking a set of potential targets and then applying a series of filters in turn to narrow down your the set until you're left with who you want to target. Look at this gist if you'd like to see an example of what a data class for that might look like.

Game Actions

Now this is where it gets fun! Having done the boring but necessary bits of specifying when something should happen and who it should happen to, now we can start making all the different things you might want to do!

public class GameAction : ScriptableObject
{
	public virtual void Invoke(List targets) { }
}

[CreateAssetMenu(menuName = "Definitions/Game Action/Inflict Damage")]
public class InflictDamageAction : GameAction
{
	public enum DamageType
	{
		Physical,
		Fire,
		Water,
		Earth,
		Wind,
		Electric,
		Heart,
	}

	public int InflictedDamage;
	public DamageType InflictedDamageType;
}

[CreateAssetMenu(menuName = "Definitions/Game Action/Animation")]
public class AnimationAction : GameAction
{
	public string AnimationName;
	public float Speed = 1f;
}

[CreateAssetMenu(menuName = "Definitions/Game Action/Spawn VFX")]
public class SpawnVFXAction : GameAction
{
	public GameObject Prefab;
	public Vector3 Rotation;
	public Vector3 OffsetFromTarget;
	public float Scale = 1f;
	public bool ParentToTarget;
	public string NamedTransformParent;
}

[CreateAssetMenu(menuName = "Definitions/Game Action/Camera Shake")]
public class CameraShakeAction : GameAction
{
	public enum ShakeType
	{
		Offset,
		Rotation,
	}

	public ShakeType Type = ShakeType.Offset;
	public float Frequency = 24;
	public AnimationCurve DampingCurve = AnimationCurve.EaseInOut(0, 1, 1, 0);
	public Vector3 Direction = Vector3.down;
	public float Duration = 3f;
}

I've used polymorphism inheriting from ScriptableObject to specify the different types of game action so we can just specify the information pertinent to the type of action. This does mean that for each action we'll need to create a new asset in the project and link them in the inspector. Compare this to triggers and targeting which contained all the information for all options and were displayed inline. Let's see how that looks in Editor.

Inspector view of an ability definition

Inspector view of an ability definition

We have to use scriptable objects if we want to use polymorphism as the Unity's default display can not cope with polymorphism directly inline and we need to use the asset referencing as a workaround. This is fine for complex and reused data such game actions, but we probably don't want an asset in the project for every conceivable time we might trigger some effects, so using serializable classes for those makes more sense.

Couple of things worth noting: whilst I've displayed the code as if it were in a single file, each ScriptableObject class needs to be has its own file whose name matches the class name in order for duplicating assets in the editor to work. Additionally you'll notice that the base class has an Invoke method but the child classes don't, well they would need define an override in a full implementation, but the focus of this post is the configuration classes!

We get additional benefits to abstracting GameAction in the way that we have in that as well as exponentially increasing the power of the system each time we add a new action type, can easily start creating more generic action types.

[CreateAssetMenu(menuName = "Definitions/Game Action/Compound Action")]
public class CompoundAction : GameAction
{
	public GameAction[] Actions;
}

[CreateAssetMenu(menuName = "Definitions/Game Action/Timeline")]
public class TimelineAction : GameAction
{
	public float Duration;
	public Outcome[] Outcomes;
}

Summary

Phew that was quite a lot, hopefully you get the idea of how you could use scriptable objects to create the basis for an extensible skill system for an ARPG! I've actually glossed over a fair few of extra things which I've implemented in Seekers to keep this core understandable. An example being Seekers also has the concept of 'movement curve definitions', each ability can optionally specify one so that characters can rush their towards targets and these definitions can also be used to specify knockback on abilities, and movement of projectiles.

The next Seekers post will probably focus on how I've been working on feeding player input into abilities, the system detailed in this post is good responding to button presses, but it doesn't allow for aiming of abilities independent of character movement. There's also a design challenge on how to facilitate aimable abilities across multiple platforms. I also apologise if this post was a little dry, I promise more gifs in the next post.