All Scrolls

How to Build an RPG Stat System in Godot 4

Coding Quests/February 22, 2026/Tutorials

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:

GDScript
# Don't do this
extends CharacterBody3D
var max_health: int = 100
var health: int = 100
var strength: int = 10
var defense: int = 5
var speed: float = 5.0

This 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:

GDScript
# stat.gd
class_name Stat
extends Resource
signal 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.0
var 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:

GDScript
# stat_modifier.gd
class_name StatModifier
extends Resource
enum Type { FLAT, PERCENT }
@export var type: Type = Type.FLAT
@export var value: float = 0.0
@export var source: String = "" # Track where this modifier came from

This 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:

GDScript
# stats_component.gd
class_name StatsComponent
extends Node
signal stat_changed(stat_name: String, new_value: float)
@export var stat_preset: StatPreset # Optional: load from preset Resource
var stats: Dictionary = {} # stat_name -> Stat
func _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.0
func 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:

GDScript
# stat_preset.gd
class_name StatPreset
extends 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:

GDScript
# leveling_component.gd
class_name LevelingComponent
extends Node
signal 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.5
var level: int = 1
var current_xp: int = 0
var available_stat_points: int = 0
func 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 true

The 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:

GDScript
# equipment.gd
class_name Equipment
extends Resource
@export var equipment_name: String
@export var slot: EquipmentSlot
@export var modifiers: Array[StatModifier] = []
enum EquipmentSlot { WEAPON, HELMET, CHEST, BOOTS, RING }
GDScript
# equipment_manager.gd
extends Node
@export var stats_component: StatsComponent
var equipped: Dictionary = {} # EquipmentSlot -> Equipment
func 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:

GDScript
# damage_calculator.gd - static utility
class_name DamageCalculator
static 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_chance

The 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:

GDScript
# buff.gd
class_name Buff
extends Resource
@export var buff_name: String
@export var duration: float = 10.0
@export var modifiers: Array[StatModifier] = []
GDScript
# buff_manager.gd
extends Node
@export var stats_component: StatsComponent
var 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.

godottutorialrpgstats