VG3, Quest 7 - Inkle Dialogue Engine

Download Files

Supporting files for VG3 quests are part of a single archive that you can download here.

Introducing Ink

For this assignment, we will explore creating a text-based game using Ink. Ink is a narrative scripting language for writing interactive stories. Using Ink, a Narrative Designer can author the content and structure of a story with a text-based toolset specifically intended for their craft. Another benefit of using a tool external to Unity is that narrative designers can deliver work that is reusable across multiple game engines.

To make terminology a little confusing, Ink is a product of Inkle Studio, and the text editor you can optionally use to write Ink stories is called Inky. Inkle Studio is famous for many highly-awarded narrative games such as "80 Days", "Overboard!", and "Heaven's Vault". Ink itself can be written in plain-text using any text editor, and using Inky is not required. A ".ink" (dot-ink) file is simply a ".txt" file with the ".ink" file extension instead. Inky does provide a helpful interface for assisting with Ink syntax and for previewing the story as you write it. Inkle Studios provides Ink, Inky, and its related official plugins for free under the MIT license.

Below is a screenshot of the Inky interface with a sample Ink story written on the left in plain-text. On the right is a preview of the story progressed up to its first dialogue branch.

The process for this assignment comes in two halves: First, we will write a story in Ink outside of Unity; Finally, we will integrate our Ink story to be playable within Unity.

Ink Syntax

As a scripting language, Ink contains a variety of syntax allowing for variable tracking, flow control, and conditional logic. As a narrative language, Ink also puts a strong focus on presentational logic for how content should be portrayed.

Writing in Ink

Text absent of any special symbols is shown as typed sequentially as a story advances.

This is a very short story...
...The End

Choices and Sticky Choices provide opportunities to branch a story based on the player's decisions. Normally choices (denoted by a star *) are automatically removed once chosen, so as to funnel readers through all possible content. Sticky Choices (denoted by a +) can be repeatedly chosen by players.

You have very important choices to make...
	* A Choice
		You chose Choice A
	* Another Choice
		You chose Choice B
	+ Sticky Choice
		You chose Choice C. Maybe you'll choose it again.

Knots and Diverts provide shortcuts for an Ink story to jump around. "Knots" (denoted by triple = signs at the beginning and end of a line) act as a label for a section of text, and a "divert" (denoted by a ->) jumps a story to a target knot. "END" is a built-in knot in Ink, and most stories eventually divert to the END.

Let's jump to a knot!
-> ReadFirst

=== ReadSecond  ===
You'll read this second 
-> END

=== ReadFirst ===
You'll read this first.
-> ReadSecond

Variables provide the ability to store and recall information throughout a narrative. (Also, notice the use of Sticky Choices and Diverts in this example, so that the player can keep trying different shoe colors.)

VAR myColor = ""

-> Intro
=== Intro ===
What color shoes should I wear?
	+ Pick Red
		~myColor = "red"
		-> Shoes
	+ Pick Blue
		~myColor = "blue"
		-> Shoes
	+ Pick Green
		~myColor = "green"
		-> Shoes
		
=== Shoes ===
I'm wearing {myColor} shoes!
	+ Pick again?
		-> Intro

