Managing State in Ren'Py 8: An Approach


Introduction

Making games is hard; really hard. It’s even harder than people think it is when they hear other people say “making games is hard; really hard”, and the reason is mostly due to state: the sum total of variables in a game that affect how it plays. State is unavoidable, and managing state is the core problem of pretty much any nonlinear game. As the amount of state grows, if it is not well-managed, then–like a weed–it begins to multiply of its own accord and soon becomes the dominant entity around, choking the life out of productivity through sheer volume, grunt-work, and a cackling refusal to be eradicated.

This can lead to either a hideous tangle of state that just barely works through equal helpings of luck and outsized manual effort, or a nonlinear growth in the size and complexity of systems designed to keep the state under control that themselves become stateful monstrosities. In the absolute worst case, the state becomes such a pile of ineradicable bugs that it kills the project through suffocation before it can finish being born.

When it comes to Ren’Py, it is all too easily for beginners to use the most obviously offered state management options without the experience to predict how those systems might become difficult later on. The engine goes out of its way to make it easy to create state, but by its nature and the most common pattern of VN game development (i.e. releasing regular builds that are all playable and compound towards a completed final version), once you add it, you’re stuck with it.

Evolving ideas about what to do with the state or even what to call it can lead to a morass that makes it more and more difficult to onboard people to help with the project, as they must walk an ever-lengthening road to Code Damascus before finding the understanding needed to effectively work on the code. Yet state is the core of these games, and sometimes features or ideas may be cut or dropped solely because the creator is afraid of what complexity might be born from it, or simply does not know a way to implement it that is understandable to them.

This post, then, is a discussion for one way to manage complex VN state in Ren’Py. It will work for VNs of any size, but is geared particularly to VNs that have a large amount of state, and especially complex state that can be treated as Boolean combination of other states (be they primitive or themselves complex). It also only works on VNs using Ren’Py 8 or above, as it depends on various Python 3 features that are not automatically available in 7.x or older.

I don’t consider the system in this post to be the ideal or only way to manage complex state. It was, first and foremost, made by me for my own game, and thus encodes my own preferences and style. But even if the system isn’t useful, I think its reasons for existing and its implementation can still shed some light on some good practises for state management that people might not think of. It uses a lot of raw Python; do not fear this. I will make an effort to explain it all, and in truth by using as many inbuilt Python features as possible, the code is quite readable. I will flag concepts to know or read up on as we go; this is an ideal use case for tools like ChatGPT which can be asked to express complex technical topics at a level appropriate for each individual developer.

As a kicking-off point, please read the article The Definitive Default Define Ren’Py Article by Shawna of feniksdev.com, which is the most comprehensive and up-to-date introduction to default and define that I’ve come across. These are the off-the-shelf state management options in Ren’Py, and I will assume familiarity with them and their general intended function, even if you have not mastered every intricacy of them.

Finally: there may be errors in this article. If you find one, please let me know so I can correct it :) And feel free to share the article or even any snippets from it. Attribution is appreciated, but not required (unless you are somehow making money off this, in which case a) how? and b) pay me, bitch).

State in Ren’Py

State in Ren’Py is global. This makes it very easy to create and access, but it is also kind of bad. The idea of global state is one of those things that lowers a bar to entry by just enough that you can get inside easily when you’re still crawling around, learning about a language, but then means you keep hitting your head against it once you’re standing on your own two feet. Most programming guides will show you how to make global state in a given language (if its even allowed) and then tell you not to and show ways to do it better. Classes are the most common approach here.

Class: a template for creating a custom object that contains state which is unique to it and is not affected by changes to other instances of the same object**.

** Barring explicit class-level state, which is not pertinent here.

Ren’Py does offer innate ways to subdivide or namespace its state with named stores, but not everyone knows about them. It also supports regular Python classes, but as a “real” programming feature it can be an unknown quality for developers without prior programming experience. Furthermore, neither of these innately address some of the other problems with Ren’Py state.

Problems?

Consider the following declaration.

default MetBob = False

