Inzignia

Social artifacts and digital curiosities

Sculpting Generative Text with Tracery

As humans, we take our ownership of words very seriously. We see words as a way for a human to express their thoughts and feelings, and while all words have concrete meanings, there are connotations and sentiments associated with certain words and phrases that we think a machine could never truly master as an unemotional third party. Even if we sometimes perceive emotion in a machine’s words (thanks to the ELIZA effect), a computer will never learn language the same way a human does, so can they deliver narratives with the same authenticity, accuracy, and appeal as a person?

As a programmer, I’ve dabbled in generative text before as a method for creating novel interactive content for users, typically for games. As a linguist, I’ve studied generative text for its abilities to trace new connections betweens things, people, places, and feelings in a way unchartered by humans. My experiments have yielded some ideas and practices that I’d like to share as I discuss techniques for sculpting generative text, specifically with Kate Compton’s Tracery.

Procedurally Generated Content (PGC)

Generative text regularly receives attention in scholarly articles and real-world applications, but in the greater category of procedurally generated content, it sometimes gets lost in the mix. When people talk about procedurally generated content, they often think of sweeping, monolithic titles like Spore or Dwarf Fortress. While both are excellent examples of PGC, the generator doesn’t have to dominate the game or be the core of the engine in order to be effective. Sometimes, a subtle application of generative methods allows for unique experiences to surface in otherwise boilerplate content.

When people talk about generative text, they often put machine-generated and human-generated passages side by side and challenge the reader to find the difference. More recently applied in journalism, generative text is sometimes seen as a way to quickly produce editorial content without the bias of a human. However, the same texts are often just as quickly criticized for being too unemotional, not taking all facts into account, missing the point, being awkwardly phrased, or all of the above.

In the title of this post, when I say “sculpting generative text,” I truly mean that. The text generated by a computer is often a great canvas, an excellent block of marble, and a good starting ground for any written content. But taking the text at face value can introduce many problems into your project, and I’ll demonstrate why it’s important to craft an editorial voice and vision for your generative text projects, without leaning entirely on the generator.

Iterate, iterate, iterate, and at the same time: sculpt, sculpt, sculpt!

Using a Generative Grammar

When we generate text, we use an iterative grammar because language is defined as a set of rules. Grammars allow us to iterate over logic structures to create content based on rules. Language is such an excellent candidate for iterative grammars because all language is constructed through association and context in this way.

Kate Compton has a fantastic JavaScript library called Tracery that defines rules for these iterative grammars and provides the tools needed to curate content with a generator. Tracery is available on Node, in Twine, and in Ruby and Python, so it’s compatible with a wide range of projects.

Tracery grammars are usually defined in simple JSON files, where a key represents a symbol for a rule and a value is an arrays of expansions for that symbol.

1
2
3
4
{
"animal": ["lion","tiger","bear","rhino","zebra","giraffe","groundhog"],
"zooDescription": ["Today at the zoo, I saw a #animal#."]
}

But a grammar can also be defined as a JavaScript object and passed directly into Tracery, like so:

1
2
3
4
const grammar = tracery.createGrammar({
"animal": ["lion","tiger","bear","rhino","zebra","giraffe","groundhog"],
"zooDescription": ["Today at the zoo, I saw a #animal#."]
})

We can then use Tracery to expand text from these symbols, which can in turn be chained together. To help the results conform to natural English grammar, we can also use modifiers within the expansion symbols. For example, if we added another animal to this list that starts with a vowel (say, aardvark, for example), we now need to accomodate for the indefinite article (which can be “a” or “an” in this case).

We can account for this with built-in modifiers. Tracery comes with several (represented on the tracery object as baseEngModifiers), and it’s very simple to write new ones. Useful modifiers include .s for plurals, .ed for past tense, and .capitalize but you can use these rules to completely rewrite the text (with accents, robot-talk, hissing, or other rule-based text modifications you can think of!). Not all of them are perfect (primarily: .ed can render incorrect words, which can be avoided by customizing this modifier to account for these cases).

The result is a simple but powerful interface for generating text.

1
2
3
4
5
6
7
const grammar = tracery.createGrammar({
"animal": ["lion","tiger","bear","rhino","zebra","giraffe","groundhog","aardvark"],
"zooDescription": ["Today at the zoo, I saw #animal.a#."]
})
grammar.addModifiers(tracery.baseEngModifiers);
console.log(grammar.flatten("#zooDescription#"))
console.log(grammar.flatten("#zooDescription#"))

Output:

1
2
Today at the zoo, I saw a zebra.
Today at the zoo, I saw an aardvark.

Sculpting Text for Interactive Fiction

Now that we have the basics, we can discuss more advanced applications of Tracery, specifically for interactive fiction. Before using Tracery, I embarked on an ambitious solo project in 2016 to create a live text-based simulation using generative text in Inform. At that point in time, I had been focusing on Inform as a narrative tool, and was less focused on scripting languages like JavaScript. Using what I was familiar with, I leveraged Inform’s rule-based systems to create a fantasy landscape with reactive weather. Weather seemed like a good starting place to me, because weather is parametric and can cover a wide breadth of imagery. If you’re not familiar with Inform, it creates turn-based text games using natural language programming (you write your code like you’re writing English). For that reason, it’s very approachable for new programmers and writers (and if you’ve never used it before, I encourage you to check it out!)

