Coding into the Void

Coding into the Void

A blog that I’ll probably forget about after making thirty-five posts.

Making a Dungeon Crawler in Unity Part 2: Inked Up

After integrating Tiled, we have the maps set up, but no interactions. You can do that in Unity—I did that for Sanctuary’s Grasp, after all—but having a hodgepodge of differently configured interactables is a recipe for pain if you want to go back and modify them.

This is where ink comes in.

Ink is a narrative scripting language, suited for handling branching narrative structures and with basic variable support (int, float, string, bool, a few others). Before Champions of Shond, I’d used it in a much more limited fashion for some games, but nothing I’d finished.

Using ink

Ink is incredible. It’s a simple markup language for determining narrative flows, but it also allows so much more. I did terrible, unholy things with ink, and it felt natural. It makes it trivial to do branching narratives and pull in a choice the player made hours ago. It comes with an editor, inky, which allows you to play through your game while you’re writing it.1 It also has official web and Unity integrations, which make integrating with those platforms trivial. I cannot praise it enough.

Amusingly, while ink does support player choice in spades, due to time and screen real estate constraints,2 the only choice you get in COS is whether you choose to interact with something or not.

It’s hard to express the power of ink in a single code snippet, but here’s a short example:3

=== f1 ===
= stairway_door
{ 
- flag_f1_has_dungeon_key:
    Mas: Key works.
    Zel: Down we go, into the abyss.
    ~ flag_f1_f2_door_open = true
- else:
    Mas: Door's locked.
    Zel: Can you bust it down?
    Mas: C'mon, have some respect for the temple.
    Zel: You're just saying that because you can't.
    Mas: ... Shut up.
}
-> DONE

=== f1 === is a knot, which is a way to subdivide code, and provides a target for execution of ink to jump to. Similarly, = stairway_door specifies a stitch, which is essentially a subheading for the knot it belongs to. So if I want to jump to the dungeon_key segment, I’d tell the ink story object to go to the f1.stairway_door location in the story.

Once it gets there, it checks the value of the flag_f1_has_dungeon_key flag. Based on that, it runs through either the first set of lines or the second. At the end of the first, it sets the value of the flag_f1_f2_door_open to true. I’m not going to explain all the syntax of the conditionals and such, but it ends up feeling natural while you’re writing it.

The normal dialogue lines, such as Mas: Key works. are returned to you as you continue on the story. To determine the speaker, I check if the sentence has a :, and treat anything before that colon as the speaker.

This is a very naive approach, but it worked as long as I didn’t use a colon in character names or descriptive texts. Ink supports tags, so I could have had the line be Key works. #speaker=Mas, but that felt worse to write and worse to read, so I just stuck with avoiding colons in descriptive lines.

There are other solutions, like escaping colons in dialogue or choosing a character that you definitely won’t use in dialogue. It didn’t end up being a problem for me. I could only use colons in lines where I had a speaker, which was most of them.

Normally you end a stitch in one of a few ways. -> DONE signifies that the story is finished executing. -> f1.get_key tells execution to continue at the get_key stitch in the f1 knot. ->-> tells it to return to the line that the current passage was branched to from. In this case, -> DONE is used, as each individual stitch is called directly as the player interacts with objects.4

To avoid issues with name collision, I put all the object interactions for each floor as stitches under a knot with the floor name. I associate them with Tiled objects if the Tiled object has the InkInteract class, in which case I automatically tag it as having the <map_name>.<object_name> passage id. If that passage doesn’t exist, I log a warning and have that object ignore attempts at interaction.

Basic Implementation

Hooking ink into your Unity game is easy. You create a new Ink.Story, passing it the json that the ink Unity plugin generates from your ink files, like so:

_inkStory = new Story(InkFile.text);

You can listen to changes in ink variables by adding a callback like this:

_inkStory.variablesState.variableChangedEvent += VariableChanged;

void VariableChanged(string name, Ink.Runtime.Object value) {
    // Callbacks to any Unity scripts that care about the value.
    // You probably want to give a callback to that script when
    // it registers as well, so that it can handle the initial
    // value. 
    // Maybe also distinguish between the two cases, so that a
    // door can start open, but also animate open if the state
    // changes mid-scene.
}

It can be useful, for error handling and reporting, to know if a passage exists before trying to run it:

bool IsValidKnotOrStitch(Story story, string name) {
    if (name == null) return false;
    return story.ContentAtPath(new Ink.Runtime.Path(name)).correctObj != null;
}

And, of course, actually handling the dialogue:

public IEnumerator PerformInkActionCoroutine(string passage, LineSpecQueue queue) {
    MovementAllowed.Value = false;
    Story story = InkStateManager.INSTANCE.InkStory;

    if (!InkStateManager.IsValidKnotOrStitch(story, passage)) {
        Debug.LogWarning($"Invalid knot/stitch {passage}! Ignoring interaction.");
    } else {
        story.ChoosePathString(passage);

        while (story.canContinue) {
            string line = story.Continue();

            // Can happen when setting variables.
            if (line.Length == 0) continue;

            string[] segments = line.Split(':', 2);
            string speaker = segments.Length == 2 ? segments[0] : "";
            line = (segments.Length == 2 ? segments[1] : segments[0]).Trim();

            yield return queue.EnqueueAndAwait(task, new LineSpec(speaker.Trim(), line));
        }

        if (story.currentChoices.Count > 0) {
            Debug.LogWarning("Choices not supported.");
        }
    }

    MovementAllowed.Value = true;
}

This has some technical details of my convenience libraries, like LineSpecQueue, but all you need to know is that it’s a way of coordinating messages displayed in textboxes.

This is the core behavior of every InkInteract in the game. You interact with it. It talks at you. You can play it again. But, of course, I want it to do more.

Function Hookups

In addition to allowing you to specify variables and condition execution, ink lets you specify function calls that you can bind in the engine. This allows me to write code like this:

=== f2 ===
= entry
~ setMusic("theme1")
~ setFog("\#000000", 0.15)
~ setSky("\#000000")
~ fadeIn(true, FADE_DURATION)
{ not first_entry:
  -> first_entry ->
}
-> DONE

= first_entry
~ farthest_floor = max_value(farthest_floor, 1)
Mas: Something is wrong with this place.
Zel: It feels like entering a tomb of stone.
->->

When I run the f2.entry stitch, it changes the music to the appropriate one, sets the fog level and color, and specifies the skybox color. It then fades in from black and plays the dialogue in the f2.first_entry stitch, if it hasn’t been visited yet.

Note that I’m conditioning on the stitch itself in not first_entry:, not a variable of the same name. Stitches and knots can be used like variables. When referenced like that, their value is how many times that passage has been run.

To avoid inky complaining about an unrecognized function reference and permit manual testing, you need to declare the function as external and provide a fallback implementation.

It’s as simple as this:

EXTERNAL setMusic(musicName)
=== function setMusic(musicName) ===
~ return 1

On the Unity side, registering the function is similarly easy:

_inkStory.BindExternalFunction("setMusic", (string music) => {
    // Set the music track.
});

Task Queueing

A problem that I ran into with ink’s external functions is that they’re all synchronous. So when I call ~ fadeIn() in the example above, it doesn’t wait for the screen fade to finish executing—it immediately moves onto displaying the lines of text.

That’s problematic, since in the ink story:

  1. I don’t know when the fade is finished
  2. I need a mechanism to handle these in an orderly fashion.

What better way to accomplish these objectives than a queue?

In a display of wanton over-engineering, I create a queue to order all the lines of text and functions. A function call comes in? Wrap it in a task and enqueue it. A line of text comes in? Wrap it in a task and enqueue it. After every line read, drain the queue.

The implementation of TaskQueue is left to the reader,5 but it should be straightforward to follow in the code.

Function binding now looks like this:

_inkStory.BindExternalFunction("setMusic", (string music) => {
    InteractQueue.Enqueue(new MusicTask(music));
});

and reading the story like this:

public IEnumerator PerformInkActionCoroutine(string passage, LineSpecQueue queue) {
    MovementAllowed.Value = false;
    Story story = InkStateManager.INSTANCE.InkStory;

    if (!InkStateManager.IsValidKnotOrStitch(story, passage)) {
        Debug.LogWarning($"Invalid knot/stitch {passage}! Ignoring interaction.");
    } else {
        story.ChoosePathString(passage);

        while (story.canContinue) {
            string line = story.Continue();

            // Can happen when setting variables.
            if (line.Length == 0) continue;

            string[] segments = line.Split(':', 2);
            string speaker = segments.Length == 2 ? segments[0] : "";
            line = (segments.Length == 2 ? segments[1] : segments[0]).Trim();

            TextTask task = new(queue, new LineSpec(speaker.Trim(), line, col));
            yield return TaskQueue.EnqueueAndAwaitTaskFinished(task);
        }

        if (story.currentChoices.Count > 0) {
            Debug.LogWarning("Choices not supported.");
        }
    }

    // This is necessary, as tasks can be enqueued after the last line,
    // and we should wait for those to finish too.
    yield return TaskQueue.WaitUntilEmpty();
    MovementAllowed.Value = true;
}

