A dialogue system for a Source engine mod

Back in 2013 I was working on a Half-Life 2 mod with a couple of other people. The mod was to be a four player cooperative game with a narrative. Each level would be a semi-open world with a set of objectives, some optional, and there would be a freedom of approach to the order they could be completed in, similar to Deus Ex or Dishonored. One of the first systems I decided to prototype was a dialogue system, as dialogues were meant to be the way players would get their objectives and it would allow us to start putting in a rough narrative as we began designing levels. The dialogue system ended up being pretty solid and is easy to present, so I've decided to put together a write-up on how it worked.

The functionality of the dialogue system took a lot of inspiration from games like Mass Effect or Fallout 3. The way a dialogue works in these games is fairly simple: you approach a character, press the "interact" key, the character speaks a line, and then you're presented with a list of responses to choose from. When you pick a response, the character speaks another line in reply, and this process repeats until you choose a response that doesn't lead to more response choices. It's possible to think of the structure of a dialogue that follows this pattern as a directed graph: each character reply is a node, and each player response represents an edge to another node. The starting node is arbitrarily chosen. The graph representation is a loose analogy but it helped with keeping a clear picture in my head and also with choosing suggestive names for variables.

Beyond the basic functionality, there were a few additional requirements and ideas we had. The dialogues had to be able to be quickly created by level designers, so a script format had to be devised. The dialogue system had to be able to interface with the Source engine's event queue system, so that a character could, for example, open a locked door for the players after delivering a response. Also, there needed to be the possibility of changing the starting node of a dialogue, so that for example, if a player found a character's lost cat and brought it back, the character would have a different line of conversation to go down allowing for progression. Aside from these requirements there was the idea to hook up the dialogues to Source's choreography system, so characters could actually speak lines, emote and walk around and interact with the environment during dialogues instead of just staring at the player while text responses show up on screen.

At the time I didn't have much experience writing C++ in larger codebases. The code given for Half-Life 2 mods to start from is pretty sprawling and uses a client/server architecture where a large amount of the codebase is used in both the client and server binaries. For shared source files this means frequent #IFDEF CLIENT_DLL or SERVER_DLL blocks of code that only apply to one or the other side, and lots of initially mysterious client-server communication macros. Also, some practices in the codebase date back to the original Quake (which the Source engine is derived from), and sometimes things are written in a suspiciously C-like way. When I first started trying to write parts of the dialogue system in, there was a lot of second-guessing where things should go, and whether there were more conventional or just better ways of doing things that I hadn't noticed. The various components of the dialogue system came together slowly, sort of in parallel. I would get far enough in one thing to realize that I needed to add something to the other thing.

Not knowing where to start, I decided to just figure out what the dialogue script format would look like, and then afterward figure out how to wrangle everything together to make it work. I had envisioned the format basically just being a list of the dialogue nodes, where each node was a block with various parameters that could be specified, including connections to other nodes. It felt like JSON would be able to do this pretty easily, but then I remembered how all the script files that Source already uses look roughly alike and decided to try and figure out what format they used. The Source engine uses an internal format called KeyValues. It's rudimentary, but still more than enough for the purposes of the dialogue system, and all the parsing code is already there. KeyValues actually looks vaguely like JSON:

CharacterDialogue {
    FullName "John Edmond"
    EntryNode "initial_greet"
    "initial_greet" {
        Scene "scenes/sdk_campaign/john_greeting.vcd"
        OnCompletion {
            "brief" "#john_choices.brief" // <node name> <choice display text>
            "leave" "#john_choices.leave"
            "insult" "#john_choices.insult"
        }
    }
    "leave" {
        Text "#john_text.leave"
    }
    "brief" {
        Scene "scenes/sdk_campaign/john_lostcat.vcd"
        OnCompletion {
            "accept_sidequest" "#john_choices.accept_sidequest"
            "deny_sidequest" "#john_choices.deny_sidequest"
        }
    }
    "accept_sidequest" { // no quest-tracking functionality at this point
        Text "#john_text.accept_sidequest"
    }
    "deny_sidequest" {
        Scene "scenes/sdk_campaign/john_needhelp.vcd"
    }
    "cat_return" {
        Scene "scenes/sdk_campaign/john_thanks.vcd"
        SetEntryNode "post_cat_return"
        Target1 "doorbutton_target"
        OnTrigger1 {
            Trigger {
                "relay_opendoor" "Trigger" // <entity name> <input to fire>
            }
        }
    }
    "post_cat_return" {
        Scene "scenes/sdk_campaign/john_thanksagain.vcd"
    }
    "insult" {
        Scene "scenes/sdk_campaign/john_wat.vcd"
        SetEntryNode "insulted"
        OnCompletion {
            GoTo "nevermind" // skip displaying options, go directly to node
        }
    }
    "nevermind" {
        Scene "scenes/sdk_campaign/john_mad.vcd"
    }
    "insulted" {
        Scene "scenes/sdk_campaign/john_refuse.vcd"
    }
}

