Narrative System Experiment: Reactive Storylets

~20 minutes to read

“Project Yarrow” is one of my oldest running side projects. I’ve been tinkering with it on-and-off (mostly off) since 2013. I needed a way for characters to interject outcome-altering responses into storylets based on their personality and abilities. This version of the system is something I first implemented around 2016.

The Problem

Yarrow is a fantasy-world adventurer’s guild management sim.

You take the scouting, training, and tactical planning parts from sports sims like Football Manager, put that alongside the indirect-control encounter reports from Final Fantasy Tactics’ Tavern Errands or D&D Tiny Adventures’ quests, and put that in a systems-driven world.

This experiment had to do with the encounter reports.

An encounter in Yarrow is a storylet[1] - or a small archipelago of storylets[2] - that an author writes for an event a party you’ve dispatched runs into. The encounter is picked based on the world simulation, and then it’s played out using the stats/traits/personalities of the party members involved. The resulting text would then be shown to the player as an encounter report, and the outcomes would be applied to the characters and world state.

It was very important to me that the encounter storylets did a few things:

  1. Your party’s abilities/traits/personalities should have a significant impact on how an encounter plays out. A large part of the game is about finding the right set of adventurers to recruit, giving them the right training, and picking the right groupings to dispatch with each other. This also means poor decisions, calculated risks, and character weaknesses should be able to impact the flow of an encounter.
  2. When an ability/trait/etc is tested, the results display should call it out. The player needs to see the consequences of their decisions in the management layers for the feedback to work.
  3. It should be easy for people writing encounters (read: me) to be able to define the rough kind of check the story needs, without having to enumerate every specific trait/stat/etc to check against. If something should be relevant to a check, the storylet’s author forgetting to account for it shouldn’t prevent it from being relevant. This is especially important for maintenance over time - the cost of adding a new quality balloons if that also involves revamping all existing storylets.
  4. Party members should be able to follow up on those checks systemically, with any qualities that’d follow up on the check and be able to modify the outcome. Doing this manually is branching hell. The storylet author shouldn’t need to care about any follow-ups, just the final outcome.

The solution I came up with I named Reactive Storylets.

Reactive Storylets

My house style for storylets looks something like Ink’s. You have lines of text that play through linearly, with the ability to do inline branching and data adjustments. The storylets also had a rudimentary version of what’s since been named a ‘Casting Call’ system[3], where the author defines roles and the game fills those from available context.

To this base, I added ‘interjections’ - tiny storylets from a library of them - which the system would dynamically pull in to flesh out the results of a test. These were usually variations on a character performing some check, but could also be follow-ups from other characters. (The system to do this was a rudimentary implementation of the since-monikered Story Sifting[4] technique - though at the time I called it the Valve Method since I based my implementation on Elan Ruskin’s famous 2012 dialogue talk).

Reactive Storylets are standard storylets where the author writes in specific spots for the system to interject on the designated topic, and then use branching or flag mechanics to handle whatever kind of flow control is needed based on those results.

Example

I can’t find my original source code, so I worked up some pseudocode from some old notes of mine. The actual code version of this is how I’d do first drafts of an encounter - descriptions of what was going on or what characters would talk about but not rendered out into interesting prose. Think of it like a greybox of the encounter to test early.

This encounter was one your adventurers would be able to run into while heading towards their destination. A village your party passes through has been, without their knowledge, infected by a magical disease from a monster in the nearby woods that will turn people into plant monsters.

It’s a medium-size encounter, so it wound up being an archipelago of 10 storylets where the action from the main visit play out via report, some world-sim logic for after you leave, plus a few storylets of epilogue when you revisit the town later (based on your outcome from the first visit).

The full encounter wound up being way more material than this blog post wanted for an example, so I’m just going to focus on the Intro storylet.

Things in {squiggly brackets} are game-level context objects - things the system knows about before picking the encounter.

