How to Build an RPG Stat System in Godot 4
Every RPG needs stats. Strength, health, stamina, defense - the numbers behind every sword swing and damage calculation. A good stat system is flexible enough to handle equipment bonuses, temporary buffs, level-up point allocation, and damage formulas without turning into a tangled mess.
This tutorial shows you how to build a clean, component-based stat system in Godot 4 that scales from a simple action game to a full RPG.
The Wrong Way: Hardcoded Variables
The beginner approach is to declare stats directly on the character:
# Don't do thisextends CharacterBody3Dvar max_health: int = 100var health: int = 100var strength: int = 10var defense: int = 5var speed: float = 5.0This works until you need equipment that modifies strength, a buff that temporarily doubles defense, or an enemy that uses the same stat system. You end up with base_strength, bonus_strength, total_strength, and a function that recalculates everything when anything changes. It doesn't scale.
The Resource Approach: Stats as Data
Instead, model each stat as a Resource. This makes stats composable, serializable, and reusable:
# stat.gdclass_name Statextends Resourcesignal value_changed(new_value: float)@export var stat_name: String@export var base_value: float = 10.0@export var min_value: float = 0.0@export var max_value: float = 999.0var modifiers: Array[StatModifier] = []var value: float: get: return clampf(_calculate(), min_value, max_value)func _calculate() -> float: var flat_bonus: float = 0.0 var percent_bonus: float = 0.0 for mod in modifiers: match mod.type: StatModifier.Type.FLAT: flat_bonus += mod.value StatModifier.Type.PERCENT: percent_bonus += mod.value return (base_value + flat_bonus) * (1.0 + percent_bonus)func add_modifier(mod: StatModifier) -> void: modifiers.append(mod) value_changed.emit(value)func remove_modifier(mod: StatModifier) -> void: modifiers.erase(mod) value_changed.emit(value)And the modifier:
# stat_modifier.gdclass_name StatModifierextends Resourceenum Type { FLAT, PERCENT }@export var type: Type = Type.FLAT@export var value: float = 0.0@export var source: String = "" # Track where this modifier came fromThis is the key design decision: stats don't store a single number. They store a base value plus a list of modifiers, and calculate the final value on demand.
An iron sword adds a FLAT +5 modifier to strength. A rage buff adds a PERCENT +0.3 (30%) modifier. A poison debuff adds a FLAT -2 to health regen. When the item is unequipped or the buff expires, you remove the modifier. The stat recalculates automatically.
The source field on modifiers is important - it lets you find and remove all modifiers from a specific source (e.g., "iron_sword" or "rage_buff") without tracking them externally.
The Stats Component
A component that holds all stats for a character:
# stats_component.gdclass_name StatsComponentextends Nodesignal stat_changed(stat_name: String, new_value: float)@export var stat_preset: StatPreset # Optional: load from preset Resourcevar stats: Dictionary = {} # stat_name -> Statfunc _ready() -> void: if stat_preset: _load_from_preset(stat_preset)func get_stat(stat_name: String) -> Stat: return stats.get(stat_name)func get_stat_value(stat_name: String) -> float: var stat := get_stat(stat_name) return stat.value if stat else 0.0func add_stat(stat: Stat) -> void: stats[stat.stat_name] = stat stat.value_changed.connect(func(val): stat_changed.emit(stat.stat_name, val))func add_modifier(stat_name: String, mod: StatModifier) -> void: var stat := get_stat(stat_name) if stat: stat.add_modifier(mod)func remove_modifiers_from_source(source: String) -> void: for stat in stats.values(): var to_remove: Array[StatModifier] = [] for mod in stat.modifiers: if mod.source == source: to_remove.append(mod) for mod in to_remove: stat.remove_modifier(mod)func _load_from_preset(preset: StatPreset) -> void: for stat_data in preset.stats: var stat := Stat.new() stat.stat_name = stat_data.stat_name stat.base_value = stat_data.base_value stat.min_value = stat_data.min_value stat.max_value = stat_data.max_value add_stat(stat)The preset system lets you define stat templates in the Inspector:
# stat_preset.gdclass_name StatPresetextends Resource@export var stats: Array[Stat] = []Create a warrior_preset.tres with high strength and health, a mage_preset.tres with high intelligence and mana, and assign them to different characters in the Inspector. No code changes needed to create new character archetypes.
The Leveling Component
Leveling is a separate component that modifies stats. It tracks XP, computes levels, and awards stat points:
# leveling_component.gdclass_name LevelingComponentextends Nodesignal leveled_up(new_level: int, stat_points: int)signal xp_changed(current_xp: int, required_xp: int)@export var stats_component: StatsComponent@export var stat_points_per_level: int = 3@export var base_xp_requirement: int = 100@export var xp_growth: float = 1.5var level: int = 1var current_xp: int = 0var available_stat_points: int = 0func xp_for_level(lvl: int) -> int: return int(base_xp_requirement * pow(xp_growth, lvl - 1))func add_xp(amount: int) -> void: current_xp += amount while current_xp >= xp_for_level(level): current_xp -= xp_for_level(level) level += 1 available_stat_points += stat_points_per_level leveled_up.emit(level, available_stat_points) xp_changed.emit(current_xp, xp_for_level(level))func allocate_point(stat_name: String) -> bool: if available_stat_points <= 0: return false var stat := stats_component.get_stat(stat_name) if not stat: return false stat.base_value += 1.0 available_stat_points -= 1 stat.value_changed.emit(stat.value) return trueThe XP curve uses base * growth^(level-1). With base=100 and growth=1.5:
- Level 2 requires 100 XP
- Level 5 requires 506 XP
- Level 10 requires 3,844 XP
This creates a curve that feels fast early and slows down naturally. Adjust base_xp_requirement and xp_growth to control the pace.
Equipment Integration
Equipment is where the modifier system pays off. Each piece of equipment carries modifiers that get applied when equipped and removed when unequipped:
# equipment.gdclass_name Equipmentextends Resource@export var equipment_name: String@export var slot: EquipmentSlot@export var modifiers: Array[StatModifier] = []enum EquipmentSlot { WEAPON, HELMET, CHEST, BOOTS, RING }# equipment_manager.gdextends Node@export var stats_component: StatsComponentvar equipped: Dictionary = {} # EquipmentSlot -> Equipmentfunc equip(item: Equipment) -> void: # Unequip existing item in the same slot if equipped.has(item.slot): unequip(item.slot) equipped[item.slot] = item # Apply all modifiers from this equipment for mod in item.modifiers: mod.source = item.equipment_name # Tag the source stats_component.add_modifier(mod.stat_name, mod)func unequip(slot: Equipment.EquipmentSlot) -> void: if not equipped.has(slot): return var item: Equipment = equipped[slot] stats_component.remove_modifiers_from_source(item.equipment_name) equipped.erase(slot)Notice how remove_modifiers_from_source does the heavy lifting. You don't need to track which specific modifiers were added - you just remove everything tagged with that equipment's name. Clean and error-proof.
Damage Formulas
With a proper stat system, damage calculations become straightforward:
# damage_calculator.gd - static utilityclass_name DamageCalculatorstatic func calculate_physical(attacker: StatsComponent, defender: StatsComponent) -> int: var attack_power := attacker.get_stat_value("strength") var defense := defender.get_stat_value("defense") # Simple formula: attack minus defense, minimum 1 var raw_damage := maxf(attack_power - defense * 0.5, 1.0) # Random variance (+/- 15%) var variance := randf_range(0.85, 1.15) return int(raw_damage * variance)static func calculate_critical(base_damage: int, crit_multiplier: float := 1.5) -> int: return int(base_damage * crit_multiplier)static func should_crit(attacker: StatsComponent) -> bool: var crit_chance := attacker.get_stat_value("crit_chance") / 100.0 return randf() < crit_chanceThe formula is deliberately simple. attack - defense * 0.5 with random variance. You can make it more complex later - add elemental resistances, critical hits, armor penetration - but start with something you can feel and adjust.
Temporary Buffs and Debuffs
Buffs are modifiers with a timer. When the timer expires, the modifier is removed:
# buff.gdclass_name Buffextends Resource@export var buff_name: String@export var duration: float = 10.0@export var modifiers: Array[StatModifier] = []# buff_manager.gdextends Node@export var stats_component: StatsComponentvar active_buffs: Array[Dictionary] = [] # {buff, timer}func apply_buff(buff: Buff) -> void: # Apply modifiers for mod in buff.modifiers: mod.source = buff.buff_name stats_component.add_modifier(mod.stat_name, mod) active_buffs.append({"buff": buff, "timer": buff.duration})func _process(delta: float) -> void: var expired: Array[Dictionary] = [] for entry in active_buffs: entry.timer -= delta if entry.timer <= 0.0: expired.append(entry) for entry in expired: stats_component.remove_modifiers_from_source(entry.buff.buff_name) active_buffs.erase(entry)A health potion applies a FLAT +5 to health_regen for 10 seconds. A war cry applies a PERCENT +0.2 to strength for 30 seconds. The buff manager handles timing and cleanup automatically.
Common Mistakes
Recalculating totals manually. Don't keep base_strength and total_strength in sync yourself. Let the Stat Resource calculate its value from base + modifiers on demand.
Forgetting to remove modifiers. Always tag modifiers with a source and use remove_modifiers_from_source when unequipping items or expiring buffs. Orphaned modifiers are the #1 stat bug.
Over-engineering the damage formula. Start with attack - defense. Playtest. Adjust. Only add complexity when simple math doesn't produce the feel you want.
Storing stats as plain ints. Using Resources with signals means the UI updates automatically when stats change. Plain variables require manual UI refresh calls that you'll forget.
Going Further
This tutorial covers the core: stat resources, modifiers, leveling, equipment, and buffs. A production RPG stat system also handles:
- Stat point respec - resetting allocated points
- Derived stats - Max HP calculated from constitution, dodge chance from agility
- Status effects - poison tick, stun immunity, elemental resistance
- Stat scaling curves - diminishing returns on high stats
- Save/load - serializing base values, allocated points, and active buffs
We cover all of this in our Stats & Leveling System course - 8 hands-on lessons that build a complete, component-based RPG stat system with modifiers, leveling, equipment integration, and UI. Each lesson builds one piece, and by the end you have a system that works for any character in any RPG.