Some notes about RenPy

When I started work on Tramp it was an exercise to learn more about RenPy. As I went along I made notes, and here they are. It’s not intended as a tutorial, more a collection of observations and gottchas.

Whitespace

Python, and by extension Ren’Py is obsessed with whitespace at the start of lines.

Unlike most programming languages that have explicit markers for what constitutes a block of code (ie. begin/end block/end block, or ‘{’ and ‘}’ in BCPL,C,C++,Java et al.) Python uses indentation. Python/RenPy also hates mixtures of spaces and tab characters so you will have to configure your editor to replace tabs with spaces.

For example of indentation blocks, here is a nested menu:

menu:
    "Choice 1":
        stuff1
    "Choice 2":
        menu:
            "Choice 2a":
                stuff2a
            "Choice 2b":
                stuff2b
"Done making choices."

The indentation of the second menu means it will only appear if “Choice 2” is selected in the first. You also don’t need a jump after stuff1 to reach the last statement. Menu sort-of behaves like a switch construct in most other languages.

Files

There’s nothing special about the four .rpy files the generated game gives you. You certainly don’t have to put all your story in script.rpy. In fact, it’s often easier to cope with if you break bits of the story into separate files, using sub-folders under game if you wish. It’s much easier to find a problem if the error message says it’s at line 53 of town.rpy than line 12549 of script.rpy. So long as the extension is .rpy and it’s somewhere under the game folder, RenPy will find it.

Also unlike some other languages the various files and folders don’t affect the structure of your code when it’s run. It behaves as though it is all in one big file.

Identifiers (variable/character names)

The default game comes with a character assigned to the variable with the identifier ‘e’ (short for Eileen). This should probably be regarded as a bad practice. Single letter variable names should really be avoided except in very tight scopes such as an index in a for-loop.

What do you do if you have another character called Eric? Are you going to use ‘e’ for Eileen and ‘eric’ for Eric, of ‘f’ for Eric? If you have a “Louise” are you going to use ‘l’? Is that a ‘1’ or an ‘l’ there? Have fun debugging that! Yes, it’s a pain to type louise each time she has to say something, but you’ll spend far less time getting it to work.

Like any other language, Python has reserved words that can’t be used as identifiers. So you can’t use “for”, “if”, “else” as variable names.

Identifiers can also refer to functions and classes as well as more normal variables. In fact the line:

 define e = Character("Eileen")

assigns a function to the variable ‘e’, not as you might expect, some kind of object.

There are lots of built-in functions in RenPy/Python. For example ‘len’, you may have already used this function to count the number of elements in a list. However if you use ‘len’ as the name of one of your own variables it will replace the function ‘len’ and break things. Badly.

To find if your new candidate variable name is already in use for something you can either:

  1. Trawl through the documentation, or
  2. Open the console (shift-o), type in the name, and press return. If it says “NameError: name whatever is not defined” your new name is safe to use

There’s one last oddity specifically with characters that allows you to have a regular variable and a character with the same idenitifier. In this example the identifier ‘lacy’ refers both to a variable of my custom NPC class and the character. RenPy contextually uses the right one. It’s like magic!

default lacy = Npc()
define character.lacy = Character("Lacy")

start:
    $ lacy.pcMinBfp = 30
    lacy "Hello"

Default, declare, and assignment

One of the cool things the RenPy engine does is handle game save and load, and also roll-back where you can use the mouse wheel to go back and forth in the story. Roll-back can be thought of as a series of auto-saves held in memory after each piece of dialogue (checkpoint).

However, RenPy needs to be given some hints as to what should be in the save game, and what to do with new variables when an save game from an old version is loaded.

1. Default

A variable declared with default will have that value until it is changed by the game.
It will always be included in a save game.
When a save game is loaded if the save doesn’t include the defaulted variable (for example because
it is a new thing in the new release) it will be given the default value.

In general all the variables that control the player’s path through the story should be declared with default.

default pcHasKey = False

Consider putting all the default statements in a separate file to help keep track of your variables. Add comments to show what they are for, and their expected values.

2. Define

A variable declared with define will have that value until it is changed by the game.
It will only be included in a save game if another value is assigned to it.
When a save game is loaded if the save doesn’t include the defined variable (for example because it is a new thing in the new release or never altered) it will have the define value.

In general things that are constant should be declared using define:

define cupcakeKcals = 150

This way a later version of the game can have a different definition of this value, and it won’t be overwritten by the value in the save game.

3. Assignment

