Ren'Py Tutorial: Cutscene Mode


When is it a good idea to take control away from the player?

Half-Life 2 was revolutionary in many ways, but one of its biggest contributions to narrative was that control is never taken away from the player, even during scripted story sequences. This helped build immersion, letting the player really step into the shoes of Gordon Freeman. Over time, this method of narrative structuring has evolved over time to involve player direction as UI elements (Portal), dynamic narration (Bastion, Transistor, The Stanley Parable), extensive choice systems (Mass Effect) and extremely deep levels of player freedom during narrative sequences (The Elder Scrolls III: Morrowind). Generally, the trends of game design have leaned towards the notion that player freedom is paramount.

But are there times when you want to remove player interaction? And in an adventure game, already a minimally interactive genre, is this really a good idea?

In Employee A, which uses a visual novel style dialogue system where players click to advance text, I wanted to have moments where player interaction was completely removed and the game proceeded through a narrative sequence on its own. There were both technical and artistic reasons for this. From the technical side, these sequences have fixed-duration non-looping music that goes with them, and the music needs to end at a specific point in the narration. If the player is allowed to control the rate of text scrolling, there's no way to guarantee that the music will play for the entire duration, or that it will end at the right time without being cut off. And from an artistic perspective, these sequences represent moments when protagonist A loses control over her environment. In the first such sequence, A attempts to hypnotize herself using a pre-recorded hypnosis tape. Hypnosis is an experience where one must relinquish control of their conscious thoughts to the hypnotizer in order to attempt to access their subconscious mind. Though on the surface level a cutscene removes interaction from the player and thus breaks immersion, in this instance it allows the player to experience what A herself experiences: a complete loss of control. So now the question is: how do we actually make a cutscene in Ren'Py?

The simple solution: movies

Ren'Py has built-in support for displaying videos. The simplest way to create a cutscene would be to write a short section of script behind an inaccessible label, jump to the label in the Ren'Py dev console, then use software like OBS to record a video of myself playing through the sequence at an appropriate pace. So why didn't I just do that?

$ renpy.movie_cutscene("a_gets_hypnotized.mp4")

There are a few reasons I chose not to do this. For one, it means that every time there's a script change, asset change, layout change etc to the game this movie needs to be re-recorded. For two, the movie will be recorded at a fixed resolution, frame rate, etc which may make it visually stand out from normal gameplay. I wanted my cutscenes to be easy to work with and to also visually blend in with the rest of the game.

The flexible solution: cutscene mode

Creating a flexible cutscene system required overcoming two major technical hurdles: preventing the player from advancing the text manually and forcing the text to advance without any external player input. Let's start with the first problem.

Ren'Py has non-configurable controls for advancing text: the built-in behavior `SayBehavior` advances to the next dialogue line on left mouse button up, enter key up and spacebar up events. There is a facility for changing the response characteristics of key events by using the event cache, but the lambdas used here are part of config, not preferences, so they can't be changed after startup. This would prevent us from being able to turn cutscene mode on or off based on the game state. We can take a look inside `SayBehavior`'s event handling to see if there's any way to disable the event response, but there unfortunately is not. So what can we do from here? Simple: we block events from ever reaching the event handler.

pygame.event.set_blocked([pygame.KEYDOWN,
  pygame.KEYUP,
  pygame.MOUSEBUTTONUP,
  pygame.MOUSEBUTTONDOWN])
config.say_allow_dismiss = lambda: False

Ren'Py uses Pygame to handle all of its events, and Pygame allows the user to, at any time, enable and disable handling for any of its basic event types. So we can simply turn off all key and mouse events! This prevents the player from advancing text, pausing, or opening any other in-game menus. But now we need a way to undo this so the cutscene can actually progress. And that's where we introduce a timer thread.

t = threading.Timer(wait_time, unblock_queue_and_fire)
t.start()

Here we use a timer thread from python's threading library. A timer is a thread which, upon being started, waits for a specified time, then executes a function. In this case we're waiting for a variable amount of time `wait_time` and then calling a function `unblock_queue_and_fire`. So what does `unblock_queue_and_fire` do?