Things in [square brackets] are cast objects - they aren’t known before picking the encounter, but must be cast for the encounter to work. I also use it to indicate a test that needs to remember who/what passed it if it checks multiple people/things.

Things in <angle brackets> are tests. These give a test type, and optionally specializations. When resolving the test, the system will attempt to take the best matching specialization into account - then check worse matches at a higher difficulty. Tests are also used for things that aren’t skill-based. Really, these are the places where the author decided this may be worth an interjection.

If this is more than you want to read, feel free to skip it and go to the discussion afterwards.

Encounter Setup (not seen by players)

Conditions for selection:
{Village} is small~medium in size
{Village} is next to some woods
{Village} has no current residents that pass the test <Diagnose:Disease:Nature>
{Village} has no ongoing story beats
[Woods] is not known as a high-danger area
[Woods] can host plant monsters
{This Encounter} hasn't been fired yet this playthrough

Cast the following data:
[Woods] - Nearby woods
[Chief] - The Village Chief, existing in world data or if not create them

Intro

Your party arrives at a village close to nightfall

if <Dislikes Towns> or <Bad History With {Village}>:
	Your party decides to leave and make camp elsewhere
	> goto the SkipTown storylet

Your party settles in at the village tavern for the night
While nobody is hostile, you get a cold reception
if <Vibe Check>:
	They notice that the villagers are nervous, but not about you
	> remember VibeCheck

if <Someone in the party has a positive relationship with the {Village} or the [Chief]>:
	> remember FriendlyChief
	You approach the village chief to talk
	if you passed the Vibe Check:
		You ask about the nervous villagers, he tells you
		> goto the Truth storylet
	else:
		He appologizes for the cold reception and blames it on an old superstition
		> remember ToldAboutSuperstition
else if <[Chief] is not Isolationist>:
	The [Chief] approaches you and appologizes
	Some of the villagers have fallen ill and it's triggered an old superstition
	> remember ToldAboutSupersition

if ToldAboutSuperstition and <[Someone] in your party has an interest in local rites>:
	They tell the party they want to find out more about it
	They approach the [Chief] and ask about it
	if <[Chief] is Isolationist> and !FriendlyChief:
		The [Chief] asks you to mind your own business
		if <Someone in your party is Suspicious>:
			> remember HidingSomething
	else if <[Chief]'s talking skill against [Someone]>:
		The [Chief] gives a vague-but-believeable answer about what causes colds
	else:
		The [Chief] blusters but doesn't answer
		if <Someone in your party is Insightful or Aggressive>:
			You push the [Chief] on the topic
			> goto the Truth storylet
		if you passed the Vibe Check and <[Someone] in your party is Paranoid>:
			[Someone] doesn't want to stay
			> goto the SkipTown storylet
		if <Someone in your party is Suspicious>:
			> remember HidingSomething

if HidingSomething or (VibeCheck and <Someone in your party is Suspicious>):
	Your party thinks there's more going on here than a simple sickness
	if <[Someone] in your party is Sneaky>:
		[Someone] splits off and go evesdrop on the other patrons
		> goto Report, remembering [Someone] was Sneaking
	else if <[Someone] in your party is Charismatic of Flirty>:
		[Someone] splits off to talk to the other patrons
	 	> goto Report, remembering [Someone] was Flirting

> goto NoAction

Referenced Storylet Summaries

  • SkipTown has you camp outside of town, nothing happening, and starts up some world simulation events about the sickness going undiscovered and untreated
  • Truth has the Village Chief tell you the sick villagers are sick in a weird way
  • Report has your person come back later - either having succeeded in learning the sick villagers are sick in a weird way or failing to learn anything

To generate the encounter report, we start at the top of the Intro storylet and then advance through it - including all interjections and jumps to other storylets - until it terminates.

It’s the combination of the interjections and all the inline result tracking design pattern that make me call these Reactive Storylets.

Interjections