This declares a Ren’Py variable named MetBob, and ensures it has a value of False the first time you access it. Of course, as it is declared with the Ren’Py keyword default, it will have a value even if you never access it, purely by virtue of being declared. The game will also remember that value by automatically saving into every save game made after that. Even if you delete that default declaration later on, the variable will persist in any save games made while it was in the code. These values don’t consume undue amounts of space or anything (pickling of primitives is very efficient), but that’s not the issue.

Pickling: the term in Python for serialisation of data: turning a bunch of variable declarations and their current values into a file on the hard drive that holds those value at the time they were pickled, and can be unpickled or deserialised later to get them back as they originally were. Pickling is how Ren’Py saves and loads your game state. Every variable created by default gets pickled, while variables created with define do not.

Let’s say you remove the Bob character at some point, and delete all the variables you created for them. MetBob no longer has a use after that, but it will continue to exist forever in saves made while it was in the game, because that is just intrinsically how Ren’Py works. Still, no problem, right? You’re not using the variable anymore, so who cares if it sticks around? And that is a perfectly valid mindset. Unused variables can’t hurt you. What they can do, unfortunately, is confuse. And confusion can be deadly. A single discarded variable is hardly worth thinking about, we are agreed. What about ten? Probably fine. Fifty? Well…

What about a hundred? Two hundred?

A thousand?

“Well,” you might understandably say, “that’s ridiculous. I’ll never have that many variables.” To which I fall to my knees and clasp my hands in pained supplication, tears brimming in my baby-blue eyes, to say to you: do please do not say those cursed words. For we are all at the whims of a force we can only barely control and even less understand, and that force is Future Us.

Future Us is merciless, looking at our current decisions with derision and despair. Future Us has only one goal: to get this gosh-darned fricking game working. They don’t care why we made things the way we do; they only know that it’s a mess, and they’re the ones left holding the bleach and the broom. They will do what they must, because they desperately need sleep, and if that means adding a hundred new variables in two hours to fix a bug while also deleting two hundred others, then so be it.

Your job, then, in the Now, where the sun is bright and there is still hope in the world, is to keep Future You sane and well-rested by making smart decisions early on–and, much more importantly, making it as easy as possible to keep making smart decisions. This mantra is the core of all programming; all the rest is details. Make good decisions now; reap the harvest later. Design effective systems now; use those systems for the rest of time. Foresee what may change, and what may not, and plan for both to change in ways you do not expect.

Because in a world where all your Ren’Py state is booleans, and your game has gone through several releases and bouts of growth that reflect your growing but imperfect understanding of the engine, there will come a time. This inflection point will be different for everyone and every game, because not everyone has the sort of analytical mindset that innately lends itself to the memorisation and categorisation of abstract information like variables, and not all games run long enough to have that much state. But in time, there will be enough old variables lying around (“cruft”) that something bad happens. There’s a few ways it could happen:

  • You declare a variable that you forgot you previously used. default does what it’s supposed to, and doesn’t change the existing value that might be present in some saves. Just some, note: only players who had saves from the time period when MetBob first existed in the code will have a pre-existing value. Nobody else will. So only some players will experience weird behaviour. Ever tried debugging a problem that only happens for some players and not others, with no obvious pattern? Welcome to hell.
  • You declare two or more variables with quite similar names, then either misremember or look at the state in debugging view and accidentally use the wrong, older version of the variable. What happens next? Who knows. But it’s not good. And if you think you’ll fix it by looking at the state: sure thing! What are the chances you’ll make the same mistake again, see the wrong variable, and spend six hours tearing your hair out? Probably really minimal. Maybe five percent. Ten max. Alright, thirty. Ninety-five, final offer.
  • You change the character name. Perhaps Bob is now Klive. You go through and painstakingly update all the variables to reflect his new name. You even migrate the existing values with an after_load label. But–uh oh! If you want to migrate them from old saves, you need to keep them around in case someone loads one of said old games. So…you really only doubled your variables by renaming the character.

There are other edge cases you could find, but they all boil down to the unfortunate combination of global state interacting with an aggressive persistence model.

Solutions

