Flows: A Story Concurrency Primitive

Table of Contents
  1. Flowing Ink
  2. How might this work in practice
  3. Concurrent access and nerd stuff
  4. Flow context for Cues
  5. Will it work?
  6. Rescuing an overly vague cue
  7. Wrapping it up

This post builds on “🫟 Ink for show control”. Here, we will explore how we provide context to each cue we run in a story.

Space center stories often have multiple concurrent story flows active at a time. The most basic example would be having the main story running along with a couple of side stories and perhaps side conversations with damage, security, or engineering teams on the ship. A more advanced example would be having multiple parallel bridges (both those with crew members and NPC bridges) that interact with each other.

Each of these examples (from the more commonplace damage teams to having people you can interact with at Starbase 12, a friendly NPC, an enemy NPC, and another bridge) are, abstractly, the same primitive: a story flow.

By explicitly modeling parallel/concurrent story flows, we can take a single Ink story and get everything to work together.

Flowing Ink

Historically, Ink-based games have had a single entrypoint into the runtime. The game starts at the top and makes its way to the bottom, looping along the way.

Although this works well for choose-your-own adventures, having multiple concurrent interactions with the story (from multiple players, or from non-player characters) becomes difficult.

Fortunately, Ink has a concept called “Flows” that address this very issue. The goal with Flows is to allow just such concurrent activity in a story.

Every Ink story has a default flow - the one you start with. The engine can boot up new flows as well. Flows share game state, but otherwise can run independently.

From the Ink docs, one example of this would be having a flow modeling two NPCs talking to each other. The player could join this conversation, interrupting that flow.

How might this work in practice

Let’s suppose we have an Ink story with a main mission knot to kick off the primary story. Let’s suppose we have a separate set of knots for handling a side story that is told through a chat interface with our damage team. The main story is going to basically flow through top to bottom from knot to knot, while the damage team approach may have more looping involved. This is because the main storyline has a definite end, whereas we can communicate with damage team members however/whenever we like throughout the flight. Barring some mortal danger for our NPCs, they should be around to communicate.

  VAR alert_condition = 5

  This is the main entrypoint into the story.
  Which flow do you want to run on?

  * Mission
      -> mission
  * Damage Control
      -> damage_control

  = mission

  Tex: Okay, let's start the story.

  * [Some choice]
      -> banter

            - (banter)

  // We're going to run this in a loop to emulate a story being told over time. Tex should chime in every 10 seconds under this scenario.

  + [Wait: 10 seconds]
    Tex: Something else happened! We've done this {banter} times! Meanwhile, damage control has done their thing {damage_control.banter} times!
  + {alert_condition != 5} [Alert condition 5]
    ~ alert_condition = 5
    Computer: Alert condition 5
  + {alert_condition != 1} [Alert condition 1]
    ~ alert_condition = 1
    Computer: Alert condition 1
  - -> banter

  = damage_control

  VAR damage_control_alert_condition = 5

  Tech Sergeant Chen: Hello, I'm pleased to meet you. Let's fix this ship.

  - (banter)

  // Another loop. In reality, I'd probably use our hooks/invites system to weave in multiple bits of banter that could happen as part of damage control conversations.

  {damage_control_alert_condition != alert_condition: <- alert_condition_happened}

  + [Wait: 10 seconds]
    Tech Sergeant Chen: More things have happened! We've done this {banter} times! Meanwhile, the main story has done their thing {mission.banter} times!
    -> banter


  == alert_condition_happened

  {damage_control_alert_condition == 5 and alert_condition == 1:
  Main Computer: Alert Condition 1. All hands, battle stations!
  Tech Sergeant Chen: Oh no, what's going to happen?
  }

  {damage_control_alert_condition == 1 and alert_condition == 5:
  Main Computer: Alert Condition 5. Stand down from red alert.
  Tech Sergeant Chen: Whew, glad that's over with.
  }

  ~ damage_control_alert_condition = alert_condition
  -> DONE

