Posted December 18, 2023 by Zaggy Norse
#devlog
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 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.
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 withdefine
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:
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.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.
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.
My goals for a revised state system were:
MetBobInCave
for now, but later who’s to say you didn’t MetBobInBigCave
.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.
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.set()
of integers, which are just the values allocated to enum members. This is a minimally compact, source invariant, fungible representation of state.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.define
-d in this mode, so they do not get pickled and keep the store clean.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.There are several ways this code can be extended or simplified, depending on personal taste. See the section at the end for some ideas.
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:
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.
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:
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.
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:
tuple
list
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.
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 Nestable
on 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.
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.
@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.
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
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:
mypy
to get better warnings about passing the wrong enums around.__getattr__
on BitwiseIntEnum
might allow using enum values directly, as if they were still booleans, without having to call has
at all.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.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.@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!