A variable declared with an assignment will not be defined until the assignment is reached in play.
After that it is part of the save game.
This is a risky strategy in a branching story: generally you want to be sure all the important variables have some value so subsequent tests don’t throw an exception if the player chose a path that didn’t include an assignment.

A simple approach is don’t use assignment for variables that need to be available for the whole game.

If you need a temporary variable, consider using renpy.dynamic to limit its
scope to the current label.

Jump Considered Harmful?

Jump is RenPy’s equivalent of goto. Goto is generally considered a nightmare in programming. It’s a very easy concept to understand, but can result in tangled messy code that is difficult to follow, test, and debug. And it is used a lot in tutorials (naughty tutorials!).

If your VN is has a straightforward branching structure with few decisions and no repeated parts, jump will work fine. If it is more of a life-sim with repeated sections consider using call and return instead.

Call works like jump in that it transfers control to some new label, but the difference is the return at the end transfers control back to where you were before.

For example, say the game needs the player to talk to Anne in the bar and the library. Using jump this quickly gets messy, you either have to duplicate the dialogue in each location or record where to jump back to like this example:

label bar:
    $ playerLoc = "bar"
    menu:
        "Talk to Anne":
            jump anneChat

    jump bar

label library:
    $ playerLoc = "library"
        menu:
            "Talk to Anne":
                jump anneChat

        jump library

label anneChat:
    anne "whatever..."

    # This is nasty!
    if playerLoc == "bar":
        jump bar
    elif playerLoc == "library":
        jump library
    else:
        "Debug: I done broke it! The player is in [playerLoc]"

With call/return:

label bar:
    menu:
        "Talk to Anne":
            call anneChat

    jump bar

label library:
    menu:
        "Talk to Anne":
            call anneChat

    jump library

label anneChat:
    anne "whatever..."
    return

Using call/return also makes testing easier. If you have a debug menu can do something simple like:

$ prompt = "Enter label:"
$ name = renpy.input(prompt, length=36)
$ name = name.strip()
if len(name) > 0:
    call expression name

and allow you to call any label by name, returning to the debug menu.

Call/return also allows you to pass parameters to the called label and return a value.

Local labels

A local label is a label beginning with a ‘.’ after a normal (global) label. Within the scope of the global label you can use simply the local name. Outside you can reference them with the full name “global.local”.

Using these can make your code easier to follow.

label bar:
    menu:
        "Talk to Anne":
            call anne.chat

    jump bar

[and in the anne.rpy file:]

label anne:

label .chat:
    menu:
        "What's a girl like you doing in a place like this?":
            call .flirt
    return

label .flirt:
    stuff
    return

Using local labels can also help create a rudimentary “interface” - a “call” shared by many implementations.

In this example there’s some simple navigation. Each location has its own implementation of “.choice” and which one gets called depends on the playerLoc variable:

[In main.rpy]

default playerLoc = "bar"

main:
.loop:
    $ locnLabel = playerLoc + ".choice"
    if (renpy.has_label(locnLabel))
        call expression locnLabel
    else
        "Debug main.loop: no implementation of [locnLabel]"

    # End of day checking?
    jump .loop

[In bar.rpy]

bar:

.choice:
    menu:
        "Go to the library":
            "You walk to the library."
            $ playerLoc = "library"
        "Talk to Anne":
            call anne.chat
    return

[In library.rpy]

library:

.choice:
    menu:
        "Go to the bar":
            "You walk to the bar."
            $ playerLoc = "bar"
        "Talk to Anne":
            call anne.chat
    return

There are two big advantages with this.

Firstly control always returns to the main.loop after each interaction is finished, making it a great place to check the number of actions taken, handle end-of-day conditions and so on.

The second is that adding a new location is trivial: create a new file with that location’s label, and an implementation of the “.choice” label. Then wherever that location can be reached from, add it as an option. The main loop doesn’t need to know how many or what locations there are in the game.

[In dumpster.rpy]

dumpster:

.choice:
    menu:
        "Go back to the bar":
            "You head back inside."
            $ playerLoc = "bar"
        "Search dumpster":
            stuff
    return

[In bar.rpy]

bar:

.choice:
    menu:
        "Go to the library":
            "You walk to the library."
            $ playerLoc = "library"
        "Head out back":
            "You walk out of the bar to the alley at the back."
            $ playerLoc = "dumpster"
        "Talk to Anne":
            call anne.chat
    return

More on persistence and rollback

On first glance, this line of code creates an empty Python list:

default listRen = []

However, if you go into the console and do:

type(listRen)