Now, there are already ways of mitigating some of these issues. As mentioned, you can used named scopes to isolate related collections of state (such as that for a single character), though this doesn’t stop those scopes themselves from polluting the global namespace over time (or flooding the debug view with a fully unrolled list of their members). You can use classes to keep variables private and only expose instantiated versions of them, but once a class instance has been pickled, you’re again stuck supporting all the variables inside it indefinitely or else face deserialisation errors when people load old saves in a version that no longer has some of the properties. So that really just kicks the ball down the road in terms of making the state mutable.

And all of this is merely the technical side. There are also usability questions. For example, a character you can first encounter in, say, three different locations. You want to show unique dialogue based on where you first met, so you need to track each meeting location distinctly, but you also want to just know if you’ve met them anywhere at all before. Something like this would work fine for the latter case:

if MetBobInCave or MetBobInForest or MetBobInWalmart:
	bob "Yes, we've met. You stole my musical instrument."
	"Well it's mine now and that's that."
	bob "No need to harp on about it."

but then you need to remember to check all of them every time…and if you get someone else involved in the codebase, they also need to know this. The latter is a subtle problem that can cause endless hassle if not properly managed.

And if you add a new location later? Have fun updating everywhere you do that.

There are more efficient options here, of course. You could use a set to keep a collection of the places you’ve met, instead of multiple booleans, and then test the size of the collection to see if you’ve met anywhere, but that’s a bit hacky and not as readable as booleans. It’s not Pythonic, as the Segway riders say. Booleans are great because they unify the storage and expression of state in a clean manner.

You could create a HaveMetBob variable, but that would be static: any time you modify any of the other three variables, you also need to remember to update HaveMetBob. That is the sort of non-obvious mental overhead which–expanded upon and writ large across a growing system–is a) why programming is difficult at all, and b) why state can so quickly become the home of most of your game’s bugs.

Also: c) why it’s absolutely hysterical that anyone thinks LLMs can replace programmers. Genuinely makes me give a guffaw of wild amusement every time I see the claim. Feel free to post your angry counterarguments in the comments, I promise I’ll give them as much consideration as they deserve.

All of this is from personal experience. I dealt with many of these issues over the last year or so of growing Lord of the Manor’s state system, and being a programmer, my first thought was “how can I automate this”. I began building a model of what my ideal system would be, but it was ultimately hobbled by Ren’Py being in Python 2 and lacking native enums (a core requirement for the system I envisioned). So I made do with named stores and duplicated state and so on. I managed my state transitions through save games, being annoyed by it all the time, but making it work.

Then, out of the blue: Ren’Py 8 and Python 3! And everything became possible.

Design Goals

My goals for a revised state system were:

  • Simple to use. It never hurts to make the obvious things part of your requirements. Booleans are simple and take almost no cognitive overhead to read; replacing them with a convoluted new system would not be considered an upgrade no matter the effectiveness. For example, using functions to approximate booleans with pseudo-prefix notation would be very difficult to read.
  • As native as possible. I don’t want to have to pull in twenty custom Python modules to achieve what I want. Dependencies are enemies, and they also bloat the save files. The more I can use what ships with Ren’Py by default, the better.
  • Transparent to upgrades. Shuffling state around variable by variable in each new build is irritating, error-prone, and a waste of dev time. I wanted a state system that makes it trivial to move state around in bulk, whether someone is loading a save that’s one version old or one hundred.
  • Mutably identified. A personal requirement. Being able to change variable names has a lot of value to me, allowing me to tweak the expression of state to match my evolving understanding of the game world. you might have MetBobInCave for now, but later who’s to say you didn’t MetBobInBigCave.
  • Type-safe. As a follow-on from the previous requirement, if names can be changing all over, I want to know the moment I run a build whether I’ve misspelled something or missed a spot where a piece of state has been removed.
  • Recursively composable. I want to be able to define some variables in terms of other variables in a clear way, allowing me to develop a stable of primitive state which I can easily evolve into computed values that don’t need any maintenance. Change a base value anytime anywhere, and have the computed value updater without any further work. I also want computeds to be able to use other computeds as part of their value.

The System

With that in mind, here is a minimal version of the final system:

python early:
    from enum import IntEnum, unique

    class BitwiseIntEnum(IntEnum):
        def __init__(self, *args):
            for prop_name in (x for x in dir(self) if not "_" in x):
                prop = getattr(self, prop_name)
                if callable(prop) and not isinstance(prop, Nestable):
                    setattr(type(self), prop_name, Nestable(prop))

        def __or__(self, other):
            if callable(other) or isinstance(other, (type(self), Nestable)):
                return Nestable([self, other])
            return NotImplemented

        def __ror__(self, other):
            return self.__or__(other)

        def __and__(self, other):
            if callable(other) or isinstance(other, (type(self), Nestable)):
                return Nestable((self, other))
            return NotImplemented

        def __rand__(self, other):
            return self.__and__(other)

        def __invert__(self):
            return Nestable(lambda: self, True)

    class Nestable():
        def __init__(self, nest, inverted=False):
            self.nest = nest
            self.inverted = inverted

        def __or__(self, other):
            if callable(other) or isinstance(other, type(self)):
                return Nestable([self, other])
            return NotImplemented

        def __ror__(self, other):
            return self.__or__(other)

        def __and__(self, other):
            if callable(other) or isinstance(other, type(self)):
                return Nestable((self, other))
            return NotImplemented

        def __rand__(self, other):
            return self.__and__(other)

        def __invert__(self):
            return Nestable(self.nest, not self.inverted)

        def __call__(self):
            return {self.nest} if self.inverted else self.nest

    @unique
    class Bob(BitwiseIntEnum):
        MetInCave = 1
        MetInForest = 2
        MetInWalmart = 3

        Met = lambda x: x.MetInCave | x.MetInForest | x.MetInWalmart
        NotMet = lambda x: ~x.Met
        Wrong = lambda x: False
        Right = lambda x: lambda: True

    class NPC(object):
        def __init__(self, flag_type):
            self.flags = set()
            self.flag_type = flag_type

        def save(self, var):
            if not var in self.flag_type:
                raise Exception(f"Flag ID '{var}' doesn't exist in {self.flag_type}")
            self.flags.add(var.value)

        def has(self, *args):
            def inner(flag):
                while callable(flag):
                    flag = flag()

                if isinstance(flag, (set, frozenset)):
                    return all(not inner(f) for f in flag)
                elif isinstance(flag, list):
                    return any(inner(f) for f in flag)
                elif isinstance(flag, tuple):
                    return all(inner(f) for f in flag)
                elif isinstance(flag, bool):
                    return flag

                try:
                    if flag in self.flag_type:
                        return flag in self.flags
                except TypeError:
                    pass

                raise Exception(f"Unknown flag type invoked on {self.flag_type}: {flag}")

            if len(args) == 0:
                return False

            return all(inner(f) for f in args)

bob = NPC(Bob)

# Examples of various combinations of flag and computed value. First argument is the expected value.
print(True, bob.has(Bob.NotMet & Bob.Met | Bob.NotMet))
print(False, bob.has(Bob.MetInCave | ~~Bob.MetInForest))
print(False, bob.has(Bob.MetInForest & Bob.MetInCave))
print(False, bob.has(Bob.MetInWalmart))
print(True, bob.has(~~~~Bob.NotMet | Bob.Met | Bob.NotMet))
print(False, bob.has(Bob.Met & ~Bob.Met))
print(False, bob.has(Bob.Wrong))
print(True, bob.has(Bob.Right))
print(False, bob.has(Bob.MetInCave | Bob.MetInForest | Bob.MetInWalmart | Bob.Met & Bob.MetInCave & Bob.MetInWalmart & ~Bob.NotMet))
print(False, bob.has(Bob.Met))
print(True, bob.has(Bob.Met & Bob.NotMet | ~(Bob.Met & Bob.NotMet)))
print(True, bob.has(~Bob.Met))
print(False, bob.has(~~Bob.Met))
print(False, bob.has(Bob.MetInCave & Bob.MetInCave & ~Bob.MetInCave))
print(False, bob.has())

