Snowman Dev Update (2.2: 5 June 2022)

Snowman 2.2 Development Updates:


Last week, I wrote about some of the changes I was making to Snowman for its 2.2 release. This post continues in the same theme, documenting changes and the reasons behind them.

Lifting State (Containers)

Up until a few weeks ago, I was on rotation to teach an introductory course on React each year. One of the topics covered in the course was a concept React calls “lifting state up.” This happens when multiple components need access to values. The state has to be “lifted up” to the closest ancestor of the group of components, which then acts as a store. The ancestor handles the state internally, passing down to its child components accessor functions (called reducers) to change the state as needed.

Within the terminology of state management, this creates two things:

  • store: a centralized collection of values
  • reducers: a set of conditions for changing one or more values in the store

The community behind the state container Redux has done much to standardize this way of thinking about working with data in an application. Redux (which is often paired with React) follows this model and has defined several code patterns for working with state management using these terms.

In Redux, changes to the state happen via the use of the method dispatch() to signal a change should be made. The store itself has a collection of conditions and the dispatch() method sends which of the conditions should be met and ultimately leads to a state change. A simple (and rather contrived) example might look like the following:

function counterReducer(action) {
    switch (action) {
      case 'increment':
        store.counter = store.counter + 1;
        return;
      case 'decrement':
        store.counter = store.counter - 1;
        return;
      default:
        return;
    }
}
  
let store = {
    dispatch: (action) => {
        counterReducer(action);
    },
    counter: 0
};

// Shows 0
console.log(store.counter);
store.dispatch('increment');

// Shows 1
console.log(store.counter);
store.dispatch('decrement');

// Shows 0
console.log(store.counter);

Returning to development on Snowman, I wanted to apply a similar idea. Traditionally, many authors would define a window.story.state global object, which was condensed into a global shortcut named s for the template system. If an author wanted to store global data, they created a property on the global s. For example, in the “Lock and Key: Variable” example in the Twine Cookbook, the property s.key is tested for as part of the Front Room passage:

:: Front Room
<% if (s.key) { %>
[[Exit]]
<% } else { %>
*Locked Door*
<% } %>

I did not want to change too much away from assumptions authors might bring to using Snowman. This meant continuing the use of the s global shortcut mapped to window.story.state property. However, I also wanted a way to notify an author if a change had been made to any properties of the object. This meant getting more creative with state management internally and creating a Proxy for accessors and mutations.

Proxy and Events

In newer versions of JavaScript, the Proxy class allows for wrapping an existing object and controlling how its properties are accessed and mutated. You create an object and then define a handler for the get and set operations on it.

In Snowman 2.2, the s global is a proxy to a property of another global State. Similar to Redux, this new global acts as a central store for all operations pertaining to data within the store. It holds the history (passages visited by a reader), a property named store, which is wrapped via proxy property, and, most importantly, an event emitter named events. Now, whenever a property is changed, an event is emitted named change detailing the changed property and the new value.

In practice, the watching of mutations to the global s seems a little silly by itself. However, the use of the events property creates the ability to use s as a store, create reducers with author-defined conditions (the event itself), and then dispatch the events using user interactions using jQuery event binding. A simple example of this new usage might look like the following:

:: Start
<a role="link" id="increase">Click to increase</a>
<a role="link" id="decrease">Click to decrease</a>
<span id="counter"></span>
<script>
$(() => {
    // Setup the initial value to track 
    //  as traditionally done in Snowman.
    s.counter = 0;

    // Create two event listeners.
    // The first listens for 'increment', increasing counter.
    // The second listens for 'decrement', decreasing counter.
    State.events.on('increment', () => {s.counter = s.counter + 1;});
    State.events.on('decrement', () => {s.counter = s.counter - 1;});

    // Listen for user clicking, emit state events.
    $('#increase').on('click', () => {State.events.emit('increment');});
    $('#decrease').on('click', () => {State.events.emit('decrement')});

    // We can detect any internal State store changes via the Proxy.
    // Any time a property is changed, the 'change' event is emitted.
    State.events.on('change', (property, value) => {
        // Check for change on 'counter'
        if(property == 'counter') {
            // Update the visual counter
            $('#counter').html(`Counter: ${value}`);
        }
    });
});
</script>

The new events property in State allows an author to handle state management in a much more efficient way. They can define the values and operations they want to handle in one passage and then trigger them via the global State.events property in others using the built-in use of jQuery.

The use of the proxy around the global s also serves to maintain the previous patterns an author may have adopted from reading examples in the Twine cookbook. They can still create new properties on the global s and test for them, as the “Lock and Key: Variable” example shows, but authors can now also use the event system as well.

Using <script>

The previous example could have used the template system of using <% and %> around JavaScript code. In fact, this was the standard for most of the lifespan of Snowman. I’ve now changed it slightly. Unlike most previous versions of Snowman, the <script> element now works in passages alongside the template system. This allows an author to use their preferred method of writing JavaScript in passages, using either the templated tags or with the <script> element.

The change comes with a small caveat, however. The template system allows for outputting the values of variables (a process called interpolation) using a function named print(). The <script> element does not allows this. In the cases where an author might want to show a value more easily to a reader, the template system is the preferred way to do this. If an author is much more knowledgeable of JavaScript, they can also use jQuery, as the previous code example shows, to do the same thing.