Sea Duck is a minimally opinionated rough-draft JavaScript framework for producing narratives through simulation. All you have to do is define a list of nouns, a list of actions that happen to those nouns (thereby producing events), and a Tracery grammar to turn those events into text.
Note: This library uses a number of ES6 features (maps, generator functions) and I haven't used a transpiler or anything to provide backwards compatibility. Internet Explorer probably won't work (but Edge probably will?). Sorry!
I made this framework primarily as a toy for experimentation and teaching, because I couldn't find any similar frameworks (available in a more-or-less complete form to the general public) that make it easy to get started with making narratives from simulated events. The idea was to facilitate quick prototypes for projects along the lines of Darius Kazemi's Teens Wander Around A House (NaNoGenMo thread here) As a consequence, this framework doesn't contain a built-in world model, or a system for solving constraints or action planning (though you could implement any of those things yourself on top of the framework).
Pull requests or suggestions for overall architecture are solicited.
You can npm install from this git repo and require('seaduck')
in your code:
npm install https://github.com/aparrish/seaduck --save
Or you can download the bundle (click "Raw" and
then File > Save as...
or equivalent in your browser) and add it to your
project with a <script>
tag:
<script src="seaduck-bundle.js"></script>
... which will create a global seaduck
variable available to the rest of your
JavaScript code.
You can find annotated examples in the examples/
folder. If you've downloaded
this repo, you can run them like so:
node examples/01_basic.js
The examples are also available in adapted form on the p5.js web editor:
The purpose of a Sea Duck narrative is to generate sentences from events that transpire as nouns interact according to a set of rules. At each step of the simulation, the state of the nouns (properties and relations) are checked, and then potentially changed, generating narrative events in the process, which are rendered as text using Tracery grammars. A Sea Duck narrative consists of the following components:
- Nouns
- Actions
- Relations
- Events
- Discourse
Create Sea Duck narrative object like so:
let n = new seaduck.Narrative({
"nouns": [
... list of noun objects ...
],
"actions": [
... list of action definitions ...
],
"initialize": function() { ...code to execute on startup... },
"traceryDiscourse": {
... tracery rules for each kind of event ...
}
});
After creation, you can step through the simulation using .step()
, which
returns a list of StoryEvent
objects created during the simulation step; or
you can call .stepAndRender()
, which collects events from .step()
and
renders them using the associated Tracery grammar.
A noun
is something that will participate in the narrative (a person, place,
or thing). Here's an example noun:
{
"name": "Joe",
"properties": {
"happiness": 0,
"hungry": true
},
"tags": ["person"]
}
The name
attribute identifies the noun and is also the string that will be
used to render it in the Tracery output; the properties
attribute is an
object for storing values associated with this noun; and the tags
attribute
is a list of strings "tagging" this noun. (The tags are used to group objects
into categories for the purpose of relations and action matching; see below.)
Nouns must have all of these attributes defined.
An action
is a series of checks to perform against nouns on each step, along
with code for what to do if those checks pass. A typical action is structured
like this:
{
"name": "eat",
"match": ["#person", "#food"],
"when": function(a, b) {
return a.properties.hungry && !b.properties.eaten;
},
"action": function*(a, b) {
yield (new seaduck.StoryEvent("eat", a, b));
a.properties.hungry = false;
b.properties.eaten = true;
}
}
The value of the when
attribute should be a function that takes one or two
noun
s as parameters and returns true
or false
. If this function returns
true
, then the action check succeeds, and the function associated with
the action
attribute will be executed.
Inside the when
and action
functions, this
is bound to the Sea Duck
Narrative
object itself, to make it easy to call any of its methods. (Read
on for more information on the methods you can call.)
The name
attribute of an action is currently ignored (but might be used for
something else in the future).
The match
attribute of an action
specifies which nouns or groups of nouns
to will be given to the when
and action
functions. The list can contain
either one or two items; if one item, then the when
function will be called
for each matching noun(s) it turn. If two items, then the when
function will
be called once for each pair of items from both matching lists, with the items
from the corresponding lists being passed as the first and second parameters,
respectively. If the string in the list starts with #
, then all nouns
matching the tag will be included; otherwise, only the noun whose name exactly
matches will be included.
For example, the following match
list:
"match": ["#person"]
... would cause the when
function to be called once for every noun tagged
"person," while this match
list:
"match": ["#dog", "#cat"]
... would cause the when
function to be called for every possible pairing of
nouns labelled dog
and cat
. Without the #
sign, only the noun with
exactly the matching name will be included, so:
"match": ["Joe", "cupcake"]
... would cause the when
function to be called only once (with the noun named
Joe
and the noun named cupcake
as parameters).
Note that updating parameters or manipulating relations does not itself create narrative events. It's up to you to decide what changes to story state "mean" in terms of the events that they create.
To that end, the action
function should be a generator
function
that yield
s StoryEvent
objects. (Generator functions are used so that
actions
can potentially generate multiple events; you need to put a *
after
the word function
in the function definition to make it a generator
function.) The StoryEvent
constructor takes at least one and up to three
parameters, specifying the event's verb, the first noun, and the second noun.
For example, the following action
function would yield three different
events:
"action": function*(a, b) {
yield new seaduck.StoryEvent("rain"); // just verb
yield new seaduck.StoryEvent("sleep", a); // verb + subject
yield new seaduck.StoryEvent("see", a, b); // verb + subject + object
}
When "rendering" StoryEvent
s as sentences, the name of the verb will be
matched to a Tracery rule. When rendering, the rules nounA
and nounB
will
be set to the corresponding nouns in the StoryEvent
. For example, the
traceryDiscourse
section to render the events in the example above might look
like:
"traceryDiscourse": {
"rain": ["It's raining."],
"sleep": ["#nounA# falls asleep."],
"see": ["#nounA# sees #nounB# out of the corner of their eye."]
}
The properties of each noun are also available as rules in the Tracery grammar
with the names nounA_<propname>
and nounB_<propname>
, where <propname>
is
the name of the property.
In Sea Duck, a relation is a named boolean value that relates one noun to
another. You can check for relations between nouns in a when
function and
create (or destroy) relations between nouns in an action
function. For
example, if you wanted to create an in_love
relation between two nouns in an
action
function, you might write:
this.relate("in_love", a, b);
To check to see if two nouns are related to each other with a particular
relationship, use this.isRelated(...)
:
if (this.isRelated("in_love", a, b)) { ... }
The this.reciprocal()
method creates a two-way relation (so that a
is
in_love
with b
and b
is in_love
with a
):
this.reciprocal("in_love", a, b);
You can erase a relation using this.unrelate()
or this.unreciprocal()
:
this.unrelate("in_love", a, b); // a is no longer in_love with b
this.unreciprocal("in_love", a, b); // a & b are no longer in_love w/each other
The function this.allRelatedByTag(...)
function gives you a list of all
objects with the given tag that a noun is related to with the named relation.
For example, to find all nouns that a
is in_love
with tagged person
:
this.allRelatedByTag("in_love", a, "person") // array of related nouns
If you're pretty sure that there's only one related noun by tag, you can use
this.relatedByTag(...)
to only get the first result:
this.relatedByTag("in_love", a, "person") // one noun object
The initialize
attribute of the object passed to the Narrative
constructor
should point to a generator function, which will be called when the .step()
method is called before any actions are checked. This is a great place to put
any code to initialize relations between objects or generate introductory
story events. For example, the following initialize
function generates
story events for each hungry
noun:
"initialize": function*() {
for (let noun of this.getNounsByProperty("hungry", true)) {
yield (new seaduck.StoryEvent("isHungry", noun));
}
}
Two handy methods for looking up noun objects:
this.getNounsByProperty(prop, val)
returns an array of noun objects for each noun whose property named byprop
is equal toval
;this.noun(name)
returns the noun with the given name. (This is handy ininitialize
or advanced uses ofaction
functions where you don't have a reference to the object itself.)
Narration ends when either of the following two conditions obtain:
- No events are generated by any action
- The state of the narrative (i.e., noun properties and relations) did not change from one step to the next
In both cases, Sea Duck inserts a special StoryEvent
with the verb _end
to
indicate that the story is over. You can manually end a narration by yielding
an event with this verb from an action
function. Subsequent calls to
.step()
(and .stepAndrender()
) will return empty arrays.
A typical loop for stepping through the narration simulation might look like this:
let n = new seaduck.Narrative(...your narration specification here...);
let maxSteps = 100; // maximum number of steps to perform
for (let i = 0; i < maxSteps; i++) {
let storyEvents = n.stepAndRender();
if (storyEvents.length > 0) {
for (let ev of storyEvents) {
console.log(ev);
}
}
else {
break;
}
}
Copyright 2018 Allison Parrish
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.