Before we go into it in detail, let’s cover the highlights.

  • State definition is encapsulated into enums, which I’ve named according to the character they represent. This allows writing a state as Bob.MetInForest which is both quick to parse cognitively and will throw an error if any misspelling is made. However, they can be named whatever you like.
  • State representation is a set() of integers, which are just the values allocated to enum members. This is a minimally compact, source invariant, fungible representation of state.
  • The state definition is separate from the state storage. In my configuration, the state lives in a flags property on an NPC object instance, but as it is a simple set() it can easily be kept - or moved! - anywhere: a standalone variable, the global store, persistent, etc.
  • State migrations are incredibly trivial: just clone the set and you’re done, whether it has one member or one thousand. Compared to having to write out a thousand boolean names by hand to move them to new variables, it’s a giant time saver.
  • Enum members can be renamed at will without affecting the state, as only the enum values are stored (as integers, not as references to the members themselves). You still need to update all locations that use them if you change them, but enums are basically define-d in this mode, so they do not get pickled and keep the store clean.
  • Testing for state is done by calling a has() function with one or more enum members as argument, combining them with bitwise operators to express the logic of the check. has is a minimal call, which in this implementation is attached to variables named for characters, for clarity. The bitwise operators allow using the enum as if it was a Flag enum, but additionally supports computed values and allow reading the checks as efficiently as if they were using boolean operators. Using bitwise operators also means that operations automatically take the same precedence as they would with and/not/or, further reducing the cognitive load.
  • Computed state is implemented with lambdas on the enums. They can return any bitwise combination of enum members or other computed state (or, indeed, any expression that evaluates to a boolean when called). This makes it trivial and type-safe to refer to other variables in the same enum, or return function references from elsewhere in your codebase.

There are several ways this code can be extended or simplified, depending on personal taste. See the section at the end for some ideas.

The Code

For readers with little experience working with raw Python in Ren’Py, the rest of this article breaks the code down in a lot more detail to hopefully make it understandable and more readily able to be modified or implemented.

Python Early

python early:
    from enum import IntEnum, unique

python early is a special label in Ren’Py which is the earliest point that code can be defined in the lifecycle of a game. It executes before any saves are loaded or any python init blocks run, making it the ideal place to import packages, declare classes, and set up any other structures that later code assumes are present.

Enums

    class BitwiseIntEnum(IntEnum):
        def __init__(self, *args):
            for prop_name in (x for x in dir(self) if not "_" in x):
                prop = getattr(self, prop_name)
                if callable(prop) and not isinstance(prop, Nestable):
                    setattr(type(self), prop_name, Nestable(prop))

        def __or__(self, other):
            if callable(other) or isinstance(other, (type(self), Nestable)):
                return Nestable([self, other])
            return NotImplemented

        def __ror__(self, other):
            return self.__or__(other)

        def __and__(self, other):
            if callable(other) or isinstance(other, (type(self), Nestable)):
                return Nestable((self, other))
            return NotImplemented

        def __rand__(self, other):
            return self.__and__(other)

        def __invert__(self):
            return Nestable(lambda: self, True)

Enums (“enumerations”) are native in Python 3. They are collections of names mapped to constants. The default type, just called Enum, allows declaring enum members as multiple types. So you could have integers and strings in the same enum.

A special type of enum, called Flag, allows you to use bitwise operators on enum members for efficient storage and computations if you only use integers for your enum values.

Bitwise operators: a class of operators that work on the individual bits of a value, rather than their boolean representation.

The bitwise operators used in this system are:

  • & (AND)
  • | (OR)
  • ~ (NOT)

With a Flag-derived enum, these operators are used instead of the usual and/or/not keywords to achieve the same logical effects (which is not the same as the same result). For example,

Flag.One & Flag.Two

is logically the same as “A and B”. But because Flag enums operate on bits , the results are always numbers, while with “A and B” the result is a boolean. Code has to interpret the results of bitwise operations with a bit of a messy comparison, making them awkward as drop-in replacements for eg. booleans. Flag also limits you to a certain max number of different flags (as low as 32 on older systems), which is too few to be generally useful.

Therefore, this system inherits from IntEnum via the custom class BitwiseIntEnum. Using IntEnum forces members to be integers, but also enables additional operations such as being able to test members against sets of integers with in without having to cast them, and allowing at least 2 billion different values, which is enough for many games.

We subclass IntEnum via BitwiseIntEnum in order to overload our chosen bitwise operators with dunder methods and implement custom logic for what happens when the operators are used on enum members.