This little exercise should be a minimum-viable flow example. We have two potential loops of content happening, and they ought to be able to run at the same time. Moreover, the state of each flow influences the other one: the main story knows how many times we’ve looped in the damage control sequence, and the damage control sequence knows not only where we’re at in the main story, but also knows the state of the alert condition.

A more sophisticated story would be able to incorporate other elements as needed. In particular, changes in alert condition would not be tracked this way since they’re happening in the “mechanics” part of the simulation. However, that information could be retrieved using a cue. State that only matters to the narrative could be maintained inside of Ink itself (e.g. do we know about the CRM-1138 yet?).

I think that the narrative state of something like the alert condition and other ship properties should probably be modeled in Ink with a LIST that captures the current state of narrative-relevant concepts from the mechanics simulation (updated by some background process reading the ECS simulation on a loop with a reasonable delay, maybe once every second or 500 ms or something). Then, the main mission could have its own LIST that tracks what we have recently processed about the changing mechanical simulation. In this way, we can diff the “real-time” state with the narrative state at the right moments in the story and react accordingly. We would use a separate LIST for our damage control flow and do the same thing there, allowing Tech Sergeant Chen the opportunity to chime in about changes in alert condition as needed.

I think that some of this starts to push the limits of how Ink is designed to execute. Ink’s strengths are battle tested from actual games. It embodies a lot of learning on how to make the types of games that Inkle wants to tell. That said, reacting with on-the-spot dialog changes when the game simulation is happening in the background at a rapid pace (60 fps) may not be one of its strengths. Although Ink has been used in games like Heaven’s Vault or A Highland Song, both of which have rapid mechanics and slower narration, the dialog in those do not seem to react instantly to unexpected changes in game state.

Specifically, we might set up a rapid response by saying, “Once these conditions are met, move on with the narrative”. We anticipate some action that may be split-second, and because computers can react in split-second ways the game appears highly reactive.

This contrasts with saying, “A crew could move to Alert Condition 1 and then Condition 5 and then back to 1 multiple times in a tight time span.” A human actor could trivially improvise their dismay at a crew flip-flopping back and forth. The software version of this would need to have a means of detecting how often we might be switching back and forth, and have a reasonable, context-sensitive way to respond to that when assembling the next line of dialog.

Concurrent access and nerd stuff

When running two flows on the same story object, we need a way to know which flow is active at a time. For my purposes, I’m going to write about this from the perspective of Elixir. Although the concurrency/locking access issue are going to apply in any programming environment, I know how to solve them best using Elixir concurrency primitives. If you’re using something else, you’re on your own.

In Elixir, everything is modeled as a process, and a program is really just a bunch of concurrent processes. If you’re familiar with Javascript or Node or any single-threaded runtime, you’re halfway there. Imagine building an application out of multiple running instances of Node, though instead of needing an actual OS thread per process, you use “green threads” which take microseconds to start and shut down. Each thread manages its own memory, and threads communicate via message passing.

Without getting too far into the weeds, Processes can spawn other processes. Processes can also supervise other processes, so that we can build a tree of concurrent activity that starts up, shuts down, or fails as a unit with a number of built-in affordances for things like cleaning up state or making sure child processes restart after crashes.

All of this is also known as the “actor model”, which is fitting since the whole point is that we can have a stage of actors and each actor is doing their own thing. That’s precisely what we’re going to model here, though.

Running with a literal theatrical stage of actors as our metaphor, then, the concurrency issue happens if we have a stage manager that has the only copy of the script, and two actors (the main story actor and the damage control actor) both ask for their next line. Our stage manager ought never be confused about which part of the script should be considered the “next” part. This means that each actor needs to wait in line in order to get the next set of cues to act on: we need to serialize access to the stage manager and their precious script.

As cues are read off of this script, our stage manager does some basic bookkeeping to note which bits of the script have already been visited, and to otherwise keep the state up to date. The benefit of this is that if one story flow puts us into alert condition one, we can notice the change in our other story flow and use that to trigger some banter about the change.

