Writing IF with Raconteur, part 3: adapting some text

This is the third part of a series walking step-by-step through developing a game with Raconteur. In this post, I’ll explain Raconteur’s adaptive text features.

Let’s get right to it. I’ll be starting from the complete example from the last tutorial. So far, we’ve only seen situations that have a long String as their content property:

situation 'go_right',
content: """
The tunnel opens up into a wide chamber, dominated by a bright
reflecting pool.
"""
optionText: 'Go right'

Until now, I’ve been hiding one of the key features of Raconteur: The fact that content can not only be a simple string but also a function that returns a string. So we could rewrite this situation:

situation 'go_right',
content: () ->
"""
The tunnel opens up into a wide chamber, dominated by a bright
reflecting pool.
"""
optionText: 'Go right'

As we know, () -> is CoffeeScript for a function declaration, with no named arguments. Everything indented right of that declaration is part of the function body, which in this case includes only the string. In CoffeeScript, the last expression in a function is taken as its return value, so that the function simply returns that string.

You can make that change to the full example from the last tutorial and run it, and you will find that nothing has changed. But it’s important to note the difference between using a function as a content property, and using a String.

goldPieces = 500
situation 'count_gold',
content: """
You count your gold and find that you have #{goldPieces}gp.
"""
situation 'count_gold2',
content: () ->
"""
You count your gold and find that you have #{goldPieces}gp.
"""

On first glance those two situations may seem functionally identical, but they are actually doing quite different things. count_gold is looking at the value of goldPieces when the situation is created, and baking” that value into its content. Remember, the #{} syntax in CoffeeScript is equivalent to evaluating the expression inside the brackets, converting it to a String, and then concatenating it inside the parent string. So the first situation will always say 500gp” no matter what happens to the value of goldPieces.

The other situation will work as you might intuit that it should: Because the string has been wrapped in a function, it gets evaluated only when that function is run. So it will print the current value of goldPieces, whatever it may be at the time when the situation is entered.

A content function can do anything — including have side-effects — but the most common use of them is to construct text procedurally based on the story state. In the last tutorial, we blocked a path from the player if they didn’t pick up the lamp outside the cave; let’s say we wanted to acknowledge that they had the lamp in the other branch, too, without blocking it:

situation 'go_right',
content: (character) ->
poolAdj =
if character.sandbox.hasLamp then "bright reflecting" else "dark"
"""
The tunnel opens up into a wide chamber, dominated by a #{poolAdj}
pool. #{if character.sandbox.hasLamp then 'Flickering light from your
lamp bounces off the slick walls.' else 'It is very dark, and you all
but fall into the pool.'}
"""
optionText: 'Go right'

content, as a function, gets passed the same Character and System objects that we saw previously, as well as the name of the previous expression.

Note how if statements in CoffeeScript are themselves expressions, so they can be used inline inside a string interpolation, or as the left hand of an assignment statement. Note that an if-expression can evaluate to undefined if there’s no else branch:

day.beautiful = false
"Hello!#{if day.beautiful then ' What a beautiful day!'}"
# -> "Hello!undefined"
"Hello!#{if day.beautiful then ' What a beautiful day!' else ''}"
# -> "Hello!"

Another important thing about content: Inside that function, the value of this is set to the situation object itself. Situations can have custom defined own properties, so you can use this behaviour to encapsulate something about a situation:

situation 'examine_dog',
breed: 'dalmatian'
size: 'large'
colours: 'spotted'
content: () ->
"""
The dog is a #{this.size} #{this.breed}, covered in #{this.colours} fur.
"""
# -> The dog is a large dalmatian, covered in spotted fur.

This will prove useful when we start dealing with text iterators.

Text Iterators

Text iterators are one of Raconteur’s convenience features, and the first one we’ll talk about that comes in a separate module from the main situation module.

oneOf = require('raconteur/lib/oneOf.js')

oneOf() is a factory, a function that builds a certain kind of object. Unlike a constructor, it’s not meant to be invoked with new. And we’re only really interested in oneOf objects for their particular methods. All this is to say that oneOf implements a way of creating variable text snippet that should be familiar to Inform 7 users. An example:

ring = oneOf('The phone rings.',
'The phone rings a second time.',
'The phone rings again.').stopping()
situation 'phone_rings',
content: () -> "#{ring()}"
choices: ['ignore_phone']
situation 'ignore_phone',
content: () -> "#{ring()}"
optionText: 'Ignore the phone'
choices: ['ignore_phone_again']
situation 'ignore_phone_again',
content: () -> "#{ring()}"
optionText: 'Let it ring'

This is a toy example, but it shows how oneOf works. We create a oneOf object with by passing any number of strings as arguments to oneOf(). Then we call the stopping() method on that object to get a special function, a closure.

Each time you call that function, it’ll return one of your strings — until it runs out; then it’ll keep repeating that last one. The advantage of writing adaptive text this way is that you don’t have to separately keep track of state — the closure contains the text and tracks how many times it’s been invoked. oneOf supplies five methods currently:

  • cycling: The closure iterates over the list of snippets and returns each one in order, then goes back to the start when it runs out.
  • stopping: The closure iterates over the list of snippets and returns each one in order, then repeats the last item when it runs out.
  • randomly: The closure returns a random item from the list each time it’s called, except it will never return the same one twice in a row.
  • trulyAtRandom: Similar to randomly, except it can return the same item twice in a row.
  • inRandomOrder: Works like cycling, except the list is shuffled first. This guarantees that every item will be shown exactly once before the list repeats.

Execution time

Keep in mind: When you call one of the object methods, like cycling(), you are creating a new function. So you should declare your snippets once and bind them to a name. And, similarly to the caveat above about function versus string content, you should use them inside functions, unless you intend to run them once and then keep the result indefinitely:

poolColor = oneOf('blue', 'green', 'red').trulyAtRandom()
# This pool will always be the same colour...
situation 'mystic_pool',
content:
"""
The pool shimmers with #{poolColor} light.
"""
# ...this one will vary each time it's visited.
situation 'mystic_pool',
content: () ->
"""
The pool shimmers with #{poolColor} light.
"""

Randomness, and saves

Undum uses a particular way of retaining save games: It saves every player input, and then when a save is restored it essentially replays them in order to rebuild their game. This works perfectly well… as long as the results of every input are always the same, ie if the game is deterministic.

Of course, if you want to incorporate randomness into your game, then your game isn’t deterministic. To square that circle, Undum supplies the System.rnd object. This object is a pseudorandom number generator which Undum itself initialises. Because the save game includes the random seed, save games can work. However, this architecture requires a bit of care on the part of the author to only use the Random object supplied by Undum.

oneOf is built ot use that object, but that means you have to initialise oneOf objects that use randomness inside your undum.game.init function, by passing system into the factory methods:

snippets = {}
undum.game.init = (character, system) ->
snippets.randomColour = oneOf('green', 'red', 'blue').randomly(system)

We bind the closure to a name we can reach from our situations — here I’m making a snippets object (local to main.coffee) and binding my snippet to that, but I could just as well attach them to character.sandbox.

randomly, trulyAtRandom, and inRandomOrder all take a System object as an argument. If you don’t initialise them with one, they’ll work — they’ll simply use the general Math.random() pseudorandom number generator that JavaScript supplies. This means that every time a save is loaded, they’ll produce different results. This might be okay for testing, prototyping, or inconsequential text variations, but almost all of the time you want to initialise them properly with system.

Text variations are a really powerful tool, and they give you a lot of flexibility in structuring your story. With text variations, you can ensure that the story is always calling back to previous story events; allow the player to return to previous situations without repetition; and have the same situation present itself differently depending on the state of the story.

One major use of this is to merge branches in a way that flows naturally with previous story events; the bottleneck” will look different depending on what has happened previously in the story. Another use is to insert variations into a situation that gets revisited, such as a hub” that the player might see several times over the course of the game.

In the next part of this tutorial, we will look at qualities and what they’re good for. But first, a few parting notes:

  • oneOf isn’t just good for text; in fact, it’ll take arguments of any type. Mere Anarchy, for example, uses a similar technique to oneOf in various places, including to randomise the sigil” images that show up at various points in the narrative.
  • The Undum documentation details how to use System.rnd, which you can use to randomise things in your own functions.
  • Are you using Raconteur or reading this tutorial? I’d love to hear about it; I can be reached on Twitter or through the intfiction.org forums.
  • Are you failing to use Raconteur because it’s broken in some way? I’d appreciate bug reports and issues at the Github page for the project.