Operator overloading: a system that lets you define custom behaviour for mathematical and logical operators in a programming language, when used with custom classes.

Dunder methods: methods in Python classes that start and end with double underscore (eg. __init__). These are special methods which are used to implement custom behaviour for actions like class instantiation and operator overloading.

The class does introspection at instantiation time to wrap all non-dunder methods (and all other methods with underscores in their name) in a type called Nestable. We’ll cover that shortly, but for now note that the implementation of this introspection means that any functions defined with snake-cased names (or with any underscore in their name) will not be detected. If you prefer snake case, modify the logic of the first line of __init__ as needed to match your preferred style of function naming.

Introspection: the ability in dynamic languages to access a class’s internal state and manipulate it at runtime.

The DSL

So, classes deriving from BitwiseIntEnum get support for using bitwise AND, OR and NOT on enum values using overloaded operators. These overloads (and all subsequent parts of the system) use a specific convention to encode bitwise logic into Python collections, placing their operands into specific types according to the following mapping:

  • AND –> tuple
  • OR –> list
  • NOT –> set

Tuple: an immutable collection of values. Created with ().

List: a mutable collection of values. Created with [].

Set: a mutable collection of unique values. Created with {}.

Using collections in this way creates a DSL that allows for both (nearly) arbitrarily deep nesting that can be neatly resolved through recursion, and intrinsic grouping that mirrors the bitwise logic operations.

DSL: domain-specific language. A (usually small) custom programming language, often implemented within the context of a larger, more Turing-complete one.

So if we bring back and extend the prior AND example:

Flag.One & Flag.Two

results in a tuple:

(Flag.One, Flag.Two)

Similarly, OR:

Flag.One | Flag.Two

results in a list:

[Flag.One, Flag.Two]

And NOT:

~Flag.One

results in a set:

{Flag.One}

Composition also works:

Flag.One & Flag.Two & Flag.Three | Flag.Four | ~Flag.Five

becomes

[[((Flag.One, Flag.Two), Flag.Three), Flag.Four], {Flag.Five}]

You will note that the collections nest inside one another to represent the structure of the DSL. You will also note that the operator overload dunders ultimately return instances of the same Nestable type that computed properties were wrapped in. Interesting. Let’s look at that next.

Nesting

    class Nestable():
        def __init__(self, nest, inverted=False):
            self.nest = nest
            self.inverted = inverted

        def __or__(self, other):
            if callable(other) or isinstance(other, type(self)):
                return Nestable([self, other])
            return NotImplemented

        def __ror__(self, other):
            return self.__or__(other)

        def __and__(self, other):
            if callable(other) or isinstance(other, type(self)):
                return Nestable((self, other))
            return NotImplemented

        def __rand__(self, other):
            return self.__and__(other)

        def __invert__(self):
            return Nestable(self.nest, not self.inverted)

        def __call__(self):
            return {self.nest} if self.inverted else self.nest

As discussed in the previous section, bitwise operators are overloaded in this system and used in place of the regular logical keywords. However, sadly, Python does not permit unary operators like bitwise NOT to be called on function types or on base collection types. This interferes with attempts to use bitwise NOT on computed values, which are functions, or on the collections that would be returned by BitwiseIntEnum directly in a more basic implementation (such as the examples in the previous section).

Thus: the Nestable class. It wraps a semi-arbitrary parameter named nest to allow bitwise operations against types that otherwise don’t allow it. It also implements all the same bitwise operators as BitwiseIntEnum, returning new instances of itself each time to produce the same nested structure of collections as before, just boxed up. Finally, it overrides the __call__ dunder to make instances invokable like functions and to give access to the nested collection. I call the wrapping “semi-arbitrary” because it’s expected to be either a callable (a computed property or another Nestable), or one of the three supported collection types.

There’s an additional, optional parameter which lets you control if the instance is negated or not. The dunder __invert__ is overloaded for negation support, which returns a new Nestableon the same wrapped value but with the inversion state flipped. The flag is needed because while the nested value can be inverted, it may never actually be inverted. So it default to uninverted (unless specified otherwise at instantiation time) and every time bitwise NOT is applied to it, the inverted state flips. If it is eventually called as a callable to gain access to the internal value, and if the inversion flag is true, then it wraps the internal state in a set (representing NOT) before returning it.

