1.1.2 • Published 5 years ago

windrift v1.1.2

Weekly downloads
4
License
MIT
Repository
github
Last release
5 years ago

Windrift

A framework for writing mutable narratives.

Windrift was used to write two games by Liza Daly: Stone Harbor, an entry in the 2016 Interactive Fiction Competition 4th place, and Harmonia, in 2017 3rd place. It was also used by Enrique Henestroza Anguiano to write The Imposter (2018).

Windrift relies heavily on the React/Redux JavaScript frameworks. You don't need to know anything about React/Redux to understand Windrift's design principles, but you probably need JavaScript experience to be able to effectively write a Windrift story.

Project structure

This repository contains the Windrift core library. You may be interested in quickly browsing some of the example stories:

Starter project

If you are interested in writing a narrative with Windrift, use this repository.

The starter project contains all of the machinery necessary to create a Windrift web application, including a development web server and deployment instructions.

Works

Windrift is designed to produce narratives that are informed by user selection and choice, similar to systems like Twine. A Windrift story may appear, in its final form, almost indistinguishable from a work authored in Twine.

As an authoring environment, though, it has different goals:

  • Twine is optimized for writing branching stories. Windrift is better suited for mutable stories.
  • An explicit design goal of Windrift was that a story could be readable from start-to-finish as if no user interaction had occurred. You don't have to author your story this way, but Windrift makes that possible.
  • Twine is meant to be approachable to non-programmers. Windrift is a JavaScript framework on top of other JavaScript frameworks. I hope you like JavaScript.
  • Windrift stories are easy to unit test, snapshot, and make use of the excellent set of developer tools available for React/Redux out of the box. Everything about Windrift's design is meant to be consistent with current best practices in web development.
  • The Windrift UI has been thoroughly tested in all major desktop and mobile browsers and is fully accessible to screenreaders.

Design

Redux principles

Windrift was made to leverage the React/Redux frameworks, which I think have interesting properties that can apply to narrative-driven games.

Redux manages an application's state through a series of actions, or assertions about events that occurred. Instead of calling functions like, "Change the score variable from X to X+1," a Redux application instead creates a message: "The player's score has increased."

These messages are funneled through functions called reducers, which take the message, any data that goes along with it, and the previous snapshot of the world, and return a new value in response to the message. In the score example, a reducer would look at the previous version of the score, add one to it, and return the new value.

Finally, event listeners are assigned to any UI components that might need to know about these changes—for example, in a application where a score was visible in the status bar, a Score component could be written that updates itself every time that value changes.

The above is all vanilla React/Redux and is used to write thousands of web applications. I was interested in how this message/event model could apply to narrative games, by reducing them to a series of messages about things that happened, and components that return updated text in response to those changes.

The Inventory

The core Windrift library provides one global bucket for these messages, called the inventory, after the traditional interactive fiction word for "Stuff the player is carrying." (In practice, the inventory is more likely to be a record of the series of choices a user made, rather than a list of objects in their possession.)

The inventory can be thought of as a catch-all for messages of the type, "The user picked choice B out of A, B, C." Without the inventory, a Windrift author would have to write an action, a reducer and a listener for every choice presented in the game. Because most choices in a narrative game are ephemeral, it makes sense to provide a single action/reducer/store for these kinds of interactions. More complex transformations are possible by writing custom actions and reducers.

A Windrift story that uses just the core components could almost certainly be implemented just as well in Twine or other systems. Using Redux principles does enable more complex stories to be written that would be difficult or error-prone if implemented in more traditional hypertext systems.

The timeline

Windrift has an implicit sense of time. It provides a global counter that increments on each user choice or interaction. At each user interaction, a few events happen in the background:

  • The users' choice is persisted to the Redux store (as part of the inventory)
  • The current global counter value is pushed to the user's browser history.
  • The entire world model (the Redux store) is saved off in an array called past. See the section below on the Redux store for more details on the past/future.

Authors can decide whether a particular user action should trigger one of three timeline-based actions:

  • Advance to the next section
  • Advance to the next chapter
  • Do nothing

Story structure

A Windrift story is composed of a series of chapters contained in individual files. Each chapter is made up of sections that are revealed in response to a user interaction.

The section/chapter division is primarily for the author's benefit. You could write an entire novel's worth of text in a single chapter if you like. You don't have to call them "chapters" or express the division to the reader if you don't want.

