Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when building character abilities — Resource-based abilities with cost/cooldown/cast, buffs/debuffs, stat modifiers, gameplay tags, and HUD binding
.claude/skills/jame581-ability-system/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 51% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 99% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 137% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 93% | 0% |
| case-16 | ✗→✓ | ▲ Improved | 568% | 0% |
Build a data-driven ability system from Godot-native parts: abilities are Resources, an AbilityComponent node owns and runs them, and effects/stats/tags compose on top. No third-party addon required.
> Related skills: resource-pattern for the Resource data containers, component-system for the component node pattern, event-bus for cross-system ability events, state-machine for caster states (e.g. casting/stunned), hud-system for cooldown UI.
An ability system in Godot 4.x is built from three collaborating layers:
Ability (Resource): Each ability is a Resource subclass with exported fields (ability_name, cost, cooldown, cast_time) and two methods: can_activate(caster) -> bool to validate preconditions, and activate(caster) -> void to execute the effect. Storing abilities as Resources lets designers create and balance them in the Godot editor without touching code.AbilityComponent (Node): A single node added to any entity that should use abilities. It holds the granted ability set, enforces cost/cooldown, and drives the Ability.activate() call. Four signals keep the rest of the game informed without coupling: ability_activated(ability), ability_failed(ability, reason), cooldown_started(ability, duration), and cooldown_finished(ability). Grant new abilities at runtime with grant(ability) and trigger them with try_activate(ability_name).StatSet (a Resource that owns a dictionary of named StatModifier entries) to apply temporary or permanent stat changes. A GameplayTagContainer node (a child of the caster) gates activation — for example, a "stunned" tag can prevent any ability from firing. These three deep-dives are covered in the reference documents:Core rule: Data in Resources, behavior in the component, communication via signals.
This separation means an Ability resource carries no Node references and can be safely duplicated, saved, and loaded as any other Godot Resource. The AbilityComponent owns runtime state (cooldown timers, active ability set), keeping Ability resources stateless and reusable across multiple casters simultaneously.
gdscript# ability.gd class_name Ability extends Resource @export var ability_name: String @export var cost: float = 0.0 @export var cooldown: float = 1.0 @export var cast_time: float = 0.0 # Override in subclasses or compose via exported effect resources. func can_activate(caster: Node) -> bool: return true func activate(caster: Node) -> void: pass
gdscript# ability_component.gd class_name AbilityComponent extends Node signal ability_activated(ability: Ability) signal ability_failed(ability: Ability, reason: String) signal cooldown_started(ability: Ability, duration: float) signal cooldown_finished(ability: Ability) @export var resource_pool: float = 100.0 var _granted: Dictionary = {} # ability_name -> Ability var _cooldowns: Dictionary = {} # ability_name -> seconds remaining func grant(ability: Ability) -> void: _granted[ability.ability_name] = ability func _process(delta: float) -> void: for name in _cooldowns.keys(): _cooldowns[name] -= delta if _cooldowns[name] <= 0.0: _cooldowns.erase(name) if _granted.has(name): cooldown_finished.emit(_granted[name]) func try_activate(ability_name: String) -> bool: var ability: Ability = _granted.get(ability_name) if ability == null: return false if _cooldowns.has(ability_name): ability_failed.emit(ability, "on_cooldown") return false if resource_pool < ability.cost: ability_failed.emit(ability, "insufficient_resource") return false if not ability.can_activate(get_parent()): ability_failed.emit(ability, "conditions_unmet") return false resource_pool -= ability.cost ability.activate(get_parent()) _cooldowns[ability_name] = ability.cooldown cooldown_started.emit(ability, ability.cooldown) ability_activated.emit(ability) return true
csharp// Ability.cs using Godot; [GlobalClass] public partial class Ability : Resource { [Export] public string AbilityName { get; set; } = ""; [Export] public float Cost { get; set; } = 0.0f; [Export] public float Cooldown { get; set; } = 1.0f; [Export] public float CastTime { get; set; } = 0.0f; public virtual bool CanActivate(Node caster) => true; public virtual void Activate(Node caster) { } }
csharp// AbilityComponent.cs using Godot; using System.Collections.Generic; public partial class AbilityComponent : Node { [Signal] public delegate void AbilityActivatedEventHandler(Ability ability); [Signal] public delegate void AbilityFailedEventHandler(Ability ability, string reason); [Signal] public delegate void CooldownStartedEventHandler(Ability ability, float duration); [Signal] public delegate void CooldownFinishedEventHandler(Ability ability); [Export] public float ResourcePool { get; set; } = 100.0f; private readonly Dictionary<string, Ability> _granted = new(); private readonly Dictionary<string, float> _cooldowns = new(); public void Grant(Ability ability) => _granted[ability.AbilityName] = ability; public override void _Process(double delta) { foreach (var name in new List<string>(_cooldowns.Keys)) { _cooldowns[name] -= (float)delta; if (_cooldowns[name] <= 0.0f) { _cooldowns.Remove(name); if (_granted.TryGetValue(name, out var finished)) EmitSignal(SignalName.CooldownFinished, finished); } } } public bool TryActivate(string abilityName) { if (!_granted.TryGetValue(abilityName, out var ability)) return false; if (_cooldowns.ContainsKey(abilityName)) { EmitSignal(SignalName.AbilityFailed, ability, "on_cooldown"); return false; } if (ResourcePool < ability.Cost) { EmitSignal(SignalName.AbilityFailed, ability, "insufficient_resource"); return false; } if (!ability.CanActivate(GetParent())) { EmitSignal(SignalName.AbilityFailed, ability, "conditions_unmet"); return false; } ResourcePool -= ability.Cost; ability.Activate(GetParent()); _cooldowns[abilityName] = ability.Cooldown; EmitSignal(SignalName.CooldownStarted, ability, ability.Cooldown); EmitSignal(SignalName.AbilityActivated, ability); return true; } }
Effects are Resource subclasses — designers create them in the editor and abilities apply them. An EffectHolder node owns the runtime: it tracks elapsed time, calls periodic ticks, and removes effects when they expire.
gdscript# effect.gd class_name Effect extends Resource @export var effect_name: String @export var duration: float = 5.0 # seconds; <= 0 means instant @export var tick_interval: float = 0.0 # 0 = no periodic tick func on_apply(target: Node) -> void: pass func on_tick(target: Node) -> void: pass func on_expire(target: Node) -> void: pass
gdscript# effect_holder.gd class_name EffectHolder extends Node signal effect_applied(effect: Effect) signal effect_expired(effect: Effect) # effect -> [elapsed, tick_accum] var _active: Dictionary = {} func apply_effect(effect: Effect) -> void: effect.on_apply(get_parent()) effect_applied.emit(effect) if effect.duration <= 0.0: effect.on_expire(get_parent()) effect_expired.emit(effect) return _active[effect] = [0.0, 0.0] func _process(delta: float) -> void: var to_remove: Array = [] for effect in _active: _active[effect][0] += delta if effect.tick_interval > 0.0: _active[effect][1] += delta if _active[effect][1] >= effect.tick_interval: _active[effect][1] -= effect.tick_interval effect.on_tick(get_parent()) if effect.duration > 0.0 and _active[effect][0] >= effect.duration: to_remove.append(effect) for effect in to_remove: _active.erase(effect) effect.on_expire(get_parent()) effect_expired.emit(effect)
csharp// Effect.cs using Godot; [GlobalClass] public partial class Effect : Resource { [Export] public string EffectName { get; set; } = ""; [Export] public float Duration { get; set; } = 5.0f; // <= 0 = instant [Export] public float TickInterval { get; set; } = 0.0f; // 0 = no tick public virtual void OnApply(Node target) { } public virtual void OnTick(Node target) { } public virtual void OnExpire(Node target) { } }
csharp// EffectHolder.cs using Godot; using System.Collections.Generic; public partial class EffectHolder : Node { [Signal] public delegate void EffectAppliedEventHandler(Effect effect); [Signal] public delegate void EffectExpiredEventHandler(Effect effect); private readonly Dictionary<Effect, (float Elapsed, float TickAccum)> _active = new(); public void ApplyEffect(Effect effect) { effect.OnApply(GetParent()); EmitSignal(SignalName.EffectApplied, effect); if (effect.Duration <= 0f) { effect.OnExpire(GetParent()); EmitSignal(SignalName.EffectExpired, effect); return; } _active[effect] = (0f, 0f); } public override void _Process(double delta) { var toRemove = new List<Effect>(); foreach (var effect in new List<Effect>(_active.Keys)) { var (elapsed, tickAccum) = _active[effect]; elapsed += (float)delta; if (effect.TickInterval > 0f) { tickAccum += (float)delta; if (tickAccum >= effect.TickInterval) { tickAccum -= effect.TickInterval; effect.OnTick(GetParent()); } } if (effect.Duration > 0f && elapsed >= effect.Duration) toRemove.Add(effect); else _active[effect] = (elapsed, tickAccum); // still active — write back updated elapsed and tick accumulator } foreach (var effect in toRemove) { _active.Remove(effect); effect.OnExpire(GetParent()); EmitSignal(SignalName.EffectExpired, effect); } } }
> Footgun: Never iterate _active directly while removing entries. GDScript builds a to_remove list and erases after the loop; C# iterates new List<Effect>(_active.Keys) for the same reason.
Resources; behavior lives in AbilityComponent, not in the data.can_activate() before spending resources._process/_Process and emit start/finish signals.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 20,318 | 13,990 | -31% | 1 | 1 | 0% | 4,196 | 6,315 | +51% | 0 | 0 | — |
case-02 | fail→pass | 17,089 | 15,986 | -6% | 1 | 1 | 0% | 3,467 | 6,884 | +99% | 0 | 0 | — |
case-03 | fail→fail | 19,657 | 18,722 | -5% | 1 | 1 | 0% | 3,984 | 7,258 | +82% | 0 | 0 | — |
case-04 | pass→pass | 7,854 | 6,207 | -21% | 1 | 1 | 0% | 1,578 | 4,414 | +180% | 0 | 0 | — |
case-05 | pass→pass | 13,971 | 11,302 | -19% | 1 | 1 | 0% | 2,505 | 5,176 | +107% | 0 | 0 | — |
case-18 | fail→fail | 7,989 | 3,726 | -53% | 1 | 1 | 0% | 1,311 | 3,715 | +183% | 0 | 0 | — |
case-06 | pass→pass | 12,262 | 12,066 | -2% | 1 | 1 | 0% | 2,154 | 5,223 | +142% | 0 | 0 | — |
case-07 | pass→pass | 8,007 | 7,480 | -7% | 1 | 1 | 0% | 1,297 | 4,332 | +234% | 0 | 0 | — |
case-08 | fail→pass | 9,266 | 5,469 | -41% | 1 | 1 | 0% | 1,767 | 4,187 | +137% | 0 | 0 | — |
case-09 | pass→pass | 11,633 | 5,704 | -51% | 1 | 1 | 0% | 2,015 | 4,027 | +100% | 0 | 0 | — |
case-10 | pass→pass | 15,226 | 7,182 | -53% | 1 | 1 | 0% | 2,535 | 4,342 | +71% | 0 | 0 | — |
case-11 | fail→pass | 13,394 | 6,980 | -48% | 1 | 1 | 0% | 2,194 | 4,242 | +93% | 0 | 0 | — |
case-12 | pass→pass | 12,569 | 9,037 | -28% | 1 | 1 | 0% | 1,942 | 4,556 | +135% | 0 | 0 | — |
case-13 | pass→pass | 9,299 | 6,418 | -31% | 1 | 1 | 0% | 1,555 | 4,055 | +161% | 0 | 0 | — |
case-14 | pass→pass | 12,662 | 7,502 | -41% | 1 | 1 | 0% | 2,199 | 4,499 | +105% | 0 | 0 | — |
case-15 | pass→pass | 13,298 | 9,998 | -25% | 1 | 1 | 0% | 2,347 | 4,841 | +106% | 0 | 0 | — |
case-16 | fail→pass | 3,535 | 3,397 | -4% | 1 | 1 | 0% | 558 | 3,730 | +568% | 0 | 0 | — |
case-17 | fail→pass | 3,325 | 3,698 | +11% | 1 | 1 | 0% | 578 | 3,798 | +557% | 0 | 0 | — |
case-19 | fail→pass | 8,870 | 5,438 | -39% | 1 | 1 | 0% | 1,638 | 4,106 | +151% | 0 | 0 | — |
case-20 | fail→pass | 14,607 | 6,064 | -58% | 1 | 1 | 0% | 2,459 | 3,993 | +62% | 0 | 0 | — |
case-21 | pass→pass | 8,889 | 6,808 | -23% | 1 | 1 | 0% | 1,757 | 4,467 | +154% | 0 | 0 | — |
case-22 | pass→pass | 10,325 | 5,618 | -46% | 1 | 1 | 0% | 1,747 | 4,027 | +131% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +36 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.