
A little under ten years ago (dear god) I tried, and eventually gave up on, building what I called at the time a "general-purpose QBN system". That is to say, a system for storylet narrative that could be used as narrative middleware or as a narrative scripting engine, like Ink or Yarn Spinner.
A lot has happened in the intervening time, and I've come to this problem with a different perspective. I now think this is feasible. It's so feasible, in fact, that I've done it.
What changed? Well...
- I spent four years working with Failbetter Games' StoryNexus system, which back in 2017 was already not available to the general public. I wrote that original article having just shipped Voyageur, of course, which is significantly inspired by StoryNexus; but between then and now I've written an order of magnitude more material intended for this kind of narrative, and I have a much clearer picture of the kinds of affordances I want.
- Yarn Spinner's model of storylet narrative (what it calls node groups) usefully solves for a lot of storylet narrative use cases, which effectively narrows what I'm trying to do to the use cases that Yarn isn't particularly suited to. I feel happier building something opinionated for a narrow use case.
- The overall success of both Ink and Yarn led me to conclude that a middleware-style DSL tool was the way to go, rather than something with an obligate GUI.
- I simply decided to level up my technical skills to the point where I could do it.
The result is lyre, a new narrative scripting language designed specifically for building storylet narratives in the vein of games like Sunless Sea, Voyageur, Fallen London, and the various third-party games that used to be available on StoryNexus.
Now, to be clear: I am not releasing lyre yet. I plan for a first public release sometime later this year, after the release of the first game made with it (which should be a short piece of IF and should be out, hopefully, at the end of the month). This post is a preview, though the lyre compiler and runtime are already working software. I'm writing this mostly to start articulating some of the design choices and intentions while it's still all fresh in my mind; I'll probably write more about it down the line when it's out.
"Storylet narrative" is a very broad term that can include everything from Citizen Sleeper, to Sunless Skies, to the event system in a Paradox game. The specific concept lyre is built for is a narrower and more specific design, where...
- Game state is represented entirely[1] in terms of qualities. Qualities are essentially variables meant to be exposed to the player as visible information. Every time a quality changes, this is made visible to the player.[2]
- Consequently, all changes to persistent state in lyre are explicit; the language disallows silently mutating state as a general rule.
- The story is made up of storylets, individual narrative nodes. Every storylet has branches, possible options that can be chosen from them. Branches lead to other storylets; once the player has completed a run of storylets, however, they can then return to a hub area. Lyre calls this the anchor.
- Qualities determine which storylets are available and the possible outcomes of branches, but players are in control of which branches they take; the language is designed for quality-based narratives as open story spaces rather than as a way of assembling a story by supplying specifically what happens next.
This scopes lyre down to a practical design. It's not trying to cover every use case, and particularly it's not trying to cover the main use case of Yarn node groups.
A taste of lyre
Here's a commented sample of what lyre source code looks like, which is a partial implementation of Road of Darkness:[3]
// Lyre files are made out of nested, structured definitions. This line
// defines a storylet with `start` as its internal name and "Forest Clearing"
// as its visible title; the `anchor` keyword marks that this is a hub area
// the story returns to if it has nowhere else to go.
storylet anchor start "Forest Clearing"
"""
You stand before a crossroads that splits to the left and right. A man
stands next to the path on the right, wearing a large black velvet cloak.
{
// Arbitrary expressions are allowed in { } blocks inside body text, and
// their evaluation gets interpolated into the text as you'd expect.
if scarf_color then
// You can even nest interpolated text inside other interpolated text.
`Your { scarf_color } scarf is reassuringly warm.`
else
"You are wearing a warm scarf." }
"""
branch "Examine your scarf"
"""
What color was it again?
"""
// `show` creates a condition on whether this branch is visible;
// there's also `req` for locking a branch without hiding it.
// Conditions in lyre require special syntax, because they have to be
// legible to the language system which creates a player-visible
// description. Specifically, they always have to name a quality
// and a comparison operation in a sort of subject-verb-object
// structure.
show ( scarf_color == "" )
-> select_scarf_color
branch "Take the left path"
// Just like in Storynexus, lyre inherently supports "branch text",
// ie additional detail or extra story attached to branches.
"""
It looks well-lit. Inviting even.
"""
// Every branch leads to one or more other storylets. They can have
// arbitrary conditions; the story routes through the first one
// with a condition that evaluates to true.
( asked_about_thief ) -> ending_escape
// The compiler enforces that branches must have a default route.
-> ending_death
branch "Talk to the man"
"""
What's he doing hanging around here?
"""
-> thief_conversation
branch "Examine the cloak"
"""
It drinks in the light, doesn't it?
"""
// A "result" in lyre is like an anonymous storylet that you can
// write right inside the branch. Lyre isn't designed for deep
// tree-like narrative structures; results can't contain branches.
// This strictly limited nesting makes lyre's syntax work, where it's
// neither a bracket language, nor an indentation language, nor a
// depth-marker language like Ink.
-> result "Curious indeed"
"""
It drapes rather loosely about his shoulders... to your trained
eye, it looks rather like a powerful magical artifact that absorbs
the light from the surrounding area.
"""
effects if ( !cloak_knowledge )
+cloak_knowledge
// Every quality has to be explicitly declared. Many of them will have
// information meant to go into an inventory or game state screen.
quality cloak_knowledge: boolean "Examined the Cloak"
"""
You've taken a closer look at the Cloak of Darkness.
"""
// Lyre comes with a system for creating generic messages to communicate
// quality changes (and an API for replacing that with a custom system).
// But a quality can also have a custom function to create those messages;
// this is the simplest case where it's just the same string every time.
describe_change: () => "You add your observations to your mind palace."
Structure, affordances, and influences
Most narrative scripting languages are designed like markup languages; mechanical information exists inside "islands" in a "sea" of body text. Lyre isn't designed like this, mainly because lyre stories are closer to Inform stories than to Ink stories; the ratio of mechanical information (code) to textual information (content) is higher. Text exists as quoted strings, separate from code.
In lyre, state is meant to change only through explicit quality changes. In the sample above, for example:
effects if ( !cloak_knowledge )
+cloak_knowledge
Means "if cloak_knowledge is false, then set it to true."[4] This is only allowed within effects blocks inside storylets (or results, which are basically just anonymous storylets).
Basically, every time the player takes an action in lyre, all of the effects of that action happen sequentially and they all live in the same place. It's a different way of thinking about narrative state—Ink, Yarn, ChoiceScript, and Twine make no distinction between code that can change state, and code that can only reference it.
But lyre's event model, which is obviously heavily influenced by StoryNexus, has a lot of utility for certain types of stories. It makes changes to state easier to see and reason about, which is beneficial for heavily stateful stories. It makes both the compiler and the runtime significantly simpler; a lot of the operations that the lyre runtime does are "pure" in the functional-programming sense, ie they can't mutate state. It enforces the idea that every state change is a visible thing that can be looked at and surfaced to the player.
Lyre is very unabashedly designed around my own preferences about workflows. It's meant to be written in a regular-ass text editor—I actually already wrote a tree-sitter parser for it—with no hard requirement for a specialized tool or IDE. A language server with some basic completion would be nice to have down the line, but it's hardly required. The syntax is meant to make large volumes of prose comfortable to write while also being relatively simple to parse. Lyre's parser is built by ANTLR, the same parser-generator[5] tool used by Yarn Spinner. ANTLR is unusually smart in various ways that lyre makes use of, though some aspects of the syntax are still in flux.[6]
Lyre is obviously heavily influenced by StoryNexus; it's in a lot of ways an attempt at building something more flexible and usable than SN while preserving its specific model of narrative state and player interaction, which I am very fond of. Lyre, in particular, is designed explicitly around the idea that it can work equally well as narrative middleware similar to Yarn, as the core of a full-fat "authoring system" like Inform, or as the narrative engine of a server-based game like StoryNexus.
But it's not quite a "clone" of StoryNexus. It doesn't have any of the SN features that are purely tied to its live-game nature, like living stories or world qualities (though those are something you could very easily implement around the language). It doesn't, and isn't intended to, have a CMS-style author interface. As a language with a compiler, lyre probably doesn't scale to several million words of content straightforwardly.[7]
The language tries to regularize various things about storynexus. In lyre, the player is always inside a storylet; there's no concept of a "location"—the anchor feature is intended to fulfill the role that locations have. Lyre is generally designed around the idea that the only state that exists, that the runtime cares about, is the player's current storylet and their qualities.
But lyre also takes some significant inspiration from Inform, Yarn, Ink, and a whole pack of real programming languages. Lyre's compiler and the first implementation of the runtime are both written in Typescript,[8] and some syntactic conventions (like using backticks for interpolated strings and the "fat arrow" to signify functions) seep in from that. Lyre currently has only one collection type, which are sets—largely inspired by Ink's lists but named something other than lists because lists, in Ink, are not meaningfully lists in any normal sense (why are they called lists?).
But lyre is also weirdly influenced by pure functional languages, particularly Haskell. The fact that Lyre puts stateful operations (ie, anything that creates a quality change) in a little box where they can't hurt anyone is very analogous to Haskell's treatment of IO. Quality effects are not currently implemented as a monad, but they conceptually are one.
Implementation-wise, lyre superficially resembles Ink and Yarn in that lyre stories compile down to a JSON bundle that the runtime uses. But lyre doesn't have the same kind of "engine"; it doesn't have anything inside it that really resembles a virtual machine. Most of the lyre runtime is a narrative engine dedicated to lyre's specific semantics around branching, determining what the player sees, and so on. Lyre does compile and evaluate arbitrary expressions, but for the sake of compiler and runtime simplicity those are compiled down just to s-expressions in a specified JSON-compatible format. The runtime is more like an old-school lisp reader or scripting language interpreter.
This is not efficient, but it's extremely easy to implement—lyre's expression evaluator is only about 100 lines of typescript code, not including the definitions of the language's builtin function primitives. I think making it as dead-easy as possible to port the runtime to other environments is a very significant goal for something like lyre; the reality of game dev right now is that you generally can't run code written in any arbitrary language in any given game, because most engines really don't want you to. And while there's a few things about the runtime that I want to revise, refactor, and ideally simplify even more... it is also true that I ported the lyre runtime to GDScript already and it wasn't all that painful to get it working and passing the same test suite the reference implementation passes.
Some More Examples
These come from the project I'm currently working on with lyre, and show off some other language features.
// Functions in lyre are always pure; they're a way of reusing chunks of
// expressions rather than a way of "doing" things.
fn desc_movement (f, t, n, v) => (
if f && f == location then `{ n } { v } away to the { loc_name(t) }.`
else if t == location then `{ n } { v } in from the { loc_name(f) }.`
else "")
quality harriett_location : string "Harriet's Location"
describe_change: (f, t) => desc_movement(f, t, "Harriett", "glides")
quality dean_location : string "The Dean's Location"
describe_change: (f, t) => desc_movement(f, t, "The Dean", "squirms")
/* ... */
// A `table` in lyre is just a way of writing a bunch of variable text; these
// are a generalization of the main variable-text feature in StoryNexus.
table dean_next_location
with dean_location
== "living_room": "dining_room"
== "dining_room": "downstairs_landing"
default: "living_room"
// Like functions are reusable chunks of expressions, procedures are reusable
// quality effects. Like Ruby and some other languages, lyre adopts the
// convention that effectful actions have a ! on them; this is actually
// enforced (mostly because it greatly simplifies parsing).
proc move_dean! ()
dean_location = dean_next_location()
/* ... */
storylet anchor kitchen "Icebox Temple"
"""
The kitchen is small and modest relative to the rest of the house.
Notably, you see not one actual cooking implement other than a well-loved
coffee percolator.
"""
{ room } // This is a tag, marking this storylet as a room.
{ location = "kitchen" } // This is an alternate syntax for a single effect
// This is an arbitrary key/value pair attached to this storylet, which
// the surrounding game can use to decide how to display the storylet.
// For example, this is how you'd implement giving a storylet an
// illustration.
loc_name: "Kitchen"
branch "Dining Room"
"""
Back to where the bar is.
"""
direction: "n"
-> dining_room
/* ... */
storylet conversation_dean "Dean Liam Harcourt, Holding Court"
"""
( actual text excised to avoid spoilers )
"""
// This is how lyre actually implements stories that can grow organically
// and "true" storylet narrative. A `from` block creates a branch, but
// the branch leads to the surrounding storylet, and it has a reference
// to where it's available. In this case, it's available from every
// storylet that has the `room` tag, so the dean can wander around and
// you can talk to him wherever he is.
from { room } "Talk to Dean Harcourt"
"""
( snip )
"""
show ( location == dean_location )
Lyre will be available to people who are not-me... eventually. Look forward to the currently-untitled game at the end of October.
Image credit: Fir0002/Flagstaffotos via Wikimedia. Used under the GFDL.
StoryNexus has a few other chunks of game state that aren't qualities, like the player's current location or their lodgings. In lyre, everything truly is a quality. ↩︎
Both StoryNexus and lyre have a concept of "hidden" qualities that aren't surfaced to the player. But the idea of exposed narrative state is central to StoryNexus' design. ↩︎
Road of Darkness is a Cloak of Darkness analogue meant specifically for choice-based narrative systems (while Cloak of Darkness was envisioned as a way of exploring how different parser-game systems express world model). In truth neither is a particularly good fit for lyre; maybe I should write a spec for Island of Darkness? ↩︎
The condition is in fact not redundant here. Lyre, like StoryNexus, will generate quality change events that say "Such-and-such quality remains unchanged" if you tell it to. Ie, setting a value that's already true to true again isn't a no-op. ↩︎
A parser generator is a tool that takes a grammar (a formal specification of the rules that define a language) and generates the source code to an implementation of a parser (a computer program that recognizes and understands a language). These are not AI in any sense, and they've been around for around 40 years. ↩︎
I have a somewhat dumb idea of giving lyre Haskell-like expression syntax, ie a style of writing expressions that makes parentheses largely unnecessary. I'll probably not do it because it would make parsing more annoying to please no one but myself, but I'm tempted. ↩︎
This is mostly due to compilation times, which would become unreasonable at that scale, though it may be possible down the line to support partial recompilation and multiple files to ease that. Lyre's runtime also assumes that you can just hold the entire story as an immutable object in memory, though this isn't really such a problem. ↩︎
The reasoning for this choice is that Typescript is, of all the languages that ANTLR generates code for, both the one I know best and the one I hate the least. Obviously having a runtime that can run in a browser is also a very nice thing. The other sensible option would have been C#, but that would have given me a runtime that runs nowhere I care about. ↩︎
