Understanding Visit Counts and Choice Analytics in Ink

[This is a preview of draft content that will appear in the next updates of the Unofficial Ink Cookbook and Learning Ink + Unity open source books in 2021. This assumes some knowledge of Ink API methods and common design patterns explained in those books.]

Reviewing Knots and Stitches

The different sections of an Ink story are called knots. These can be locations, people, or anything else used to break a story up into parts. Ink uses the metaphor of strings and threads in a larger weave to describe stories. Where different threads intersect are a knot.

A stitch is a sub-section of a knot. It can be thought of as a “smaller knot” where the name of the knot is needed to access the stitch itself.

Reminder: The names of knots and stitches follow the rules of variables. Letters, numbers, and the underscore can be used. Special symbols and spaces cannot be used.

Knot Example

== Chapter1 ==

Stitch Example

Chapter1.Library

Story and StoryState

The method Continue() loads and returns the next chunk of a story in Ink. This internally updates the current choices, tags, and other data. As a part of the object Story, it works across the entire story, loading the next part and updating everything.

The method Continue() is nearly always paired with the property canContinue. If there is more story, the property will be true. A common pattern with code using the Ink API will use a while() loop to check this property and then, inside the loop, call Continue() to load and return the next chunk.

JavaScript Example

// Load story content
let story = new inkjs.Story(storyContent);

// Check if there is more story
while(story.canContinue) {
  // Load the next chunk of content         
  let paragraphText = story.Continue();
}

Understanding State

The term state refers to the internal values of some programming-related structure. As code is run, its internal values change. This means its state also changes.

In Ink, the concept of state is used with the Story object. Because stories have multiple internal values like choices, tags, and other data, its state is updated as new chunks are loaded.

The state of a Story can be access via its property of the same name, state.

JavaScript Example

// Load story content
 let story = new inkjs.Story(storyContent);
 // Check if there is more story
 while(story.canContinue) {
   // Load the next chunk of content         
   let paragraphText = story.Continue();
   
   // Save a reference to the state from the current story
   let storyState = story.state;
 }

Reminder: Each call to the method Continue() updates the state of the Story. Any variables using its value should be updated after any use of the method in case they change as a result of new chunks being loaded.

StoryState

In Ink, the class StoryState provides properties and methods for accessing and mutating the state of a story. When a story is running, the property state of the Story object is an instance of StoryState updated as the story loads new chunks.

Understanding Visit Counts

All knots and stitches have internal counts representing how many times a user has visited the section of the story. These are updated after any calls to the method Continue().

The object StoryState has a method VisitCountAtPathString() for checking the visit counts of any knots and stitches. It accepts the “path string” (name of the knot or stitch) and returns how many times it has been visited as part of current play.

Ink Example

-> Chapter1

== Chapter1 ==
You enter the castle.
Where do you want to go?
+ [Library]
  -> Chapter1.Library
+ [Observatory]
  -> Chapter1.Observatory
+ [Kitchen]
  -> Chapter1.Kitchen
+ [(Leave castle)]
  -> END 

= Library
This is the library

+ [Go back]
  -> Chapter1 

= Observatory
This is the observatory.

+ [Go back]
  -> Chapter1 

= Kitchen
This is the kitchen

+ [Go back]
  -> Chapter1 

JavaScript Example

while(story.canContinue) {
   // Load the next story chunk         
   let paragraphText = story.Continue();

   // Save all of the path strings (knots and stitches)
   let pointsInStory = ["Chapter1.Library", "Chapter1.Observatory", "Chapter1.Kitchen"];
   
   // Save a reference to the state from the current story
   let storyState = story.state;
   
   // Accepts a path string in the form of "knot" or "knot.stitch"         
   // Returns the count.
   pointsInStory.forEach(function(entry) {           
      console.log("Point: " + entry + ": " + 
                  storyState.VisitCountAtPathString(entry));         
});

In the above code, all of the existing knots and stiches are saved as part of the code. Once a chunk of the story is loaded via the Continue() method, the current state of the story is saved in the variable storyState. A <Array>.forEach() method is used to test each entry in the pointsInStory variable.

For each entry in the array, it is shown in the console (via console.log() method) along with its count using the VisitCountAtPathString() method. As part of the while() loop, the current state is updated and the visit counts shown.

If certain knots and stitches are important for tracking, the method VisitCountAtPathString() is the best option. As long as it is called after any Continue() method usages, it will always have the latest counts for particular knots and stitches.

Tracking Choices

The property currentChoices of the Story object will always contain the most-recent set of choices as an Array. Like most data as part of the Story object, it is updated whenever the Continue() method is called.

A common design pattern when using the Ink API is to update the choices using the currentChoices array within the canContinue while() loop.

JavaScript Example

while(story.canContinue) {
   // Load the next story chunk         
   let paragraphText = story.Continue();

   story.currentChoices.forEach(function(choice) {
     // The variable choice, passed to the callback function, 
     //  will contain:
     // - choice.text: Text of the choice
     // - choice.index: Index of the choice within the current set
   });
});

