An Ink/Unity Storylet Framework

Ian Thomas
8 min readJun 23, 2023

--

TL;DR — a simple Storylet implementation for Ink in Unity. You can find the code here.

You know what Storylets are, right? Rearrangeable chunks of story that a player can encounter in any order — the sort of setup you find in Fallen London, King of Dragon Pass, Wildermyth, Reigns, and increasingly sneaking into AAA games and in particular open-world experiences. Storylets are very useful for bringing a world to life.

Emily Short has a lot more to say about them than me.

I’ve been working with them for a bit in both hobby and commercial projects and developed a very lightweight framework for Unity a few years ago. I thought I’d clean up the code. Nothing fancy, but might be useful to get a project started.

The Basic Idea

At heart, a Storylet is a chunk of narrative with a condition on the front. A big fat IF statement, if you like, which says whether that chunk of narrative is currently available and appropriate to play.

e.g.

// Pseudocode

IF carrying_crown
THEN STORY royal_welcome IS AVAILABLE

IF carrying_crown
THEN STORY republican_protest IS AVAILABLE

IF at_war_with_trolls
THEN STORY troll_deserter IS AVAILABLE

IF not at_war_with_trolls
THEN STORY troll_ambassador IS AVAILABLE

IF day==tuesday AND at_war_with_trolls AND available(strawberries)
THEN STORY combat_tuesday_teaparty IS AVAILABLE
THEN STORY tuesday_combat_teaparty IS AVAILABLE

This storylet system runs through all the possible storylets checking those conditions and gathers them into a list of currently playable storylets.

If your game state changes — after playing out a storylet, or after picking up a new item, or spending XP, or anything at all — you can call the storylet system again to refresh the current list.

A good way to think about all this is that each storylet is a card in a deck of cards. That whole deck of cards is every storylet that could ever be played. The list of what is currently playable can be thought of as a hand of cards. In some games you want to allow the player to pick a card from their hand(“do you want to do this, this, or this next?”). In other games you might want to automatically pick from the hand of possibilities (“an event happens!”).

(Fallen London and Reigns explicitly present their storylets as cards. That’s a presentation choice, nothing to do with mechanics, but is a very useful way to think about storylet systems under the hood!)

The Ink Side

You might want to read up on Ink before going further in this.

In our Ink scripts, we’re not going to assume anything about the actual content, only about the containers of the content. We’re going to define each storylet as a knot in Ink, and use our storylets framework to decide which knots are currently playable.

In normal Ink syntax you can have loads of knot IDs i.e. header labels for content. So we can’t have a blanket approach where we say “all knot IDs are storylets!” because we might be using Ink for all sorts of other things too. So instead, let’s assume that all knot IDs with a particular prefix — say story_ — are our storylets.

For example:

// Sample stories

=== story_troll_ambassador ===
You meet a troll. They are extremely polite, offer their hopes
for longstanding peace, and offer you golden chocolates.
You insult their hospitality, and start a war.
-> DONE

=== story_troll_deserter ===
You meet a troll. They claim to have escaped from persecution
in the Troll Army, and ask for sanctuary.
-> DONE

=== story_rainy_day ===
It's a surprisingly rainy day, the sort of day that only happens
once a century.
-> DONE

=== story_sing ===
You sing a silly song, just to pass the time.
-> DONE

Then we attach our storylet condition to each of these stories in the form of a function() which returns true if a storylet is currently available or false otherwise. We name it the same as the knot ID but prefix it with an underscore character to indicate that it’s the storylet function, like so:

// Note that this var could be altered
// in Ink or by a binding to the game
// state in Unity.
VAR at_war_with_trolls = false

=== function _story_troll_ambassador() ===
~ return not at_war_with_trolls
=== story_troll_ambassador ===
You meet a troll. They are extremely polite, offer their hopes
for longstanding peace, and offer you golden chocolates.
You insult their hospitality, and start a war.
~ at_war_with_trolls = true
-> DONE

=== function _story_troll_deserter() ===
~ return at_war_with_trolls
=== story_troll_deserter ===
You meet a troll. They claim to have escaped from persecution
in the Troll Army, and ask for sanctuary.
-> DONE

=== function _story_rainy_day() ===
~ return true
=== story_rainy_day ===
It's a surprisingly rainy day, the sort of day that only happens
once a century.
-> DONE

=== function _story_sing() ===
~ return true
=== story_sing ===
You sing a silly song, just to pass the time.
-> DONE

And that’s mostly it!

Hopefully you can see how easy it is to build up a library of potential storylets here using Ink’s built-in functions and variables — particularly when you’re reading Unity variables via Ink’s integration. So you can tell Ink what the game state is, and the storylet condition functions as given above can query that state and decide if they are playable at any given moment.

The Unity Side

So first of all you’ll need the Ink/Unity integration plugin installed.

This storylet system is a Unity class that will gather up all the available storylets whenever you want to refresh them by calling the condition function of each storylet.

It’s important to remember that you can have lots and lots of storylets in a project. These things get big. So instead of gathering everything in one call — which could significantly slow things down — this system collects the information over a few frames and returns when it’s ready. This is generally fine — it’s rare a story system needs to give you a new story right here, right now, in 60fps.

You can find the code here — it’s just the one class in StoryletsManager.cs

Here’s a quick run through its public properties and methods:

class StoryletsManager {
public int StoryletsToProcessPerFrame;
public Action OnRefreshComplete;
public bool IsReady;
public bool IsRefreshing;
public bool NeedsRefresh;

public StoryletsManager(Story story);
public void AddStorylets(string prefix);
public void Refresh();
public void Tick();
public List<string> GetPlayableStorylets(bool weighted = false)
public void MarkPlayed(string knotID);
public string PickPlayableStorylet();
public void Reset();
public string SaveAsJson();
public void LoadFromJson(string json);
}

The normal usage pattern is:

  • Load an Ink story (that’s an Ink.Runtime.Story) the usual way.
  • Create an instance of StoryletsManager, passing it the story.
  • Hook up the Tick() method of StoryletsManager to be called once a frame (for example via an Update() method on a MonoBehaviour or something similar). If you don’t do this, the list of playable storylets will never be collected!
  • Add a set of storylets from your source ink file by prefix using AddStorylets(<prefix>) e.g. in the example I gave above it’d be AddStorylets("story_"). All you’re doing there is telling the storylets system which knotIDs from the whole of your Ink project it should treat as storylets.
  • Call Refresh() when you’ve added all the prefixes (you might only use one, but in some projects I might have various classes of storylet with different prefixes).
  • Choose A New Story: When you want to let the player encounter or play a storylet, either:
    - Call GetPlayableStorylets(), let the player choose one from the list, then call MarkPlayed(knotID) to tell the storylet manager that a card has been ‘drawn’, or:
    - Call PickPlayableStorylet() to randomly choose a storylet to play ( MarkPlayed() will be called automatically).
  • Playing out your storylet probably changed the game state — call Refresh() again before drawing any more storylets.
  • Repeat from the Choose A New Story step above!

And you can also call Reset() to start again, or SaveAsJson()/LoadAsJson() to keep track of the storylet states.

Once or For Always

By default if a card has been played it will keep on reappearing. However, if you set the tag #once on a storylet in Ink then it will only ever be playable once — otherwise it will keep appearing in the hand (if the storylet function is true) until it is MarkPlayed().

For example:

=== function _story_rainy_day() ===
~ return true
=== story_rainy_day ===
#once
It's a surprisingly rainy day, the sort of day that only happens
once a century.
-> DONE

Storylet Weighting

Sometimes you want a particular story to be more likely to be played than other stories.

Here this system uses the card metaphor again. If you think of your list of playable storylets as a hand of cards, we can make sure that there are several copies of the same card in your hand if you want it to be more likely to be chosen.

To do that, we use the storylet condition function again. It normally returns true to mean “is playable” or false to mean “not playable”. However, you can also return an integer instead. 0 means “not playable”, 1 means “put one copy in the list”, 2 means “put two copies in the list” and so on. And Ink is pretty flexible how we do that. For example:

=== function _story_troll_ambassador() ===
~ return (not at_war_with_trolls) * 10 // We want this to be much more likely!
=== story_troll_ambassador ===
You meet a troll. They are extremely polite, offer their hopes
for longstanding peace, and offer you golden chocolates.
You insult their hospitality, and start a war.
~ at_war_with_trolls = true
-> DONE

=== function _story_rainy_day() ===
~ return 5 // If this is available we want it to be likely
=== story_rainy_day ===
It's a surprisingly rainy day, the sort of day that only happens
once a century.
-> DONE

=== function _story_sing() ===
~ return true
=== story_sing ===
You sing a silly song, just to pass the time.
-> DONE

Simple Working Sample

Here’s a main class you could put on an object in Unity. It uses the built-in UI as a very basic interface to whatever you’re doing in Ink.

public class Main : MonoBehaviour
{
public TextAsset inkJSON;
private Story _inkStory;
private StoryletsManager _storylets;

private void Awake()
{
_inkStory = new Story(inkJSON.text);
_storylets = new(_inkStory);
_storylets.AddStorylets("story_");
_storylets.Refresh();
}

private void Update()
{
_storylets.Tick();
}

private string output = "";

private void OnGUI()
{
if (_storylets.IsRefreshing)
GUILayout.Label("Refreshing");

if (!_storylets.IsReady)
return;

List<string> available = _storylets.GetPlayableStorylets();
GUILayout.Label($"Storylets available: {available.Count}");

foreach (var knotID in available)
{
if (GUILayout.Button(knotID))
{
_storylets.MarkPlayed(knotID);
RunStorylet(knotID);
_storylets.Refresh();
return;
}
}

if (GUILayout.Button("Pick Random"))
{
var knotID = _storylets.PickPlayableStorylet();
if (knotID != null)
{
RunStorylet(knotID);
_storylets.Refresh();
}
}

GUILayout.TextArea(output, GUILayout.MinWidth(200), GUILayout.MinHeight(100));
}

private void RunStorylet(string knotID)
{
_inkStory.ChoosePathString(knotID);
var text = _inkStory.ContinueMaximally();
output = $"Running {knotID}:\n";
output += $" {text}\n";
}
}

Conclusions

And that’s it! This is a very basic framework, I’ve used this or variants of it for a few things, I hope it’s useful for you or inspires you! MIT License, use it for what you want, but I’d appreciate a shoutout or something — or get in touch — if you use it!

Caveat: This is old code I’ve resurrected and I haven’t tested it in a while, so ping me if there are obvious errors!

--

--

Ian Thomas

Ian is narrative director, coder, and writer of video games, films, larp, books, and VR/AR experiences. He has worked on well over 100 titles.