Chapters and sections are currently modeled as arrays: it's easy to move forward or back with relative offsets, but not particularly easy to index into arbitrarily. An obvious area for future development would be a structural model that more easily permitted jumping to named chapters/sections.

New in 1.0.5: if you choose the by-chapter pagination option (see windrift-starter), Windrift will not call render() on any but the current chapter. This change makes the engine more performant for very large stories.

Lists

The primary mode of interaction with a Windrift story is via Lists.

A List is an array of choices—called expansions—that are presented sequentially. Each item in a List is rendered as a clickable link. Lists are identified by a unique label—called a tag—that the author assigns. This tag becomes the reference to the choice in the inventory.

Flat List

A flat list is an array of one-item arrays, each containing a string. As the user clicks on each list item, the next will be revealed, replacing the previous. You might use this device to have a character appear to stammer or talk over themselves:

Given this List: [['awkward'], ['interesting'], ['wonderful']]

...the rendering will display this text, in sequence, with each choice replacing the previous as the user clicks on the hyperlinks:

  1. "Uh, yeah, it's really awkward to see you"
  2. "Uh, yeah, it's really interesting to see you"
  3. "Uh, yeah, it's really wonderful to see you"

The last item in any List is not a hyperlink, and that text remains after the list sequence is completed.

Choice List

It's more common for a List to be composed of a mix of strings and arrays. Each expansion is rendered sequentially, but when an expansion is itself an array, Windrift will present all items at once, giving the player the ability to pick a choice:

Given a List tagged as dinner_choice:

['the usual', ['mutton pudding', 'salad cake', 'pine nut loaf']]

The rendering will produce, in sequence:

  1. "What do you want for dinner? We got the usual."
  2. "What do you want for dinner? We got mutton pudding, salad cake, and pine nut loaf."

If the player choose the second item:

  1. "What do you want for dinner? We got salad cake."

List options

Windrift allows the author to override aspects of how the List is rendered. By default, list items are separated by a comma-space (,) and joined together with a conjunction (and). This behavior follows usual English rules (with the Oxford comma!)

<List expansions={[['mutton pudding', 'salad cake']]} />

mutton pudding and salad cake

<List expansions={[['mutton pudding', 'salad cake', 'pine nut loaf']]} />

mutton pudding, pine nut loaf, and salad cake

<List expansions={[['mutton pudding', 'salad cake', 'pine nut loaf']]} conjunction="or" />

mutton pudding, pine nut loaf, or salad cake

<List expansions={[['mutton pudding', 'salad cake', 'pine nut loaf']]} separator="|" conjunction="" />

mutton pudding|pine nut loaf|salad cake

List completion

When the player has selected the final item in a list, two events are triggered:

  1. The choice is added to the player's inventory
  2. The next section or chapter is revealed

You can configure what happens when the list is exhausted by modifying the nextUnit property, which can be:

  1. section: Show the next section (default)
  2. chapter: Show the next chapter
  3. none: No further action

Inventory

When a final choice is made in a list, it goes into the reader's inventory, the part of the state containing all the selections they've made. The inventory is made up of buckets indexed by List tag. The value in each bucket is accessible as inventory.tag at any point in the story.

Maps

Maps are the primary way to implement conditionals: "If the player chose mutton pudding, show them this text about how it was dry and tasteless."

Maps take a string from, to be evaluated, and an Object to, which is the map (or dictionary) of expected values and the text to return in response.

from: {inventory.dinner_choice}
to: {
  'mutton pudding': 'It was dry and tasteless',
  'salad cake': 'Just like mom used to make!',
  'pine nut loaf': 'You call this a loaf?'
}

Any string can be passed to a Map, but typically you'll pass in a specific inventory value, as above.

Maps can return a variety of types:

  • Plain text (this is most common)
  • HTML, of arbitrary complexity
  • A function, which executes arbitrary code

The combination of Lists and Maps can unlock a surprising amount of interactivity:

  • A List can take, as its expansions argument, a function rather than a handwritten array.
  • A Map can return a List, or other Maps.
  • Both Lists and Maps can accept various callbacks to execute arbitrary code during their lifecycles.

See the Advanced demo for some ways to use Lists and Maps to create rich experiences.

Other components

AllButSelection takes an array and a string, and returns all items but that string. It's used to produce renderings like this:

  1. "What do you want for dinner? We got mutton pudding, salad cake, or pine nut loaf."
  2. "What do you want for dinner? We got pine nut loaf. Actually, good choice, since it turns out we're all out of mutton pudding and salad cake."

