Heart(b)eat Script Service

A Unity-integrated narrative writing application written in C#, Haskell, and HTML

Programmer

November 2019 -
Februrary 2020

Independent Project

heart(b)eat was a game I worked on in the 2019-2020 academic year with a team of 4 other friends. It was largely a visual novel, but with an interactable 2D world and plans for a platform fighter-style combat system. Unfortunately, the team fell apart at the start of the COVID-19 pandemic, and the game was never continued beyond the prototype stage. I did complete the development of a robust system to import dialogue into the game, and the story of its production is presented below. 

Even though the game was a visual novel at its core, the interactive world and combat system meant we needed to use a general-purpose engine, rather than something purpose-built for visual novels like RenPy. We chose Unity mostly because of prior experience, and from there needed a dialogue system. I wasn’t aware of existing tools like YarnSpinner or Inkle at the time, so I worked with the other programmer on our team to develop our own dialogue system in Unity.

Some sample dialogue in the system we built. The Unity scripting was built collaboratively with a teammate, but the rest of the dialogue framework was built by me.

The dialogue display itself was pretty straightforward, but this gave us an opportunity to design a powerful data structure for storing and interacting with the game’s dialogue. The structure for each individual line of dialogue is straightforward, storing a character name, the dialogue line itself, and a list of lines that can follow this line to allow for branching choices. Each line also gets a unique ID number and a number identifying which sprite to display for that line.

I also included a “story event” class, which allows characters to enter or exit the visible stage or to signal the end of a dialogue scene. Each story event can have multiple actions and connects to a single line ID for the next step in the dialogue.

public class StoryLine

{

    public int ID;

    public string Name;

    public string Line;

    public int[] Connects;

    public bool IsChoice;

    public int ArtNum;


    ...

}

public class StoryEvent

{

    public enum Action

    {

        Enter,

        Exit,

        LeaveScene

    }


    public int ID;

    public Tuple<Action, string>[] Actions;

    public int Connect;

}

This system covered all of the features we needed for the game itself, but it wasn’t exactly the most convenient for my team’s writer to interact with. We needed to store each line of dialogue in a separate object, which leads to dialogue initialization code that looks something like this:

public static StoryLine  line2 = 

new StoryLine (2, "Nyx", "Hi! <3 ", new int[] {2001,2002,2003}, true, 34);

public static StoryLine  line2001 = 

new StoryLine (2001, "Dawn", "Just remember to clean up after yourself, please? ", new int[] {3}, false, 2);

public static StoryLine  line2002 = 

new StoryLine (2002, "Dawn", "What are you doing??? ", new int[] {301}, false, 1);

public static StoryLine  line2003 = 

new StoryLine (2003, "Dawn", "Don't you think it's a bit early for this? ", new int[] {3}, false, 1);

Of course, I wouldn’t ask our writer to write dialogue like this, but I also don’t want to convert a human-readable script to this format line-by-line. Instead, I used Haskell’s built-in ParserCombinators library to write a straightforward parser to accept a human-readable input and out this formulaic C# code. In the process, I could perform story-level analysis on the code, ensuring things like scene continuity (i.e. checking that characters don’t leave the stage before they enter) and collecting meta-data for other use, like collecting a list of character names. 

This greatly improved our story pipeline, just requiring me to feed the written text into the parser and import the resulting C# script.

Input:

1. Dawn: Good morning, Nyx! {2} 5

5. Enter: {Dawn, Nyx} 2

2. Nyx: Hi! <3 {2001, 2002, 2003}

2001. Dawn: Just remember to clean up after yourself, please? {3} 4

2002. Dawn: What are you doing??? {301} 8

2003. Dawn: Don’t you think it’s a bit early for this? {3} 2


Output:

line1 = new StoryLine (1, "Dawn", "Good morning, Nyx! ", new int[] {2}, false, 5);

line5 = new StoryEvent (5, new Tuple<StoryEvent.Action, string>[] {Tuple.Create(StoryEvent.Action.Enter, "Dawn"),Tuple.Create(StoryEvent.Action.Enter, "Nyx")}, 2);

line2 = new StoryLine (2, "Nyx", "Hi! <3 ", new int[] {2001,2002,2003}, true, 0);

line2001 = new StoryLine (2001, "Dawn", "Just remember to clean up after yourself, please? ", new int[] {3}, false, 4);

line2002 = new StoryLine (2002, "Dawn", "What are you doing??? ", new int[] {301}, false, 8);

line2003 = new StoryLine (2003, "Dawn", "Don\8217t you think it\8217s a bit early for this? ", new int[] {3}, false, 2);



However, this system still wasn’t perfect. In particular, it still required our writer to enforce the syntax used by the Haskell parser, and it was very difficult to debug any syntax errors while running the story text through the parser. I overcame this issue by developing a custom editing web application hosted by my web server. Using a barebones Haskell HTTP server and HTML generation framework, I generated a script editing interface using an HTML form. 

When you load the page, the server generates an HTML page prefilled with the story as it currently exists. Our writer could make changes or add new lines and simply submit the form. The server would parse the HTTP POST data, store the script internally, and immediately generate the new C# script. This also allowed for a clearer layout of the different information associated with a story line or event, and far fewer parsing errors due to syntax violations. I also implemented a simple password lock on the script editing, since the application was hosted on the public Internet.  Below is a screenshot of the form as it appeared with the first few lines of dialogue.

heart(b)eat Script Service Retrospective

I’ve very proud of the systems I built up for the heart(b)eat Script Service. However, the project definitely had its flaws and I likely would not use it again for a future project. The Unity/C# architecture could definitely be improved – if I were to redo this, I would instead store the generated dialogue as a Scriptable Object, removing the current need to load all the StoryLine objects into a dictionary at runtime. I’d also like to have implemented a more user-friendly web editor than a simple HTML form. Now that I’m familiar with tools like Twine built from the ground up for branching narratives, I now really understand the appeal of a tree-like visualization of these narratives, and the web pages that I generated just don’t hold up.


One feature that I’d like to add would be the ability to track variables across the story and branch based on their values, allowing the story to “remember” your past choices. This could be done with more types of StoryEvents and should be possible to add without needing to rework any existing parsing, only adding new syntax for a “SetVariable” event and a “BranchIf” event.


The heart(b)eat team fell apart when our school when remote for the COVID-19 pandemic, and we did not continue work on the game. More recently, I worked on a similar visual novel-style (href Catching Up) project using YarnSpinner for Unity. Using the external library was definitely easier than building something like this from scratch, and for projects like these on a small scale and limited timeframe, it’s undoubtedly the better choice. However, I’m heartened to see that the features provided by YarnSpinner aren’t too different from the dialogue system I built here, and I could easily imagine building the HSS into a more fully-featured tool in the future.