As we’re reading out the lines of dialogue, the tasks are executed, waiting until the most recent one (the text read task) is finished. That means that the fade from black will finish before any later lines are displayed on the screen.

Silent vs. Loud Execution

Sometimes I want to run an ink passage without handling any queued tasks. In that case, I may not even have a text queue for the lines of dialogue to be added to. In that case, I don’t bother waiting on anything:

private void SilentlyRunPassage(string passage) {
    if (!IsValidKnotOrStitch(_inkStory, passage)) {
        Debug.LogWarning($"Failed running {passage} silently. It doesn't exist!");
    }
    _inkStory.ChoosePathString(passage);
    while (_inkStory.canContinue) {
        _inkStory.Continue();
    }
}

This doesn’t handle enqueued tasks at all, and it bails out at the first choice. It could be more robust, or complain if it encountered either. I just ended up using this for my startup passages, so it didn’t become an issue.

Special Passages

I found it useful to have “magical” stitches that I automatically called from Unity under fixed circumstances.

  • Each floor’s startup stitch is called silently when loading or starting a game. This is a historical artifact, and all these passages are empty.6
  • Each floor’s entry stitch is called loudly when entering the floor. This is used, as shown in the Function Hookups section, to set how the fog rendered, the music, and some other metadata. These could have also been specified using Tiled map properties, but I wanted to be able to change these throughout the game. For instance, the outside goes from dusk to dawn as the story progresses.
  • Each floor’s exit stitch is called loudly when exiting the floor, before saving progress. I had plans to update quest flags here, but I ended up doing that elsewhere instead due to not knowing the player’s destination. Areas where I was planning on setting triggers were instead set in the entry stitch or on a tile trigger.7 In practice, it ended up just being a call to fadeOut in every circumstance.

With ink, you don’t need to limit yourself to following the conceit of passages only being for dialogue. Sometimes things can just run in the background, and that’s fine.

Auto-Convos

Champions of Shond does not have any conversation choices. However, I wanted dialogue to naturally progress if you talk to NPCs again and again.

In my initial approaches, I ended up tackling this problem using one of two solutions:

  • A secondary passage, which I’d check on the visit count before executing.
  • A flag for the dialogue option, which I’d set after first execution.

The logic for conversations became convoluted and error-prone, occurring across multiple passages and using multiple variables.

However, ink makes this easy if you’re using conversation choices.

Take a look at this boring story:

=== knot ===
Hey, pick an option.
* [Choice1] 
This option disappears on later visits if chosen.
+ [Choice2] 
This option will show up forever.
- -> knot

Hmm. Well, that’s basically what I want, right? What if, any time there was a choice, I just chose the first option available to me. That lets me do this:

=== npc ===
>>> AUTOCONVO
* [Intro1] 
NPC-y: Hey, nice to meet you. My name's NPC-y.
* [Intro2]
NPC-y: Talking to me again? You sure are chatty.
* { should_prompt_quest and not quest_complete } [Quest Intro]
NPC-y: Hey, you should do this quest!
+ { should_prompt_quest and not quest_complete } [Quest Prompt]
NPC-y: I'm going to remind you to do this quest until you finish it.
+ { quest_complete } [Quest Complete]
NPC-y: Thanks for doing the quest, I'm so impressed!
+ [Fallback]
NPC-y: I'm just going to talk to you forever with this line, unless you impress me.
- -> DONE

This allows for a natural ordering of the precedence of the lines of dialogue, as it will favor earlier options that meet the criteria over later ones, and it’s trivial to only show lines once. Since the chosen dialogue is a waterfall, it’s easy to see what line an NPC will read at any given time.

Because I thought it was possible that I’d add choices later, I decided to have a way to signal that I wanted the next choice to be made automatically. I did this using the line keyword >>> AUTOCONVO.8 When reading that line specifically, it flags the code to automatically handle the next choice by picking the first option.

It complicates the story script, but not unreadably so:

...
story.ChoosePathString(passage);
bool autoConvo = false;