This example script is the one demonstrated in the video, and it's enough to get a rough idea of the format. Inside the outermost block there are a few special parameter names like EntryNode, and then any other unrecognized key names are treated as node declaration blocks. Inside a node block there are some parameters like a filepath to the choreography scene file, as well as a number of block types that can be specified: OnTrigger1-16 correspond to triggers placed on the choreography timeline (the door opens when the character does the button-press animation), OnCompletion is where dialogue choices for the player are searched for, and so on. There are other blocks for triggering events when the scene first starts to play or if it gets interrupted, and you are also given the option to display a text response instead of playing a scene. The strings starting with a pound symbol are tokens that are resolved to localized strings by the engine, a pre-existing feature I hooked up early just to see if it was possible. The script format was not conceived in full and had various changes done to it as the whole system started coming together.

The relationship between the character and player during a dialogue was confusing to figure out but eventually came together as follows: the player presses the use key on the character. If the character has something to say and there are choices to give to the player, then the line is spoken, they get handles to each other, and player movement is locked. (If there are no choices to send, the player isn't locked.) The choices are sent to the player over the network. Upon reception of the choices, a menu is populated and shown to the player. The player's choice is sent to the server as a usercmd. The choice corresponds to a new node and the process restarts, letting go of the player if there are no more choices to show at the new node. Figuring out and implementing this process was messy and I don't remember the exact chain of events. Creating the menu UI element was a big diversion and so was figuring out the networking, and on the other hand adding support for triggering events in the level and changing the starting node were basically single function calls to be put in the right place. There were a handful of ambiguities remaining to be figured out, like making sure the best recovery possible happened if one of the dialogue participants suddenly disappeared (made complicated by a work-in-progress save/restore system), but for the sake of making progress I let them go.

Hooking up the choreography system turned out to be fairly easy. The usual way to include choreography in a level is to place a logic_choreographed_scene entity in the level that has a reference to the scene file and to the characters involved and then trigger it from another entity. The obvious approach to take was to just programmatically create one of these entities whenever a scene needed to be played. For whatever reason, the entity's code was all contained in a single source file and not exposed to the rest of the project, and reading through it I couldn't find any reason to not move the declarations into a header, so I did it and had no problems spawning it programmatically.

I waited until this point to actually test what I had in game, which I would never do again even though it didn't turn out to be a huge disaster. I wrote a dumb test dialogue and got one of the team members to record lines for it, and ran it through Faceposer (the choreography tool) to get the character's mouth moving accurately and also have him emote a little. Initially, the dialogue system hardly worked. There were a lot of embarrassing mistakes like incorrectly passing strings around or misunderstanding how things were cached that caused the game to crash or kept sounds from playing. Eventually things started showing up in the UI, and I could step through the dialogue choices. Once choreography scene precaching was hooked up and the character acted out its lines, it felt like the whole system had suddenly clicked into place. There were still rough edges: weird things like not being able to have a character look into your eyes (a result of the choreography system not being designed for multiplayer), or more trivial things like not yet having certain features exposed in the script format.

Something I overlooked until dialogues were working was the multiplayer side of the system. A second player would have a very limited experience of the dialogue, especially since having the players audibly speak lines wasn't part of the plan at any point. The player engaged in the dialogue might have to stop to explain what just happened to everybody else. Once I realized this, I put extra work in making it so both sides of the dialogue would be printed in the in-game text chat. It didn't feel like a good solution since people often miss or ignore on-screen text. Having the players speak lines would probably have been the most intuitive solution, but having on-screen text felt like a good enough stopgap solution while I continued prototyping the other systems for the game.

So the system was working now (more or less complete if the game had been singleplayer), even though there were a number of things that would eventually have to be changed. The script system would inevitably need some sort of cross-referencing tool to make sure connections with maps were valid, and maybe even have some sort of graph visualization to see how nodes are connected and possibly avoid cycles or show redundant or never-used nodes. Not much validation was done on the script and I didn't play too much with malformed scripts to see what happens. The way the character and player stored handles to each other was pretty dangerous and I don't think I ever tested having another player disconnect in mid-conversation. Just having the character check for the presence of the player before interacting with them isn't necessarily enough because of the possibility of interruption between nodes that have effects in the map (starting the dialogue over could cause something to happen multiple times instead of just once).

Before development stopped on the mod there were some other issues involving engine-level functionality that we didn't have access to from the mod code (file caching related), and also the choreography tool was pretty unstable, making animation of the characters extra time-consuming when it crashes and you lose your work.

The mod never got off the ground in any meaningful way. The dialogue system was just one of a number of big systems that were planned. I was the only programmer touching the game code. We were all students and had other responsibilities of varying degrees, and our plans were far too time-consuming for just us. It was especially not worth it since Source mods officially couldn't be sold, and indie development had been making real money for years at that point. At the very least, it let me have the experience of taking a few things from concept to reality in a "serious" codebase.

Back to home