How Doki Doki Literature Club plays your computer

gamingddlcpythonrenpy

What is Doki Doki Literature Club?

DDLC is a dating simulator/visual novel (opens in a new tab) that first released in 2017 on PC. Thanks to its unique and groundbreaking gameplay at the time, it took the internet by storm and became a veritable staple of PC video games, especially in the indie sphere.

If you've never played the game, here is your spoiler warning. The game is free to play on Steam, and this article will go in deep detail about some of the most interesting parts of the game, how they're programmed, and how you could go about implementing them in your own game. I highly recommend you experience them yourself first.

Decompiling the game.

If we want to get a look at how the game works, we first will have to decompile the game. DDLC is written in Ren'Py (opens in a new tab), a visual novel engine written in python which allows you to easily describe scripts in a very friendly language, which supports using python for highly complex behavior.

Luckily, Ren'Py games can easily be decompiled back to their original source code, though there are a couple of steps to doing so.

The game is free which is why I'll be posting a couple of snippets of the game's code, but I still highly suggest you support the game and perhaps even purchase the recent Plus version (opens in a new tab) which adds a whole lot of new content for fans of the game.

Step 1: Download the game.

Obviously if we want to decompile the game, we need an actual copy of the game. Go on Steam (opens in a new tab) and download the game. If you're on Linux, you might want to use the Windows version and run it via Proton, since it will otherwise attempt to use your system python install which risks compatibility issues.

Step 2: Get the rpyc files.

Ren'Py games are usually composed of several .rpy files that contain the scripts of the game, compiled into a more efficient .rpyc bytecode, and all of these files are compressed into a .rpa archive. To do these steps in reverse, we first need to decompress the archive and get back the files.

Navigate to your game folder by right-clicking on the game in your Steam library and clicking "Manage" > "Browse local files...". You're looking for the game folder which, contains all of the game-specific files.

In there, you'll find a couple of .rpa archives. You can extract the audio and images if you like, but we're interested specifically in scripts.rpa. That's the file that contains all of the game's code, scripts, as well as extra files the game's logic needs.

To extract an .rpa archive, I'd suggest using unrpa (opens in a new tab), which simply runs on python3 and can be installed via pip.

pip install unrpa

or if you're a Windows user:

py -3 -m pip install unrpa

Once you've installed it, you can run it on the scripts.rpa file to extract all of the files it contains.

unrpa ../scripts.rpa

Step 3: Get the rpy files.

You should now see a bunch of new files. Here, you'll mostly find .rpyc files as well as a couple of extra files the game uses.

These are the files we're interested in. Since they're in a subdirectory of /game they will automatically be loaded by the game, but since the game crashes if the same things are defined twice, we need to remove the .rpa archive that we extracted these files from. You can delete it or move it to some other directory, either way the Steam integrity check will restore it if you need it again.

At this point, you can ensure that the game still runs by starting it up:

../DDLC.sh

To get the .rpy files, we'll use a tool helpfully called unrpyc (opens in a new tab). Navigate to the Releases tab and download the latest un.rpyc file.

unrpyc releases

To run the tool, simply move this file into the game directory and run the game. Going back to your terminal, you should see a bunch of new files.

Specifically, you'll notice that every single .rpyc file has a .rpy equivalent! You can now get rid of all the .rpyc files, since we'll be working with the .rpy files from now on.

rm *.rpyc

To test out if everything is working fine, you can go in the splash.rpy file and change the splash_message_default text to something new, and then simply delete the firstrun file to reset the game's state.

# splash.rpy
init python:
    menu_trans_time = 1
    splash_message_default = "This game is not suitable for children\nor those who are easily disturbed.\nThe game has been pwned!"
# ...
rm firstrun

The game might bring up the fact that you have to delete your previous save files, simply click "Yes", skip through the trigger warnings and you will see your new splash screen!

If you've had issues up to this point, feel free to contact me for troubleshooting, but if everything is working fine, we can now start going through the scripts and see how the game works.

Understanding the game's files

The entirety of the game's scripts are spread among several dozen files. A couple are just regular code definitions, such as transforms.rpy that defines how a number of visual effects are run, or options.rpy that defines how Ren'py should handle certain things.

The majority of the files are script- files, these contain the actual dialogue of the game. Every other file is still loaded by the game, but they're used for different things such as poems, the poem game, or the credits.

Persistent data

Probably one of the first really interesting things this game does is storing persistent data across saves. While at first it might seem like something the game has to do itself, it's actually a feature of Ren'Py that allows you to store data across saves.

This is done by accessing the persistent object, which is a dictionary that can be accessed from anywhere in the game. This is used to store a wide variety of events that the game can react to.

This includes the player's name (To prevent the player from changing it), how many playthroughs the player has already been through, how long until Yuri's blood dries, and a bunch of other story-related events.

Since these values are automatically updated and flushed to disk as soon as they are changed, the game can prevent you from using save abuse to replay events or to try to go back in time. This is really clever in my opinion, in many games the player feels completely in control of the game's state, but here the game is in control of the player's state.

Importantly, the persistent object stores the girl cgs that the player has encountered in order to reach the happy ending. If you're a coward, you can set the default values to be all True in this file and get the happy ending without having to go on a date with every girl.

The character files

The most well-known and interesting aspect of DDLC is probably the fact that the game has you delete files from the game in order to win. Ren'py provides a function to open files, but the game only really uses it to check if it can read and write from the file. If it can't, it will assume that the file has been deleted. The game uses this short snippet to delete files:

import os
try: os.remove(config.basedir + "/characters/" + name + ".chr")
except: pass

The usage of try here has interesting consequences, as this function has no way to actually inform its parent if the deletion is successful. If you remove write permissions from the directory, the game will behave as if the file has been deleted even though it hasn't.

