@eit6609/storyteller

1.0.7 • Public • Published

Storyteller, a generator of interactive ebooks

Storyteller is a tool for the generation of game ebooks that, given

  • a set of XHTML templates representing the locations of the game
  • a class containing the data and implementing the actions of the game

generates an ePUB containing all the possible situations of the game. The player then plays the game by following the links.

It was inspired by the Medusa compiler of Enrico Colombini, which was used to create his Locusta Temporis interactive ebook.

Install it by running:

npm i @eit6609/storyteller

And use it like this:

const Storyteller = require('@eit6609/storyteller');

const options = ...
const storyteller = new Storyteller(options)
const initialTemplateName = ...
const initialState = ...
await storyteller.generate(initialTemplateName, initialState);

Why interactive ebooks?

You can find the details behind the idea in Colombini's Interactive Fiction & ebooks: Designing puzzles for digital books, and an introduction in the slides about Medusa from Lua workshop 2014.

With an interactive ebook you don't need an engine to play the game, because the game has been pre-played by Storyteller. All you need is an ebook reader.

Since there are ebook readers for every device you get the maximum portability.

Of course there are some limitations in the design of the game:

  • the actions can be performed only by following links
  • you cannot use random generators or unlimited counters
  • you must avoid a combinatorial explosion

Interactive Fiction & ebooks deals thoroughly with these issues, however I give you some tips & tricks at the end of this document.

There are also some limits for the player:

  • the player must only follow the links and cannot navigate the book by freely turning the pages

This limitations notwithstanding, you can create amazing games.

If you try Locusta Temporis you won't believe your eyes. And it was created with Medusa, which is functionally equivalent to Storyteller.

You can find some example ePUBs in the examples.

How does it work?

The state class

The logic of the game is implemented with a class that contains the state of the game and the methods to access and manipulate the state.

Let's see an example, that is included in the complete examples:

class Hanoi {

    static configure (config) {
        Hanoi.config = config;
    }

    constructor () {
        this.reset();
    }

    reset () {
        this.pegs = [
            new Peg(Hanoi.config.nDiscs),
            new Peg(),
            new Peg()
        ];
    }

    getConfig () {
        return Hanoi.config;
    }

    isFinished () {
        return this.pegs[2].discs.length === Hanoi.config.nDiscs;
    }

    canMove (from, to) {
        from = this.pegs[from];
        to = this.pegs[to];
        return from.discs.length > 0 && (to.discs.length === 0 || to.getTopDisc() > from.getTopDisc());
    }

    move (from, to) {
        this.pegs[to].discs.push(this.pegs[from].discs.pop());
    }

}

In this state class, the property pegs and the methods getConfig(), canMove() and isFinished() let you access the state to display information and make decisions in the template.

The methods reset() and move() are modifiers, and will be used with the goto() function, explained later.

The templates

The pages of the ebook are generated by a set of XHTML templates, one for every location of the game.

After many experiments with the most popular templating engines for Node.js, I have chosen Pug because it gives you enough freedom to call JavaScript code inside the template. This is vital, but many engines (the very popular Handlebars among others) make the call of methods on a class instance a nightmare.

Of course EJS gives you complete freedom, but I prefer higher level engines like Pug.

I have added experimental support for markdown templating by means of my Markdown Templates engine.

Let's see an example of template:

doctype strict
html(xmlns='http://www.w3.org/1999/xhtml', xml:lang='en')
    head
        link(href="style-epub.css", rel="stylesheet", type="text/css")
        title The Tower of Hanoi
    body
        h3 The Tower of Hanoi
        hr
        p!= debug()
        p.first The situation is:
        ul
            li Peg 1: #{state.pegs[0]}
            li Peg 2: #{state.pegs[1]}
            li Peg 3: #{state.pegs[2]}
        if state.isFinished()
            h1 YOU’VE WON!
            br
            p.first Want to #[a(href=goto((state) => state.reset())) play again]?
        else
            p.first Possible moves are:
            ul
                if state.canMove(0, 1)
                    li #[a(href=goto((state) => state.move(0, 1))) 1 ==> 2]
                if state.canMove(0, 2)
                    li #[a(href=goto((state) => state.move(0, 2))) 1 ==> 3]
                if state.canMove(1, 2)
                    li #[a(href=goto((state) => state.move(1, 2))) 2 ==> 3]
                if state.canMove(2, 1)
                    li #[a(href=goto((state) => state.move(2, 1))) 3 ==> 2]
                if state.canMove(2, 0)
                    li #[a(href=goto((state) => state.move(2, 0))) 3 ==> 1]
                if state.canMove(1, 0)
                    li #[a(href=goto((state) => state.move(1, 0))) 2 ==> 1]
            p.first Of course you can also #[a(href=goto((state) => state.reset())) restart the game].
            p.first Or you may want to review the #[a(href=goto('start', (state) => state.reset())) instructions].
        hr

As you can see the accessors are used to display data:

            li Peg 1: #{state.pegs[0]}

and to make decisions:

                if state.canMove(0, 2)

The modifiers are used in the links, with the goto() function, to perform actions:

                    li #[a(href=goto((state) => state.move(2, 0))) 3 ==> 1]

            p.first Or you may want to review the #[a(href=goto('start', (state) => state.reset())) instructions].

The generator

Following the links

Starting from a template and a state, the generator recursively follows all the links, generating all the possible pages.

This is done with the goto() function, provided by the context of the template.

You can pass goto() a template name and/or a function (called action) that modifies the state. The action usually just calls a modifier method on the state.

As the action is optional, it is possible to change only the template (that is, the location) and keep the state as is.

As the template name is optional, it is possible to change only the state without changing the location.

Generating the pages

The combination of a template and a state generates a page:

template + state = page

To keep track of the generated pages, every state instance is reduced to a hash, which is a human readable string that uniquely identifies the state instance. It's a kind of compact JSON, that can deal also with the situations that are not handled by JSON, like circular references and the new data structures of ES6, Maps and Sets.

A template name and a state hash uniquely identify a page:

template name + hash(state) = page key

For debug and learning purposes, you can use the debug() function inside a template to show the page key. Because the hash is human readable, it is a meaningful representation of the state. It can be very useful to understand why the template engine has generated the page as it is.

API reference

The generator class

constructor(options?: object)

These are the supported options:

  • templatesDir, string, required: the path of the directory containing the templates
  • outputDir, string, required: the path of the directory to use for the generated XHTML files. This directory is used as input for the ePUB creator, so you can put in this directory any extra file (images, stylesheets) that you need in the ePUB.
  • metadata, object, required, the options for the ePUB creator, with these properties:
    • title, string, optional, default untitled: the title of the ePUB
    • author, string, optional, default no author: the author of the ePUB
    • language, string, optional, default en: the language of the ePUB
    • cover, string, optional, default no cover: a path relative to outputDir of an image that will become the cover of the ePUB
    • filename, string, required: the path of the generated ePUB
  • markdown, boolean, optional, default false: if false the template engine is Pug, otherwise it is Markdown Templates
  • debug, boolean, optional, default false: if true the debug() function called in the templates will return the page key, otherwise the empty string.
  • contentBefore, array, optional, default []. Extra, static pages to insert into the generated ePUB before the generated pages. The items of the array are objects with these properties:
    • fileName, string, required. The path of the file relative to outputDir.
    • tocLabel, string, optional. The label to use in the TOC. Leave it out if you don't want the page to be added to the TOC.
  • contentAfter, array, optional, default []. Extra, static pages to insert into the generated ePUB after the generated pages. The items of the array are the same as contentBefore.

generate(initialTemplateName: string, initialState: object): promise

It generates the ePUB given:

  • initialTemplateName: the path of the initial template file, without the extension, relative to the templatesDir
  • initialState: the initial state instance

It returns a promise with no value.

The template context

These are the properties of the "locals" of the template engine.

state

It is the current state instance, that can be used to call its accessors to display data and make decisions.

debug(): string

If the debug option is true, this function returns the key of the current page, that is the template name and the state hash, wrapped in a <code> element. Otherwise it returns the empty string. You can place its result wherever you like in the templates to display the info, and then simply disable it by setting the debug option to false without modifying the templates.