But since I wanted to make a live simulation, I had to find a workaround for Inform’s turn-based structure. To achieve this, I used the Guncho server to house my project. Guncho is an application that uses Inform stories to create online “realms” which are served on a MUD-like environment to become multiplayer. Guncho is essentially an extension of the Inform system, and isn’t formally supported by the developers. In fact, Guncho is mostly a hobbyist endeavor and unfortunately suffers from poor documentation and broken resources online. But my ideas were strong and I was naively avoiding “biting the bullet” and formally picking up a programming language, so I decided to proceed anyways with my test case.

After two months of constant development, the result was a (very shaky) generative text engine in Inform that could create dynamic rain, thunder, and saltstorms using reactive properties like current weather conditions, wind, time, and location. I expanded into crafting and had a generative crafting system for creating pottery. My monster was 65,000 words and 460,000 characters (and capable of creating millions of possible weather and craft descriptions), but I felt like I had only barely scratched the surface of what I was trying to achieve.

Sample output:

1
2
3
4
5
6
7
8
9
10
11
Spire's Peak [D]
The spire comes to a circular minaret here, the highest point in the compound. The structure has been worn smooth from the ever-present wind. The center of the platform features a small sheltered dome, housing a large black bell. Due to the saltstorm, you can't see any further than an arm's length away. The wind, salt, and sand are powerful at this height. A tireless saltstorm howls around you, covering the daylit sky.
The black bell of the spire hangs overhead.
The sun reaches its peak in the sky, claiming its zenith on the firmament.
A draught blows through your surroundings, picking up speed.
Suddenly, jagged salt forms slice at the air around you. At once, razor-like pockets of salt collect overhead and fall.
Then dark salt clusters are carried overhead.
With a powerful gust of wind, shimmering salts whiz past overhead.
As winds whip past, colorful dust crystals whiz past overhead.
Evenly, the inclement weather clears and leaves the daytime firmament clear once more. This reveals a shimmering landscape of salt encompassing the geography.
The compound churns industriously below.

Some of my roadblocks included:

  • Inform isn’t really an object-based language, so it’s difficult to reuse grammars across the code. I found myself woefully copy-pasting grammars I had already built in the Inform syntax, bloating my code and violating the DRY principle.
  • Guncho’s architecture meant that everything had to be defined in one file. This resulted in a massive tangle of spaghetti code.
  • Inform is designed for purposefully-crafted words by authors, not generating text. As a result, there was little support for grammatical modifiers or nesting expansion rules. This limited my lexicon.
  • Debugging was a nightmare because it was difficult to determine and maintain a state with Inform and Guncho (which used custom server variables on top of Inform’s native capabilities).

To further illustrate my points, here are some code excerpts that illustrate how I was (painfully) doing this:

Crafting a generated object:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
To say clay-jug-result of the (person - person):
let craft be 0;
let random-sigil be indexed text;
let quality be indexed text;
let RNG be a random number between 1 and 4;
if RNG is 1:
let random-sigil be realm storage slot "red-sigil";
if RNG is 2:
let random-sigil be realm storage slot "blue-sigil";
if RNG is 3:
let random-sigil be realm storage slot "green-sigil";
if RNG is 4:
let random-sigil be realm storage slot "yellow-sigil";
let random-color be "[one of]red[or]ruby[or]scarlet[or]crimson[or]garnet[or]merlot[or]mahogany[or]sanguine[or]blue[or]cerulean[or]cobalt[or]cyan[or]navy[or]periwinkle[or]teal[or]turquoise[or]green[or]chartreuse[or]clover[or]lime[or]jade[or]mint[or]malachite[or]celadon[or]yellow[or]golden[or]honey[or]blonde[or]mustard[or]lemon[or]amber[or]sunglow[purely at random]";
let random-pattern be "[one of]strips[or]dots[or]spots[or]whorls[or]grids[or]swirls[or]triangles[or]squares[or]circles[or]rectangles[or]ovals[or]pentagons[or]hexagons[or]stars[or]crescents[or]starbursts[or]lemniscates[purely at random]";
let random-motif-sub be "[one of]man[or]woman[or]child[or]queen[or]king[or]prince[or]princess[or]god[or]goddess[or]snake[or]manticore[or]spider[or]moth[or]serpent[or]warden[or]prisoner[or]scholar[or]professor[or]rhetorician[or]philosopher[or]priest[or]linguist[or]historian[or]librarian[or]writer[or]poet[or]banker[or]governor[or]aide[or]lawyer[or]mayor[or]councilmember[or]commander[or]warrior[or]guard[or]mercenary[or]legislator[or]woodworker[or]shoemaker[or]jeweler[or]sculptor[or]clayworker[or]tinkerer[or]tailor[or]metalworker[or]florist[or]hairdresser[or]baker[or]stoneworker[or]potter[or]glassworker[or]weaver[or]beadworker[or]butcher[or]boneworker[or]rancher[or]dyer[or]performer[or]bard[or]miner[or]courtesan[or]farmer[or]laborer[purely at random]";
let random-motif-act be "[one of]crying[or]sleeping[or]eating[or]urinating[or]praying[or]looking in a mirror[or]reading a book[or]sitting[or]running[or]walking[or]screaming[or]writing[or]talking to a group of people[or]talking to a group of children[or]drinking[or]ringing a bell[or]using a well pump[or]cultivating crops[or]harvesting crops[or]making pottery[or]weaving[or]being eaten by a large moth[or]being eaten by a large snake[or]being eaten by a group of people[or]sitting at a banquet[or]playing the lyre[or]playing the drums[purely at random]";
let random-motif-loc be "[one of]in a cave[or]in a field[or]in a salt flat[or]in a lake[or]in an underground lake[or]in a cavern[or]atop a mountain[or]in the clouds[or]in the sky[or]on the sun[or]on on the moon[or]in a spider's web[or]atop a god's palm[or]atop a goddess' palm[or]in the mouth of a snake[or]in a building[or]atop a tower[or]in a temple[or]in a tunnel[or]in a warehouse[or]in a plaza[or]in a workshop[or]by an oasis[or]in the shade of a building[or]in an alleyway[or]around a bonfire[purely at random]";
say "<$t [mud-id of X]>[name in sentence case] mixes three handfuls of clay with two rations of water, [one of]forming[or]shaping[or]creating[or]crafting[purely at random] a clay jug. They fire [one of]the pottery[or]their creation[or]the piece[or]the creation[purely at random] in the kiln, burning one bundle of fiber to [one of]do so[or]reach the desired heat[or]warm the furnace[purely at random]. When they pull the jug from the kiln, you notice...[line break][line break][clay-jug-result of the actor]</$t>";
tell "A [one of]warden[or]wardeness[purely at random] [one of]claims[or]takes[purely at random] the [one of]piece[or]creation[or]pottery[purely at random] and [one of]stashes[or]hides[or]sets[or]moves[or]relocates[purely at random] it in a [one of]bin[or]crate[or]box[purely at random].[if a random chance of 1 in 3 succeeds] [quotation mark][one of]Yes, this will do nicely.[or]Excellent.[or]Thank you, prisoner.[or]Nice work.[or]Interesting details.[or]Hmmm... This will do.[or]You could do better.[or]Poor craftsmanship, honestly.[purely at random][quotation mark][end if]" to everyone in Cluttered Workshop.

Output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Cluttered Workshop [S]
Chitinoid tools are arranged in clusters around this dusty workshop, stacked with arrangements of raw materials and goods in various stages of production. Fragments of silk, clay, chitin, and bone litter the ground here, creating a dense artisanal mulching underneath. The studio seems to be segmented by trade, with many large workstations scattered around. An industrious feeling of constant toil hangs heavily in the air here. The only way out of this maze of tools is southward through the archway into a plaza. Some daylight streams in from outside, lighting the region.
A pottery wheel and a small clay kiln are here.
>craft jug
You mix three handfuls of clay with two rations of water, craft a clay jug. You fire your creation in the kiln, burning one bundle of fiber to warm the furnace. You feel rested. You gain some focus as you work. When you pull the jug from the kiln, you inspect it and see...
It's a crude scarlet jug featuring a golden eye on the lip. It's covered in yellow crescents. Near the center, on the side, there is a depiction of prisoner being eaten by a large snake in a field.
A warden takes the pottery and sets it in a crate. "You could do better."
>craft jug
You mix three handfuls of clay with two rations of water, shape a clay jug. You fire the piece in the kiln, burning one bundle of fiber to do so. You feel slightly fatigued. You gain some focus as you work. When you pull the jug from the kiln, you inspect it and see...
It's a crude cyan jug featuring a garnet fist on the lip. It's covered in honey stars. Near the center, on the side, there is a depiction of king being eaten by a group of people in a warehouse.
A warden takes the piece and hides it in a box. "You could do better."
>craft cup
You mix three handfuls of clay with two rations of water, form a clay jug. You fire the creation in the kiln, burning one bundle of fiber to warm the furnace. You feel slightly fatigued. You gain some focus as you work. When you pull the cup from the kiln, you examine it and see...
It's a crude garnet jug featuring a green circle on the lip. It's covered in crimson pentagons. Near the center, on the side, there is a depiction of woman cultivating crops on the sun.
A wardeness claims the creation and hides it in a bin.

