Building a stat system in Ren'Py? You'll hit the same walls everyone else does. "My save data broke." "Rollback doesn't revert my values." "Players with old saves crash after my update."
This guide shows you how to build stat systems in Ren'Py 8.5.2 that survive saves, rollback, and game updates. It assumes you've read the getting started guide and know the basics of define and default.
Five Ways to Break Your Save Data
Before writing any code, you need to understand where Ren'Py's save system breaks. These five patterns come up repeatedly in the community.
Pattern 1: Declaring Stats with define
# This will break
define player_hp = 100
define declares a constant. It's not included in save data. When the game restarts, any HP changes are lost and the value resets to 100.
Pattern 2: Creating Instances Inside init python
init python:
class PlayerStats:
def __init__(self):
self.hp = 100
self.mp = 50
# This will break
player = PlayerStats()
init python blocks re-execute every time the game starts. Creating an instance here means it gets overwritten with initial values on every load.
Pattern 3: Class Attribute Changes Not Tracked by Rollback
default player = PlayerStats()
label battle:
$ player.hp -= 30
# ← Rolling back here does NOT restore hp to its previous value
Ren'Py's rollback tracks whether a variable's reference changed. player.hp -= 30 doesn't change what player points to (the object itself), so rollback doesn't catch it.
Pattern 4: Declaring Lists and Dicts with define
# This will break
define inventory = []
label start:
$ inventory.append("potion")
# Save → Load and inventory is back to an empty list
Same reason as Pattern 1. Lists, dicts, and any mutable objects that change during gameplay need default.
Pattern 5: Changing Class Structure Breaks Old Saves
init python:
class PlayerStats:
def __init__(self):
self.hp = 100
self.mp = 50
# Added in v1.1
self.stamina = 80
Save data from v1.0 doesn't have a stamina attribute. Accessing player.stamina after loading that save crashes the game.
Patterns 1 and 4 are fixed by using default. Pattern 3 requires changing the class's parent. Patterns 2 and 5 each have dedicated solutions. Let's go through them.
define vs default: When to Use Which
The getting started guide introduced this distinction. Here's the deeper picture.
# Constants — not saved
define MAX_HP = 100
define e = Character("Ellen", who_color="#c8ffc8")
# Variables — saved
default player_hp = 100
default inventory = []
default game_flags = {}
| Statement | Saved | Rollback | Use For |
|---|---|---|---|
define | No | No | Character objects, config values, max constants |
default | Yes | Yes | HP, items, flags, game state |
The rule is simple. If the value changes during gameplay, use default. If it doesn't, use define.
The init python Relationship
Variables assigned inside init python blocks are also not saved.
init python:
# Class definitions go here (OK)
class PlayerStats:
def __init__(self):
self.hp = 100
# Do NOT instantiate here (WRONG)
# player = PlayerStats()
Define classes in init python. Instantiate with default. This is the rule.
# Class definition
init python:
class PlayerStats:
def __init__(self):
self.hp = 100
# Instantiation (this gets saved)
default player = PlayerStats()
Rollback-Safe Class Design
Even with default, class attribute changes aren't tracked by rollback (Pattern 3). The fix is changing the class's parent.
Inherit from renpy.store.object
The object in Ren'Py's store namespace is actually RevertableObject. Inheriting from it makes attribute changes trackable by rollback.
init python:
class PlayerStats(renpy.store.object):
def __init__(self):
self.hp = 100
self.mp = 50
self.attack = 10
self.defense = 5
default player = PlayerStats()
label start:
"The hero's HP is [player.hp]."
$ player.hp -= 30
"Hit! HP dropped to [player.hp]."
# Rolling back here correctly restores player.hp to 100
Classes inheriting from renpy.store.object have their __setattr__ hooked, logging all attribute changes. This lets rollback restore attribute values correctly.
Lists and Dicts Are Also Rollback-Aware
Similarly, lists and dicts created inside init python blocks automatically become rollback-aware versions (RevertableList, RevertableDict). The same applies to default declarations.
# These automatically become rollback-aware
default inventory = []
default game_flags = {}
If you explicitly need plain Python built-in types (for data that shouldn't be rolled back), use python_list() or python_dict(). In most cases, the default rollback-aware versions are what you want.
Building a Stat System
Time to put it all together into a working stat system.
Class Definition
init python:
class PlayerStats(renpy.store.object):
def __init__(self, name, hp=100, mp=50, attack=10, defense=5):
self.name = name
self.max_hp = hp
self.hp = hp
self.max_mp = mp
self.mp = mp
self.attack = attack
self.defense = defense
def take_damage(self, amount):
actual = max(0, amount - self.defense)
self.hp = max(0, self.hp - actual)
return actual
def heal(self, amount):
self.hp = min(self.max_hp, self.hp + amount)
def use_mp(self, cost):
if self.mp < cost:
return False
self.mp -= cost
return True
def is_alive(self):
return self.hp > 0
Upper and lower bounds are enforced with max() / min(). This prevents HP from going negative or exceeding the maximum.
Instantiation and Usage
default player = PlayerStats("Hero", hp=120, attack=15)
label start:
scene bg room
with fade
"The adventure begins."
"[player.name]'s HP: [player.hp]/[player.max_hp]"
menu:
"An enemy appears!"
"Attack with sword":
$ damage = player.take_damage(25)
"Counterattack! Took [damage] damage!"
jump check_status
"Cast a spell":
if player.use_mp(20):
"Defeated the enemy with magic!"
jump victory
else:
"Not enough MP!"
$ player.take_damage(30)
jump check_status
label check_status:
"[player.name] HP: [player.hp]/[player.max_hp] MP: [player.mp]/[player.max_mp]"
if not player.is_alive():
"You have fallen..."
return
"The adventure continues."
return
label victory:
"You won the battle!"
return
Stats HUD Screen
To display stats on screen at all times, define a screen.
screen stats_hud():
frame:
xalign 1.0
yalign 0.0
xpadding 15
ypadding 10
vbox:
spacing 5
text "[player.name]" size 18
text "HP: [player.hp]/[player.max_hp]" size 14
text "MP: [player.mp]/[player.max_mp]" size 14
text "ATK: [player.attack] DEF: [player.defense]" size 14
label start:
show screen stats_hud
# Stats stay visible in the top-right corner from here on
Stat Changes from Choices
The heart of any RPG or TRPG — choices that shape your character.
default player = PlayerStats("Hero", hp=100)
label training:
menu:
"Which training do you choose?"
"Strength training":
$ player.attack += 3
"Attack power rose to [player.attack]!"
"Defense drills":
$ player.defense += 2
"Defense rose to [player.defense]!"
"Meditation":
$ player.max_mp += 10
$ player.mp = player.max_mp
"Max MP rose to [player.max_mp]!"
jump next_scene
Because the class inherits from renpy.store.object, all attribute changes are covered by save and rollback.
Keeping Old Saves Alive After Updates
After releasing your game, you'll inevitably want to add new stat attributes. But old save data doesn't have those attributes, and accessing them crashes the game (Pattern 5).
default Auto-Fills Simple Variables
Simple variables declared with default are automatically initialized to their default value if they don't exist at load time.
# v1.0
default player_hp = 100
# Added in v1.1 — old saves automatically get 80
default player_stamina = 80
This only works for top-level variables declared with default. It does not apply to class attributes.
Migrate with label after_load
For class attribute additions or changes, write migration logic in label after_load. This label runs automatically every time save data is loaded.
init python:
class PlayerStats(renpy.store.object):
def __init__(self):
self.hp = 100
self.mp = 50
self.attack = 10
self.defense = 5
# Added in v1.1
self.stamina = 80
self.max_stamina = 80
default player = PlayerStats()
label after_load:
# v1.1: Handle old saves missing stamina
if not hasattr(player, "stamina"):
$ player.stamina = 80
$ player.max_stamina = 80
return
Use hasattr() to check if the attribute exists before adding it. This way, v1.0 save data loads correctly with stamina properly initialized.
Version Tracking Pattern
When migrations span multiple versions, tracking a version number keeps the code organized.
default save_version = 1
label after_load:
if save_version < 2:
# v1.0 → v1.1: Add stamina
if not hasattr(player, "stamina"):
$ player.stamina = 80
$ player.max_stamina = 80
$ save_version = 2
if save_version < 3:
# v1.1 → v1.2: Add luck
if not hasattr(player, "luck"):
$ player.luck = 5
$ save_version = 3
return
save_version is declared with default, so auto-fill kicks in. Old saves won't have save_version, so it initializes to 1, and all migrations run in order.
Wrapping Up
Here's what we covered:
- Five save-breaking patterns:
definedeclarations,init pythoninstantiation, rollback-blind attributes, mutabledefine, and missing attributes after updates - define vs default: Anything that changes during gameplay uses
default; everything else usesdefine - Rollback-safe classes: Inherit from
renpy.store.objectand instantiate withdefault - Stat system implementation: Bounds enforcement, HUD screen, choice-driven stat changes
- Save-compatible migration:
label after_load+hasattr()+ version tracking
If you're new to Ren'Py, start with the getting started guide for setup and basics.
Official resources:
- Python Statements — official reference for
define/default/init python - Save, Load, and Rollback — how the save system and rollback work
- Store Variables — list of variables in the store namespace
- r/RenPy — community for questions and discussion