Started building abilities for my action RPG and went down the inheritance hole first. Base Ability class, then DashAbility, FireballAbility, HealAbility all extending it. Fine at three. At fifteen you need a "dash-that-also-deals-damage-but-only-when-airborne" variant and suddenly the class hierarchy is actively fighting you.
Switched to a tag-based approach, loosely inspired by Unreal's GAS but without the forty-file boilerplate. Core is a TagComponent node you attach to anything:
class_name TagComponent
extends Node
var _tags: Dictionary = {}
func add_tag(tag: StringName) -> void:
_tags[tag] = _tags.get(tag, 0) + 1
func remove_tag(tag: StringName) -> void:
_tags[tag] = max(0, _tags.get(tag, 0) - 1)
if _tags[tag] == 0:
_tags.erase(tag)
func has_tag(tag: StringName) -> bool:
return _tags.get(tag, 0) > 0
func has_all(required: Array[StringName]) -> bool:
return required.all(func(t): return has_tag(t))
func has_any(checked: Array[StringName]) -> bool:
return checked.any(func(t): return has_tag(t))Abilities are Resources with tag conditions baked in as exports:
class_name Ability
extends Resource
@export var required_tags: Array[StringName] = []
@export var blocked_tags: Array[StringName] = []
@export var granted_tags: Array[StringName] = [] # applied to owner while ability is active
@export var cooldown: float = 0.0
func can_activate(tags: TagComponent) -> bool:
return tags.has_all(required_tags) and not tags.has_any(blocked_tags)"Can't dash while airborne" becomes required_tags = [&"grounded"]. A silence debuff just adds &"silenced" to the player's TagComponent and every ability with &"silenced" in its blocked_tags is automatically gated. No override chains, no shared base state, no special-casing anywhere.
The part I'm still not happy with is cooldown tracking. Right now it lives on the AbilityComponent as a Dictionary[StringName, float] keyed on resource paths. Works for singleton abilities but falls apart if you ever want two instances of the same ability resource with independent cooldowns. Anyone done this pattern and found a cleaner approach for that part?