In cases where the current choices are wanted, the currentChoices array is very useful. However, it is updated after a choice is made and, in most patterns, when the Continue() method has been called. It contains the current choice, not which one was chosen from any set.

Recording Choices Chosen (JavaScript)

The method ChooseChoiceIndex() as part of the Story object accepts the index of a choice within a set and processes the choice. In JavaScript, it often appears as part of a listener function used as part of a click event.

A frequent usage pattern for ChooseChoiceIndex() method is inside a forEach() loop using the currentChoices property of the Story object. For each choice, an anchor element, <a>, is created and an event listener added. When the link is clicked, the ChooseChoiceIndex() method is called, passing the index of the choice within the set.

JavaScript Example

story.currentChoices.forEach(function(choice) {
   // Look for all A elements and grab the first (0-indexed) one
   var choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0];
   // Attach an event listen to to the element.
   // Listen for the 'click' event
   choiceAnchorEl.addEventListener("click", function(event) {
     // Using the current choice in the forEach() loop,
     //  pass its index. 
     story.ChooseChoiceIndex(choice.index);
    });
});

To record each choice as it is made, additional code can be added before the call to the ChooseChoiceIndex() method to save each choice inside the click event listener function and before the story itself updates.

JavaScript Example

// Save all choices as they happen
let choicesChosen = [];

story.currentChoices.forEach(function(choice) {
   // Look for all A elements and grab the first (0-indexed) one
   var choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0];
   // Attach an event listen to to the element.
   // Listen for the 'click' event
   choiceAnchorEl.addEventListener("click", function(event) {
     // Record the choice
     choicesChosen.push(choice);
     
     // Log to the console the current array
     console.log(choicesChosen);
     
     // Using the current choice in the forEach() loop,
     //  pass its index.
     story.ChooseChoiceIndex(choice.index);
    });
});

In the new, updated code, an array called choiceChosen is created outside of the forEach() loop using the values in the currentChoices array. When an anchor element is clicked, the choice passed to the event listener is recorded and then ChooseChoiceIndex() method is called. This saves the Choice object (and all of its own values) as part of an array.

Either during a session or after a game completes, this array could be examined to determine which choices were made by a user in the order they made them. A much more advanced usage could also use Date.now() or other information to record the exact time of the choice, the time between choices, and the time since the beginning of the session.

