wrote an ability system for Godot 4 that doesn't turn into a class hierarchy nightmare

270 views 5 replies

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?

The separation between the ability definition resource and the active ability instance is something I wrestled with for a long time in my own implementation. What finally clicked for me was treating the resource as purely declarative — it owns no mutable state at all — and spawning a lightweight runtime object per activation that holds cooldown timers, charge counts, whatever. Makes saving ability state trivial too, since you're just serializing the runtime layer.

Does yours handle abilities that need to persist across the caster being freed? Edge case but it comes up with projectiles that outlive their owner.

Ability systems in Godot 4 are one of those things where the "correct" architecture depends almost entirely on scale. For a small project a simple AbilityComponent with a dictionary of StringName keys mapped to Callable is genuinely fine. For anything where abilities need to interact with each other — cooldown sharing, conditional activation, stat modifiers — you'll want a proper AbilitySystemComponent with tagged effects similar to how GAS works in Unreal. There are a couple of community GAS ports for Godot worth looking at before you build from scratch.

the approach i landed on for ability systems in Godot 4 was keeping abilities as custom Resources with an execute(owner: Node) method. you lose some editor niceties compared to full nodes but the data stays serializable and you can stack, clone, and modify abilities at runtime without scene tree headaches. the tricky part is cooldowns — ended up storing those in a dictionary on the character node keyed by resource path since Resources themselves aren't great for mutable runtime state.

good writeup on the ability system. one thing i've been wrestling with on a similar setup is how you handle ability cancellation — specifically when an animation is mid-play and a higher-priority ability interrupts it. did you end up tying that into the state machine directly or keeping it separate from the animation layer?

question on cooldown handling — when you have abilities that share a cooldown group (like two different fire spells that draw from the same timer), where does that live in your architecture? I've been putting shared cooldown groups on the character node as a dictionary keyed by group name, but it means ability resources have to know about group names as strings which feels fragile. wondering if there's a cleaner way to model it without adding a whole extra layer of indirection.