Every RPG lives and dies on its numbers. Strength, health, stamina, defense: the math behind every sword swing. And almost everyone builds their first stat system wrong. I did, twice. The system has to handle equipment bonuses, temporary buffs, level-up point allocation, and damage formulas without collapsing into a tangle of variables that all secretly depend on each other.
Here's how I build stat systems in Godot 4 now: component-based, Resource-driven, and flexible enough to scale from a small action game to a full RPG.
The Wrong Way: Hardcoded Variables
The beginner move is declaring stats straight on the character:
# Don't do thisextends CharacterBody3Dvar max_health: int = 100var health: int = 100var strength: int = 10var defense: int = 5var speed: float = 5.0And honestly, for a jam game, fine. But it falls apart the moment you need equipment that modifies strength, a buff that doubles defense for ten seconds, or an enemy that shares the same system. You end up with base_strength, bonus_strength, total_strength, and one cursed function that recalculates everything whenever anything changes. I've maintained that function. Don't build it.
The Resource Approach: Stats as Data
Model each stat as a Resource instead. Stats become 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 fromThe entire design fits in one sentence: a stat doesn't store a single number, it stores a base value plus a list of modifiers and calculates the final value on demand.
An iron sword adds a FLAT +5 to strength. A rage buff adds a PERCENT +0.3 (that's 30 percent). A poison debuff adds a FLAT -2 to health regen. Unequip the item or let the buff expire, remove the modifier, and the stat recalculates itself. No bookkeeping.
The source field is the underrated part. It lets you find and remove every modifier from a specific source ("iron_sword", "rage_buff") without tracking them anywhere else.
The Stats Component
One component 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 is where this gets pleasant. 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 per character in the Inspector. New archetypes cost zero code.
The Leveling Component
Leveling is its own component that sits next to stats. It tracks XP, computes levels, and hands out 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 is 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
Fast early levels, a natural slowdown later. Tune base_xp_requirement and xp_growth until the pacing matches your game. A growth of 1.5 gets steep quickly. If you want leveling to stay snappy for longer, pull it down toward 1.2 and see how it feels.
Equipment Integration
Equipment is where the modifier system pays for itself. Each item carries modifiers that apply on equip and vanish on unequip:
# 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)remove_modifiers_from_source is doing the heavy lifting. You never track which specific modifiers an item added. You just remove everything tagged with that item's name. Hard to get wrong, which is exactly what you want in code that runs every time the player swaps gear. If you're pairing this with an inventory, the Godot 4 inventory system tutorial covers the other half of that handshake.
Damage Formulas
With real stats in place, damage math gets short:
# 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 basic. Attack minus half defense, with 15 percent variance either way. Resist the urge to make it clever on day one. Elemental resistances, armor penetration, crit scaling, all of that can come later. Start with math you can feel in playtests and adjust from there.
Temporary Buffs and Debuffs
A buff is just modifiers with a timer attached. Timer ends, modifiers leave:
# 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 FLAT +5 to health_regen for 10 seconds. A war cry applies PERCENT +0.2 to strength for 30. The manager handles timing and cleanup, and because everything routes through remove_modifiers_from_source, an expired buff can't leave residue behind.
Common Stat System Mistakes
Keeping base_strength and total_strength in sync by hand. Don't. The Stat Resource computes its value from base plus modifiers on demand, and that's the entire reason it exists.
Forgetting to remove modifiers. Always tag them with a source and clean up by source when items unequip or buffs expire. Orphaned modifiers are the number one stat bug, and they're miserable to track down because the numbers are only slightly wrong.
Over-engineering the damage formula before anyone has played the game. Start with attack minus defense. Playtest. Adjust. Complexity is easy to add and painful to remove.
And storing stats as plain ints. Resources with signals mean the UI updates itself when a stat changes. Plain variables mean manual refresh calls scattered everywhere, and you will forget one.
Going Further
The core is here: stat Resources, modifiers, leveling, equipment, buffs. A production RPG also wants stat respec, derived stats (max HP from constitution, dodge chance from agility), status effects like poison ticks and stun immunity, diminishing returns on high stats, and saving. That last one matters more than it looks. You serialize base values, allocated points, and active buffs, never computed totals. The full pattern is in how to build a save system in Godot 4.
Or build the whole thing guided: the Stats and Leveling System quest is 8 hands-on lessons that assemble this exact system, modifiers, leveling, equipment integration, and UI included. By the end it runs every character in your game, player and enemies alike. Now go make the numbers go up.
FAQ
Should stats be Resources or just variables in Godot?
Resources, as soon as anything modifies a stat from outside the character. Plain variables are fine for a jam game with three stats and no equipment. The moment an item or buff needs to change strength and later un-change it, the modifier list on a Resource saves you from sync bugs, and you get Inspector presets and easy serialization for free.
How do I save and load stats in Godot 4?
Save the inputs, not the outputs: base values, allocated stat points, current XP and level, and active buffs with their remaining time. Don't save computed totals. On load, rebuild the stats and re-apply modifiers by re-equipping gear, and everything recalculates to the same numbers it had before.
How do I handle derived stats like max HP from constitution?
Recompute them when the source stat changes. Connect to the stat's value_changed signal and update the derived value there, or expose the derived stat as a calculated property the same way Stat.value works above. One source of truth per stat. Copying the number around is how desyncs are born.


