How Doki Doki Literature Club plays your computer
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.
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.
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.
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.
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:
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
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.
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!" # ...
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.
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.
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 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")
The player's username is fetched from an environment variable. It specifically checks
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
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
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 < targetpos: renpy.display.draw.set_mouse_pos((currentpos * 9 + targetpos) / 10.0, (currentpos * 9 + targetpos) / 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.
Here is a list of really small things that I found interesting while reading through the code.
- Time passing in the Yuri death scene is calculated in real time, the "skip" button is actually a fake button. At that point, dialogue is actually disabled.
- The game has a dynamic "glitchy text" effect which simply prints a series of non-ASCII characters.
- The game has two text styles, "normal" and "edited", which is how the creepy text Monika wrote in is displayed.
- The game only checks if you deleted the
monika.chrfile when you start a new game and regularly in the final scene, meaning you can just delete it after starting a new file and it will not be noticed.
- The game doesn't indicate this clearly but some words in the poem game will actually be liked by two characters instead of just one. The character that likes the word the most will gain 3 points but sometimes another character may gain 2 points. Every word gives at least 1 point to one every character.
- To avoid issues with the hard-coded screen coordinates in the game, it enforces a certain aspect ratio and prevents resizing to certain resolutions. This is not a native feature of renpy, it was implemented by the game's developers.
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.