The design of skill systems is a frequent topic on the Unity Scripting forum. This post describes one approach that follows the Unity philosophy of using prefabs and component-based design.

You can download the project here: Skill System Example. It includes a working example scene.

The key is to split out functionality into small, abstract pieces. MonoBehaviours are perfect for this. To make it abstract, instead of hard-coding, say, a single projectiles script with all of the different behaviors that can happen when the projectile hits, attach a simple MonoBehaviour whose only purpose is to do something on collisions. This way, your projectiles script doesn’t need to know anything about hit behavior. When the projectile hits, it just passes the message along to the hit handler. This lets you add new types of hit handlers without having to touch any code in the projectiles script. Then apply this principle to every part of a skill — how it targets, how the player triggers it, etc.

This makes the system data-driven and component-based. By data-driven, I mean that the scripts provide a general-purpose method for defining skills. But the actual skills are designed in the inspector by composing different parts (casting prerequisites, aiming, firing, etc.) and optionally saved as prefab. To add a new skill, you just use the inspector. You don’t have to modify code.

You generally want to avoid designs that require you to go back and modify existing code, since it’s easy to break existing functionality. For example, avoid enums and switch statements unless you’re absolutely sure you won’t ever add any new spells or skills. Otherwise you’ll have to go in and edit the code with those enum and switch statements. This is a very common source of bugs.

The example code in this post uses a handful of general-purpose, reusable scripts for each spell, and it uses SendMessage. Someone asked if this design has any impact on performance. In practice, no. If you’re unsure about that, just profile it. Few of the scripts have Update() methods, so they don’t do anything on a regular interval. And if you’re concerned about SendMessage (though you shouldn’t be, since it’s only called once when the character casts a spell, so it has no performance impact), you could always use C# delegates. But in this scenario it’s not worth the extra complexity.

In the example project, the player has a GameObject named Skillbar. Skills are defined as children:
[​IMG]

The skillbar and skills use scripts in these folders:
[​IMG]

Each skill has different scripts that, as a whole, define the skill’s behavior. The scripts are generic, nothing hard-coded for specific skills. The Launcher (rocket launcher) skill is below.

  • For aiming, it uses “AimCenter”, which aims at targets at the center of the screen.
  • For triggering, it uses “ClickToCast”, which casts the skill when the player mouse-clicks. This sends an OnFire message to the skill.
  • For shooting, it uses “SpawnWithVelocity”, which listens for OnFire and spawns a prefab with a velocity.

[​IMG]

The prefab that it spawns is a Rocket. The rocket has three behaviors:

  • “Temporary” despawns the rocket after 5 seconds.
  • “CollisionDamage” causes damage to whatever it hits, by sending an OnTakeDamage message.
  • “SpawnCollisionReplacement” destroys the rocket immediately and replaces it with an Explosion prefab. (The Explosion prefab also has a Temporary that despawns it after 1 second.)

[​IMG]

So you can see that none of the code is skill-specific. All skill-specific data is managed in the inspector. You can create prefabs of these skills and add and remove them as the player gains and loses abilities.

Here are some of the main scripts:

Skillbar.cs draws the skill bar and lets you select the active skill.

using UnityEngine;

public class Skillbar : MonoBehaviour {

	private Skill[] skills;

	private Skill activeSkill;

	void Start() {
		skills = GetComponentsInChildren<Skill>();
		SetActiveSkill((skills.Length > 0) ? skills[0] : null);
	}

	public void SetActiveSkill(Skill newActiveSkill) {
		activeSkill = newActiveSkill;
		foreach (var skill in skills) {
			skill.gameObject.SetActive(skill == activeSkill);
		}
	}

	void OnGUI() {
		GUILayout.BeginHorizontal();
		foreach (var skill in skills) {
			GUI.color = (skill == activeSkill) ? Color.yellow : Color.gray;
			if (GUILayout.Button(skill.name, GUILayout.Width(64), GUILayout.Height(64))) {
				SetActiveSkill(skill);
			}
		}
		GUILayout.EndHorizontal();
	}

}

Skill.cs is really just a placeholder in this example. The other scripts compose the actual functionality.

using UnityEngine;

public class Skill : MonoBehaviour {

	public GameObject target;

}

AimBase.cs provides basic targeting functionality.