Understanding Story Events (C#)

As part of story execution, different events occur in a story. These include things like if an error occurred, when the next chunk of story is loaded, and if a function is about to be executed. Important for choices, each story also has the onMakeChoice Action when a choice is about to be executed.

NOTE: Story events do not currently exist in the JavaScript port and only exist in the C# version of the Ink API.

Working with Actions

An Action in C# is a delegate accepting parameters but cannot return a value. It is a common way to collect multiple methods to be called when an event happens, using the more generic Action encapsulation to include any possible future types as configured during runtime.

A delegate method is “added” or “subtracted” in C# through using the addition and subtraction assignment operators, “+=” and “-=”. These add or remove the method to an existing collection to be called when the Action happens.

C# Example

// Add a delegate to the onMakeChoice property.
//
// Any time a choice is about to executed,
//  it will be called.
story.onMakeChoice += ShowInConsole;

void ShowInConsole(Choice chosen) {
  // Show the text of the choice
  Debug.Log(chosen.text);
}

In the above code, the method ShowInConsole() is defined and added as a delegate to the onMakeChoice property of the Story object. Whenever a choice is about to be executed as part of the event, it will be called.

As defined in the code, it passes the current Choice object as a parameter. This allows the call to Debug.Log() inside the method the use the text property of the chosen object, showing the text of the choice as they happen within the story.

Ink Example

-> Chapter1

== Chapter1 ==
You enter the castle.
Where do you want to go?
+ [Library]
  -> Chapter1.Library
+ [Observatory]
  -> Chapter1.Observatory
+ [Kitchen]
  -> Chapter1.Kitchen
+ [(Leave castle)]
  -> END 

= Library
This is the library

+ [Go back]
  -> Chapter1 

= Observatory
This is the observatory.

+ [Go back]
  -> Chapter1 

= Kitchen
This is the kitchen

+ [Go back]
  -> Chapter1 

Extended C# Example

using System.Collections;
 using System.Collections.Generic;
 using UnityEngine;
 using Ink.Runtime;
 public class InkScript : MonoBehaviour
 {
     // Public asset of Ink JSON story
     public TextAsset InkJSONAsset;
     
     // Private Story instance
     private Story story;
    
     // Start is called before the first frame update
   void Start() {
       // Create a Story object based on the JSON
       story = new Story(InkJSONAsset.text);
       
       // Load everything until it encounters a set of choices       
       story.ContinueMaximally();
       
       // Add a function to the onMakeChoice event.
       //
       // Any time a choice is about to executed,
       //  it will be called.
       story.onMakeChoice += ShowInConsole;
       
       // For the first set of choices, choose the first one     
       story.ChooseChoiceIndex(1);
  }

  void ShowInConsole(Choice chosen) {
    // Show the text of the choice
    Debug.Log(chosen.text);
  } 

  // Update is called once per frame
  void Update() { }
}

In above code, the C# code loads the compiled JSON Ink code, creating a private instance of a Story object in the Start() method. Next, the method ContinueMaximally() is used to load everything up to and including the next encounter set of choices.

Reminder: The method Continue() loads the next chunk of a story. ContinueMaximally(), however, loads all chunks until it encounters the end of the story or a set of choices.

Recording Chosen Choices (C#)

Using the delegate keyword in C#, it is possible to create an anonymous method without needing to add to the current object. This can be an easy way to quickly add a delegate to an Action without needed to write a new method for some quick computation or task.

C# Example

// Add a delegate directly using its keyword
story.onMakeChoice += delegate(Choice chosen) {
  // Do something!
};

In the above code, the delegate keyword allows for writing a faster and more compact shorthand example. As the onMakeChoice Action will happen as the API is about to process a choice, this also allows for capturing the choice as it happens using this structure. Instead of simply showing it on the Console, the Choice can be recorded.

Extended C# Example

using System.Collections;
 using System.Collections.Generic;
 using UnityEngine;
 using Ink.Runtime;
 public class InkScript : MonoBehaviour
 {
     public TextAsset InkJSONAsset;
     private Story story;
     // Keep a public record of all Choices made
     // (Since it is public, other components can access it!)
     public List<Choice> ChoicesChosen;

    // Start is called before the first frame update
    void Start() {
     // Create the List
     ChoicesChosen = new List<Choice>();
     // Create a Story object based on the JSON
     story = new Story(InkJSONAsset.text);
     // Load everything until it encounters a set of choices      
     story.ContinueMaximally();
     // Add a delegate directly using its keyword
     story.onMakeChoice += delegate(Choice chosen) {
         // Update the List of choices
         ChoicesChosen.Add(chosen);
     };
     // For the first set of choices, choose the first one
     story.ChooseChoiceIndex(1);
   }
 
   // Update is called once per frame
   void Update() {
   }
}

In the extended example above, a List<Choice>is used to record each choice as they happen using the delegate keyword shorthand form instead of adding a method to the object. As it is public, this allows the List<Choice> to be accessed outside of the object and component.

In a more complex example, an additional object could be added to access this List external to the object running the story and performing additional tasks with the data. Like the JavaScript version, additional data such as the time of the choice, time since last choice, and time between start of session and the most current choice could also be added as part of this additional object or code.

Reviewing Story Analysis Approaches in Ink

What do you care about? Story sections or choices? Maybe both?

Determining analytics in an Ink story boils down to the above question: what do you care about? If the story is divided into important knots and stitches, using the VisitCountAtPathString() method might be the best fit. If certain sections mean more than others, this method can be used to determine if a player has reached them or is repeating certain locations, dialogue options, or other sections of the story.

If choices are more important, recording the chosen option in JavaScript or using the onMakeChoice Action in C# might be a better fit. These allow for tracking choices as they happen and recording them in different ways.

As mentioned multiple times, more complex examples could also record more details. This draft text reviews some methods and two main approaches (tracking story section visit counts or choices). It does not include additional details like timing, user session information, or other data a more in-depth analysis might need.