It’s actually something called a RevertableList! This is a list that takes part in RenPy’s rollback. RenPy does this a lot in its “compile” phase, replacing collections with it’s own implementations with rollback.
Occasionally it may miss some though, so if rollback isn’t working as expected, check the types of your variables.

Substituting text - let me count the ways

Most tutorials cover simple replacing text with variables:

$ coins = 25
"You have [coins] coins."

You can extract attributes in these expressions, but unlike some other languages you can’t call functions or methods. So:

"Health: [player.health]"        # Works
"Name len: [len(player.name)]"   # Fails

These substitutions can also take a format specification that harks back to the C language’s printf/sprintf function.

$ weightKg = 70
$ weightLbs = weightKg * 2.2
"You weigh about [weightLbs:.0f]."

The mystery “:.0f” format specifier tells the game to take the variable as a floating point value and display it with no decimals (ie. round the result to a whole number). You can do a lot with these, including justification, truncation, hex, octal, binary, and control appearance of signs and padding characters.

Python has some additional tweaks to the format specification:

!r convert the value to a string before formatting using repr() - this
creates a string as though it were a literal value in python, so a
string value would have quotes around it

!s convert the value to a string before formatting using str()

RenPy adds a few more:

!c capitalize the first character

!i if the substituted text includes substitutions itself , process those too

!l convert substitution to lowercase

!q causes formatting characters to display as-is. For example, if you allow
a player to enter their name they could type “{b}Me!{/b}” which would cause
“Me!” to display in bold where ever it was substituted - unless you use this
flag. Though it makes more sense to do this once; straight after the initial
input of the name

!t translate: run the substituted text through the translation engine

!u capitalize the whole substitution. For example:
“SHUT UP [playerName!u]!”

Note: !cl will capitalize the first letter and force the rest to lower case.

You can of course do all this in Python directly, concatenating strings or using the string format method (which is mostly like RenPy’s substitution except with {} instead of []). The big difference is that format can take expressions as its parameters:

$ weightStr = "{:.0f}lbs".format(weightKg * 2.2)
"You weigh about [weightStr]"

Testing

Before even starting testing use the RenPy launcher to “Check Script (Lint)”. This should find the majority of bugs/typos in your code. Make sure to get a clean run before testing. It will also tell you how many words you’ve written. It does not spell check your dialogue though!

In most other languages the majority of testing is usually done with unit tests. The idea is to design a test for one part of the code in isolation, and check it does exactly what is expected in an automated fashion. However, I haven’t found any way to test RenPy this way. Which leaves testing the whole thing by hand.

There are three core things to testing:

Observability

Can you test/see the internal state of the program?

The console can help here: use shift-O to open it, and then type the name of a variable to see it’s current value. There’s also the developer menu’s (shift-d) variable viewer, though this is less helpful if you’ve many variables or are using classes and collections (lists, sets etc.)

Another approach is to have a debug flag and if it is set print out the value of a variable when it changes. “dbg” is a RenPy character in my code and if the debug flag is set is used to “say” things at various moments in the game when debug is enabled. There is a danger though of accidentally leaving non-debug code inside statements that are conditional on the debug flag.

A third way is to have a debug screen or menu that displays the current state of the game that is only shown when debug is on.

Controllability

Can you get the program into a state where a needed set of conditions to test a path through the code are met?

If it takes several hours of play to reach the condition for that one special scene, then it will be a problem to test!

You can set variables through the console or build a debug menu that allows you to change variables from within the game.

If you have random numbers influencing the game this can get more tricky. You may have to separate out random number generation from conditions that test them so that you can temporarily fudge the random result:

if (renpy.random.random() < 0.3):

becomes:

$ rnd = renpy.random.random()
# $ rnd = 0.1
if (rnd < 0.3):
    stuff

That commented out line allows you to fudge the roll in testing. It’s not ideal (you have to remember to comment it out before release), but better than nothing.

Coverage

How much of the code has been tested?

Any code that hasn’t been tested could contain errors that will show up when players play your game. So it’s important to make sure every line of code is executed during play testing. It’s all to easy to write code that will pass lint and compile, but fail when it is run. For example I use this line to deliberately cause an error when the code gets somewhere it shouldn’t:

dbg "[RhubarbRhubarbRhubarb]"

Here dbg is a valid character, but there is no variable called RhubarbRhubarbRhubarb. It lints just fine though (yes, there are better ways to do this) and causes an exception when that line is reached.

RenPy does record which labels have been used at least once, and can provide a list of all the labels in the game:

