skills/godot-ability-system/SKILL.md
Expert patterns for RPG/action ability systems including cooldown strategies, combo systems, ability chaining, skill trees with prerequisites, upgrade paths, and resource management. Use when implementing unlockable abilities, character progression, or complex skill systems. Trigger keywords: PlayerAbility, AbilityManager, cooldown, SkillTree, SkillNode, prerequisites, can_use, execute, ComboSystem, ability_chain, global_cooldown, charge_system, upgrade_path.
npx skillsauth add thedivergentai/gd-agentic-skills godot-ability-systemInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Expert guidance for building flexible, extensible ability systems.
is_casting or animation_playing before allowing new casts. Interrupting animations breaks state machines.MANDATORY: Read the appropriate script before implementing the corresponding pattern.
Ability orchestration with cooldown registry, can_use checks, and visual cooldown progress. Decoupled from character logic for use on players, enemies, or turrets.
Scriptable ability resource base class with metadata, stats, and effects array. Virtual execute() method for inheritance (ProjectileAbility, BuffAbility).
Resource-Driven Buff System setup. Extends Resource and creates highly modular, drag-and-drop ability data.
# ability_base.gd - Base class for all abilities
class_name Ability
extends Resource
@export var ability_id: String
@export var display_name: String
@export var icon: Texture2D
@export var description: String
@export_group("Costs")
@export var mana_cost: int = 0
@export var stamina_cost: int = 0
@export var health_cost: int = 0 # Life tap abilities
@export_group("Timing")
@export var cooldown: float = 5.0
@export var cast_time: float = 0.0 # 0 = instant
@export var channel_time: float = 0.0 # Channeled abilities
@export_group("Unlocking")
@export var unlock_level: int = 1
@export var prerequisites: Array[String] = [] # Other ability IDs
## Override these
func can_cast(caster: Node) -> bool:
return true # Additional checks (range, target, etc.)
func execute(caster: Node, target: Node = null) -> void:
pass # Ability effect
func on_cast_start(caster: Node) -> void:
pass # Animation, effects
func on_cast_complete(caster: Node) -> void:
execute(caster)
func on_cancel(caster: Node) -> void:
pass # Refund resources
# fireball.gd
class_name FireballAbility
extends Ability
@export var damage: int = 50
@export var projectile_scene: PackedScene
@export var range: float = 500.0
func can_cast(caster: Node) -> bool:
var target = caster.get_target()
if not target:
return false
var distance := caster.global_position.distance_to(target.global_position)
return distance <= range
func execute(caster: Node, target: Node = null) -> void:
var projectile := projectile_scene.instantiate()
caster.get_parent().add_child(projectile)
projectile.global_position = caster.global_position
projectile.target = target
projectile.damage = damage
# ability_manager.gd
class_name AbilityManager
extends Node
signal ability_cast(ability_id: String)
signal ability_ready(ability_id: String)
signal cooldown_started(ability_id: String, duration: float)
var abilities: Dictionary = {} # ability_id → Ability
var cooldowns: Dictionary = {} # ability_id → float (time remaining)
var is_casting: bool = false
var global_cooldown: float = 0.0 # GCD timer
@export var gcd_duration: float = 1.0 # Global cooldown
func register_ability(ability: Ability) -> void:
abilities[ability.ability_id] = ability
cooldowns[ability.ability_id] = 0.0
func can_use_ability(ability_id: String, caster: Node) -> bool:
var ability := abilities.get(ability_id) as Ability
if not ability:
return false
# Check GCD
if global_cooldown > 0.0:
return false
# Check specific cooldown
if cooldowns.get(ability_id, 0.0) > 0.0:
return false
# Check if already casting
if is_casting and ability.cast_time > 0.0:
return false
# Check resources
if not has_resources(caster, ability):
return false
# Ability-specific checks
return ability.can_cast(caster)
func use_ability(ability_id: String, caster: Node, target: Node = null) -> bool:
if not can_use_ability(ability_id, caster):
return false
var ability := abilities[ability_id]
# Consume resources
consume_resources(caster, ability)
# Start cast
if ability.cast_time > 0.0:
start_cast(ability, caster, target)
else:
# Instant cast
ability.execute(caster, target)
trigger_cooldown(ability_id, ability.cooldown)
ability_cast.emit(ability_id)
return true
func start_cast(ability: Ability, caster: Node, target: Node) -> void:
is_casting = true
ability.on_cast_start(caster)
# Create timer for cast completion
var timer := get_tree().create_timer(ability.cast_time)
await timer.timeout
if is_casting: # Not interrupted
ability.on_cast_complete(caster)
trigger_cooldown(ability.ability_id, ability.cooldown)
is_casting = false
func interrupt_cast() -> void:
if is_casting:
is_casting = false
# Trigger ability.on_cancel() if needed
func trigger_cooldown(ability_id: String, duration: float) -> void:
cooldowns[ability_id] = duration
global_cooldown = gcd_duration
cooldown_started.emit(ability_id, duration)
func _physics_process(delta: float) -> void:
# Tick cooldowns
for ability_id in cooldowns.keys():
if cooldowns[ability_id] > 0.0:
cooldowns[ability_id] -= delta
if cooldowns[ability_id] <= 0.0:
ability_ready.emit(ability_id)
# Tick GCD
if global_cooldown > 0.0:
global_cooldown -= delta
func has_resources(caster: Node, ability: Ability) -> bool:
return (caster.mana >= ability.mana_cost and
caster.stamina >= ability.stamina_cost and
caster.health > ability.health_cost)
func consume_resources(caster: Node, ability: Ability) -> void:
caster.mana -= ability.mana_cost
caster.stamina -= ability.stamina_cost
caster.health -= ability.health_cost
# combo_tracker.gd
extends Node
var combo_chain: Array[String] = []
var combo_window: float = 2.0 # Seconds to continue combo
var last_ability_time: float = 0.0
func register_ability_use(ability_id: String) -> void:
var current_time := Time.get_ticks_msec() * 0.001
# Reset if too much time passed
if current_time - last_ability_time > combo_window:
combo_chain.clear()
combo_chain.append(ability_id)
last_ability_time = current_time
# Check for combo completion
check_combos()
func check_combos() -> void:
# Example: "slash" → "slash" → "spin" = "whirlwind"
if combo_chain.size() >= 3:
var last_three := combo_chain.slice(-3)
if last_three == ["slash", "slash", "spin"]:
trigger_combo_ability("whirlwind")
combo_chain.clear()
func trigger_combo_ability(combo_id: String) -> void:
# Execute powerful combo ability
pass
# charge_ability.gd - Abilities with multiple charges (like League of Legends Flash)
class_name ChargeAbility
extends Ability
@export var max_charges: int = 2
@export var charge_recharge_time: float = 20.0
var current_charges: int = max_charges
var recharge_timer: float = 0.0
func can_cast(caster: Node) -> bool:
return current_charges > 0
func execute(caster: Node, target: Node = null) -> void:
current_charges -= 1
# Start recharging if not at max
if current_charges < max_charges and recharge_timer == 0.0:
recharge_timer = charge_recharge_time
func tick(delta: float) -> void:
if recharge_timer > 0.0:
recharge_timer -= delta
if recharge_timer <= 0.0:
current_charges += 1
if current_charges < max_charges:
recharge_timer = charge_recharge_time # Continue recharging
else:
recharge_timer = 0.0
# skill_node.gd
class_name SkillNode
extends Resource
@export var skill_id: String
@export var display_name: String
@export var description: String
@export var icon: Texture2D
@export_group("Requirements")
@export var prerequisites: Array[String] = [] # Other skill_ids
@export var character_level_required: int = 1
@export var points_required: int = 1
@export var mutually_exclusive_with: Array[String] = [] # Can't have both
@export_group("Progression")
@export var max_rank: int = 1
@export var current_rank: int = 0
@export_group("Effects")
@export var unlocks_ability: String = "" # Ability ID to grant
@export var stat_bonuses: Dictionary = {} # "strength": 5, "crit_chance": 0.05
func can_unlock(player_skills: Dictionary, player_level: int, available_points: int) -> bool:
# Already maxed
if current_rank >= max_rank:
return false
# Not enough points
if available_points < points_required:
return false
# Level requirement
if player_level < character_level_required:
return false
# Prerequisites
for prereq_id in prerequisites:
if not player_skills.has(prereq_id) or player_skills[prereq_id].current_rank == 0:
return false
# Mutual exclusivity
for exclusive_id in mutually_exclusive_with:
if player_skills.has(exclusive_id) and player_skills[exclusive_id].current_rank > 0:
return false
return true
func unlock() -> void:
current_rank += 1
# skill_tree.gd
class_name SkillTree
extends Node
signal skill_unlocked(skill_id: String, rank: int)
signal points_changed(new_total: int)
var skills: Dictionary = {} # skill_id → SkillNode
var skill_points: int = 0
func add_skill(skill: SkillNode) -> void:
skills[skill.skill_id] = skill
func can_unlock_skill(skill_id: String, player_level: int) -> bool:
var skill := skills.get(skill_id) as SkillNode
if not skill:
return false
return skill.can_unlock(skills, player_level, skill_points)
func unlock_skill(skill_id: String, player_level: int) -> bool:
if not can_unlock_skill(skill_id, player_level):
return false
var skill := skills[skill_id]
skill.unlock()
skill_points -= skill.points_required
# Apply effects
apply_skill_effects(skill)
skill_unlocked.emit(skill_id, skill.current_rank)
points_changed.emit(skill_points)
return true
func apply_skill_effects(skill: SkillNode) -> void:
# Grant ability if specified
if skill.unlocks_ability != "":
var ability_manager := get_node("/root/AbilityManager")
# Register new ability
# Apply stat bonuses
var player := get_tree().get_first_node_in_group("player")
for stat_name in skill.stat_bonuses.keys():
var bonus = skill.stat_bonuses[stat_name]
player.set(stat_name, player.get(stat_name) + bonus)
func add_skill_points(amount: int) -> void:
skill_points += amount
points_changed.emit(skill_points)
func reset_tree(refund_points: bool = true) -> void:
var total_spent := 0
for skill in skills.values():
total_spent += skill.current_rank * skill.points_required
skill.current_rank = 0
if refund_points:
skill_points += total_spent
points_changed.emit(skill_points)
# Already shown in AbilityManager above
# Each ability has independent cooldown
# All abilities of type "summon" share cooldown
var summon_cooldown: float = 0.0
func use_summon_ability(ability: Ability) -> void:
ability.execute()
summon_cooldown = 3.0 # All summons on 3s cooldown
Multiple uses, recharges over time.
# save_system.gd
func save_ability_cooldowns() -> Dictionary:
var data := {}
var current_time := Time.get_unix_time_from_system()
for ability_id in ability_manager.cooldowns.keys():
var remaining := ability_manager.cooldowns[ability_id]
if remaining > 0.0:
data[ability_id] = current_time + remaining # Absolute time
return data
func load_ability_cooldowns(data: Dictionary) -> void:
var current_time := Time.get_unix_time_from_system()
for ability_id in data.keys():
var end_time: float = data[ability_id]
var remaining := max(0.0, end_time - current_time)
ability_manager.cooldowns[ability_id] = remaining
# Prevent ability spam during attack animations
func _on_animation_player_animation_started(anim_name: String) -> void:
if anim_name.begins_with("attack_"):
ability_manager.is_casting = true
func _on_animation_player_animation_finished(anim_name: String) -> void:
if anim_name.begins_with("attack_"):
ability_manager.is_casting = false
Design your ability scenes so they have no hardcoded dependencies on the player context (e.g., getting parent nodes to reduce health). Instead, the parent context should inject itself or wire the signals.
Do not enforce strict class checks. Rely on duck-typing: if collision.get_collider().has_method("hit"): collision.get_collider().hit()
For Area-of-Effect abilities, assign entities to groups. Process damage efficiently by calling get_tree().call_group("enemies", "apply_damage", 50) instead of looping manually.
Status effects should be represented as custom Resource scripts. This allows them to be data containers with logic, encapsulated methods, and signals for data changes.
[!CAUTION] When applying a status effect template to a character at runtime, you MUST use
duplicate(true)to create a deep copy. Modifying a shared resource instance will apply changes to EVERY character using that template globally.
# status_effect.gd
class_name StatusEffect extends Resource
@export var effect_name: String = "Unknown"
@export var duration: float = 5.0
@export var tick_rate: float = 1.0
var _time_since_last_tick: float = 0.0
var _elapsed_time: float = 0.0
func apply_effect(target: Node) -> void:
# Logic to apply effect (e.g., damage, stat change)
pass
func process_tick(target: Node, delta: float) -> bool:
_elapsed_time += delta
_time_since_last_tick += delta
if _time_since_last_tick >= tick_rate:
apply_effect(target)
_time_since_last_tick = 0.0
return _elapsed_time >= duration
# status_effect_manager.gd
class_name StatusEffectManager extends Node
var active_effects: Array[StatusEffect] = []
func add_effect(effect_template: StatusEffect) -> void:
# Essential: duplicate to avoid global state pollution
active_effects.append(effect_template.duplicate(true))
func _process(delta: float) -> void:
# Backward iteration for safe removal
for i in range(active_effects.size() - 1, -1, -1):
var effect: StatusEffect = active_effects[i]
var is_finished: bool = effect.process_tick(get_parent(), delta)
if is_finished:
active_effects.remove_at(i)
To eliminate perceived lag in multiplayer, use a combination of local prediction and authoritative server validation via RPCs.
# ability_caster.gd
class_name AbilityCaster extends Node
@rpc("any_peer", "call_remote", "reliable")
func server_request_cast(target_pos: Vector3) -> void:
var sender_id := multiplayer.get_remote_sender_id()
# Authoritative check
if has_sufficient_resources():
consume_resources()
rpc("client_execute_cast", target_pos) # Confirm for everyone
else:
rpc_id(sender_id, "client_cancel_cast") # Reject prediction
@rpc("authority", "call_remote", "reliable")
func client_execute_cast(target_pos: Vector3) -> void:
if not is_multiplayer_authority():
_play_cast_animation() # Sync for observers
_spawn_projectile(target_pos)
@rpc("authority", "call_remote", "reliable")
func client_cancel_cast() -> void:
# Rollback local visuals/state
_cancel_animation()
Use @tool and GraphEdit to create visual auditing tools for skill dependencies and balance.
@tool
class_name SkillTreeVisualizer extends GraphEdit
@export var skill_database: Array[Resource] = []:
set(value):
skill_database = value
if Engine.is_editor_hint(): _rebuild_graph()
func _rebuild_graph() -> void:
clear_connections()
for child in get_children(): if child is GraphNode: child.queue_free()
# Instantiate GraphNodes and connect via connect_node(from, port, to, port)
# based on Resource dependency properties.
development
Godot Expert Auditor: Aurelius. Exhaustive never-list enforcement and architectural slap-down for Godot 4.6 projects.
development
--- name:# Godot Expert Analyst: Anara ## Visionary Architect of Godot 4.6+ Excellence > "Scale is not a feature; it is a philosophy. I don't look at what your game is today; I look at whether it can survive tomorrow." — Anara You are **Anara**, the visionary architect of Godot 4.6+ excellence. You evaluate projects not for "if they work", but for "how well they scale". Your purpose is to certify professional-grade projects and provide the blueprint for architectural transcendence. Your voice
development
Expert blueprint for AI pathfinding (tower defense, RTS, stealth) using NavigationAgent2D/3D, NavigationServer, avoidance, and dynamic navigation mesh generation. Use when implementing enemy AI, NPC movement, or obstacle avoidance. Keywords NavigationAgent2D, NavigationRegion2D, pathfinding, NavigationServer, avoidance, baking, NavigationObstacle.
development
Expert patterns for Godot audio including AudioStreamPlayer variants (2D positional, 3D spatial), AudioBus mixing architecture, dynamic effects (reverb, EQ,compression), audio pooling for performance, music transitions (crossfade, bpm-sync), and procedural audio generation. Use for music systems, sound effects, spatial audio, or audio-reactive gameplay. Trigger keywords: AudioStreamPlayer, AudioStreamPlayer2D, AudioStreamPlayer3D, AudioBus, AudioServer, AudioEffect, music_crossfade, audio_pool, positional_audio, reverb, bus_volume.