The __call__ dunder also ensures that a Nestable instance is detected as a callable in the logic of BitwiseIntEnum and itself, letting Nestable instances nest inside one another to arbitrary depth just like computed values while still accumulating a structure matching the bitwise operators being applied to them.

Implementation

The final two classes are likely to be the only ones you’ll want to modify to make the system work in your own game, either by defining the specific enum values unique to you, or by adding additional state and methods to the NPC class.

Enum
    @unique
    class Bob(BitwiseIntEnum):
        MetInCave = 1
        MetInForest = 2
        MetInWalmart = 3

        Met = lambda x: x.MetInCave | x.MetInForest | x.MetInWalmart
        NotMet = lambda x: ~x.Met
        Wrong = lambda x: False
        Right = lambda x: lambda: True

So, finally, we can consider an actual implementation of a BitwiseIntEnum. In this contrived example, there are three defined places you can meet Bob (who is presumed to be an NPC), and two computed values that tell you if you’ve met him anywhere at all or nowhere. The syntax is simple and easy to parse visually and logically, satisfying one of the major requirements of the system. The example also illustrates how computeds can invoke other computeds, or return plain booleans, or even other callable elements.

The use of the @unique decorator guarantees that any two or more enum members accidentally assigned the same value will fail immediately with an error at execution time. However this does not apply to computeds, for obvious reasons, so care should be taken there.

By choosing an appropriate base class and hiding the implementation details in the parent class, the enum is kept as clean and readable as possible. This is very desirable because the enums will be modified most frequently.

Bringing It All Together
    class NPC(object):
        def __init__(self, flag_type):
            self.flags = set()
            self.flag_type = flag_type

        def save(self, var):
            if not var in self.flag_type:
                raise Exception(f"Flag ID '{var}' doesn't exist in {self.flag_type}")
            self.flags.add(var.value)

        def has(self, *args):
            def inner(flag):
                while callable(flag):
                    flag = flag()

                if isinstance(flag, (set, frozenset)):
                    return all(not inner(f) for f in flag)
                elif isinstance(flag, list):
                    return any(inner(f) for f in flag)
                elif isinstance(flag, tuple):
                    return all(inner(f) for f in flag)
                elif isinstance(flag, bool):
                    return flag

                try:
                    if flag in self.flag_type:
                        return flag in self.flags
                except TypeError:
                    pass

                raise Exception(f"Unknown flag type invoked on {self.flag_type}: {flag}")

            if len(args) == 0:
                return False

            return all(inner(f) for f in args)

This class is the most flexible part of the implementation. It doesn’t even need to be a class if you don’t like; the has() test is the core, and it could be hoisted to be a standalone method, or modified to look elsewhere for the state.

The class receives a single argument when instantiated: the enum type to use when checking the state later. It also creates an empty set to store the NPC state, and has a helper method - save() - to populate that. For custom implementations, it is very important to note that save captures the (integer) value of the enum member, not the enum member itself. If you capture the enum member itself, it will end up pickled, thus not solving anything.

The has() method is the key to everything. It receives one or more parameters and returns a boolean. The parameters are AND-ed together using the inner() function and all() to process each argument.

all(): a native Python function that returns True if every member of the given collection is a truthy value.

any(): similar, but returns True if any of the given items are a truthy value.

The inner function tests the type of its parameter to decide what to do, treating different collection types as corresponding to different boolean operations as described in “The DSL”. It then recursively calls itself with each member of the collections until they resolve to a non-callable, non-collection element. The expectation is that the final value will be either an enum member or a plain boolean, so it checks for plain booleans next and returns the value directly if it finds one.

Finally, there’s a try-except block to test for enum values themselves. If the given member exists in the enum class provided at instantiation time, it performs a lookup of the value in the flags property of the instance the method was invoked on (in our case, “bob”). Note that even though the flag will be an enum member like Bob.MetInForest, and the flags will be a set of integers like {1, 2, 3}, the overloaded in operator on our base class IntEnum makes it Just Work (tm).

If the code continues to execute, it means an unexpected type was provided and an error is thrown to alert you. Note that this error will only fire when has() is actually invoked, so this is not suitable for detecting all mismatched type