renpy.renpy.get_all_labels()
renpy.seen_label(label)

In theory you could use these two to print a list of labels which haven’t been visited (ie. tested). If you are obsessive in using many labels and call/return this could provide insight into what has been tested.

In all likelihood you are going to have to just have to remember what you’ve tested and what you haven’t. For a final pre-release test you may have to go old-school and print out your code and use coloured pencil to mark off sections as you test them!

And that’s just the code: you still need to check for spelling errors, grammatical errors, and to make sure the story is self-consistent!

Version Control

RenPy’s recommended editor, Atom, has a plug-in that understands RenPy’s syntax and Atom also has built in version control, which is great. And probably not too surprising as Atom is created by the people at GitHub. It’s really easy to set up and use, so do it! Just remember to commit changes and push them to GitHub and you’ve an off-site backup for starters.

However, it doesn’t seem to want to work with other git providers, which is understandable I suppose. But GitHub has a restrictive acceptable use policy which might conflict with, well, what some RenPy VN’s end up being.

There’s also a bit of a challenge with the way RenPy projects work as there’s no clear delineation between your code and RenPy’s runtime. It’s all kind of mushed into the project folder. There are “.ignore” lists available to download that will stop git trying to track the RenPy bits.

That was rather long, but I hope it helps someone!

16 Likes

Very helpful supplemental guide!

This. This was the biggest takeaway for me. Thank you.

2 Likes

Glad to help! It’s one of those things that I feel should be pointed out from the start but many tutorials are “let’s start hacking around in script.rpy” as they are just trying to show one thing. The ones that don’t tend to be “I’m going to write a VN, but first I need some python classes, an inventory system, …” and easily lose the audience if you are just getting started.

2 Likes

As someone who dabbled in some Ren’Py by way of reverse engineering VNs from this site, there were some various hiccups and quirks that I didn’t fully grasp when playing around. This not only helped confirm some suspicions I held, but also answered some questions I had (and a few I didn’t even know I did!). Thanks for the info!

1 Like

Hey man, I really enjoyed Tramp, I learnt a lot by having a look through your code as I was curious how you’d structured it all, very different to how I would have thought of doing it. So thanks for leaving it uncompiled.

By far the hardest part of RenPy for me is the UI. (The bit you didn’t really touch haha). It took me a long time to pick apart the default screens as they all make use of multiply nested references spread across screens.rpy, gui.rpy and options.rpy in seemingly random ways. Eventually I ended up just making new ones from scratch as it was easier than trying to modify the existing ones.

Very good guide, although id add some notes if i get something wrong please feel free to correct me.

First and probably most importantly being renpy 7.4 uses python 2.7 i think i remember seeing that the goal is v8.0 is going to support python 3. Good to know when looking up python coding lessons to make sure your looking up a guide for the correct python version so you don’t try something that is not yet apart of python. This also affects how variable work because there are some key differences between 2.7 and 3.0+.

Second would be that renpy uses the python pickle module(again the pickle module supporting python version 2.7) for saving. If your having trouble with things saving looking into how the pickle module works, and that will help you get all your variables saved. Also note that from my observation it seems the pickle module needs roughly double the ram requirements of your game to be able to save. IE if your game used 1gb while playing its possible to see up to 2gb or more of usage while pickle is saving the game. This is important to keep in mind for pre 7.4 renpy where its 32bit and you have a 4gb mem limit.

Last i would add is about rollback and and python code blocks. Rollback cannot step back to an intermediate step in a python code block, and only works with renpy script not python. Also there are cases where the way the renpy script is evaluated means that it executes out of order of how you may have written it. On the other hand any python code block is treated as one action meaning all code inside written in python is executed together when that code block is executed in the explicit order your wrote the code in, in that code block.

1 Like

That’s how I first got started with RenPy too when I wrote the translator/parser for A Piece of Cake. I might have to re-visit that now I know a bit more as it honestly does a better job at linting variable substitutions than RenPy’s built in linter - it would totally catch the "[RhubarbRhubarbRhubarb]" one.

That’s great, and one of the reasons I left it like that. Feel free to crib bits you like. The other being that archiving offers very little. In terms of space the source text is always going to be overwhelmed by the size of all the images in a typical VN (and images are already compressed, so there’s little gain there). And it offers little in the way of security - what python can archive it can un-archive.

Screens are complicated, or if you come from a GUI development background the “story” part of RenPy is really wierd.