In other words:

  1. There is one script, and there are many actors
  2. Where we are in the script is the responsibility of the stage manager
  3. Each actor’s job is to get whatever the next part of the play happens to be, and to deliver those lines from the stage manager
  4. By having a single stage manager responsible for a single script, we can maintain coherence in the narrative, while the actors themselves can handle doing whatever they need to be doing on the side1

These constraints make it possible to have a single, authoritative, contextually-aware script that allows for concurrent story flows. Getting back to Elixir and the actor model, we would model this with one GenServer that serializes any interaction with the underlying Ink story (called the Ink.Server)2 and another running GenServer for each concurrent story flow we have running (Ink.Flow). When we boot up a story, we take the Ink script and load it into Ink.Server to get things ready. When we boot up a Flow, we give it a name and the name of the Ink.Server it’s bound up with. From there, our Flow can read in content and choices and start driving the story, as needed. The Flow would handle cues and watch conditions, reacting accordingly to power the story forward.

Flow context for Cues

This brings us to executing Cues. In previous examples, we have cue snippets that look like this:

Panau Bounty Hunter: Give it back to me!

Hint (Protect Rool):
    Weapons: Fired?
    Short range comms: Disconnected?
>>> $$$

// ...

* [Protect Rool]
    -> continue_story_where_we_protect_rool
* [Chicken Out]
    -> some_other_story_branch

This abbreviated example shows the Hint cue. Our Flow process would get this cue, and it would store this in its internal state under the key Protect Rool. When we get to the choice block, our Flow process would then be able to look up that key to get the list of cues to use to inform how we advance the story.

Of course, this begs the question: Whose weapons? Which short range comm?

The cue system proposed is explicitly designed to be flexible - perhaps too much so. The goal is to make it trivial to author human-readable scripts that enable branching narrative. Building up a complex story becomes an exercise in writing Ink that might incidentally be executable. If it is executable, then we can start to shift more of the storytelling burden into the computer.

Of course, the syntax involved could be designed around Typescript, for example. This would lend some niceties to mission authors with ensuring that whatever the wrote was actually executable, at the expense of expressiveness and fluidity in editing up a script. Being able to go from “This thing is valid Ink and can be drafted and iterated on, despite being only somewhat executable” to “This is a tight, fully automated script” on a gradual curve is one of the goals of this approach. Whereas Typescript would be more correct up front, it would also be more demanding from the author that the story and the author conform to what is available. I’m leaning into the opposite: the tools ought to degrade gracefully in a way that favors humans, not the other way around.

Hence, a poorly formed mission script will still hit the Teleprompter so long as the script can compile to valid Ink. An under-specified cue should still be executable if at all possible.

This means that a Flow needs to be able to:

  1. Ask for cues from an Ink.Server
  2. Try to execute those cues, falling back to our Teleprompter
  3. Provide additional context to a cue so that it can meet the intent of the author of the cue

This means that a Flow must have its own context provided, so that when we say Weapons fired?, our cue executor has enough information to know what it means for weapons to fire, and to disambiguate between one or another ship firing weapons. Although I doubt this scenario is practical for a few reasons, suppose we have two missions running concurrently in the same simulation sandbox. If I’m arguing with a bounty hunter while another crew a million lightyears away in another story battling pirates, then firing weapons in that other story should not set off a choice for our bounty hunter.

In order to do that, our Flow for the main story of the ship we’re on would have its own context that it would carry and update as cues come and go. Cues could operate on that context as part of their actions. This context would be internal state to the Flow, I wouldn’t expect it to spill out into other places. That said, it might have a :pov_from_entity_id property that lets us specify which entity this Flow should consider.

Then, our Weapons fired? query starts to take shape with some implicit, ambient context that shapes when and how that condition might be met: Without specifying other context, if our point of view is tied to the ship we’re in and we want to watch for Weapons fired? then we know to set up a Watcher on weapons on our ship to see when they fire.