Generating a sunrise:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Reporting a sunrise is an activity.
Rule for reporting a sunrise:
if realm storage slot "weather" is "saltstorming", let saltstorming be true;
if realm storage slot "weather" is "cloudy", let cloudy be true;
if realm storage slot "weather" is "rainy", let raining be true;
if realm storage slot "weather" is "thunderstormy", let thunderstorming be true;
change realm storage slot "sun-string" to "[sunrise type] [if saltstorming is true][one of]Due to[or]Because of[purely at random] the saltstorm, the sunrise is [one of]mostly[or]partially[or]almost entirely[or]partly[or]slightly[purely at random] [one of]obscured[or]hidden[or]clouded[or]blotted[or]struck out[purely at random] by [one of]sand[or]salt[or]silt[or]dust[purely at random] particles [one of]in the sky[or]in the firmament[or]all around you[or]in the area[or]overhead[purely at random], which [one of]become backlit[or]now glow[or]now glimmer[or]now shimmer[or]now glitter[purely at random] with [one of]orange[or]vermilion[or]sanguine[or]red[or]yellow[or]golden[or]blood-like[or]tangerine[purely at random] [one of]hues[or]colors[or]tints[or]shades[purely at random].[run paragraph on][else if cloudy is true]The clouds [one of]in the sky[or]in the firmament[or]in the area[or]overhead[purely at random] take on the [one of]bright[or]shining[or]new[or]glowing[or]warming[purely at random] [one of]hues[or]colors[or]tints[or]shades[purely at random] of the sun.[else if raining is true]The [one of]dark[or]heavy[or]morose[or]gray[or]hazy[purely at random] rainclouds take on the [one of]bright[or]shining[or]new[or]glowing[or]warming[purely at random] [one of]hues[or]colors[or]tints[or]shades[purely at random] of the sun, and the rain grows warm from its [one of]light[or]arrival[or]heat[or]rays[or]presence[purely at random].[run paragraph on][else if thunderstorming is true]The thunderstorm [one of]blots out[or]blocks[or]obscures[purely at random] most of the [one of]arriving[or]new[or]bright[or]shining[or]warming[purely at random] sunlight, but the rain warms up [one of]slightly[or]a bit[or]some[or]slowly[purely at random] from its [one of]light[or]arrival[or]heat[or]rays[or]presence[purely at random].[end if][run paragraph on][sunrise weather type 1][run paragraph on]";
let sun-string be realm storage slot "sun-string";
tell "[sun-string]" to everyone in Obelisk Garden;
tell "[sun-string]" to everyone in Brackish Pool;
tell "[sun-string]" to everyone in Western Mall;
tell "[sun-string]" to everyone in Southern Mall;
tell "[sun-string]" to everyone in Ornamental Gates;
tell "[sun-string]" to everyone in Expansive Plaza;
tell "[sun-string]" to everyone in Salty Field;
tell "[sun-string]" to everyone in Eastern Mall;
tell "[sun-string]" to everyone in Animal Pasture;
tell "[sun-string]" to everyone in Sun Enclave;
tell "[sun-string]" to everyone in Moon Enclave;
tell "[sun-string]" to everyone in Spire's Peak;
To say sunrise type:
let R be the numeric value of realm storage slot "sun-type";
if R is 1:
say "The sun [one of]pokes[or]lifts[or]shows[purely at random] its [one of]rosy[or]warm[or]bright[or]shimmering[or]shining[purely at random] rays out from the eastern horizon, beginning to light the [one of]sky[or]firmament[purely at random].[run paragraph on]";
if R is 2:
say "Never [one of]asleep[or]slumbering[purely at random] for long, the sun [one of]awakens[or]stirs[or]rises[purely at random] once more and begins to [one of]climb[or]ascend[purely at random] into the [one of]sky[or]firmament[purely at random].[run paragraph on]";
if R is 3:
say "The sun rises in a [one of]dazzling[or]shining[or]shimmering[or]spectacular[or]bright[or]glimmering[purely at random] [one of]display[or]showcase[or]exhibition[purely at random] of [one of]bright[or]shining[or]new[or]glowing[or]warming[purely at random] [one of]hues[or]colors[or]tints[or]shades[purely at random].[run paragraph on]";
if R is 4:
say "[one of]Slowly[or]Steadily[or]Readily[or]Calmly[or]Unannounced[or]Silently[purely at random], the sun [one of]pokes out from[or]rises in[or]wakes up in[purely at random] the far east and begins its [one of]ascent[or]climb[purely at random] into the [one of]sky[or]firmament[purely at random].[run paragraph on]";
To say sunrise weather type 1:
if realm storage slot "weather" is "clear", let clear-skies be true;
say "[if clear-skies is true][one of]Because the sky is clear[or]Because of the clear skies[or]Due to the clear skies[or]Unfettered by weather conditions[or]Lacking any weather in the sky[purely at random], you can [one of]observe[or]behold[or]see[or]witness[purely at random] the sunrise in [one of]all of its glory[or]all of its splendor[or]all of its power[or]its entirety[purely at random]. The [one of]massive[or]huge[or]giant[or]gigantic[purely at random] [one of]red[or]tangerine[or]golden[or]vermilion[or]red[or]orange[or]bronze[or]sanguine[or]blood-red[purely at random] sun fills the [one of]sky[or]firmament[purely at random] and immediately begins to [one of]warm[or]heat up[purely at random] the [one of]environment[or]surroundings[or]area[or]region[or]compound[purely at random] until a [one of]dry[or]staggering[or]blazing[or]powerful[or]hazy[purely at random] heat is [one of]unavoidable[or]omnipresent[or]everywhere[purely at random].[run paragraph on][end if][if clear-skies is true and a random chance of 1 in 5 succeeds] It is [one of]unrelenting[or]awesome[or]humbling[or]breathtaking[or]staggering[or]intense[purely at random] and [one of]comforting[or]inspiring[or]empowering[or]incredible[purely at random] to [one of]observe[or]behold[or]see[or]witness[purely at random].[run paragraph on][end if][run paragraph on]";