while (story.canContinue) {
    while (story.canContinue) {
        string line = story.Continue();

        // Can happen when setting variables.
        if (line.Length == 0) continue;

        string trimmedLine = line.Trim();
        if (trimmedLine.StartsWith(">>>")) {
            if (trimmedLine == ">>> AUTOCONVO") {
                autoConvo = true;
            } else {
                Debug.LogWarning($"Unrecognized command: {trimmedLine}");
            }
            continue;
        }

        string[] segments = line.Split(':', 2);
        string speaker = segments.Length == 2 ? segments[0] : "";
        line = (segments.Length == 2 ? segments[1] : segments[0]).Trim();

        TextTask task = new(queue, new LineSpec(speaker.Trim(), line, col));
        yield return TaskQueue.EnqueueAndAwaitTaskFinished(task);
    }

    if (story.currentChoices.Count > 0) {
        if (autoConvo) {
            story.ChooseChoiceIndex(0);
            autoConvo = false;
        } else {
            Debug.LogWarning("Choices not supported");
        }
    }
}

...

The nested story.canContinue checks do feel odd, but they don’t overly complicate the logic.

Handling Scripted Fights

For the most part, encounters in Champions of Shond are random. However, in a few cases, I have the opponent visible and interactable. If the NPC is interacted with, it spits off a few lines and triggers the fight through an external function. Furthermore, the player can run from battles.

It’d be simple if every battle ended with either the player winning (the golden path) or loading a save (causing a load of ink state).

However, since running is an option, I need to differentiate between the “the game continues because the player won” and the “the game continues because the player ran” situations. To do so, I have a last_battle_won ink variable that I write to at the end of every battle.

My first attempt was something like this:

= boss
Mas: Hey, can you talk?
The skeleton swings at him.
Mas: Guess not.
~ triggerBattle("1,skeleton_king")
{
- last_battle_won:
~ flag_f3_boss_dead = true
Mas: That's got to be one of their bigger guys, right?
Zel: I sure hope so.
- else:
Zel: Let's scram.
}
-> DONE

The problem here is, perhaps, obvious. When I handle the triggerBattle task, I write the last_battle_won variable. However, remember that the task queue can only be processed once a line of dialogue has been added. In fact, I have no way of having the function calls execute without reading the next line of dialogue. Once the fight starts, the variable has already been read.

My answer was simple, but gross. I introduced >>> AWAIT.

AWAIT tells the code “process all the tasks in the queue, but don’t print this line out”. However, simply having one of these lines is not sufficient. I need to add two >>> AWAIT lines between fights and checking the results. I suspect this is ink preemptively evaluating the following line to determine the canContinue and currentChoices state.

Fights end up looking like this:

= boss
Mas: Hey, can you talk?
The skeleton swings at him.
Mas: Guess not.
~ triggerBattle("1,skeleton_king")
>>> AWAIT
>>> AWAIT
{
- last_battle_won:
~ flag_f3_boss_dead = true
Mas: That's got to be one of their bigger guys, right?
Zel: I sure hope so.
- else:
Zel: Let's scram.
}
-> DONE

The code modifications, however, are minimal.

...

string trimmedLine = line.Trim();
if (trimmedLine.StartsWith(">>>")) {
    if (trimmedLine == ">>> AUTOCONVO") {
        autoConvo = true;
    } else if (trimmedLine == ">>> AWAIT") {
        yield return TaskQueue.WaitUntilEmpty();
    } else {
        Debug.LogWarning($"Unrecognized command: {trimmedLine}");
    }
    continue;
}

...

However, since adding in AWAIT calls is safe, and all fights should have them, the triggerFight function can be wrapped to always add them, causing it to look like the previous implementation once again.

=== function triggerFight(fight) ===
~ triggerFightInternal(fight)
>>> AWAIT
>>> AWAIT
~ return 1

Like hiding toys under a bed, we’ve successfully hidden the gross bits from the writer.

Notes & Quests

Quests and notes are primarily handled through ink. To keep track of the gathered notes and valid quests, I use ink’s list type. Declaring a list looks like this:

LIST item_list = item1, (item2), item3

Lists in ink are strange beasts. Instead of being the standard programming “this is a group of things you have”, they’re a combination of an enum and a dictionary.

Declaring a list does two things:

  1. It creates a set of values namespaced to the list’s name (like an enum).
  2. It creates an ink list variable with the same name (like a dictionary). This list is populated by the items in the list declaration surrounded by parentheses.

List variables are not constrained to the items created with them. They can also contain other types. Ink lists are a strange beast, but they’re considerably less confusing if you don’t use the actual variable declared.9

Before I standardized on this approach, I iterated heavily. When I released the game, I had notes managed by a list and quests managed by a collection of constants. Neither approach is as messy as you might think, but they weren’t exactly clean.

Notes

We’ll start with notes, since the states that a note can be in are simpler: collected or not collected.

When writing this, I had two goals:

  • Reduce the amount of boilerplate when adding a new note.
  • Never use any string values if I could use something that could be checked by the ink compiler.

