godot-ability-system

SKILL.md

Ability System

Expert guidance for building flexible, extensible ability systems.

NEVER Do

  • NEVER use _process() for cooldown tracking — Use timers or manual delta tracking in _physics_process(). _process() has variable delta and causes cooldown desync in slow frames.
  • NEVER forget global cooldown (GCD) — Without GCD, players spam instant abilities. Add a small universal cooldown (0.5-1.5s) between all ability casts.
  • NEVER hardcode ability effects in manager code — Use the Strategy pattern. Each ability is a Resource with execute() method, not a giant switch statement.
  • NEVER allow ability use during animation lock — Check is_casting or animation_playing before allowing new casts. Interrupting animations breaks state machines.
  • NEVER save cooldown state without time normalization — Save "cooldown_end_time" (OS.get_unix_time() + remaining), not "remaining_time". Prevents exploits (change system clock, reload game).

Available Scripts

MANDATORY: Read the appropriate script before implementing the corresponding pattern.

ability_manager.gd

Ability orchestration with cooldown registry, can_use checks, and visual cooldown progress. Decoupled from character logic for use on players, enemies, or turrets.

ability_resource.gd

Scriptable ability resource base class with metadata, stats, and effects array. Virtual execute() method for inheritance (ProjectileAbility, BuffAbility).


Architecture Patterns

Resource-Based Abilities

# 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

Concrete Ability Example

# 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 (Centralized)

Core Manager

# 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

Advanced Patterns

Combo System

# 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-Based Abilities

# 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 Tree System

Skill Node

# 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 Manager

# 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)

Cooldown Strategies

Per-Ability Cooldown (Standard)

# Already shown in AbilityManager above
# Each ability has independent cooldown

Shared Cooldown (Hearthstone-style)

# 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

Charge System (Already shown above)

Multiple uses, recharges over time.


Edge Cases

Cooldown Persistence

# 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

Animation Lock

# 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

Reference

Weekly Installs
53
GitHub Stars
35
First Seen
Feb 10, 2026
Installed on
gemini-cli53
codex52
opencode51
kimi-cli50
amp50
github-copilot50