Output:

1
2
The sun rises in a spectacular exhibition of glowing hues. Because the sky is clear, you can see the sunrise in all of its power. The gigantic blood-red sun fills the sky and immediately begins to heat up the environment until a blazing heat is unavoidable.
The daylit sky aggressively becomes scattered with a whitish veil of silky clouds from the west, soaked with sunlight.

Generating a weather event (specifically, a saltstorm):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
A real-time event rule (this is the weather checking rule):
let H be the numeric value of realm storage slot "hours";
if the remainder after dividing H by 4 is 0 and the numeric value of realm storage slot "saltstorm-coming" is 0:
if the numeric value of realm storage slot "weather-check" is 0 and a random chance of 1 in 4 succeeds:
let X be a random number between 1 and 10;
let W be the numeric value of realm storage slot "wind";
if X is less than 3 and the realm storage slot "weather" is "cloudy":
[announce "It begins to rain.";]
change realm storage slot "weather-type" to "[a random number between 1 and 4]";
carry out the reporting rain activity;
change realm storage slot "weather" to "rainy";
change realm storage slot "weather-check" to "1";
if X is less than 4 and the realm storage slot "weather" is "rainy" and the numeric value of realm storage slot "weather-check" is 0:
[announce "The rain turns into a thunderstorm.";]
change realm storage slot "weather-type" to "[a random number between 1 and 4]";
carry out the reporting a thunderstorm activity;
change realm storage slot "weather" to "thunderstormy";
change realm storage slot "weather-check" to "1";
let W be the numeric value of realm storage slot "wind";
let X be W + 15;
change realm storage slot "wind" to "[X]";
[announce "The wind is now [X] (+15) because of the thunderstorm.";]
carry out the reporting a strong plus wind activity;
otherwise if X is greater than 4 and W is greater than 50:
if realm storage slot "weather" is not "saltstormy" and the numeric value of realm storage slot "weather-check" is 0:
change realm storage slot "saltstorm-coming" to "1";
carry out the creating a saltstorm activity;
change realm storage slot "weather-check" to "1";
otherwise if the numeric value of realm storage slot "weather-check" is 0:
if the numeric value of realm storage slot "weather-happening" is 0 and a random chance of 1 in 6 succeeds:
[announce "The sky grows cloudy.";]
change realm storage slot "weather-type" to "[a random number between 1 and 4]";
carry out the reporting clouds activity;
change realm storage slot "weather" to "cloudy";
change realm storage slot "weather-check" to "1";
change realm storage slot "weather-happening" to "1";
otherwise if the numeric value of realm storage slot "weather-happening" is 1 and a random chance of 1 in 5 succeeds and the numeric value of realm storage slot "weather-check" is 0 and the numeric value of realm storage slot "saltstorm-coming" is 0:
change realm storage slot "weather-happening" to "0";
change realm storage slot "weather-check" to "1";
change realm storage slot "thunder-next" to "0";
[announce "The weather clears up.";]
change realm storage slot "weather-type" to "[a random number between 1 and 4]";
carry out the reporting clear skies activity;
if realm storage slot "weather" is "saltstormy":
let W be the numeric value of realm storage slot "wind";
let X be W - 30;
let R be a random number between 1 and 4;
change realm storage slot "weather-type" to "[R]";
carry out the reporting a strong minus wind activity;
if X is less than 0:
let X be 0;
change realm storage slot "wind" to "0";
[announce "The wind is now [X]. (zero'd out)";]
otherwise:
change realm storage slot "wind" to "[X]";
[announce "The wind is now [X]. (-30)";]
change realm storage slot "weather" to "clear";
otherwise:
change realm storage slot "weather-check" to "1";
otherwise:
change realm storage slot "weather-check" to "0";
A real-time event rule (this is the saltstorm movement rule):
if the numeric value of realm storage slot "saltstorm-coming" is 1 and the numeric value of realm storage slot "saltstorm-distance" is greater than 0:
let W be the numeric value of realm storage slot "wind";
let D be the numeric value of realm storage slot "saltstorm-distance";
let X be W divided by 3;
let Y be D minus X;
change realm storage slot "saltstorm-distance" to "[Y]";
[announce "The saltstorm is now closer ([D] - [X]([W]/3) = [Y])";]
[announce "A saltstorm is coming from the [R].";]
if the numeric value of realm storage slot "weather-happening" is 0 and the numeric value of realm storage slot "daytime" is 1 and the numeric value of realm storage slot "saltstorm-distance" is greater than 0:
let J be realm storage slot "saltstorm-location";
if J is "far north" and the numeric value of realm storage slot "saltstorm-distance" is less than 301:
change realm storage slot "saltstorm-location" to "near north";
if J is "far east" and the numeric value of realm storage slot "saltstorm-distance" is less than 301:
change realm storage slot "saltstorm-location" to "near east";
if J is "far south" and the numeric value of realm storage slot "saltstorm-distance" is less than 301:
change realm storage slot "saltstorm-location" to "near south";
if J is "far west" and the numeric value of realm storage slot "saltstorm-distance" is less than 301:
change realm storage slot "saltstorm-location" to "near west";
let R be realm storage slot "saltstorm-location";
let H be "[one of]billowing[or]towering[or]rolling[or]tireless[or]sky-blotting[or]resolute[or]swelling[or]churning[purely at random]";
let K be the numeric value of realm storage slot "saltstorm-distance";
tell "You see a [H] saltstorm coming from the [R]. It seems about [the estimated distance of the saltstorm] sankii away. " to everyone in Spire's Peak;
if the numeric value of realm storage slot "saltstorm-coming" is 1 and the numeric value of realm storage slot "saltstorm-distance" is less than 1:
change realm storage slot "saltstorm-coming" to "0";
change realm storage slot "weather-type" to "[a random number between 1 and 4]";
carry out the reporting a saltstorm activity;
[announce "The air clots in a saltstorm.";]
change realm storage slot "weather" to "saltstormy";
change realm storage slot "weather-happening" to "1";
Creating a saltstorm is an activity.
Rule for creating a saltstorm:
let X be a random number between 1 and 4;
let Y be a random number between 200 and 500;
if X is 1:
change realm storage slot "saltstorm-location" to "far north";
if X is 1 and Y is less than 301:
change realm storage slot "saltstorm-location" to "near north";
if X is 2:
change realm storage slot "saltstorm-location" to "far east";
if X is 3 and Y is less than 301:
change realm storage slot "saltstorm-location" to "near east";
if X is 3:
change realm storage slot "saltstorm-location" to "far south";
if X is 3 and Y is less than 301:
change realm storage slot "saltstorm-location" to "near south";
if X is 4:
change realm storage slot "saltstorm-location" to "far west";
if X is 4 and Y is less than 301:
change realm storage slot "saltstorm-location" to "near west";
change realm storage slot "saltstorm-distance" to "[Y]";
let H be "[one of]billowing[or]towering[or]rolling[or]tireless[or]sky-blotting[or]resolute[or]swelling[or]churning[purely at random]";
let R be realm storage slot "saltstorm-location";
let K be the numeric value of realm storage slot "saltstorm-distance";
[announce "A saltstorm is coming from the [R].";]
if the numeric value of realm storage slot "weather-happening" is 0 and the numeric value of realm storage slot "daytime" is 1:
tell "You see a [H] saltstorm coming from the [R]. It seems about [the estimated distance of the saltstorm] sankii away. " to everyone in Spire's Peak;
To say saltstorm location:
let X be realm storage slot "saltstorm-location";
if X is "far north" and the numeric value of realm storage slot "saltstorm-distance" is less than 301:
change realm storage slot "saltstorm-location" to "near north";
if X is "far east" and the numeric value of realm storage slot "saltstorm-distance" is less than 301:
change realm storage slot "saltstorm-location" to "near east";
if X is "far south" and the numeric value of realm storage slot "saltstorm-distance" is less than 301:
change realm storage slot "saltstorm-location" to "near south";
if X is "far west" and the numeric value of realm storage slot "saltstorm-distance" is less than 301:
change realm storage slot "saltstorm-location" to "near west";
let Y be realm storage slot "saltstorm-location";
say "[Y]".
To say the estimated distance of the saltstorm:
let N be the numeric value of realm storage slot "saltstorm-distance";
if N is greater than 5:
let R be N divided by 5;
let total be R times 5;
say "[total in words]";
else:
say "[N in words]".

