Coding Quests
The Scroll Library
Tutorials

How to Build an RPG Stat System in Godot 4

February 22, 2026Updated June 11, 20268 min read

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:

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

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

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

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

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 is where this gets pleasant. 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 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:

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

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)

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:

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

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 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.

godottutorialrpgstats

Stop reading. Start building.

Beat the demo boss by writing real Godot code, then build this for real in the Stats & Leveling System Quest.

Free, and no card needed. Built by a real person, with new quests every month.

Get the next Godot build in your inbox

New quests, project breakdowns, and game-dev tips. Free, no spam, unsubscribe anytime.

Written by Coding Quests

We teach Godot 4 by making you build complete systems: inventories, save systems, action roguelike controllers, enemy AI. The scrolls are free. The quests are where it sticks.