Ok, but how does this interjection system actually work? The pseudocode treats things as very vague, but implementations need to be very not vague.

The system has 2 parts to it: a syntax that authors use to add tests for interjections into their storylets, and a library of interjection storylets that the code processing the test query can look against.

Let’s look at the first query in the pseudocode, <Dislikes Towns> or <Bad History With {Village}>.

In the initial implementation the syntax was just setting up an InterjectionQuery object in C# (as all the storylet authoring was done in C# so I could skip writing a parser or having to work in XML), but in something closer to my Juniper language it’d look like this:

!interjection who:Party|what:sentiment {Village} dislike
	|OR|who:{Village}|what:history Party negative

To break this down, we set up two queries as an OR. Let’s focus on the first one to dig into how these queries work.

The first query checks the Party (a keyword which means the system should test every member of the Party) against the sentiment (a property with specific handling) about the {village} resolved during selection for the condition dislike. The sentiment test understands what the dislike condition means.

Query resolution is a two-stage action. The first one is the sentiment property test figuring out if it succeeds or fails, and by what margin. This is straightforward - we have some code that knows how to process the sentiment value for a given actor towards a given target. This result is not necessarily respected!

Once we have our default calculated response, the second stage is to look at the interjection storylet library and see if any of them fit the context.

Let’s say that nobody in our current party has a negative sentiment for the village, so the test returns a normal failure. We could still have an interjection that cares about the context and would overturn the result. For example:

// Make sentiment negative if a party member dislikes villages
#queryIs sentiment
#target isType Village
#actor hasTrait DislikesVillages !display

{actor}: Not a fan of these farming villages. I'd rather move on.
!set result dislike

If one of our party members happens to have the DislikesVillages personality trait (which would be visible in their character info) we would match that interjection. The report would show off the invoking of the DislikesVillages trait and then add on the text as if it was written in the main storylet. Then it sets the result and re-processes the check’s success before returning back to the main storylet, which will follow this up with some text about the party leaving to go camp nearby instead.

But! It’s possible for interjections to chain to others!

// If the party leader likes the village, overrule the sentiment check
#queryIs sentiment
#target isType Village
#responseTo dislike
#responder hasRole Leader !display
#responder test sentiment target positive

{responder}: Too bad. We have things to do here.
!set result positive

Specifically, any character established in the scene (in this case, just the Party members) can perform a response if it has a valid responseTo condition.

An interjection or response could also be flavor, and not have a meaningful impact on the flow of the scene. If no flow-altering interjections are scored it would be able to swing to one of these instead.

// Flavor if one of your party memebers was born a villager
#queryIs sentiment
#target isType Village
#actor notHasTrait DislikesVillages
#actor backgroundIs villager !display
#resultIs positive

{actor}: I miss these kinds of towns. Reminds me of home.

The selection system scores things based on the number of constraints or !set commands an interjection has, with some things being worth additional weight and recent use greatly reducing weight. Then we just pick the most ‘complex’ outcome available to us.

If this interjection also existed and its conditions were met it’d take priority over the first one above, even though it applies to any location type.

// Make sentiment negative if a party member has had a bad time specifically here
#queryIs sentiment
#target isType Location
#target test actor history >=neutral
#actor test history target negative !display

{actor}: Last time I was here things didn't go well. Do we have to stay here?
!set result dislike

It’s also possible that no interjections fire at all. The scene continues on without any reaction to the query.

The general ideal is that over the course of a project you’d build up a library of meaningful interjections for different situations that just play nice with each other as long as you get the constraints and queries set up well. Once the responses above were written, they’d just work the next time and author wants to let the party express feelings about a village.

In the end, you’d wind up getting the most responsive situation available and it’d show the player which attribute of your cast was important to the result. That delivers on all four of my major goals for the system!

Well, mostly.

Results In Practice

The system worked pretty well for the half-dozen encounters I authored, but one of the major goals I had was only half served.