Output:

1
2
3
4
5
6
You see a towering saltstorm coming from the near east. It seems about eighty-five sankii away.
You see a billowing saltstorm coming from the near east. It seems about sixty sankii away.
You see a towering saltstorm coming from the near east. It seems about forty sankii away.
You see a swelling saltstorm coming from the near east. It seems about fifteen sankii away.
There's a moment of vacuous air pressure, then a raging storm fills the air with salt, silt, and dust. It obscures the sun and tints everything in somber, hazy hues. Your limbs feel compelled to shield your face from hazardous blade-like dust particles. Your lungs become sore from the air quality. Up here, the winds are especially ravaging. You can no longer see any further than an arm's length away.
Then razor-like pockets of salt collect overhead and fall.

At a certain point, I considered building a plugin for Inform to support what I was trying to do, but this coincided around the time I moved back to America from Japan so the project slipped to the backburner, and then to my archives. I had made a mess, and it was difficult to motivate myself to revisit the project. Because I was so rigorously defining tiny details in the weather and reporting them to the user, the simulation was also notoriously “spammy” with constant updates about changes, all written in generative prose. Each of these fragments seemed perfect in isolation (even with all of their possible options) but when juxtaposed, the result had an inconsistent tone. Providing a plethora of choices for many words meant each fragment was a grab-bag of words previously used, and not every word worked in every context. Because I wasn’t defining grammars in Inform, making a single change could mean making tens or hundreds of tiny changes in order to achieve the desired result. Just thinking about it now makes my skin crawl now.

