Reactive programming in 'Clown'
You may have seen my series introducing ReactiveX: a functional-reactive programming library, adapted for Unity under the name UniRx. (Or if not, here's Part One of that series.)
As busy game developers, the last thing I want to do is sell you a bunch of bull without any practical application. Quite on the contrary, I use UniRx in Buff Mountain (in-development), and I used it in my most recent jam game Clown.
To prove it, here's how I solved some real problems in a real game.
Clown
In short, Clown is a text-based adventure game following a clown private eye named Tad as he quests to save the people of his beloved suburb, West Jester. I made the game for Resist Jam over the course of a week. (Music by aszecsei.)
Having built an adventure game engine from scratch for Troll Bridge, I wasn't looking forward to doing it again. Thankfully Inkle Studios—the force behind many fantastic adventure games—open-sourced their adventure game engine and scripting language Ink.
Stateful to Reactive and Back Again
Ink handles the script and applying selected options to drive the story forward (as well as some cool features like story-driven variables). It does not handle any of the graphical representation. That's left up to you the game developer, giving you a lot of flexibility. UniRx is a great choice to integrate Ink's "back end" engine with the game's "front end" display.
First let's look at a snippet of Ink script.
I knew I'd find Matilda holding down the front desk, a mountain
of purple hair and polka dots. She was a fixture of the Hardy
Building, and the cracks were beginning to show in both.
* [Act casual.]
* [Ask for help.]
This is Ink's fundamental element: some lines of content and some options. Together this is called a Chunk. When the player reaches this point in the story, they should be shown the text content and then presented with two options, either "Act casual" or "Ask for help". After the player makes a choice, the game proceeds to the next chunk, the player makes another choice, next chunk, and so on.
Each line of content can also have tags in the script (written like hashtags) which aren't usually shown to the player, but are used for adding special capabilities instead. For example, you could use tags as part of a system to play sound effects.
Besides just manipulating Unity's UI elements to display the content, I wanted to animate things to simulate a typewriter. In other words, I needed to get my UI to react to the flow of chunks. (See? Reactive programming.)
First we need the Observables. I'm using Subject
s for ease of use.
public Subject<Chunk> Content { get; private set; }
public Subject<string> Tag { get; private set; }
The Chunk
class contains the list of text and choice nodes for a single chunk.
The tricky part is that Ink's engine provides a stateful API. Stateful and reactive are a little like oil and
water. Bridging that gap is my StoryRunner
class: a MonoBehaviour
with an
Update()
method that looks a little like this.
// Story is Ink's API interface.
// It is created from an Ink script asset.
private Story story;
private void Update() {
// Wait for the story to move on...
if (!story.canContinue) return;
// Collect the text content and tags.
var textNodes = new List<TextNode>();
var tags = new HashSet<string>();
// Looping over story.Continue() gives us all lines of content, collect text and tags.
while (story.canContinue) {
var content = story.Continue();
var currentTags = story.currentTags;
currentTags.ForEach(t => tags.Add(t));
if (content.Length > 0) {
var text = new TextNode(content, currentTags.ToSet());
textNodes.Add(text);
}
}
// Then we can collect the available choices.
var choiceNodes = from c in story.currentChoices
let content = c.text
where content.Length > 0
let onClick = (UnityAction) (() => MakeChoice(c.index))
select new ChoiceNode(content, onClick);
// Now send the chunk.
Content.OnNext(new Chunk(textNodes, choiceNodes.ToList()));
// And send each tag individually.
foreach (var t in tags) Tag.OnNext(t);
}
// Triggered from a choice Button. This causes the story to proceed to the next chunk.
public void MakeChoice(int index) {
story.ChooseChoiceIndex(index);
}
Now we need to actually create, animate and destroy UI elements. So in another class,
ContentManager
, we subscribe to the Content
Observable.
private void Start() {
Content.Scan(ChunkState.Empty, ProcessChunk)
.Subscribe(AnimateChunk)
.AddTo(this);
}
private ChunkState ProcessChunk(ChunkState prev, Chunk chunk) {
// Transform the previous ChunkState into a new ChunkState based on the new Chunk:
// 1. destroy previous choice buttons
// 2. decide whether or not to draw a spacer '* * *'
// (if we've just changed "pages", no)
// 3. create new Text and Button UI objects
// 4. return a new ChunkState instance containing all of these references
}
private void AnimateChunk(ChunkState state) {
// Create an animation sequence for the new chunk:
// 1. block user input so the user can't accidentally click a button
// during the animation
// 2. if we're changing pages, fade out the page
// otherwise append the new chunk content (UI elements created in ProcessChunk)
// 3. start the page scroll animation
// 4. play the animation
// 5. when the animation finishes, unblock user input
}
I've skipped over much of the implementation detail in order to focus on the magic of Scan
(line
2). You may notice some of the tasks listed in the ProcessChunk
and AnimateChunk
methods depend on knowing information about the previous chunk. For example, to destroy the last set of choice
buttons, you need a reference to them. (Unless you search the scene hierarchy, and I don't recommend that!)
Hence Scan
. Its job is to go along an Observable, react to each element, and maintain an object
that somehow sums up the stream of items so far. That's an abstract explanation.
As an analogy, imagine you had a shopping cart. You walk around the store putting items (the stream of data) into the cart. You'd also like to know the number of items in the cart and their total cost (the sum-up). Also you don't want to wait until the end to find out the total, you want to know it as you go.
So with Scan
, we combine each chunk with our previous state in order to transform it into
our current state. And the state contains all the information we need to successfully animate all the
pieces.
The non-reactive (imperative) way to write this would require a bunch of variables to keep track of things like
previousChunkChoiceButtons
. I actually tried to write the code that way at first. I thought I could
save time—this was a jam, after all—by going what I knew was the messier route. Within the span of one
week, the imperative code became unmaintainable. As I tried to add features and iterate on the
animation style, the burden of all that stateful manipulation led directly to frustrating bugs and hard-to-read
code. A large part of the problem is that at some point in your code, a variable like
previousChunkChoiceButtons
has to stop referring to the previous buttons and start referring to the
current buttons so that they'll be ready for the next iteration. In other words, at some point the code lies to
you. If you're very careful you can stay on top of this, of course. But with all the other complicating factors,
this is extremely fragile. Breaking this code is as easy as reordering some lines, because the other lines of
code contain implicit assumptions about execution order.
This is exactly the kind of madness that UniRx—and the functional-reactive approach that it encourages—can save you from.
Don't forget to check out Clown to see how all this code comes together.