32blogby StudioMitsu
renpy9 min read

Ren'Py Stat Management: Design Patterns That Don't Break Saves

Implement RPG and TRPG stat systems safely in Ren'Py 8.5. Covers define vs default, rollback-safe class design, and save-compatible migration patterns.

renpyvisual-novelgame-devpython
On this page

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

renpy
# 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

renpy
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

renpy
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

renpy
# 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

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

renpy
# 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 = {}
StatementSavedRollbackUse For
defineNoNoCharacter objects, config values, max constants
defaultYesYesHP, 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.

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

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

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

renpy
# 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

renpy
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

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

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

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

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

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

renpy
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: define declarations, init python instantiation, rollback-blind attributes, mutable define, and missing attributes after updates
  • define vs default: Anything that changes during gameplay uses default; everything else uses define
  • Rollback-safe classes: Inherit from renpy.store.object and instantiate with default
  • 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: