Claude
Skills
Sign in
Back

godot-ability-system

Included with Lifetime
$97 forever

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.

Generalscripts

What this skill does


# 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).
- **NEVER use Singletons (Autoloads) for combat managers** — Centralizing combat state in a global object makes tracking bugs difficult and breaks encapsulation. Keep abilities and stats scoped to the scenes that actually use them.
- **NEVER use Object Pooling with GDScript** — GDScript uses reference counting memory management, so you generally do not need to pool instantiated abilities or projectiles. Simply instantiate and queue_free().
- **NEVER rely on deep inheritance trees** — Avoid having a BaseAbility -> MagicAbility -> FireAbility inheritance hell. Use node composition instead.

---

## Available Scripts

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

### [ability_manager.gd](scripts/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](scripts/ability_resource.gd)
Scriptable ability resource base class with metadata, stats, and effects array. Virtual execute() method for inheritance (ProjectileAbility, BuffAbility).

### [buff_stat.gd](scripts/buff_stat.gd)
Resource-Driven Buff System setup. Extends Resource and creates highly modular, drag-and-drop ability data.

---

## Architecture Patterns

### Resource-Based Abilities

```gdscript
# 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

```gdscript
# 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

```gdscript
# 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

```gdscript
# 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 

Related in General