This is how the list of available notes is declared in COS:

LIST all_notes = sur_1, sur_2, sur_3, sur_4, sur_5, sur_6

Each of the comma-separated values is an identifier for a note in my game.

The list of owned notes is declared like this:10

VAR notes_list = ()

Since lists can contain values from any number of lists, it could have instead been set up like this:

LIST f1_notes = sur_1, sur_2
LIST f2_notes = sur_3, sur_4, sur_5, sur_6
VAR notes_list = (sur_1, sur_3)

It might be better to have each floor maintain its own set of notes, but the game didn’t get complicated enough to warrant that.

Here’s the meat of the note management ink code, along with a sample note.

=== function note_for(note) ===
{
- note == sur_1:
~ return -> note_sur_1
- note == sur_2:
~ return -> note_sur_2
- else: // Unrecognized note
~ return -> note_sur_1
}

=== note_pickup(note) ===
Zel: We got another note!
~ notes_list += note
->->

=== note_sur_1 === 
# Journal Pt. 1
Had a good day today. Nothing went wrong.
-> DONE

The function note_for returns a passage reference to the note contents. The -> note_sur_1 tells the compiler to return the passage reference (just its name) instead of the count, which is what a passage variable normally refers to. It’s safe to pass this to and from Unity, as it’s just a string. The benefit to returning this over "note_sur_1" directly is that you get both autocomplete and the compiler yelling at you if it’s invalid.

note_pickup is called by my ink scripts when a note gets picked up, and all it does is play a canned line and add the note to the list of owned notes. If I wanted to check if the note had already been picked up, I could have gated it with a notes_list ? note check. The ? operator means “has”, so that line of code means “Does notes_list contain note?”

In the game’s UI, I show the note’s title before displaying the contents. In note_sur_1, the tag # Journal Pt. 1 represents the name of the note. When I get the passage name from note_for, I get the title by querying the passage for its tags.

In a previous implementation, I had a second function for retrieving the note title. I chose my current approach because it cut down on boilerplate, but the second function approach is useful for a few reasons:

  1. Tags are invisible to the ink itself. If you want to display the note’s title in ink with my current approach, you would need to use string literals instead.
  2. Tags are static. If you want a note’s name to change based on a condition, you need the if/else approach.
  3. Testing. It’s useful in inky to display the list of notes, with their titles, in a test function. This is a subset of the first reason, but it’s worth calling out.

If either of the first two cases are relevant, consider having a note_name override function for those affected.

There are some oddities on the Unity side to using lists, however. When ink calls into the note_for function, it’s not passing an item from the ink list, it’s passing an actual list. You can’t pass individual list items to ink functions, so, in note_for, it’s not checking that note is the item in the list, it’s checking that note is a list consisting of only that item.

To resolve that, I do a strange dance in which I transform the ink list containing all the items to a list of ink lists containing just one item.

public List<InkList> SpreadList(InkList list) {
    return list.Select(x => {
        InkListItem item = x.Key;
        var subList = new InkList(item.originName, _inkStory);
        subList.AddItem(item);
        return subList;
    }).ToList();
}

I then look up each title and passage like so:

string passage = (string)_inkStory.EvaluateFunction("note_for", new object[] { list });
string title = _inkStory.TagsForContentAtPath(passage).FirstOrDefault();
if (title == null) title = "NO TITLE";

While it took me arrive at this final approach, I’m happy with it. There’s no need to register individual notes on the Unity side, and it only ends up with two hardcoded references (the function note_for and the variable name notes_list).11

Possible Improvement: Note -> Passage Registration

If I wanted to eliminate the lookup chain, I could instead have an external function that took in the list item (e.g. sur_1) and the corresponding passage (e.g. -> note_sur_1). This would require manual registration for every note, but it would eliminate the need for the note_for function entirely, leaving the only hard-coded reference into ink the name of the list itself.12

The downsides of this are the same as those that arise from not having a title mapping: it requires them to be static, and it increases duplication in inky test code.

Quests

If you squint and look at them sideways, quests look a bit like notes. You can collect them. They have names. They display text in a log. However, they also have pesky details like “I have finished this quest” and “I have royally pissed off the quest-giver”. This ends up making them moderately more complicated, but they still fit rather nicely into the same paradigm.

To manage quest state, I took a page out of Bethesda’s book. All quests are controlled by a single integer value: the quest stage. By convention, I established that any stage < 10 wasn’t started and wouldn’t show up in the quest log. Any stage ≥ 100 would be marked as complete. This simplified the checks, and made telling the completion status of a quest just a static check. Notably, these constants, and the quest stages in general, are not seen by any of the Unity code.