A Watcher is something implied in other essays but we’ll take some time to spell it out here. It would be yet another Elixir process that is given a set of conditions to evaluate on a tight loop (which might sound scary but is totally normal in Elixir). Poll frequency could be tuned so we evaluate every second or as fast as possible or on any interval. Once the conditions given to our Watcher evaluate to true, it would send a message to some other process to respond to the result.3

Watchers would not be started in isolation, though - when we hit a choice point, our Flow would first spin up a ChoiceBlock to handle the logic around the choices, helping to further isolate the logic involved. A ChoiceBlock is another Elixir GenServer that would be started by our Flow. It would take all the given choices and Flow context, and use that to construct Watchers that would be told to send a signal back to the ChoiceBlock once we have evaluated to true. Those watchers themselves may be further divided into Choices that wrap each Watcher (as illustrated below). Once a choice is activated, the ChoiceBlock would then tell our Flow that we can advance the story, and we get to move on at that point.

A sequence diagram, illustrating how flow yields to choices with automatic criteria evaluation.

Going back to our Watcher example, it would need to further deconstruct Weapons fired? to be able to watch specifically for conditions that mean we’ve fired a weapon. This would presumably lean into ECS simulation architecture, perhaps having our system that handles discharging phasers attach a weapons_firing component to our weapons system, and for our watch to query for entities linked to our ship entity that also have that weapons_firing component attached. If any are found, that means we’re firing our weapons, so the logic evaluates to true and the Watcher can send a message upstream. and our choice gets activated. Once activated, our Flow would tear down the ChoiceBlock, which would clean up any pending Watchers using Elixir’s normal supervision tree cleanup process. Once the ChoiceBlock shuts down, linked or child processes would come down with it automatically. If those processes create their own resources (e.g. maybe we add entities and components modeling our watcher criteria, so that ECS is handling the watch criteria), the terminate/1 callback would be called on each child process, and we could clean up whatever resources are in play automatically. BEAM Concurrency FTW!

Will it work?

The above is half-speculative, half-proven. Most of the work involved on the technical side is well-proven and practically done (starting child processes, cleanup, looping to evaluate criteria, etc.). There are some performance considerations around tight loops, however it’s a pretty safe bet in Elixir for performance and sanity, and tuning those loops/checks is a problem that can be isolated to an individual Watcher.

Where things start to get interesting is whether it’s even feasible to draft a script with ambiguous hints in it like Weapons fired? and for that to map to the intended targets, in practice. Better yet, how early in the ideate->revise cycle of authoring a story can we detect that ambiguity, without getting in the way of creative storytelling? The goal is to make story writing highly literate, very fluid, and a “fun” experience on its own. It should be very fun to write a story and see the game react to the story you are laying down. Though perhaps not as fun as flying on a story, the “fun” of storytelling is a critical goal for these tools. Achieving flow when drafting a complex flight’s flow ought to be the default state for these tools. Having forgiving tools that fail in forgiving ways, that can carry enough context to make it possible to infer intent from vague cues makes this possible.

Rescuing an overly vague cue

Even so, one might protest that Weapons fired? is just poorly specified syntax. The late binding on how you resolve that bit of syntax is a layer of indirection that a proper type system would solve up front.

And you’d be right! Of course, your author needs to know what a type system is, nevermind how to run tsc correctly (and let’s hope they don’t need to update tsconfig.json along the way) in order to make it all work properly.

So, let’s pretend then that Weapons fired? is misspelled in some way. Maybe we have variants of Fired weapons? or Weapons fire (omitting the d? at the end), or any other combination.

