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:
- Trawl through the documentation, or
- 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!