using UnityEngine;

public class AimBase : MonoBehaviour {

	public LayerMask layerMask = 1;
	public Texture2D reticle;

	private Skill skill;

	void Awake() {
		skill = GetComponent<Skill>();
	}

	public virtual Ray GetAimRay() {
		return Camera.main.ScreenPointToRay(new Vector3(Screen.width / 2f, Screen.height / 2f));
	}

	void Update() {
		RaycastHit hit;
		if (Physics.Raycast(GetAimRay(), out hit, 100f, layerMask)) {
			skill.target = hit.collider.gameObject;
		}
	}

	void OnGUI() {
		DrawReticle();
		DrawTargetName();
	}

	public virtual void DrawReticle() {
		if (reticle == null) return;
		var rect = new Rect((Screen.width - reticle.width) / 2, (Screen.height - reticle.height) / 2, reticle.width, reticle.height);
		GUI.DrawTexture(rect, reticle);
	}

	public virtual void DrawTargetName() {
		if (skill.target == null) return;
		var size = GUI.skin.label.CalcSize(new GUIContent(skill.target.name));
		var rect = new Rect((Screen.width - size.x) / 2, ((Screen.height - size.y) / 2) - 50, size.x, size.y);
		GUI.Label(rect, skill.target.name);
	}

}

AimCenter.cs is really just another name for AimBase, since AimBase by default aims in the center of the screen.

using UnityEngine;

public class AimCenter : AimBase {
}

AimCursor.cs overrides the targeting position and the reticle GUI position. This is for skills that aim where the mouse cursor is pointing.

using UnityEngine;

public class AimCursor : AimBase {
	
	public override Ray GetAimRay() {
		return Camera.main.ScreenPointToRay(Input.mousePosition);
	}

	public override void DrawReticle() {
		if (reticle == null) return;
		var rect = new Rect(Input.mousePosition.x - (reticle.width / 2), (Screen.height - Input.mousePosition.y) - (reticle.height / 2), reticle.width, reticle.height);
		GUI.DrawTexture(rect, reticle);
	}

}

ClickToCast.cs implements one way to cast the skill (that is, send “OnFire”).

using UnityEngine;

public class ClickToCast : MonoBehaviour {

	void Update() {
		if (Input.GetMouseButtonDown(0)) {
			SendMessage("OnFire");
		}
	}
}

SpawnWithVelocity.cs responds to OnFire by firing a projectile.

using UnityEngine;

public class SpawnWithVelocity : MonoBehaviour {

	public GameObject prefab;
	public GameObject origin;
	public float force = 500f;

	void OnFire() {
		var projectile = Instantiate(prefab) as GameObject;
		projectile.transform.position = origin.transform.position;
		projectile.transform.rotation = origin.transform.rotation;
		projectile.GetComponent<Rigidbody>().velocity = origin.transform.TransformDirection(Vector3.forward * force);
	}

}

If you were to add RaycastEffect.cs or SpawnAtCursor.cs instead, it would change the way the skill handles OnFire.

CollisionDamage.cs gets added to projectiles, not to skills. When it collides, it sends “OnTakeDamage”. You could make this more generic by allowing the designer to specify the message that it sends, instead of hard-coding “OnTakeDamage”.

using UnityEngine;

public class CollisionDamage : MonoBehaviour {

	public float damage = 1;

	void OnCollisionEnter(Collision collision) {
		collision.collider.SendMessage("OnTakeDamage", damage, SendMessageOptions.DontRequireReceiver);
	}

}

In the example scene, the rocket has CollisionDamage.cs and SpawnCollisionReplacement.cs, which replaces the rocket with another prefab — in the rocket’s case, an explosion. It also has Temporary.cs, which despawns the rocket after 5 seconds in case it doesn’t hit anything.

The StormGust skill uses AimCursor and SpawnAtCursor. It spawns a Storm prefab, which has a Temporary to despawn it after 5 seconds.

Creating new skills is now just a matter of creating new GameObjects (or prefabs) and assigning the right components, without having to edit any existing code. You could create an area-of-effect Heal skill using the same components as StormGust that could spawn a healing prefab instead of the Storm prefab. Defining them as prefabs lets you add and remove skills as the character gains and loses abilities simply by instantiating the skill’s prefab.