Ace Attorney-Style Nonlinear Dialogue in Ren'Py
One of the enduring hallmarks of the Ace Attorney series is the way it gives players the opportunity to explore the game's world in a free-form way, using their own logic and deduction to figure out where to look and who to speak to next. It also gives the game developers an opportunity to toss in lots of really enjoyable optional text that serves to build up the characters' personalities without forcing players to sit through irrelevant dialogue. When I started working on Employee A, I knew I wanted to capture that gameplay experience. But as I dug into Ren'Py, an engine mainly designed for visual novels with linear paths that take infrequent, independent branches, I discovered I needed a system for managing the complex statefulness involved in the kind of open-world dialogue I wanted. In this article, I'll describe how I built Employee A's dialogue state system.
Defining conversations
As I planned out the story for the game's first day, it was immediately clear that managing the state with simple flags or if/else statements would be far too unwieldy to use. Instead, I needed a way to keep track of each individual conversation that the player can possibly have. A conversation here can include both a dialogue between the player character and an NPC, or a dialogue that occurs when the player examines an item. The idea was that each conversation would refer to a label that contained the actual script to be executed, as well as some metadata. After a few iterations, this is the metadata that I discovered would be required:
- seen: a boolean indicating whether the player in the current save file has read the label. Note that this is not the same as the renpy.is_seen function, which determines if a label has been seen in any save file.
- lab: the name of the label associated with this conversation.
- required: a boolean flag. If required is True, the player must be unable to skip this dialogue. We'll dive into what this means a little bit more later.
- deps: a list of other conversations that must have already been completed in order for it to be possible to access this conversation. There is another version of this parameter called lazy_deps which exists for code organization reasons, but which means the same thing.
There are a few other pieces of metadata attached as well, but they're used for handling things like the game's global time of day feature, or tracking progress on certain conversation menus. For the simplest dialogue system, these are the four pieces of metadata necessary. I called this type of object a ViewableStateWithDeps.
class ViewableStateWithDeps: def __init__(self, lab, required=False, deps=None): self.seen = False self.lab = lab self.required = required self.deps = deps or [] def is_active(self): return all(map(lambda l: l.seen, self.deps)) # True if every dependency has been seen
Here let's just define a quick bit of terminology: a conversation is active if all the dependencies for that conversation have already been seen. This will be important later.
Ordering
When we gave each conversation a list of dependencies (and a required flag), we gave ourselves the ability to place our conversations in an order. If conversation A depends on conversation B, we have to read B before we can be allowed to read A. How do we execute that though? We need something to track not just an individual conversation, but the next available conversation that happens when we talk to a person or examine an object. This is where we introduce AdvanceableState.
AdvanceableState is a simple object that contains a list of ViewableStateWithDeps objects, as well as a pointer to the one it considers "most recent". We create one AdvanceableState for each NPC and one for each examieable object, and each one represents all the possible conversations you can have with that NPC/object. In addition to having an ordering within themselves, they can refer to conversations that are a part of a different AdvanceableState, allowing us to have conversations with one person require conversations with another to have taken place. Let's look at how this is defined:
class AdvanceableState: def __init__(self, labels): self.labels = labels self.most_recent_label = labels[0]
So far it's quite simple. The labels variable is our list of ViewableStateWithDeps objects. Note that this is an ordered list - this provides the basic chronological ordering for the conversations within this AdvanceableState. Items earlier in the list occur before items later in the list.
Whenever we want to interact with something that has an AdvanceableState, we just jump to the label defined in most_recent_label. Easy enough, right?
label bbq_d1_action_menu: menu: "Talk to Janet": $ renpy.call(janet_state.most_recent_label()) jump bbq_d1_action_menu ...
Well unfortunately, we also have to do the work of ensuring most_recent_label is always kept up to date with the actual next available label. This means, whenever we view a new conversation, we have to figure out the next label. How do we do that?
Required conversations
To understand how to advance, we need to understand the required flag. Earlier I mentioned that required conversations cannot be skipped. This actually has a more precise definition, which we can describe in this way:
- If a required label is active and is the current most recent label, it is not possible to advance to a later label, even if that later label is also required and active, without first viewing the current required label.
- If a required label is active but a sooner non-required label is the most recent label, all non-required labels should be skipped until the most recent label is both required and active.
This sounds kind of complicated, and in practice it is a little complicated, but it's a bit simpler to represent in code. This logic is encoded in a method on the AdvanceableState class called advance. Let's take a look:
def advance(self): # Do not advance past an unseen required label. if not self.most_recent_label.seen and self.most_recent_label.required: return dep_met_labels = list(filter(lambda l: l.is_active(), self.labels)) # all labels with met dependencies unseen_dep_met_labels = list(filter(lambda l: not l.seen, dep_met_labels)) # items from above that are unseen # if there are no unseen labels with met dependencies, # the dialogue becomes stale. we just repeat what we already said. if not len(unseen_dep_met_labels): return unseen_dep_met_reqd_labels = list(filter(lambda l: l.required, unseen_dep_met_labels)) # items from above that are required # try to fast-forward to an available required label if possible if len(unseen_dep_met_reqd_labels): self.most_recent_label = unseen_dep_met_reqd_labels[0] # in order to ensure we don't jump _backwards_ in time, now # we have to mark all the labels that happened chronologically # before the required jump point as seen. barrier_idx = unseen_dep_met_labels.index(self.most_recent_label) for i in range(barrier_idx): unseen_dep_met_labels[i].complete() else: self.most_recent_label = unseen_dep_met_labels[0]
This may seem like a lot to take in, but really it's in 4 basic parts.
- If the current recent label is required and unseen, don't advance.
- If there are no unseen labels with met dependencies, don't advance.
- If there's an unseen label with met dependencies that's required, jump straight to it and mark all labels before it as required.
- Otherwise, jump to the first unseen label with met dependencies.
Managing global state
At this point we've defined how to track the state of individual conversations, how to make them depend on each other, and how to know what the soonest available state is for a particular object or person. How to we make sure that all stays synchronized? After all, each AdvanceableState needs to have its advance() method called every time we view any conversation!
To do this, we create a massive wrapper that will hold all of our states and keep them updated. This object is called GlobalDialogueState, and it represents the tracked status of every single conversation in the entire game. It's a very simple object:
class GlobalDialogueState: def __init__(self, advanceables): self.advanceables = advanceables def register(self, adv): self.advanceables.extend(adv) def advance(self): for adv in self.advanceables: adv.advance()
To make use of this object, we need to declare one with the default keyword (this ensures it's persisted when the user saves and loads their file), then register every AdvanceableState with it. Let's see an example:
default talk_janet_1 = ViewableStateWithDeps("talk_janet_1", required=True) default talk_janet_2 = ViewableStateWithDeps("talk_janet_2", required=True, deps=[talk_janet_1, talk_sunil_4]) default talk_janet_3 = ViewableStateWithDeps("talk_janet_3", deps=[talk_janet_2]) default janet_state = AdvanceableState([talk_janet_1, talk_janet_2, talk_janet_3]) ... default gds = GlobalDialogueState([]) init python: def gds_init(): gds.register([janet_state])
This is a very stripped down example of all the states that are defined in my code, but it shows how the global dialogue state is defined and how AdvanceableStates are built and added to it. The gds_init() method is called as the first line of the start label, so it happens immediately when you begin a new file.
Lastly, let's show how one of the actual conversation labels is defined, since this method for state management does incur a little bit of boilerplate overhead cost.
label talk_janet_3: janet "I held up my end of the agreement.{w=0.5} Don't expect anything more out of me unless you've got something in kind." talk_janet_3.complete() gds.advance() return
Notice that here we had to call talk_janet_3.complete() and gds.advance() before returning from this label. This tells the GDS that talk_janet_3 has now been seen, and then tells all the other AdvanceableStates to potentially advance their positioning given the updated knowledge that talk_janet_3 is now completed.
Wrapping up
I hope you found this tutorial helpful. Let me know in the comments how you approach dialogue management in your games. And don't forget to check out Employee A, demo out now!
Get Employee A
Employee A
Solve a murder in a toxic workplace
Status | In development |
Author | Cross Couloir |
Genre | Adventure, Visual Novel |
Tags | antiwork, corporate, Female Protagonist, Isometric, Mental Health, Modern, Mystery, Pixel Art, Point & Click |
More posts
- Employee A development endsNov 28, 2023
- Employee A demo v1.2.1 released!Mar 19, 2022
- Employee A demo available now!!Mar 01, 2022
- Employee A Demo drops March 1!Feb 14, 2022
- What Employee A Means to MeFeb 09, 2022
- Charlotte Vents her ProblemsJan 24, 2022
- Colour-changing Images in Ren'PyJan 12, 2022
- New Screenshots and Key Art!Jan 12, 2022
- Quick update: Employee A UI ImprovementsDec 18, 2021
Comments
Log in with itch.io to leave a comment.
Oh man, this is really helpful! Not a lot of devs aim for this kind of dialogue, so finding help was really tough. I can’t understand all of this, but I hope I can parse it and use it for my own work in a week or so. Thanks for this!
i can't seem to find where complete() is defined.
did you miss something?