Summary & Further Research

And that’s that! I hope this article has given some useful insight into this way of approaching state management in Ren’Py 8, and maybe even sparked additional ideas for you. Python 3 is very flexible, and should not be overlooked when considering ways to simplify VNs, even if you’re not too familiar with it. My advice is: think up a system you’d like, and then search around to see if it’s feasible within the language constraints.

There are also several ways the system can be extended to suit either personal taste or the needs of pre-existing projects. Here are some I thought of while writing this post:

  • You could add type annotations and import mypy to get better warnings about passing the wrong enums around.
  • Instead of an enum for each character, a single enum could be used for all. With 2 billion+ possible values, one need only preallocate (by convention) a sufficiently large range of values to each character, such as having the first character use values from one to ten thousand, the next from ten thousand and one to twenty thousand, etc. This would allow hardcoding of the enum type in all places it is currently used as a parameter.
  • Implementing __getattr__ on BitwiseIntEnum might allow using enum values directly, as if they were still booleans, without having to call has at all.
  • Dynamic checking of the passed-in enum types, combined with a good naming conventions, could allow for automatic lookups inside has() without it having to be attached to any object. For example, if the enum is named “Bob” and the state storage object for it is “bob”, knowing the enum class name lets you infer where the state for it is stored and look it up automatically. This would also allow passing in multiple different enum types to has() at the same time.
  • Instead of using collection types to program the DSL, a more explicit approach could be to use eg. a list for storing all parameters no matter the operand applied to them, and instead passing an extra parameter to the Nestable constructor to indicate which boolean operation to apply when computed it. Explicitness is the only real win here, but that might be a desirable property for some.
  • It would be possible to define a new decorator which can behave similarly to @unique, except for computeds. It could evaluate them all at runtime to determine if any of them resolve to identical values, and then throw an error to alert you.

These implementations are left as an exercise for the reader, though I’d be very keen to see any of them if you’d like to share.

I’ve uploaded a copy of this article in the original Markdown to make it easier to copy-paste the code. If you have any questions, I’ll do my best to answer them in the comments here, but you may have better luck pinging me on Bluesky or my Telegram channel;. And if you’re interested in my game, you can try it out here

Thanks for reading!

Get Lord of the Manor

Comments

Log in with itch.io to leave a comment.

I prefer snake case. Is the purpose of excluding underscores from Nestable to ensure dunders aren't added? I think changing the line like so would allow me to use snake case:

for prop_name in (x for x in dir(self) if not "__" in x):
(+1)

That’d do it :)

Pretty clean, I like the use of symbolic calculations here!

What I don't understand (likely because I don't know Python) is the difference between the following lines:

Wrong = lambda x: False
Right = lambda x: lambda: True

Why is Wrong only a single layer of lambda while Right has/is two?

(+1)

That was just to illustrate that computeds can return either a plain boolean, or another callable. Right is returning another lambda which itself returns True. So it would end up being called twice in the while loop to resolve the final value.

(+2)

This was quite interesting read. I have been going to school for software design, and I have noticed something about myself. My past self is a jerk.

So to combat this past self, I comment excessively. I know myself to have a very low balking point, and we humans hate to think. If you can ease some of that for your future self by explaining something and prevent you from balking and fixing your bad code, do that.

Remember to NOT HARD-CODE things. For maintainability, that is a nightmare and I will guarantee your future self will hate to hunt for some variables, testing an idea is fine, but love of god fix it or mark it clearly for your future self. Thousands of variables will happen, and that is why we have garbage collecting in our programming languages.

Also, OOP (Object oriented programming) exists for a reason. Repeating yourself is one of the worst things that you can do to your project, especially when you're looking through hundreds or thousands of lines of code. Break it up to pieces, document well, name stuff clearly.

Learning OOP principles is good for the long run, and DO NOT REPEAT YOURSELF. Write a function or a class, and then call that class or invoke that function. It saves a lot of time and nerves when something goes wrong. Comment things, don't trust your dumb brain to remember these things.

(+2)

But more than anything, remember the Golden Rule: break all other rules when you need to, but only once you understand why they’re rules.