While all_notes started off with no elements included, the quest list begins with all quests included.

LIST quest_list = (mq1), (sq1)

This is because with quests, my ink manually fiddles with the quest stage itself, not the items in the list.13

All the code is below, but much of it is functionally identical to the notes code.

CONST QUEST_START_STAGE = 10
CONST QUEST_DONE_STAGE = 100

=== function is_quest_ongoing(name) ===
{
- is_quest_complete(name):
    ~ return false
}
~ return quest_progress(name) >= QUEST_START_STAGE

=== function is_quest_complete(name) ===
~ return quest_progress(name) >= QUEST_DONE_STAGE

=== function quest_passage(quest) ===
{
- quest == mq1:
    ~ return -> quests.mq1_reclaim_temple
- quest == sq1:
    ~ return -> quests.sq1_purify_food
}
~ return ""

=== function quest_progress(name) ===
{
- name == mq1:
    ~ return f0_mq_quest_stage
- name == sq1:
    ~ return flag_f1_quest_stage
}
~ return 0

The primary difference in Unity is that it quests are filtered into two lists using is_quest_ongoing and is_quest_complete instead of just checking whether the note is owned or not.

Quest display passages are usually more complex, but only due to conditioning on quest state.

= mq1_reclaim_temple
# Reclaim Temple
~ temp progress = quest_progress(MAIN_QUEST)
{
- progress < 20:
We've been tasked with examining a corrupted temple.
- progress < 100:
This quest is in progress.
- progress >= 100:
The game has been won.
}
-> DONE

= sq1_purify_food
# Purify Food
~ temp progress = quest_progress(SIDE_QUEST_1)
{
- progress == 10:
A cute rat asked us to purify a tainted food store on F1.
- progress == 20:
We've purified the food. Just need to go back to the Rat King.
- else:
We purified a tainted food store on F1. A helpful rat let us know about it.
}
-> DONE

All in all, it’s very similar. The lack of titles, once again, makes test code worse, but ultimately it’s fairly readable. This is a preview of a later section, but here’s the test code:

=== function quest_stage_in_range(name, min, maxExclusive) ===
~ temp stage = quest_progress(name)
~ return stage >= min and stage < maxExclusive

== maybe_add(quest, min, max) ==
+ {quest_stage_in_range(quest, min, max)} [{quest}]
~ temp passage = quest_passage(quest)
-> passage
->->

=== quests ===
= list(min, max)
<- maybe_add(mq1, min, max)
<- maybe_add(sq1, min, max)
+ ->->
->->

= ongoing
-> list(QUEST_START_STAGE, QUEST_DONE_STAGE) ->
- -> DONE

= finished
-> list(QUEST_DONE_STAGE, 99999) ->
- -> DONE

= test
+ [Ongoing] -> ongoing ->
+ [Finished] -> finished ->
+ [Back] ->->
- -> test

The maybe_add thread is saving a lot of work for me here, as here it’s serving as a sort of template that I can use to inject all the quest options with minimal boilerplate.

Potential Improvement: Gating Quest Stage Access

Currently all of the quest stage modification is happening through direct access on the quest stage value. This is less than ideal, as it requires that the people writing quests and adding triggers need to know the name of the quest stage variable in addition to the quest list entry variable.

Reading the quest stage could happen exclusively through the quest_progress function, like so: quest_progress(mq1). Not a huge improvement, but less mental overhead.

The bigger improvement would come in the set_quest_progress function, which could then check the new quest stage and add the quest to the list, if necessary.

The biggest problem with this improvement? I don’t know how to do it without adding a new if/else chain.

See, ink allows passing parameters by reference with the ref keyword. However, to my knowledge, it doesn’t allow returning a reference to a value. If quest_progress returned a reference, this would be possible:

// THIS IS NOT POSSIBLE
=== function set_quest_progress(quest, new_stage) ===
~ temp stage = quest_progress(quest)
{
- quest < QUEST_START_STAGE and new_stage >= QUEST_START_STAGE:
~ quest_list += quest
}
~ stage = new_stage

Alas, it’d have to be yet another manual mapping of quest list item to quest flag, which I don’t want. Is it worth the added safety of not accessing the flags directly? You decide.

Simultaneous Execution

My notes and quests are in ink. This isn’t a problem normally, but if the player decides to look at their quest log while in the middle of a fight or a conversation, the ink story execution will jump from the conversation to where I’m reading the note. Once it finishes doing that, it doesn’t go back to where it came from. The notes all end with -> DONE, after all.