goto(templateName?: string, action?: function): string

The purpose of this function is to ask the generator the URL of the page identified by the template name and the hash of the state you get by applying the action to a copy of the current state. The function returns the URL of the requested page, that can be used in the href of an a to create a link.

The parameters are:

  • templateName: the path of a template file, without the extension, relative to the templatesDir. It defaults to the current template's name.
  • action: a function that receives a state as its only parameter and modifies it. It should return a falsy value unless it wants to replace the received state with a new one: in this case it should return the new state. This is handy for complex games because it enables you to move through independent stages of the game. More about this later, in the Tips & Tricks section.

With this function you actually trigger the generation of the pages, because, if the requested page does not exist, the generator creates an empty page and enqueues it for the build, that is the execution of the template with the state of the page. That execution could find and execute some goto() that could trigger the creation of new pages, and so on.

Examples

You can generate the example ebooks by moving to the code folder and running the main.js script:

node main.js

You can change the debug options to true to see who (template + state) generated the pages.

You can inspect the generated XHTML files, they are in the out subdirectories.

But if you are lazy you can just download the generated ePUBs.

Goat, Cabbage & Wolf

A classic puzzle!

There are two scripts:

  • main-xhtml.js, that uses the Pug (.pug) templates
  • main-markdown.js, that uses the experimental Markdown Templates (.md) templates

The generated ePUBs should be the same.

You can download the generated ePUB here.

Desert Traversal

A puzzle about managing scarce resources.

You can download the generated ePUB here.

The Tower of Hanoi

Not much of an adventure game, but I have loved this game when I learned about recursion and I think that it is a very neat example.

You can download the generated ePUB here.

Tips & Tricks

Keep the state "small"

The golden rule to avoid combinatorial explosion is:

You must keep the state as empty as possible

Let's see some examples.

Be smart

You can implement a combination lock by using a simple boolean:

  • true: "all digits right so far"
  • false: "at least one wrong digit"

This way you won't need to handle the exponential number of cases.

For example, you can use this state:

class Safe {
    constructor (combination) {
        this.combination = combination;
        this.ok = true;
        this.index = 0;
    }

    choose (digit) {
        this.ok = String(digit) === this.combination.charAt(this.index);
        this.index++;
    }

    isRight () {
        return this.index === this.combination.length && this.ok;
    }

    isWrong () {
        return this.index === this.combination.length && !this.ok;
    }
}

with this template:

doctype strict
html(xmlns='http://www.w3.org/1999/xhtml', xml:lang='en')
    head
        link(href="style-epub.css", rel="stylesheet", type="text/css")
        title Open the Safe
    body
        h3 Open the Safe
        hr
        if state.isRight()
            h1 YOU’VE WON!
        else if state.isWrong()
            h1 YOU’VE LOST!
        else
            p.first Choose digit ##{state.index + 1}:
            ul
                - for (let i = 0; i < state.combination.length; i++)
                    li #[a(href=goto((s) => s.choose(i))) #{i}]
        hr

The generated pages will be only n (the correct combination) + n (all the possible wrong combinations), where n is the length of the combination.

Why? Because what determines the number of pages are the possible values of the properties of the state, and they are 2n:

  • 2 for ok
  • n for index

For the sake of precision, there are only 2n - 1 pages, because the state {index=0,ok=false} is not used.

Consume objects

Almost every adventure game has a basket where the player can put the objects found around.

However, keeping objects in the basket is very expensive, because you'll have 2 ^ n possible states if you need to keep track of n objects.

You should prefer objects that get consumed, and drop them as soon as you can.

Partition the game

You should partition the game in several independent stages, in order to reset the state when you finish one stage and enter another.

You should implement the different stages with different state classes, and then exploit the feature of the generator that uses the new state returned by an action to make the transition from one stage to another.

Package Sidebar

Install

npm i @eit6609/storyteller

Weekly Downloads

0

Version

1.0.7

License

ISC

Unpacked Size

32 kB

Total Files

10

Last publish

Collaborators

  • eit6609