That said, I really liked a lot of the results, and it inspired me to build a mythos around this small simulation. I’m still actively reusing many of its elements, and I consider it a foundational (if messy) project. Now that I know ES6 and can apply modern web frameworks, I can strip out all of the dependencies on Inform and Guncho and support my procedural generation from the ground up.

1
2
3
4
5
6
7
8
9
10
11
12
Slowly, the sun dips beyond the horizon. Because of the clear skies, you can behold the sinking of the sun in all of its glory. The massive golden sun now sinks from the firmament and eases its presence from the environment. It is awesome and comforting to observe.
You were given a cup of water today. Your mind felt foggy afterwards.
You were given a ration of food today. It was chewy and soft.
The gales gain a tiny bit of intensity.
Midnight starts a new day as the firmament reaches its darkest lighting. Lacking any weather in the
sky, you can see the glimmering cobalt seamoon up above. The seamoon is a waning gibbous tonight.
> time
It's roughly 2:30 on Wellday, the 18th of Knoss, 3433 Years Disfigured.
The weather is currently clear and the wind is weak.
Glancing upward, you see that the seamoon is a waning gibbous tonight.
>l moon
There is a small blue seamoon up above, at waning gibbous.

As I do so, there are several principles I’m applying to my use of Tracery:

  • Build a lexicon - And have the willpower to throw it away (sometimes)! Building a lexicon of common phrases, idioms, and words for your narrative can be really helpful for injecting a consistent voice into your project. However, this lexicon can also point out the “seams” in your creation. Have you ever played a game with PGC and been able to single out all of the generative content against its template? Heavily recycling this lexicon can oversaturate your text with words and phrases that needlessly call attention to your PGC, so have the willpower to throw away the lexicon, or use some techniques (described in the next section) to diversify the lexicon depending on context.
  • Less is more - Not everything needs to be generated. Sometimes, a little love and attention (and maybe a few lines of code) can make an experience feel more unique and novel than generative text ever could.
  • Don’t cheapen your content with pure randomization - Pure randomness is ugly! What might appear purely random to an observer is often determined by rules. For example, most flowers aren’t randomly scattered in fields, but tend to appear in clusters. Parametric generation can achieve this distribution. You can use Markov chains to determine the chance of transitioning states, which is useful for simulations like weather.
  • Pay attention to voice, tense, and context - I’ve read (and created) generated text that struggles with tense. It can be difficult to perceive when a constructed rule will break tense, but thanks to Tracery’s aforementioned modifiers, you can account for these cases while still keeping your base grammars pure.
  • Test and test often - Even if it’s just as simple as printing your text to the console, test your grammars as you build them. Coding and writing use similar parts of the brain (both are languages), but building complex expansion rules without testing them can cause headaches and confusion down the line. More importantly, it can point out grammatical errors early so you can keep the core of your grammars functional and strong.
  • Don’t expect generative text to tell a great story every time - Instead, tell a great story and use generative text to give each playthrough a unique fingerprint. Not even DF’s generator tells a great story every time. The enjoyment comes from finding the standout elements of the text. Speaking of:
  • Not everything has to stand out - If everything is special, nothing is.
  • If you use generative text to describe absolute values (like numbers), be consistent with the vocabulary describing your ranges - If you use “a couple” for 2, and “a few” for 3-4,” but “some” for 5-8, then be consistent when reusing this counting system.
  • Give ownership of the PGC to players - This is something Compton has touched on before, and personally I think it’s the most important part of making PGC. It’s ineffective to leverage powerful algorithms to produce game content if you don’t give ownership of the results to your players. Procedurally generated games are some of the only games where the players rave about “the algorithms” that make the experience so unique and fun. Invite them in on that fun! Dwarf Fortress reigns supreme at this, using agent-based simulations to create an intricate tapestry of historical events, places, and characters and then letting the player find the interesting parts. While the player didn’t do anything specific to create the content (besides maybe entering a seed into the generator), there’s definitely a feeling of pride and ownership when interesting content is found in the generated histories. DF carries this concept all the way through by building on top of the generated data with the player’s events, so they can directly shape the their generated instance of the game.
Applications in Narrative Design

Generative text serves as an excellent vehicle for narratives, especially those with a heavy emphasis on branching. While a game like Dwarf Fortress certainly uses generative text in its histories, its usage is most relegated to filling out templates with datasets generated by the engine.

Tracery supports a similar concept with actions, which allow you to push new rules to the grammar on the fly, within the grammar itself. Let’s revisit our zoo example from above and implement this:

1
2
3
4
5
6
7
8
const corpus = {
"animal": ["lion","tiger","bear","rhino","zebra","giraffe","groundhog","aardvark"],
"zooDescription": ["Today at the zoo, I saw #animal.a#, and I also saw #otherAnimal.a#."],
"origin": ["#[otherAnimal:#animal#]zooDescription#"]
}
const grammar = tracery.createGrammar(corpus)
grammar.addModifiers(tracery.baseEngModifiers)
console.log(grammar.flatten("#origin#"))

