godot-genre-action-rpg
SKILL.md
Genre: Action RPG
Expert blueprint for action RPGs emphasizing real-time combat, character builds, loot, and progression.
NEVER Do
- NEVER make stats invisible to players — Hidden stats feel like RNG. Show damage numbers, crit chance %, armor values clearly.
- NEVER use linear damage scaling —
damage = level * 10makes early/late game boring. Use exponential:damage = base * pow(1.15, level). - NEVER forget diminishing returns on defense — Armor as
damage_reduction = armor / (armor + 100)prevents invincibility stacking. - NEVER make loot drops feel samey — Differentiate rarities with visual effects (Epic = purple glow), sound cues, and meaningful stat differences.
- NEVER skip hit recovery/stagger — Attacks without hitstun feel weightless. Add 0.2-0.5s stagger on hit for impact feedback.
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
damage_label_manager.gd
Pooled floating damage numbers with vertical stacking logic. Pre-warms pool, handles critical hit scaling, and auto-fades via tweens.
telegraphed_enemy.gd
AoE telegraph pattern for enemy attacks. Wind-up animation with visual cues gives players dodge window, then executes damage zone.
Core Loop
Combat → Loot → Level Up → Build Power → Challenge Harder Content → Repeat
Skill Chain
godot-project-foundations, godot-characterbody-2d, godot-combat-system, godot-rpg-stats, godot-inventory-system, godot-ability-system, godot-quest-system, godot-economy-system, godot-save-load-systems
Combat System
Real-Time Combat with Stats
class_name CombatController
extends Node
signal damage_dealt(target: Node, amount: int, type: String)
signal enemy_killed(enemy: Node, xp_reward: int)
func calculate_damage(attacker: RPGStats, defender: RPGStats, base_damage: int) -> Dictionary:
# Physical damage formula
var attack_power := attacker.get_stat("strength") * 2 + base_damage
var defense := defender.get_stat("armor")
# Damage reduction formula (diminishing returns)
var reduction := defense / (defense + 100.0)
var final_damage := int(attack_power * (1.0 - reduction))
# Critical hit check
var crit_chance := attacker.get_stat("crit_chance") / 100.0
var is_crit := randf() < crit_chance
if is_crit:
final_damage = int(final_damage * attacker.get_stat("crit_damage") / 100.0)
return {
"damage": max(1, final_damage),
"is_crit": is_crit,
"damage_type": "physical"
}
func apply_damage(target: Node, damage_result: Dictionary) -> void:
if target.has_method("take_damage"):
target.take_damage(damage_result["damage"], damage_result["is_crit"])
damage_dealt.emit(target, damage_result["damage"], damage_result["damage_type"])
Hitbox/Hurtbox Combat
class_name Hitbox
extends Area2D
@export var damage: int = 10
@export var knockback_force: float = 200.0
@export var attack_owner: Node
var has_hit: Array[Node] = [] # Prevent multi-hit per swing
func _ready() -> void:
monitoring = false # Enable only during attack frames
func enable() -> void:
has_hit.clear()
monitoring = true
func disable() -> void:
monitoring = false
func _on_area_entered(area: Area2D) -> void:
if area is Hurtbox:
var target := area.owner_entity
if target != attack_owner and target not in has_hit:
has_hit.append(target)
var result := CombatController.calculate_damage(
attack_owner.stats, target.stats, damage
)
CombatController.apply_damage(target, result)
apply_knockback(target)
func apply_knockback(target: Node) -> void:
var direction := (target.global_position - attack_owner.global_position).normalized()
if target.has_method("apply_knockback"):
target.apply_knockback(direction * knockback_force)
RPG Stats System
Attribute-Based Stats
class_name RPGStats
extends Resource
signal stat_changed(stat_name: String, new_value: float)
signal level_up(new_level: int)
# Base attributes (increased on level up)
@export var strength: int = 10
@export var dexterity: int = 10
@export var intelligence: int = 10
@export var vitality: int = 10
# Derived stats (calculated from attributes)
var derived_stats: Dictionary = {}
# Modifiers from equipment, buffs, etc.
var flat_modifiers: Dictionary = {} # +50 health
var percent_modifiers: Dictionary = {} # +10% damage
var level: int = 1
var experience: int = 0
var skill_points: int = 0
func _init() -> void:
recalculate_stats()
func recalculate_stats() -> void:
derived_stats = {
# Health: Vitality-based
"max_health": vitality * 10 + 100,
"health_regen": vitality * 0.5,
# Mana: Intelligence-based
"max_mana": intelligence * 8 + 50,
"mana_regen": intelligence * 0.3,
# Physical: Strength + Dexterity
"physical_damage": strength * 2,
"armor": strength + vitality,
# Critical: Dexterity-based
"crit_chance": 5.0 + dexterity * 0.2,
"crit_damage": 150.0 + dexterity * 0.5,
# Speed: Dexterity-based
"attack_speed": 1.0 + dexterity * 0.01,
"move_speed": 100.0 + dexterity * 2
}
# Apply modifiers
for stat_name in derived_stats:
var base := derived_stats[stat_name]
var flat := flat_modifiers.get(stat_name, 0.0)
var percent := percent_modifiers.get(stat_name, 0.0)
derived_stats[stat_name] = (base + flat) * (1.0 + percent / 100.0)
func get_stat(stat_name: String) -> float:
if stat_name in derived_stats:
return derived_stats[stat_name]
return get(stat_name)
func add_experience(amount: int) -> void:
experience += amount
while experience >= get_xp_for_next_level():
experience -= get_xp_for_next_level()
level += 1
skill_points += 5
level_up.emit(level)
func get_xp_for_next_level() -> int:
# Exponential scaling
return int(100 * pow(1.5, level - 1))
Loot System
Item Generation
class_name LootGenerator
extends Node
enum Rarity { COMMON, UNCOMMON, RARE, EPIC, LEGENDARY }
const RARITY_WEIGHTS := {
Rarity.COMMON: 60,
Rarity.UNCOMMON: 25,
Rarity.RARE: 10,
Rarity.EPIC: 4,
Rarity.LEGENDARY: 1
}
const RARITY_AFFIX_COUNT := {
Rarity.COMMON: 0,
Rarity.UNCOMMON: 1,
Rarity.RARE: 2,
Rarity.EPIC: 3,
Rarity.LEGENDARY: 4
}
@export var affix_pool: Array[ItemAffix]
@export var base_items: Array[ItemBase]
func generate_item(item_level: int, magic_find: float = 0.0) -> Item:
var rarity := roll_rarity(magic_find)
var base := base_items.pick_random()
var item := Item.new()
item.base = base
item.rarity = rarity
item.item_level = item_level
# Roll affixes based on rarity
var affix_count := RARITY_AFFIX_COUNT[rarity]
var available_affixes := affix_pool.duplicate()
for i in affix_count:
if available_affixes.is_empty():
break
var affix := available_affixes.pick_random()
available_affixes.erase(affix)
item.affixes.append(generate_affix_roll(affix, item_level))
return item
func roll_rarity(magic_find: float) -> Rarity:
var weights := RARITY_WEIGHTS.duplicate()
# Magic find increases rare+ drops
weights[Rarity.RARE] *= (1.0 + magic_find / 100.0)
weights[Rarity.EPIC] *= (1.0 + magic_find / 100.0)
weights[Rarity.LEGENDARY] *= (1.0 + magic_find / 100.0)
var total := 0.0
for w in weights.values():
total += w
var roll := randf() * total
for rarity in weights:
roll -= weights[rarity]
if roll <= 0:
return rarity
return Rarity.COMMON
func generate_affix_roll(affix: ItemAffix, item_level: int) -> Dictionary:
# Scale affix values with item level
var min_roll := affix.min_value * (1.0 + item_level * 0.1)
var max_roll := affix.max_value * (1.0 + item_level * 0.1)
return {
"affix": affix,
"value": randf_range(min_roll, max_roll)
}
Equipment System
class_name Equipment
extends Node
signal equipment_changed(slot: String, item: Item)
enum Slot { HEAD, CHEST, HANDS, LEGS, FEET, WEAPON, OFFHAND, RING1, RING2, AMULET }
var equipped: Dictionary = {} # Slot -> Item
func equip(item: Item) -> Item:
var slot: Slot = item.base.slot
var previous: Item = equipped.get(slot)
# Unequip old item
if previous:
remove_item_stats(previous)
# Equip new item
equipped[slot] = item
apply_item_stats(item)
equipment_changed.emit(Slot.keys()[slot], item)
return previous # Return to inventory
func apply_item_stats(item: Item) -> void:
var stats := owner.stats as RPGStats
# Base stats
for stat_name in item.base.base_stats:
stats.flat_modifiers[stat_name] = stats.flat_modifiers.get(stat_name, 0) + item.base.base_stats[stat_name]
# Affix stats
for affix_data in item.affixes:
var affix := affix_data["affix"] as ItemAffix
var value := affix_data["value"]
if affix.is_percent:
stats.percent_modifiers[affix.stat] = stats.percent_modifiers.get(affix.stat, 0) + value
else:
stats.flat_modifiers[affix.stat] = stats.flat_modifiers.get(affix.stat, 0) + value
stats.recalculate_stats()
Ability System
Skill Trees and Unlocks
class_name SkillTree
extends Resource
@export var skills: Array[Skill]
@export var connections: Dictionary # skill_id -> Array[prerequisite_ids]
func can_unlock(skill_id: String, unlocked_skills: Array[String]) -> bool:
if skill_id in unlocked_skills:
return false # Already unlocked
var prereqs: Array = connections.get(skill_id, [])
for prereq in prereqs:
if prereq not in unlocked_skills:
return false
return true
func unlock_skill(skill_id: String, player: Node) -> bool:
var skill := get_skill(skill_id)
if not skill or player.stats.skill_points < skill.cost:
return false
player.stats.skill_points -= skill.cost
player.unlocked_skills.append(skill_id)
player.ability_manager.add_ability(skill.ability)
return true
Active Abilities
class_name ActiveAbility
extends Resource
@export var name: String
@export var cooldown: float = 5.0
@export var mana_cost: int = 20
@export var damage_multiplier: float = 2.0
@export var aoe_radius: float = 0.0
@export var effect_scene: PackedScene
var current_cooldown: float = 0.0
func can_use(caster: Node) -> bool:
return current_cooldown <= 0 and caster.stats.current_mana >= mana_cost
func use(caster: Node, target_position: Vector2) -> void:
if not can_use(caster):
return
caster.stats.current_mana -= mana_cost
current_cooldown = cooldown
var effect := effect_scene.instantiate()
effect.global_position = target_position
effect.damage = int(caster.stats.get_stat("physical_damage") * damage_multiplier)
effect.caster = caster
caster.get_tree().current_scene.add_child(effect)
func update_cooldown(delta: float) -> void:
current_cooldown = max(0, current_cooldown - delta)
Enemy Design
Scaling Difficulty
class_name EnemySpawner
extends Node
@export var base_enemy_scene: PackedScene
@export var area_level: int = 1
func spawn_enemy(position: Vector2) -> Node:
var enemy := base_enemy_scene.instantiate()
enemy.global_position = position
# Scale stats with area level
var stats := enemy.stats as RPGStats
var level_mult := 1.0 + (area_level - 1) * 0.15
stats.vitality = int(stats.vitality * level_mult)
stats.strength = int(stats.strength * level_mult)
stats.recalculate_stats()
# Scale rewards
enemy.xp_reward = int(enemy.xp_reward * level_mult)
enemy.loot_table.item_level = area_level
add_child(enemy)
return enemy
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Stats feel meaningless | Ensure each point noticeably affects gameplay |
| Loot feels same | Dramatic visual and mechanical differences between rarities |
| Combat too simple | Combo systems, positioning matters, enemy variety |
| Progression walls | Multiple viable paths, catch-up mechanics |
| Inventory management tedium | Auto-sort, quick-sell, stash tabs |
Architecture Overview
AutoLoads:
├── PlayerStats (godot-rpg-stats)
├── InventoryManager (godot-inventory-system)
├── QuestManager (godot-quest-system)
├── LootGenerator (godot-economy-system)
└── GameManager (godot-scene-management)
Player:
├── CharacterBody2D/3D
├── RPGStats
├── Equipment
├── AbilityManager
├── Hitbox/Hurtbox
└── InputHandler
Enemies:
├── AI Controller (state machine)
├── RPGStats (scaled)
├── HealthComponent
├── LootTable
└── Hitbox/Hurtbox
Godot-Specific Tips
- Resources for items: Use
Resourcefor items - easily serializable for save/load - Object pooling: Pool damage numbers, projectiles, item pickups
- Animation callbacks: Use AnimationPlayer method tracks to enable/disable hitboxes
- Stat recalculation: Only recalculate on equip/level, not every frame
Example Games for Reference
- Diablo / Path of Exile - Loot-focused ARPG
- Elden Ring / Dark Souls - Combat-focused action RPG
- Hades - Roguelike ARPG hybrid
- Grim Dawn - Deep character builds
Reference
- Master Skill: godot-master
Weekly Installs
50
Repository
thedivergentai/…c-skillsGitHub Stars
35
First Seen
Feb 10, 2026
Security Audits
Installed on
codex49
gemini-cli49
opencode49
kimi-cli48
github-copilot48
amp48