Conditional Blocks allow alternate content to be shown based on the value of a variable. (Also, notice the use of NON-sticky Choices (*) in this example, which means the player can only buy one of each item.

VAR money = 3

-> Shop
=== Shop ===
I have ${money}. What should I buy?
	* Buy Chocolate ($1)
		~money -= 1
		-> Total
	* Buy Gum ($1)
		~money -= 1
		-> Total
	* Buy Coffee ($1)
		~money -= 1
		-> Total
		
=== Total ===
{money > 0:
	I have ${money} left!
	+ Buy more?
		-> Shop
	- else:
		I ran out of money!
		-> END
}

External Functions allow Ink to trigger programming that exists outside of Ink, such as C# functions you have written in Unity.

EXTERNAL NextLevel()
EXTERNAL GameOver()

-> Intro
=== Intro ===
Would you like to play a game?
	+ Yes, I am good at games!
		~NextLevel()
		-> Intro
	* No, thanks.
		~GameOver()
		-> EndGame
				
=== EndGame ===
Too bad.
->END

While an exhaustive documentation of Ink's features is beyond the scope of this assignment, this list covers the core features necessary to create a basic interactive story.

Inkle Studios provides their own intro tutorial located at https://www.inklestudios.com/ink/web-tutorial/ and full documentation found at https://github.com/inkle/ink/blob/master/Documentation/WritingWithInk.md

Writing an Ink Story

You may optionally install Inky (the text editor for Ink) to experiment with our story as you write it. https://www.inklestudios.com/ink/

Inevitably, you need only to save the following plain text in a file named "Band.ink" and drag it into your Unity /Assets/ folder to continue with these instructions.

What follows is an interactive story in which the player searches rooms to gather three bandmates who then travel to an auditorium to perform. The sequence in which players visit rooms and gather the bandmates is open-ended. Similarly, the story concludes with an endless conversation in which you can repeatedly ask any of the bandmates to adjust the individual volume of their separate instruments. (An upcoming assignment will use these conversations to influence dynamic music played during the story.)

Band.ink

// What bandmates do we have?
VAR hasKeyboard = false
VAR hasDrums = false
VAR hasBass = false

// Unity functions
EXTERNAL StartKeyboard()
EXTERNAL StartDrums()
EXTERNAL StartBass()

EXTERNAL VolumeDownKeyboard()
EXTERNAL VolumeUpKeyboard()
EXTERNAL VolumeDownDrums()
EXTERNAL VolumeUpDrums()
EXTERNAL VolumeDownBass()
EXTERNAL VolumeUpBass()

-> Intro
=== Intro ===
The concert is starting soon! Find the three missing bandmates.
	-> Hallway

=== Hallway ===
You enter a Hallway and see 4 doors.

	+ [Go into the Auditorium] You enter the Auditorium.
		-> Auditorium

	* [Go into the Lounge ] You enter the Lounge and discover a Keyboardist enjoying a boba tea.
		The Keyboardist joins your party!
		~hasKeyboard = true
		~StartKeyboard()
		-> Empty

	* [Go into the Study ] You enter the Study and discover a Drummer trying to adjust the thermostat four degrees colder.
		The Drummer joins your party!
		~hasDrums = true
		~StartDrums()
		-> Empty

	* [Go to into the Billiard Room ] You enter the Billiard Room and discover a Bassist playing video games.
		The Bassist joins your party!
		~hasBass = true
		~StartBass()
		-> Empty

=== Empty ===
There's nothing left to do in this room.
	+ [Go back to the Hallway]
		-> Hallway

=== Auditorium ===
{hasKeyboard == false || hasDrums == false || hasBass == false:
		We're missing some of the band!
			+ [Go back to the Hallway]
				-> Hallway
	- else:
		The band's all here!
		-> Band
}

=== Band ===
What will you tell the band?
	+ [Talk to the Keyboardist] You talk to the Keyboardist:
		++ Please play quieter.
			The Keyboardist says they will try to play quieter.
			~VolumeDownKeyboard()
			-> Band
		++ Please play louder.
			The Keyboardist says they will try to play louder.
			~VolumeUpKeyboard()
			-> Band

	+ [Talk to the Drummer ] You talk to the Drummer:
		++ Please play quieter.
			The Drummer says they will try to play quieter.
			~VolumeDownDrums()
			-> Band
		++ Please play louder.
			The Drummer says they will try to play louder.
			~VolumeUpDrums()
			-> Band

	+ [Talk to the Bassist] You talk to the Bassist:
		++ Please play quieter.
			The Bassist says they will try to play quieter.
			~VolumeDownBass()
			-> Band
		++ Please play louder.
			The Bassist says they will try to play louder.
			~VolumeUpBass()
			-> Band

Three variables track the progress of finding all three bandmates. Until the band is fully assembled, certain parts of the story are blocked by a conditional. (Lines 1 to 4)

We define references to several Unity functions outside of Ink that will be used in an upcoming adaptive audio assignment. For this assignment, these functions will be called, but they won't do anything yet. (Lines 6 to 16)

The Intro section is the first part of the story shown to the player. It is only shown once and immediately jumps to the Hallway section. (Lines 18 to 21)

The Hallway section is revisited multiple times as the player explores various rooms. It provides an explanatory text before offering the player four choices. Notice that one of these choices is a Sticky Choice while three of them are normal Choices. (Lines 23 and 24)

The choice to visit the Auditorium is a Sticky Choice because the player may visit the Auditorium too early before the story can progress. The player needs to be able to eventually return to the Auditorium last even if the Auditorium has been visited multiple times beforehand. The Auditorium choice diverts to a larger section of the story dedicated to the auditorium. It is not necessary to write all of the auditorium script here, when he can be better organized later in the story. (Lines 26 and 27)

The player can only choice to visit the Lounge once. Upon visiting the Lounge, some story text is shown, a variable is updated to track that the player has found the Keyboardist bandmate, a Unity function is triggered for the Keyboardist, and the story jumps to the empty room section of the story. (Lines 29 to 33)

When the player visits the Study, the same logic occurs as visiting the Lounge, except the details pertain to meeting the Drummer instead. (Lines 35 to 39)

Similarly, visiting the Billiard Room focuses on the Bassist. (Lines 41 to 45)

Visiting any of the bandmates jumps to this "Empty" room section of the story which prompts the player to return to the starting Hallway. The player will inevitably see this portion of the story 3 times, once for each bandmate. (Lines 47 to 50)

The Auditorium section is conditional whether all the bandmates have been gathered. If any of the bandmates are missing, the player is directed back to the Hallway. Once all three bandmates have joined together, the story jumps to final "Band" section. (Lines 52 to 60)

The "Band" section loops indefinitely allowing you to endlessly converse with the bandmates. This section opens with narrative text prompting the player to talk to the band. The player is given three stick choices, one for each bandmate. (Lines 62 and 63)

Players can choose to talk to the Keyboardist and are given two more choices in their conversation with that bandmate. The player can ask that band member to player louder or quieter. (Lines 64 to 72)

Players have the same options regarding volume when talking to the Drummer. (Lines 74 to 82)

Finally, players can also ask the Bassist to adjust their volume. (Lines 84 to 92)

Whatever approach you use to produce this text file, drag "Band.ink" into Unity's /Assets/ folder.

Ink Plugin

Ink stories are platform-agnostic, which means these narratives can be imported into any other game engine using the proper plugin. We must set up the Ink Unity Integration plugin to continue.

The course files supply you with a ".UnityPackage" file containing the official Ink Unity plugin at the time this tutorial was written. Look for a file named "Ink.Unity.Integration.1.2.1.unitypackage" wherever your course files are located. This version is provided to ensure consistency with the screenshots in these instructions. Should you ever need to find the most up to date version provided by Inkle Studios, you can visit https://www.inklestudios.com/ink/ and scroll to the "Unity Integration" section.

Do NOT drag the UnityPackage into your /Assets/ folder. UnityPackages must be imported from the Unity Editor application menu. At the top of the Unity Editor window, click Assets > Import Package > Custom Package...

Select the Ink UnityPackage, and you will see a rather lengthy list of files to import. As this is the official package, it should be safe to import all files.

You may see several progress bars while the files import and compile. Once compilation has completed, Ink presents you with a welcome screen. It is OK to simply close this window.

In the newly imported /Assets/Ink/ folder is a wealth of supporting libraries, documentation, and sample content you could explore. We will build our own story player in a fresh scene.

Scene Setup

Start a new scene using the "Basic (URP) template and save it as BandStory.scene in the /Assets/Scenes/ folder.

For the readability of our UI, change the Main Camera's background to black.

In the BandStory scene, we need to prepare a UI Canvas with robust Canvas Scaler configuration (as you should have practiced over many semesters at this point). We will generate our story UI game objects programatically, and a Vertical Layout Group component on the Canvas will keep this content organized.

Our story will have two types of UI content that we will instantiate via code. We need to prepare prefabs for Text and Button content.

Add a TextMeshPro Text object named "Story Text" as a child of the Canvas. Configure it with center-aligned, bold text with a font size of 20. Prefab the object into the /Assets/Prefabs/ folder and delete it from the scene.

Add a TextMeshPro Button object named "Story Button" as a child of the Canvas. Configure its child Text object with a font size of 20. Prefab the entire button object into the /Assets/Prefabs/ folder and delete it from the scene.

Your Canvas should be empty of children after all of these prefab preparation steps.

Create a new C# file named InkPlayer.cs in your /Assets/Scripts/ folder. Create an empty game object named "StoryController" in your scene Hierarchy, and attach the InkPlayer component to the StoryController game object.

This snippet has comments throughout to explain the purpose of each section of code.

InkPlayer.cs

using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Ink.Runtime;

public class InkPlayer : MonoBehaviour
{
	// Outlets
	public Canvas canvas;
	public GameObject uiPrefabText;
	public GameObject uiPrefabButton;

	// Configuration
	public TextAsset inkJSONAsset;
	public Story story;

	// State Tracking
	public bool hasBass;
	public bool hasKeyboard;
	public bool hasDrums;
	
	// Methods
	void Awake() {
		StartStory();
	}

	void StartStory() {
		// Creates a new Story object with the compiled story for us to play
		story = new Story(inkJSONAsset.text);

		// Respond to the External Functions written into our Ink story
		story.BindExternalFunction("StartKeyboard", StartKeyboard);
		story.BindExternalFunction("StartDrums", StartDrums);
		story.BindExternalFunction("StartBass", StartBass);

		// We will add audio functions here in an upcoming assignment
		story.BindExternalFunction("VolumeDownKeyboard", () => { /* Temp Placeholder */ });
		story.BindExternalFunction("VolumeUpKeyboard", () => { /* Temp Placeholder */ });
		story.BindExternalFunction("VolumeDownDrums", () => { /* Temp Placeholder */ });
		story.BindExternalFunction("VolumeUpDrums", () => { /* Temp Placeholder */ });
		story.BindExternalFunction("VolumeDownBass", () => { /* Temp Placeholder */ });
		story.BindExternalFunction("VolumeUpBass", () => { /* Temp Placeholder */ });

		// Update UI visuals
		RefreshView();
	}

	void StartKeyboard() {
		hasKeyboard = true;
	}

	void StartDrums() {
		hasDrums = true;
	}

	void StartBass() {
		hasBass = true;
	}

	// Called every time the story progresses 
	void RefreshView() {
		// Clean up old content
		RemoveChildren();
		
		// Read story content line-by-line until we can't continue
		while(story.canContinue) {
			// Get the next line of the story
			string text = story.Continue();
			
			// Clean up excess whitespace in the text.
			text = text.Trim();
			
			// Display story content
			RenderStoryText(text);
		}

		// Loop to display any story choices
		if(story.currentChoices.Count > 0) {
			foreach(Choice choice in story.currentChoices) {
				string buttonText = choice.text.Trim();
				Button button = CreateStoryButton(buttonText);
				
				// Set button callback function
				button.onClick.AddListener(delegate {
					OnButtonChoiceClick(choice);
				});
			}
		} else { 
			// The story ends once we've read all content and no choices remain.
			Button choice = CreateStoryButton("THE END.\nRestart?");
			choice.onClick.AddListener(StartStory);
		}
	}

	// Clear the canvas, so we can add fresh story content
	void RemoveChildren() {
		int childCount = canvas.transform.childCount;
		
		// Loop backwards because
		// destroying objects as we loop can mess up your position in the loop 
		for(int i = childCount - 1; i >= 0; --i) {
			Destroy(canvas.transform.GetChild(i).gameObject);
		}
	}
	
	// Render a line of story text using our prefab
	void RenderStoryText(string text) {
		TMP_Text storyText = Instantiate(uiPrefabText).GetComponent<TMP_Text>();
		storyText.text = text;
		storyText.transform.SetParent(canvas.transform);
		storyText.transform.localScale = Vector3.one;
	}

	// Create a button for a story choice
	Button CreateStoryButton(string text) {
		// Creates the button from a prefab
		Button choice = Instantiate(uiPrefabButton).GetComponent<Button>();
		choice.transform.SetParent(canvas.transform);
		choice.transform.localScale = Vector3.one;

		// Gets the text from the button prefab
		TMP_Text choiceText = choice.GetComponentInChildren<TMP_Text>();
		choiceText.text = text;

		return choice;
	}
	
	// Callback function for any time a story choice button is pressed
	void OnButtonChoiceClick(Choice choice) {
		story.ChooseChoiceIndex(choice.index);
		RefreshView();
	}
}
				

You may have noticed a Console warning mentioning that our Band.ink story has not yet been added to the ink library.

Inspect the Band.ink asset located at /Assets/Band.ink and click the "Rebuild Library" button in the Inspector.

After rebuilding the ink library, the same inspector window updates asking you to compile your ink story. Click the "Compile" button.

After compiling, there will be a new Band.json file in your /Assets/ folder. This is a version of the Band.ink story that is compatible with the Unity plugin. If you make subsequent edits to the ink file, recompilation for an updated JSON file should be automatic.

Assign the Band.json file as the Ink asset in the configuration of the Ink Player.

Assign the UI prefabs to the configuration of the Ink Player.

Playing a Story

Pressing Play should reveal the intro and first set of story choices.

Progress through the story checking that each narrative moment occurs as described in Step 3 (Writing an Ink Story).

As you explore the rooms, inspect the StoryController game object and confirm that the various hasInstrument booleans update in coordination with the story.

Verify that the Auditorium content responds to the condition of having gathered all band mates.

Confirm all of the interactions with the Keyboardist.

Confirm all of the interactions with the Drummer.

Confirm all of the interactions with the Bassist.

Save and Test

Playtest to ensure all interactions work as expected and that the addition of any new features hasn’t broken any earlier interactions.

Submit Assignment

SAVE any open files or scenes.

Submit your assignment for grading following the instructions supplied for your particular classroom.