In very general terms your GUI program sits waiting for events from the OS - things like the user clicked here, they pressed a key, I need you to repaint this bit of the screen because they brought your app to the foreground. Between you and the library you are using you handle those events, update the program’s state, work out how the UI changes, paint what you want on the screen, and then return to waiting for the next event.

RenPy does its best to turn this on its head, making it look like it’s a functional program where it’s the story that’s being executed, rather than the GUI components handling events. However actions and code in screen items expose the nature of this abstraction - they happen essentially between steps of the story, during event handling. It also doesn’t help that RenPy “caches” screens, so they may get “executed” out of order, before they are needed, and multiple times.

The other complexity with screens is that RenPy is cross-platform; not just in a Win/Mac/Linux way but also in a Desktop/Tablet/Phone way. Much of the complexity is trying to abstract those differences:

  1. gui.rpy is basically a sort-of style sheet properties. If it’s about shared sizes, gaps, padding, colour, it goes in here. The big caveat is that some colour choices are rendered as images in project creation and are “fixed” from that point on.

  2. options.rpy is the game config: name, version, save files etc.

  3. screens.rpy is, well, a mess. It defines the actual styles from the gui.rpy properties, the screens used for say and menu, and the default RenPy screens like the main menu, quick toolbar and so on. It would be easier to understand if these were separate files I think. I certainly wouldn’t add any more to it!

That’s a excellent point! If you’re searching for Python info it’s going to default to the most recent version.

The first problem is getting RenPy to hand over the right bits of state to be saved, but yes, pickle comes with its own set of potential pitfalls! I think it shares a lot of common problems with other mechanisms that convert object graphs to a writeable format (JSON, Java Serialization etc) - adding members to an existing saved class often requires extra work.

I didn’t know it looks like it holds the writeable version in memory prior to saving it though, that seems naive.

Another good one. A block of python code and a series of individual lines begining with ‘$’ aren’t handled in the same way. I should probably have also mentioned that they “story” call stack is a separate thing to the python stack too.

To summarise, in RenPy there’s:

  • RenPy scripting, the story, which is a functional language
  • RenPy screens, a (mostly) declarative language for screens and interaction
  • Python, an object-oriented language, for the engine, event handling, and anything remotely complex you want to use it for, or to intersperse in the scripting language

and they mostly play nice together, but they are not similar to each other. The fact that you can mush them all up in one “story” file can lead to confusion. The out-of-order point is particularly important when looking at screens and events.

Yes pickle due to python classes having the ability to add and remove new functions and variables(even other classes) during runtime has to check every variable and determine if its different from the base definition.

If you make a class Brains with 2 variables and a function foo, during runtime i could add a function bar and another custom class to an initialized variable alpha of class Brains. Pickle would then need to save not only the variables but functions of alpha because its structure is no longer the same as the base Brains.

Ok, so this is not correct the scripting language includes the screen language why they all are in .rpy files. They are one and the same you could define a screen in the middle of the script and renpy would treat it just the same. Im really going to get into the weeds here for the more basic vn creators, however for the power users out here, here is my best understanding.

I guess the most important thing to remember is renpy is completely written in python. The renpy script interpreter is just converting the script back to python. A “screen” is just a complex python class object.

One of the magor changes in the latest version of my game Bob, is that i did away with most of the script and just directly call everything in python. Renpys documentation has most all of this documented pretty well.

I think the problem most people have with screens is that the python “screen” object has a render function, and when you define a object via the script, your directly defining the render function!!! The implication of this is that when renpy pre calls that render function of an object to pre render a screen, any variable that state changes in the render function would obviously happen. So, that means if you don’t want a variable to change until a user input happens, all functions and operations that change the state of variables can only happen in the actions. Actions of screens do not get called during the render function. Also good to know is that screen object sub-objects like buttons can have multiple actions by use of a list of actions. @dingotush if your looking for how to use the screen objects in a more typical manner you should look into custom screen objects. Like the pong game in the tutorial.

I guess the other thing to note is renpy inherently does not rerender the screen till there is a call to do so. Unlike other 2d and 3d game engines that as soon as you finish one render cycle you start the next. Renpy just sits and waits. This is why renpy can run on such a range of different power level devices, it doesn’t have to re render a picture 60times a second it does it once then waits for the next render call. This is also good to know because in the base script not everything will cause the screen to re-render if you change a variable meaning your script can cause a rendered variable to change but not re render the screen to update the user with this information.

For high level users wanting to make cool custom screens, i really recomend sifting through the renpy code for screens on its git hub where you can see how all the python class definitions that make up renpy screens are defined and how they all work.