While we could program our cue to take any number of arguments and map them all to the same behavior4, if we have a hard-miss because the story author is trying to do something that we just don’t know how to do yet, that would require presenting our Choice via teleprompter to a human operator. Because we still need that choice to fire back to our Flow, we would need to signal to our ChoiceBlock that we’ve hit a wall, and for the ChoiceBlock to choose what should happen next. Our ChoiceBlock‘s Flow would have a mapping of how to execute cues, including our default Teleprompter implementation. When we start our ChoiceBlock, it would be given that default implementation, and we could then invoke it.

In our Elixir application, this practically means that we’d send a message to a background process for our Teleprompter, and any LiveView widgets presenting cues would see these choice cues and would handle them accordingly. Once a human clicks the button for a choice, that would signal to our ChoiceBlock that we picked that choice, and we can continue from there without having to mess with our Flow or Watchers or anything else, really. The only concerning part in that setup is ensuring that we know how to address different processes responsible for in-game state (not a novel issue), and that we have the means of cleaning up stuff that we create along the way without introducing any tight coupling.

If a cue tries to execute some action and finds that it simply can’t (maybe there really is some missing particle of syntax omitted from a script that we can’t live without), then this same fallback process would kick in, and the raw contents of our cue would be rendered accordingly.5

Wrapping it up

  1. Ink supports Flows which are parallel paths through the same compiled Ink story.
  2. Flows share the same global Ink state, and execute one at a time - though we can switch flows at any time.
  3. By wrapping our Ink story in a process, and modeling each flow as its own process, we can run stories concurrently without much trouble. Concurrent access to shared resources like the Ink story can be solved in Elixir using vanilla-flavored processes and supervisors without much effort.
  4. Each flow would have the mapping of cue executors to cues, and would handle reading lines from the Ink.Server and executing them.
  5. Choices are the most important part of it all, since that’s how we make forward progress. They are somewhat involved with a few layers of process isolation (mostly for keeping logic straight and not for fault isolation), but each part can be reasoned about in relatively simple terms.
  6. Fallback for any failure along the way should surface to a human operator, who can then fudge whatever comes next. The most important thing is that the script is able to keep running, telling the story, and that something (automated or otherwise) is able to interpret story intent and make adequate choices for satisfying that intent at every step of the story.

  1. This does mean that CPU performance matters for the stage manager: if actors are having to wait for the stage manager to figure out the next chunk of story, then that will have knock-on effects elsewhere. Once a chunk of story has been dispensed to an actor, then that actor can run with it independently. From the stage manager’s perspective, whatever that chunk of cues happens to be, those cues have basically happened once they are delivered to the actor performing those cues.

  2. My current experiments have the Ink story running in a dedicated NodeJS program that reads/writes to standard in/out. This is launched via an Elixir Port, which is a fancy way of saying that I’ve put Ink in Node and wrapped that in an Elixir GenServer. That GenServer presents the interface for continuing the story, making choices, and doing anything else that I want to do in Ink. Someday I may end up re-implementing Ink in ways that are more appropriate for multi-player, concurrent access, and do it natively in Elixir. Today is not that day.

  3. It would not, however, shut down until told to do so explicitly. This is so that and logic can evaluate correctly: if I have two Watchers A and B, and A fires true while B is false, then we need to keep watching for both signals to evaluate to true. If our A Watcher flips back to false and a moment later B reports true, we still need to wait for A to become true again. Once everyone is true, then we can treat that as true and shut down both Watchers, and commend them for a job well done.

  4. One way that I’d like to explore in practice would be computing the string distance between possible matches versus what’s given in the script. If it’s above a certain threshold, we may just say “I think you meant this” and roll with it. If it’s below that threshold, then we’d rescue as described shortly.

  5. I can imaging authoring cues with both their internal script as well as a terse description of how they ought to be rendered. In Elixir, we can leverage certain facilities to address specific functions in order to construct the interface we may want to present to render a read-only version of a cue, or an interactive version of a cue. In any scenario, we would be able to generate the interface from the cue itself and slot it into a LiveView without having to fiddle with where or how templates get pushed around. It’s just a function call. This will all be the subject of a future essay.