However, you can't save anyone by doing this. The game does not actually use the .chr files to represent game state. In fact, the .chr files aren't even checked outside of the final showdown and the very start of the game. Everywhere else, the game will actually restore the .chr files if they're missing while the player is not at the proper point in the game.

And no, the .chr files don't store any data that is relevant to the game. There's a whole ARG that starts with these files, but I'll let that as an exercise to the reader.

def restore_relevant_characters():
    restore_all_characters()
    if persistent.playthrough == 1 or persistent.playthrough == 2:
        delete_character("sayori")
    elif persistent.playthrough == 3:
        delete_character("sayori")
        delete_character("natsuki")
        delete_character("yuri")
    elif persistent.playthrough == 4:
        delete_character("monika")

Getting the player's name

The player's username is fetched from an environment variable. It specifically checks LOGNAME, USER, LNAME and USERNAME. It will check each of those in that order, and will keep the latest non-empty one. You can think of this like a priority list where USERNAME is the highest priority and LOGNAME is the lowest.

currentuser = ""
for name in ('LOGNAME', 'USER', 'LNAME', 'USERNAME'):
    user = os.environ.get(name)
    if user:
        currentuser = user

A very common issue that people have encountered is the fact that their username is something generic like admin or user, instead of their actual name. If you were to implement this yourself now, I'd suggest creating a banlist of usernames that are too generic, and avoid embarassing yourself by acting like you know the player's name.

currentuser = ""
badnames = ["admin", "user", "windows", "steamuser", ...]
for name in ('LOGNAME', 'USER', 'LNAME', 'USERNAME'):
    user = os.environ.get(name)
    if user and user.lower() not in badnames:
        currentuser = user

The game will only display the user's name if two conditions are met: - The username is not empty - The user is not live streaming

The way the game detects livestreaming is very basic if not primitive, but it works. All it does is that it checks if the system has any process running that matches a hard-coded list of streaming software. If it finds one, it will skip over the line where Monika refers to the player by name.

$ stream_list = ["obs32.exe", "obs64.exe", "obs.exe", "xsplit.core.exe", "livehime.exe", "pandatool.exe", "yymixer.exe", "douyutool.exe", "huomaotool.exe"]
    if not list(set(process_list).intersection(stream_list)):
        if currentuser != "" and currentuser.lower() != player.lower():
            m "Or..."
            m "...Do you actually go by [currentuser] or something?"

If you're worried by the fact that this is basically hard-coded to work on Windows only, don't worry that much. The game is only programmed to check your processes on Windows because it uses a windows command to get the list of processes. If you were to make your own version of this, I'd highly suggest reading up on how to get the list of processes on UNIX systems as well.

if renpy.windows:
    try:
        process_list = subprocess.check_output("wmic process get Description", shell=True).lower().replace("\r", "").replace(" ", "").split("\n")
    except:
        pass

Probably to avoid any issues with that, the user's name will also not be displayed if they are not on Windows.

In case you're curious, the best way to list the running processes on POSIX compliant UNIX systems (so basically every Linux and MacOS system) is by using the ps command. The following command will list the executable of every running process:

ps -eo exe

That can then be parsed and passed through the same logic as the Windows version, obviously without the .exe extension.

The rigged choice

Towards the end of chapter 2, the player is given a choice between Natsuki, Yuri and Monika. This choice is rigged, as the player's mouse will be slowly pulled towards the "Monika" button. If they click on another button, a slight jumpscare will play and force the player to choose Monika.

What's interesting is the rigged choice screen. While the standard choice screen simply creates a few textbuttons and waits for the player to click on one of them, the rigged choice screen is a bit more complex.

It makes use of the renpy.display.draw module which can request to move the mouse position for some reason (???). The game needs to make use of the Function class in order to make a call from the screen object.

The screen begins by initializing itself just like the regular choice screen, but every 1/30 seconds it will run a call to the RigMouse function which moves the mouse towards a hard-coded position by 10% of the distance between the current position and the target position.

init python:
    def RigMouse():
        currentpos = renpy.get_mouse_pos()
        targetpos = [640, 345]
        if currentpos[1] < targetpos[1]:
            renpy.display.draw.set_mouse_pos((currentpos[0] * 9 + targetpos[0]) / 10.0, (currentpos[1] * 9 + targetpos[1]) / 10.0)

screen rigged_choice(items):
    style_prefix "choice"

    vbox:
        for i in items:
            textbutton i.caption action i.action

    timer 1.0/30.0 repeat True action Function(RigMouse)

This moment in the game was really frightening and it's interesting that it was this simple. On the surface it seems really strange that renpy is even allowed to do this, but when you think about it, use cases become apparent. A game can use this function to reset the player's mouse after a game section where it was hidden, or to move the mouse out of the way of a cutscene. Using it repeatedly like this is just a really clever use of the function.

Miscellaneous observations

Here is a list of really small things that I found interesting while reading through the code.

Conclusion

There is probably a fair bit more to discover, but I'm satisfied this far.

DDLC was a revolutionary game and it ultimately didn't require extremely complex programming or deep knowledge of renpy to make. It's a great example of using the field of video games in a unique and creative way and it's a testament to the fact that you don't need to be some kind of genius to make something truly unique and memorable.

I'm also quite glad I could decompile the game to look around in it. Most games make use of obfuscation or encryption to prevent this kind of thing, but renpy is open source and the game's developers didn't bother to obfuscate the code. I'm glad they didn't, because it allowed me to learn a lot about how the game works and how it was made.

If there's another game you'd like me to take a look at, feel free to let me know. I'm always looking for new things to learn and write about.