Output:

1
Today at the zoo, I saw an aardvark, and I also saw a lion.

We could easily remove otherAnimal altogether and just use the animal symbol twice within zooDescription (each would pull their own random animal from the grammar), but we can actually chain these actions together within expansion rules to initialize a scoped state for that specific iteration of the generator. For example, we could extend this to construct a description of the animal, depending on what kind of animal is picked:

1
2
3
4
5
6
7
8
const corpus = {
"zooDescription": ["Today at the zoo, I saw #animal.a#. It was #animalDesc#. #animalExtra#"],
"describeAnimal": ["[animal:lion][animalDesc:big and fluffy,lithe and scary][animalExtra:It had cubs nearby.]","[animal:penguin][animalDesc:small and round,full of energy][animalExtra:]"],
"origin": ["#[#describeAnimal#]zooDescription#"]
}
const grammar = tracery.createGrammar(corpus)
grammar.addModifiers(tracery.baseEngModifiers);
console.log(grammar.flatten("#origin#"))

Output:

1
2
Today at the zoo, I saw a penguin. It was full of energy.
Today at the zoo, I saw a lion. It was big and fluffy. It had cubs nearby.

Since the description of a lion is very different from a penguin, we have to provide different descriptions depending on which animal is picked. We could define these animal descriptions as their own symbol in the grammar, but there’s no way to know which animal is picked until the rule is expanded. As a result, it’s best to define this as an action within the expansion rule, so the appropriate text is chosen depending on each option. You can use this technique to define “helper” words like pronouns when creating longer texts in a single iteration.

Note how the animalExtra symbol has to be set on the penguin, even though it’s blank. While this can create incongruous gaps in your grammar, it can also be used to vary the length of your resulting text and avoid drawing attention to the “seams” of your PGC. It’s important to note that these dynamic rules lose their value once their expansion symbol is evaluated. If you want to feed the results of your generation back into the corpus, you must do that programmatically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let corpus = {
"animal": ["lion","tiger"],
"zooDescription": ["Today at the zoo, I saw #animal.a#, but I wish I saw #favoriteAnimal.a#."],
"origin": ["#[favoriteAnimal:#animal#]zooDescription#"]
}
let grammar = tracery.createGrammar(corpus)
grammar.addModifiers(tracery.baseEngModifiers)
console.log(grammar.flatten("#origin#"))
const lionsEscaped = true
if (lionsEscaped === true) {
corpus1.animal = corpus1.animal.filter(word => word !== 'lion');
}
grammar = tracery.createGrammar(corpus)
grammar.addModifiers(tracery.baseEngModifiers)
console.log(grammar.flatten("#origin#"))

Output:

1
2
Today at the zoo, I saw a lion, but I wish I saw an aardvark.
Today at the zoo, I saw a zebra, but I wish I saw a groundhog.

Now, no matter how many times we use this new grammar, it won’t talk about a lion. Notice how we defined the rules (corpus) this time with let, so we could later redefine it. If you make a change to a grammar, you have to reprocess it with Tracery in order for it to work. And don’t forget to reapply your modifiers! If you plan to programmatically alter your grammars on the fly, I recommend wrapping these types of interactions in helper functions. Depending on how you are saving and maintaining state, your updated grammar could be saved (like any other data) for later use.

When constructing your actual grammars, I also recommend keeping the keys as pure as possible. What do I mean? Well, in our zoo example, the strings animal and zooDescription are both keys. Even though I’m writing a list of animals, I’m still using a singular noun for the key. Remember that since we’re using modifiers, we can add a .s to the symbol to make it plural when needed. This same rule should apply to your verbs: use unconjugated verbs. For example, if you have a list of synonyms for take in your lexicon, then the key should also be take. Similarly, the expansion rules for the symbols all need to have the same form. This might seem intuitive, but you’d be surprised how quickly this can get away from you, especially if you write one symbol for your grammar with a specific purpose and it later gets adapted into other symbols. Soon the words are waltzing alongside content you never designed it to be paired with. If you grammars are pure and tense-agnostic, these situations will be easy to navigate, and your own creation will start to surprise you. And being surprised by your own creation is one of the most rewarding parts of designing procedural content!

I hope these ideas are useful for you, or at least have given you something interesting to think about. I encourage you to reach out to me on Twitter for discussion. It’d be great to hear from other procedural generative artists and share best practices. In the meantime, I hope to revisit this subject again in the future and showcase more examples from my current Vue idle game project, which uses these concepts to create some of its content.

Resources

Tracery Homepage

Tracery GitHub repository

Tracery Node library GitHub repository

1
$ npm install tracery-grammar --save

Kate Compton’s interactive Tracery tutorial

Compton’s “Practical Procedural Generation for Everyone” talk at GDC 2017

Compton’s Tracery zine

Tracery: An Author-Focused Generative Text Tool

Cheat Bots, Done Quick!

Online Tracery editor

Victor Powell and Lewis Lehe’s interactive explanation of Markov chains