def unblock_queue_and_fire():
  pygame.event.set_allowed([pygame.KEYDOWN,
    pygame.KEYUP,
    pygame.MOUSEBUTTONUP,
    pygame.MOUSEBUTTONDOWN,
  ])  #  allow everything
  # now do a full mouse click
  pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=(640,360), button=1))
  pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONUP, pos=(640,360), button=1))

This is fairly straightforward. First, we re-enable all the previously disabled events in pygame's event handler. Then, we manually add a full left click event in the center of the screen, simulating the action of the user clicking to advance the dialogue. This way we don't have to jack into any of `SayBehavior`'s underlying mechanics to advance the text - we simply use what already exists.

Turning cutscenes on and off

Now that we've figured out how to make the game advance at a predictable rate, we need a way to make it happen in the right parts of our Ren'Py script. We can do this using character callbacks. Each character in the cutscene will have a character callback which will block the user from manually advancing, then automatically advance after a delay at the end of each line. I also turn off click-to-continue indicators to give some visual feedback to users that a cutscene is going on. Below is the complete code for Employee A's cutscene mode.

import threading
import pygame
def unblock_queue_and_fire():
  pygame.event.set_allowed([pygame.KEYDOWN,
    pygame.KEYUP,
    pygame.MOUSEBUTTONUP,
    pygame.MOUSEBUTTONDOWN,
  ])  #  allow everything
  # now do a full mouse click
  pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=(640,360), button=1))
  pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONUP, pos=(640,360), button=1))
def force_wait_and_advance(ev, interact=True, wait_time=6, **kwargs):
  if not interact:
    return
  if ev == 'slow_done':
    #  no key or mouse events allowed! >:(
    pygame.event.set_blocked([pygame.KEYDOWN,
      pygame.KEYUP,
      pygame.MOUSEBUTTONUP,
    pygame.MOUSEBUTTONDOWN])
    config.say_allow_dismiss = lambda: False
    t = threading.Timer(wait_time, unblock_queue_and_fire)
    t.start()
def force_wait_n(wait_time):
  def internal(ev, **kwargs):
    return force_wait_and_advance(ev, wait_time=wait_time, **kwargs)
  return internal
def enter_cutscene_mode(characters, wait_time=6):
  for character in characters:
    character.display_args['callback'] = force_wait_n(wait_time)
    character.display_args['ctc'] = None
def exit_cutscene_mode(characters):
  for character in characters:
    character.display_args['callback'] = None
    if character == n:
      character.display_args['ctc'] = "ctc_n"
    else:
      character.display_args['ctc'] = "ctc"

Note that the last lines of `exit_cutscene_mode` refer to a specific character in Employee A, `n`, who has a different click-to-continue indicator than the other characters. Obviously you won't need to do that if you choose to make use of click-to-continue differently than I did here.

To enter cutscene mode with two characters, we can simply do the following:

$ enter_cutscene_mode([me, karlson], wait_time=5)

This will put the characters `me` and `karlson` in the cutscene, preventing the player from interacting while those characters are speaking. In general, every character in the cutscene should go in that list. And then, when the cutscene reaches its end:

$ exit_cutscene_mode([me, karlson])

Likewise, all characters who entered the cutscene should exit it at the end.


I hope you enjoyed this tutorial! Keep an eye out for more tutorials and more Employee A content!

Get Employee A

Comments

Log in with itch.io to leave a comment.

(1 edit)

did some experimenting and it only worked when i added

      config.say_allow_dismiss = lambda: True

to unblock_queue_and_fire x) i have no idea whats going on but thats cool 
i have a feeling this is the wrong way to go about things- but if you have any explanation, please let me know T oT)""" i know nothing

i haven't tried this- but the fact this post exists convinces me that you're my lifesaver rn oml
i wish RenPy had a built in cutscene mode :O would be so interesting

(+1)

Thanks for this!