ManyMap is similar to Map but takes an array from rather than a single value. It will return all matching values in the to object for any item in the from array. This is useful when you want to display text based on multiple choices the user has made across different Lists.

FromInventory returns an item from the inventory with some transformations applied to it. By default, it returns the last word of a multiword string, to enable you to write:

You select the _purple spangled basset hound_ as your spirit animal.
The _hound_ squirms in your grasp.

FromInventory takes a string (usually an inventory item) and returns the string offset by the value of the offset parameter (usually -1, for the last word, but could be -2 in this example, in which case it would return "basset hound".) It takes an optional onLoad callback which will pass the inventory string through an arbitrary transformation before returning it—for example, you might use this to upcase the word if it would start a new sentence.

Writing in Windrift

This section assumes some familiarity with React/Redux.

The store

Windrift uses the Redux global store to manage the game state and tracks four values:

  • Bookmarks This data structure tracks where the user is in the story as the current chapter and current section. It's initialized as chapter 0, section 0 and increments each time the showNextSection and showNextChapter action creators are called.

  • Inventory An Object of key/value pairs, where the key is a given List tag, and the value is the most-recent selection from that List.

  • Expansions An Object of key/value pairs, where the key is a given List tag and the value is an Object containing the array of possible expansions for this List, and an index value into that array as currentExpansion, reflecting how far the user has progressed in clicking through the expansions.

  • Counter A simple integer value that increments at each step through the story. This is used exclusively to manage persisting the game state to the web browser so that resume and back/forward through browser history work properly.

Story lifecycle

Windrift initializes all the chapters that are available in the story by collecting all files in chapters/*.js. Files can be named how you like, as long as they can be evaluated in alphabetical order (e.g. 1.js or chapter1.js—if you think you'll have more 9 chapters, be sure to zero-pad the filenames.)

Each chapter is a React component with a lightweight signature:

  1. A Chapter should be a stateless functional component
  2. A Chapter is required to return a <RenderSection>

The <RenderSection> component must pass two props:

  1. currentSection, which just passes along the Chapter's own currentSection prop
  2. sections, returning an array of React nodes, typically HTML <section> elements

Example: A minimal Chapter

const React = require('react')

export default ({currentSection, inventory}) => {
  var sections = [
    <section>
      <h2>West of House</h2>
      <p>You are standing just off to the side of the famously cantankerous fictional doctor.</p>
    </section>
  ]
  return <RenderSection currentSection={currentSection} sections={sections} />
}

Plenty more examples and next steps for getting started with Windrift are available in the starter project.

The Redux store and history

Under the hood, Windrift uses redux-undo to record a ledger of every interaction at every point in time. Each time the user advances through the story, the entire state is copied into the past, an array of snapshots starting from time 0.

When a reader plays for 15 turns and hits the browser back button, an event is fired that pulls the history at time step -1. The entire game state is reconstituted at time stamp 14, and the old timestep 15 is now in the future. If the reader then hits the browser forward button, the entire state snapshot is pulled from the future and reconstituted as the present.

In turn, the whole past/present/future store is saved via redux-persist to the browser's localStorage object. If the reader refreshes the browser or returns to the story after a period of time, the entire store is reconstituted from localStorage.

The Windrift base components only know about the present; they do not have any direct access to the past or the future versions of the store. It is possible to imagine a story that made use of this additional data to implement more sophisticated state changes, but that's for a future enhancement.

Actions

Windrift exports its primary redux actions if you want to fire them yourself in custom events--a common case would be a custom component that triggered a new chapter that was not from a List.

Full game source code

You're welcome to browse the Stone Harbor source as a Windrift reference, but Windrift has moved on since the story was written and the two are not entirely compatible. Of interest might be the tarot deck implementation, which uses a custom component with custom actions and reducers.

The source for Harmonia is more recent. It may be useful for those wanting to implement complex custom components, such as Harmonia's marginal annotations, which are an extension of the List component.

1.1.2

5 years ago

1.1.1

6 years ago

1.1.0

6 years ago

1.0.12

6 years ago

1.0.11

6 years ago

1.0.10

6 years ago

1.0.9

7 years ago

1.0.8

7 years ago

1.0.7

7 years ago

1.0.6

7 years ago

1.0.5

7 years ago

1.0.4

7 years ago

1.0.3

7 years ago

1.0.2

7 years ago

1.0.1

8 years ago

1.0.0

8 years ago