The system did work out well at seamless working with old content when I added new interjections, but I would still need to go back and revisit “finished” encounters to add on to condition blocks as I figured out more narratively satisfying options for situations from other content.

Having to revisit old content is an undefeatable problem with developing a game like this, but the whole goal of this system is to make it as low-impact as possible. I don’t feel like this implementation hit that bar.

For example, I added a time-sensitive quest for parties to the game. I originally went to the generic “they stay in town and nothing happens” storylet and added logic to have them continue towards the goal instead of spending the night. Then I realized that was the wrong solution - I should add a new slightly-less-generic encounter for this case that would be picked over the generic one.

This raised the question to me: what if the party was in a hurry and they rolled an encounter like the plant monster sickness? The encounter has a concept of “nothing urgent is happening so they can just leave” but now I’ll have to go and add a handler for the “in a hurry” case to it… and any other similar encounter through the entire project. That’s a backbreaker, production-wise.

Another problem with the system is that there’s a degree of indirection between the material question being asked - does my party stick around for the night - and the queries being used. All the interjections in this example work on the question of sentiment. They’d play for any kind of sentiment-related question, and the author would follow up on them with situation-specific text once the sentiment test has triggered (or not) any interjections.

I’d like to be able to write a response specific to the case where we’re checking sentiment for the purposes of spending the night. That’s even more true in other classes of test available to authors.

Later in that example encounter there’s a test to figure out if your party can identify the nature of the illness. It winds up being a multi-skill check: it’ll look for knowledge of magical diseases (and then test up the knowledge chain for general diseases and general medicine at higher difficulties automatically), knowledge of plant monsters, knowledge of forests, and knowledge of the local area. I can’t easily write an interjection that specifically cares about the context that I’m testing because people are sick - even in the magical diseases check. That could be better.

Thoughts Now

I worked on this until I hit those two problems, put it down, and got busy with work. (That’s what happens to all my side projects.) When I had bandwidth to work on things again I started learning compilers in order to make a narrative scripting language to work in instead of C#, which resulted in Juniper. Learning how to write a robust compiler with good error messages is an enormous time suck, it turns out.

I’ve tinkered some on Yarrow in the last year or two, but it’s mostly been on the world sim side of things.

I still think Reactive Storylets are a core part of making this game idea work. The big change I’d make is in how queries are structured. The interjections and check type fall-through systems work great, but I think writing the most specific check available is the wrong approach.

What I’m really asking when I write a query is “given this fact, how does this character(s) respond to it”. Given the idea of staying in the village inn overnight, how does the party feel about it? Given that the villagers are visibly nervous, does your party pick up on it? Given that these people are infected with a magical plant-monster disease, can anybody in your party identify it?

I’d explore an architecture where an interjection query presents a fact, and we have a bunch of authored transformations that decompose fact into attribute tests.

// Don't spend the night if we don't like the place
#fact SpendTheNight
#actor isType Person
#target isType Location

!interjection who:{actor}|what:sentiment {target} dislike

The system would gather all the fact decompositions available, then perform those tests. Since the context knows the fact being asked, we would be able to write an interjection specific to that fact/test combination.

In Conclusion

I still really like the idea of Reactive Storylets. If the technical changes I want to make are enough to address the issues then I think it’d sit in a really good place between author expressivity and systemic interaction.

I’m a big proponent that you don’t go after procedural or systemic narrative because it reduces the amount of authoring you have to do (if anything it’s the opposite), you do it so you can be more responsive with your narrative to the situation the players have created.

Reactive Storylets do that.


  1. If you’re unfamiliar with storylets, check out Emily Short’s primer on them ↩︎

  2. A term of my own usage, to describe a few tightly-linked storylets that move between each other ↩︎

  3. I actually couldn’t find a link for this! Borut Pfeifer used the term to describe the system in Weird West ↩︎

  4. By Max Kreminski et al. ↩︎