I did quite a bit of experimentation to fix this. I tried with messing with the story stack manually. I even tried saving and restoring the entire game state when entering and leaving the notes screen.14

It turns out, however, that ink already supports such a thing:15 16 parallel flows. At any point, you can call story.SwitchFlow("<flow_name>") to go to a new flow. You can then call story.RemoveFlow("<flow_name>") to remove it.

It’s easy: I can switch the flow when opening the note, and then restore it once it’s finished. However, if I wanted to switch flows while in the middle of switching flows, it felt like I was just asking for trouble.17

I decided to look into a C# language construct that I’d known about, but never implemented myself: using () {}. I know that it could clean up file handles, so it seemed like a natural fit for handling story flows.

It turns out that it’s trivial. You just need to implement the IDisposable interface!

public class EphemeralInkPassage : IDisposable {
    private static int flowIncrementer = 1;
    private string _flowId;
    private string _cachedFlow = null;
    private Story _story;

    public EphemeralInkPassage(Story story, string passage) {
        _story = story;
        _flowId = $"f{flowIncrementer++}";
        if (!_story.currentFlowIsDefaultFlow) _cachedFlow = _story.currentFlowName;
        _story.SwitchFlow(_flowId);
        _story.ChoosePathString(passage);
    }

    public void Dispose() {
        if (_cachedFlow != null) {
            _story.SwitchFlow(_cachedFlow);
        }
        _story.RemoveFlow(_flowId);
    }
}

It implicitly manages a stack by restoring to the cached value. Using it was as simple as:

using (new EphemeralInkPassage(_inkStory, passage)) {
    while (story.canContinue) {
        string line = story.Continue();
        contents.Add(line);
    }
}

This could run into issues if the using lifetimes partially overlap one another, but I can’t immediately think of a case where that could happen in either normal code or nested coroutines.

You could run into an issue if you started a coroutine within a coroutine and both of them used flows, so don’t do that.

Testing

In all of my examples, I’ve been ending with -> DONE. That’s a simplification, and it’s not how I actually end all of my stitches. If you’re solely relying on interactions in Unity to run passages, it’s tedious to test your game. You have to load up the scene, initialize the state, and then get to where you need to go. That time adds up quick.

To get around that, I had been changing the passages to end with ->-> and manually writing test tunnels18 into the passages that I wanted to test. It worked well for testing, but changing it back and forth was error-prone and, more importantly, annoying. You can’t really set up a framework for testing if you’re constantly going back and forth between approaches.

I’d be nice if ink had a built-in way of saying “go back to who called me, or finish if I’m not in a tunnel.” It doesn’t, but you can fake it. See, when you run your ink in the editor, it starts execution in your main ink file. When you are manually running passages through the Unity integration, it doesn’t. Using that, you can consistently have a variable have a different value in inky than it does in Unity without having to add any code in Unity.

It’s as simple as this:

VAR test_mode = false
~ test_mode = true

Declare a variable, and then set it. We can use this to establish a passage that ends the tunnel if in test mode, and ends execution if not. This doesn’t distinguish what to do based on whether execution is in a tunnel or not, but it serves my purposes for working in both environments.

The passage:

=== KDONE ===
{ - test_mode: ->-> }
-> DONE

Simple. Instead of ending passages with -> DONE, I end them with -> KDONE.19

In my main ink file, the bit outside of any passages continues like this:

-> game_startup ->
-> f0.test
-> END

Then, in each floor, the test passage looks something like this:

= test
-> entry ->
+ [Quest log] -> quests.test ->
+ [Talk to Nal] -> acolyte ->
+ { not f0_temple_door_open } [Try door] -> door ->
+ { f0_temple_door_open } [Go to F1] -> f1.test
+ [Talk to Rif] -> townsfolk_1 ->
+ { flag_f0_show_tri } [Talk to Tri] -> townsfolk_tri ->
+ [Dog] -> dog ->
+ [Try temple door] -> temple_door ->
+ { farthest_floor >= 4} [F4 shortcut] -> f4.test
+ { farthest_floor >= 7} [F7 shortcut] -> f7.test
+ [Exit] -> game_over ->
- -> test

There is some parallel construction, giving opportunities for flag conditioning to be wrong or for physical barriers not being present in the ink, but it saves tons of time when testing.

Saving & Loading

ink, conveniently, provides a mechanism for saving and loading story state. Because I was lazy, I used it for all of my state. That includes quest stages, the player floor, flags, and even what parts of the map were revealed.

Getting the saved state is very easy. You just call:

_story.state.ToJson();

And it gives you all your state, bundled up into a nice JSON object. I just wrote that state directly to disk.

As you may have guessed, loading is similarly easy:

_story.state.LoadJson(json);

I didn’t notice any latency on loading or saving, but if you have big stories, I’d recommend seeing if it’s fast enough to happen during runtime without the player being able to tell. If not, well, don’t try to do it in the background.

For saving map progress, I disgustingly encode a bitfield of visibility into a string and set an ink variable to that value. It would feel better if I could just save the bits directly, but then I would have needed to merge the saving and loading of the two variable sets, which would have been extra work that I didn’t have time to do in the game jam.

In the end, the system worked quite well. I have no complaints, almost. See the downsides section.

Downsides/Pitfalls

No Dynamic Keys

As I mentioned above, I used ink for saving and loading everything. While this worked for my uses, it’s limited. The data types you can save are limited to primitives, and you can only write to variables that are declared explicitly in your ink scripts. If you’re doing something complex, you should absolutely use your own serialization structure, using ink only to manage its own state.

Localization is Hard20

Ink has no built-in mechanism for supporting multiple languages. The two suggestions I saw are:

  1. Have entirely different files, depending on the localizers to get the scripting and triggers right.
  2. Have the ink scripts use keys instead of messages, and have those keys point to messages that get translated.

Neither is optimal. The former is error-prone, while the latter dramatically limits what you can do with ink and how easy it is to write and maintain.

It seems like a pain, but I didn’t have to deal with it. Good luck!

Gluing it Together

Validation

The ink compiler provides a lot of checking for bad references, but if you’re passing around a bunch of strings, it won’t help you. My implementation of Tiled calls passages based on string values, so I should absolutely make a test that runs through all the InkInteractables in my Tiled maps and verifies that there is a corresponding ink passage.

Conclusion

Tiled and ink together are hugely powerful tools that allow for construction of complex, robust level construction and scripting outside of the Unity ecosystem. There are some advantages to constructing the levels inside of Unity directly, such as performance, better visualization, and more flexibility over placement, but they pale in comparison to the the ease of use of these tools.

What’s more, these tools can both be used without giving users access to your project or creating tools yourself, so you’re a good deal of the way to supporting user-generated content.

Having created dungeon crawlers both with built-in Unity tools and Tiled and ink, I can’t see a case where I wouldn’t reach for those two over ad hoc Unity scripts.


  1. More on this later. ↩︎

  2. 64x64 resolution will do that to you. ↩︎

  3. To get a much better overview, read the documentation. Unlike me, the people over at inkle actually know how to write. ↩︎

  4. This isn’t what I actually use, but it’s all you need to know now. I’ll clarify in the testing subsection. ↩︎

  5. Until I stop being lazy and add it to one library or another of mine. [Editor’s note: It’s available here.] ↩︎

  6. When I first wrote this section, this was used to register quests and notes with Unity. While writing the quests and notes sections, I found a mechanism that made that no longer necessary. ↩︎

  7. A battle can trigger if you transition from F4 to F5 while in a specific stage of a side quest. If I triggered it on entering a floor, it would have saved with no way of avoiding the fight, potentially soft-locking them. If I’d done it on exiting the zone, it would have triggered when taking the shortcut back to town, which I explicitly didn’t want to happen. ↩︎

  8. The ink docs have a similar approach and syntax for setting up a camera shot. I would’ve sworn that I hadn’t read that before making this decision, but it probably subconsciously influenced me. ↩︎

  9. I, of course, used that variable until I realized it made this post much more difficult to understand, as I am a fool. ↩︎

  10. () indicates a list with no entries. ↩︎

  11. If I was willing to register the list and note on startup, even those could be eliminated. ↩︎

  12. Which could also be registered, leaving you free to change the names of variables in ink at a whim. ↩︎

  13. You guessed it: this is an avenue for possible improvement later. ↩︎

  14. This worked, but even with my relatively small save states it felt like a crime too heinous to commit. ↩︎

  15. It is incredible how much functionality ink has. Almost every use case I have has been covered, and every time I read the docs I discover something new. ↩︎

  16. The docs say that it’s in beta, so user beware, but I didn’t run into any issues. ↩︎

  17. Did I only end up using this once? Sure. Did that stop me? Of course not. ↩︎

  18. A tunnel is a term for a real ink feature, not something I made up. Documentation is here. Basically, it lets you go into a passage and return back to where you came from. ↩︎

  19. KDONE because my name starts with a K, and I am not kreative. ↩︎

  20. In general, but even moreso here. ↩︎