Home Reference Source

Jumbo Grove: Interactive Fiction in JavaScript

Jumbo Grove is a tool for writing hypertext interactive fiction. Hypertext means text with links. Interactive fiction is a medium for storytelling that crosses over into video games.

Jumbo Grove requires beginner-level knowledge of JavaScript, but more experienced developers will find plenty of useful stuff!

Jumbo Grove is free and open source under the MIT License.

Features

  • Desktop, mobile, and gamepad support
  • Fully documented API with tutorials
  • Packaged as a single JS file or as an NPM package
  • Many games require no "coding"
  • Friendly template language; never write raw HTML

Quick Start

You can put up a fresh site with Jumbo Grove in less than 30 seconds by remixing this project on Glitch. You can edit the game in a browser and it will live-update a real site!

If you're using a bundler like Webpack, you can simply npm install jumbogrove and import {jumbogrove} from 'jumbogrove'.

If that's too much trouble for you, please grab jumbogrove.js and jumbogrove.css.

Docs

If you're new, read the tutorial.

If you're not, check out the API.

Demo

Jumbo Grove was used to make this unfinished Ludum Dare game.

If you are on the Jumbo Grove documentation site (as opposed to reading the Readme on GitHub) then you will see a quick demo here:

There are many more live demos in the guides!

Demo source code

Here's the source code for the above demo:

jumbogrove.jumbogrove('#firstdemo', {
  id: 'demo',
  situations: [
    {
      id: 'start',
      content: `
      ### The Jumbo Grove Experience
      You are looking at a [web page](>write:web_page).
      `,
      snippets: {
        web_page: `
        It is the documentation for Jumbo Grove. What do you want to do?

        * [Make a game](@make-a-game)
        * [Admire the beautiful CSS](@admire-css)
        `
      }
    },
    {
      id: 'make-a-game',
      content: `
      You type until your fingers are [sore](>replaceself:sore_fingers).
      You make something incredible.
      `,
      snippets: {
        sore_fingers: "sore (like, really sore)"
      },
      choices: ['admire-css'],
    },
    {
      id: 'admire-css',
      optionText: 'Admire the CSS',
      content: `
      Your eyes trace the loving lines of the sidebar and the curves of the fonts.

      ### The End
      `
    },
  ]
});

"Jumbo Grove"?

Interactive Fiction = IF
I + 1, F + 1 = J, G
J[umbo] G[rove]

Beginner

Twine.

Intermediate

The biggest inspiration for this project is Undum and the Raconteur layer on top of it. This project was prompted by a desire to make "Undum done right," with 2017-era best practices and great documentation.

Salet is another similar project with slightly different goals, a different API, and different features. If Jumbo Grove isn't quite right for you, Salet is probably what you're looking for.

Expert

Windrift is very linear-story-oriented, and requires a high level of JavaScript expertise, but has been used to make some really great stuff and is well-designed.

Jumbo Grove: Interactive Fiction in JavaScript

Jumbo Grove is a tool for writing hypertext interactive fiction. Hypertext means text with links. Interactive fiction is a medium for storytelling that crosses over into video games.

Jumbo Grove requires beginner-level knowledge of JavaScript, but more experienced developers will find plenty of useful stuff!

Jumbo Grove is free and open source under the MIT License.

Features

  • Desktop, mobile, and gamepad support
  • Fully documented API with tutorials
  • Packaged as a single JS file or as an NPM package
  • Many games require no "coding"
  • Friendly template language; never write raw HTML

Quick Start

You can put up a fresh site with Jumbo Grove in less than 30 seconds by remixing this project on Glitch. You can edit the game in a browser and it will live-update a real site!

If you're using a bundler like Webpack, you can simply npm install jumbogrove and import {jumbogrove} from 'jumbogrove'.

If that's too much trouble for you, please grab jumbogrove.js and jumbogrove.css.

Docs

If you're new, read the tutorial.

If you're not, check out the API.

Demo

Jumbo Grove was used to make this unfinished Ludum Dare game.

If you are on the Jumbo Grove documentation site (as opposed to reading the Readme on GitHub) then you will see a quick demo here:

There are many more live demos in the guides!

Demo source code

Here's the source code for the above demo:

jumbogrove.jumbogrove('#firstdemo', {
  id: 'demo',
  situations: [
    {
      id: 'start',
      content: `
      ### The Jumbo Grove Experience
      You are looking at a [web page](>write:web_page).
      `,
      snippets: {
        web_page: `
        It is the documentation for Jumbo Grove. What do you want to do?

        * [Make a game](@make-a-game)
        * [Admire the beautiful CSS](@admire-css)
        `
      }
    },
    {
      id: 'make-a-game',
      content: `
      You type until your fingers are [sore](>replaceself:sore_fingers).
      You make something incredible.
      `,
      snippets: {
        sore_fingers: "sore (like, really sore)"
      },
      choices: ['admire-css'],
    },
    {
      id: 'admire-css',
      optionText: 'Admire the CSS',
      content: `
      Your eyes trace the loving lines of the sidebar and the curves of the fonts.

      ### The End
      `
    },
  ]
});

"Jumbo Grove"?

Interactive Fiction = IF
I + 1, F + 1 = J, G
J[umbo] G[rove]

Related Projects

Beginner

Twine.

Intermediate

The biggest inspiration for this project is Undum and the Raconteur layer on top of it. This project was prompted by a desire to make "Undum done right," with 2017-era best practices and great documentation.

Salet is another similar project with slightly different goals, a different API, and different features. If Jumbo Grove isn't quite right for you, Salet is probably what you're looking for.

Expert

Windrift is very linear-story-oriented, and requires a high level of JavaScript expertise, but has been used to make some really great stuff and is well-designed.

Where to find the API reference

Here is the API reference. It has details about situations, models, qualities, and more.

The Basics

In this guide:

  • Situations: what are they?
  • Tags
  • Writing text
  • Presenting choices

The Maze

For our first game, we will create a simple maze for the player to solve. You won't need to write any JavaScript functions for this section.

To help us keep things straight, here's a map of the maze we'll create:

in +-+-+-+-+
 --> |   | |
   + + + + +
   |   |   |
   + +-+ +-+
   |   |   |
   +-+ + + +
   |   | | --> out
   +-+-+-+-+

All content is written in HTML and Markdown. You should read up on Markdown before continuing. Try it live in your browser here.

Introducing situations

A Jumbo Grove game is made of situations. You can also think of them as "rooms," but we use the word "situation" because all they are is some hypertext shown to the player, and a decision to be made (which link to click).

The bare minimum for a Jumbo Grove game is an id that uniquely identifies your game, and at least one situation:

jumbogrove.jumbogrove('#game', {
  id: 'maze-game-2000',
  situations: [
    {
      id: 'start',  // the situation with id=start is how the game begins
      content: `
        You are standing at the entrance of a maze.
      ` // <- backticks let you write multi-line strings.
        //    You can indent multi-line strings as much as you want.
        //    Jumbo Grove will remove the extra spaces.
    }
  ]
});

If you're not an experienced JavaScript programmer, you might be doing a lot of copying and pasting. That's OK! You might need to ask a lot of questions. That's also OK!

In fact, here is an officially sanctioned copy-paste template for situations:

{
  id: 'REPLACE THIS',
  tags: [],  // like ['maze-room', 'castle']
  autosave: false,
  displayOrder: 0,  // determines placement in choices list (higher = farther down)
  optionText: 'Go to the copy-pasted situation',
  content: `
    # Title, if you want

    Some more text

    [Append some text](>write:text_to_append)

    [Replace this with some text](>replaceself:text_to_replace)

    [Go to another situation](@some-situation-id)

    [Perform an action](>some_action)
  `,
  snippets: {
    text_to_append: "Here is some more text.",
    text_to_replace: "I'm a little teapot"
  },
  choices: ['#maze-room', 'some-situation-id'],
  actions: {
    some_action: function(model, ui, action) {
      console.log("You did a", action);
    }
  }
}

It doesn't include absolutely everything, but it does have the most common fields.

Laying out the maze

Let's assign letters to each square in the maze:

   +-+-+-+-+
->  A|B C|D|
   + + + + +
   |E F|G H|
   + +-+ +-+
   |I J|K L|
   +-+ + + +
   |M N|O|P ->
   +-+-+-+-+

Looking at the upper left, you can see that the entrance of the maze leads to situation A.

There are two ways to write that out. Here's Option 1:

jumbogrove.jumbogrove('#game', {
  id: 'maze-game',
  situations: [
    {
      id: 'start',  // the situation with id=start is how the game begins
      content: `
        You are standing at the entrance of a maze.

        [Enter the maze](@A)
      `
    },
    {id: 'A', content: "You are standing in cell A.", optionText: "Enter the maze"}
  ]
});

When you write a Markdown link whose target starts with @, it links to the situation with that ID instead of behaving like a normal HTML link. [Enter the maze](@A) creates a link that moves the player to situation 'A'. Whenever you show the user any HTML, Jumbo Grove scans it for specially formatted links like this.

Here's Option 2:

jumbogrove.jumbogrove('#game', {
  id: 'maze-game',
  situations: [
    {
      id: 'start',  // the situation with id=start is how the game begins
      content: `
        You are standing at the entrance of a maze.
      `,
      choices: ['A']
    },
    {id: 'A', content: "You are standing in cell A.", optionText: "Enter the maze"}
  ]
});

Instead of writing out the link in our content string, we provide a list of IDs that are OK to go to next, and the situation itself says what the choice text should say.

Here's the maze game so far, running right in this page:

Here's the whole maze wired up the same way. The solution is AEFBCGKLP.

jumbogrove.jumbogrove('#game', {
  id: 'maze-game',
  situations: [
    {
      id: 'start',  // the situation with id=start is how the game begins
      content: `
        You are standing at the entrance of a maze.
      `,
      choices: ['A']
    },
    { id: 'A', optionText: "Enter the maze",
      content: "You are standing in cell A.",
      choices: ['E'] },
    { id: 'B', optionText: "Go to B", content: "You are standing in cell B.",
      choices: ['F', 'C']},
    { id: 'C', optionText: "Go to C", content: "You are standing in cell C.",
      choices: ['B', 'G']},
    { id: 'D', optionText: "Go to D", content: "You are standing in cell D.",
      choices: ['H']},
    { id: 'E', optionText: "Go to E", content: "You are standing in cell E.",
      choices: ['A', 'I', 'F']},
    { id: 'F', optionText: "Go to F", content: "You are standing in cell F.",
      choices: ['E', 'B']},
    { id: 'G', optionText: "Go to G", content: "You are standing in cell G.",
      choices: ['C', 'K', 'H']},
    { id: 'H', optionText: "Go to H", content: "You are standing in cell H.",
      choices: ['G', 'D']},
    { id: 'I', optionText: "Go to I", content: "You are standing in cell I.",
      choices: ['E', 'J']},
    { id: 'J', optionText: "Go to J", content: "You are standing in cell J.",
      choices: ['I', 'N']},
    { id: 'K', optionText: "Go to K", content: "You are standing in cell K.",
      choices: ['G', 'O', 'L']},
    { id: 'L', optionText: "Go to L", content: "You are standing in cell L.",
      choices: ['K', 'P']},
    { id: 'M', optionText: "Go to M", content: "You are standing in cell M.",
      choices: ['N']},
    { id: 'N', optionText: "Go to N", content: "You are standing in cell N.",
      choices: ['M', 'J']},
    { id: 'O', optionText: "Go to O", content: "You are standing in cell O.",
      choices: ['K']},
    { id: 'P', optionText: "Go to P", content: "You are standing in cell P.",
      choices: ['L', 'win']},
    {
      id: 'win',
      optionText: 'Exit the maze',
      content: `
        You exit the maze victorious!
      `
    }
  ]
});

Introducing Tags

When you specify some choices for your situation, you can include a hashtag to refer to a group of situations.

In this case, the living-room situation would provide choices for both the bathroom and bedroom situations:

jumbogrove.jumbogrove('#game', {
  id: 'house-game',
  initialSituation: 'living-room',  // use this instead of 'start' if you want
  situations: [
    {
      id: 'living-room',
      optionText: 'Go to the living room',
      content: "The living room has a couch and a TV.",
      choices: ['#from-living-room']
    },
    {
      id: 'bathroom',
      tags: ['from-living-room'],
      optionText: "Enter the bathroom",
      displayOrder: 2,  // show up AFTER the bedroom in the choices list
      content: "The bathroom contains a sink and a toilet.",
      choices: ['living-room']
    },
    {
      id: 'bedroom',
      tags: ['from-living-room'],
      displayOrder: 1,  // show up BEFORE the bathroom in the choices list
      optionText: "Enter the bedroom",
      content: "The bedroom is filled by a queen sized bed and a dresser.",
      choices: ['living-room']
    },
  ]
})

Maze game teleporter pads

We can use this feature to add teleporter pads to a few cells of our maze. Any teleporter room can send the user to any other teleporter room. If we have lots of teleporters, we don't want to have to individually connect them all!

The marked squares will contain teleporters:

in +-+-+-+-+
->   |   |D|
   + + + + +
   |   |   |
   + +-+ +-+
   |   |  L|
   +-+ + + +
   |M  | |  -> out
   +-+-+-+-+
jumbogrove.jumbogrove('#game', {
  id: 'maze-game',
  situations: [
    {
      id: 'start',  // the situation with id=start is how the game begins
      content: `
        You are standing at the entrance of a maze.
      `,
      choices: ['A']
    },
    { id: 'A', optionText: "Enter the maze",
      content: "You are standing in cell A.",
      choices: ['E'] },
    { id: 'B', optionText: "Go to B", content: "You are standing in cell B.",
      choices: ['F', 'C']},
    { id: 'C', optionText: "Go to C", content: "You are standing in cell C.",
      choices: ['B', 'G']},
    {
      id: 'D',
      tags: ['teleporter'],
      optionText: "Go to D",
      content: "You are standing in cell D. There is a teleporter here.",
      choices: ['H', '#teleporter']
    },
    { id: 'E', optionText: "Go to E", content: "You are standing in cell E.",
      choices: ['A', 'I', 'F']},
    { id: 'F', optionText: "Go to F", content: "You are standing in cell F.",
      choices: ['E', 'B']},
    { id: 'G', optionText: "Go to G", content: "You are standing in cell G.",
      choices: ['C', 'K', 'H']},
    { id: 'H', optionText: "Go to H", content: "You are standing in cell H.",
      choices: ['G', 'D']},
    { id: 'I', optionText: "Go to I", content: "You are standing in cell I.",
      choices: ['E', 'J']},
    { id: 'J', optionText: "Go to J", content: "You are standing in cell J.",
      choices: ['I', 'N']},
    { id: 'K', optionText: "Go to K", content: "You are standing in cell K.",
      choices: ['G', 'O', 'L']},
    {
      id: 'L',
      tags: ['teleporter'],
      optionText: "Go to L",
      content: "You are standing in cell L. There is a teleporter here.",
      choices: ['K', 'P', '#teleporter']
    },
    {
      id: 'M',
      tags: ['teleporter'],
      optionText: "Go to M",
      content: "You are standing in cell M. There is a teleporter here.",
      choices: ['N', '#teleporter']
    },
    { id: 'N', optionText: "Go to N", content: "You are standing in cell N.",
      choices: ['M', 'J']},
    { id: 'O', optionText: "Go to O", content: "You are standing in cell O.",
      choices: ['K']},
    { id: 'P', optionText: "Go to P", content: "You are standing in cell P.",
      choices: ['L', 'win']},
    {
      id: 'win',
      optionText: 'Exit the maze',
      content: `
        You exit the maze victorious!
      `
    }
  ]
});

The user can now take a more circuitous route to the exit: AEIJNMLKOP. Here's the running code:

Clearing the Transcript

If you don't like the continuous scrolling style and prefer something more like Twine, you can add clear: true to your situation. Here's the maze example, with each situation clearing the screen.

jumbogrove.jumbogrove('#game' {
  id: 'maze-game',
  situations: [
    {
      id: 'start',  // the situation with id=start is how the game begins
      content: `
        You are standing at the entrance of a maze.
      `,
      choices: ['A']
    },
    { id: 'A', optionText: "Enter the maze",
      clear: true,
      content: "You are standing in cell A.",
      choices: ['E'] },
    { id: 'B', optionText: "Go to B", content: "You are standing in cell B.",
      clear: true,
      choices: ['F', 'C']},
    { id: 'C', optionText: "Go to C", content: "You are standing in cell C.",
      clear: true,
      choices: ['B', 'G']},
    {
      id: 'D',
      tags: ['teleporter'],
      optionText: "Go to D",
      clear: true,
      content: "You are standing in cell D. There is a teleporter here.",
      choices: ['H', '#teleporter']
    },
    { id: 'E', optionText: "Go to E", content: "You are standing in cell E.",
      clear: true,
      choices: ['A', 'I', 'F']},
    { id: 'F', optionText: "Go to F", content: "You are standing in cell F.",
      clear: true,
      choices: ['E', 'B']},
    { id: 'G', optionText: "Go to G", content: "You are standing in cell G.",
      clear: true,
      choices: ['C', 'K', 'H']},
    { id: 'H', optionText: "Go to H", content: "You are standing in cell H.",
      clear: true,
      choices: ['G', 'D']},
    { id: 'I', optionText: "Go to I", content: "You are standing in cell I.",
      clear: true,
      choices: ['E', 'J']},
    { id: 'J', optionText: "Go to J", content: "You are standing in cell J.",
      clear: true,
      choices: ['I', 'N']},
    { id: 'K', optionText: "Go to K", content: "You are standing in cell K.",
      clear: true,
      choices: ['G', 'O', 'L']},
    {
      id: 'L',
      tags: ['teleporter'],
      optionText: "Go to L",
      clear: true,
      content: "You are standing in cell L. There is a teleporter here.",
      choices: ['K', 'P', '#teleporter']
    },
    {
      id: 'M',
      tags: ['teleporter'],
      optionText: "Go to M",
      clear: true,
      content: "You are standing in cell M. There is a teleporter here.",
      choices: ['N', '#teleporter']
    },
    { id: 'N', optionText: "Go to N", content: "You are standing in cell N.",
      clear: true,
      choices: ['M', 'J']},
    { id: 'O', optionText: "Go to O", content: "You are standing in cell O.",
      clear: true,
      choices: ['K']},
    { id: 'P', optionText: "Go to P", content: "You are standing in cell P.",
      clear: true,
      choices: ['L', 'win']},
    {
      id: 'win',
      optionText: 'Exit the maze',
      clear: true,
      content: `
        You exit the maze victorious!
      `
    }
  ]
});

That's most of what you can do without writing any JavaScript functions.

Scripting Choices

In this guide:

  • situation.optionText as a function (function(model, host) -> Bool)
  • situation.getCanSee(model, host) -> Bool
  • situation.getCanChoose(model, host) -> Bool
  • situation.enter(model, ui, fromSituation)
  • situation.priority
  • model.globalState

Custom option text

This section builds on the code from the previous section.

You might have noticed that when you first "Enter the maze" and then "Go to E," one of the options is still "Enter the maze," which takes you back to Cell A.

We can fix this using a little bit of JavaScript. situation.optionText can be either a string, or a function! (You can see a reference for all the situation values here.)

It looks like this:

{
  id: 'my-situation',
  optionText: function(model, hostSituation) {
    return 'a string';
  }
}

The model argument will be explained later. hostSituation is the situation that is presenting the choice.

Here's how we can use that in our game. (From here on out, when we're just modifying one situation, we'll just list out the code for that situation, instead of the whole game.)

jumbogrove.jumbogrove('#game', {
  id: 'maze-game',
  situations: [
    /* ... */
    { id: 'A',

      /* THIS IS THE IMPORTANT PART */
      optionText: function(model, hostSituation) {
        if (hostSituation.id === 'start') {
          return 'Enter the maze';
        } else {
          return 'Go to A';
        }
      },
      /* end important part */

      content: "You are standing in cell A.",
      choices: ['E'] },
    /* ... */
  ]
})

We can also add some spice to our teleporter cells. Instead of "Go to X", they can say "Teleport to X" if they are being presented as a choice from another teleporter situation.

There is a method on situation objects, Situation.hasTag(), that we can use to quickly determine if the "host" situation (the situation presenting the choice) has a particular tag.

jumbogrove.jumbogrove('#game', {
  id: 'maze-game',
  situations: [
    /* ... */
      {
        id: 'D',
        tags: ['teleporter'],
        optionText: function(model, host) {
          if (host.hasTag('teleporter')) {
            return 'Teleport to D';
          } else {
            return 'Go to D';
          }
        },
        content: "You are standing in cell D. There is a teleporter here.",
        choices: ['H', '#teleporter']
      },
    /* ... */
      {
        id: 'L',
        tags: ['teleporter'],
        optionText: function(model, host) {
          if (host.hasTag('teleporter')) {
            return 'Teleport to L';
          } else {
            return 'Go to L';
          }
        },
        content: "You are standing in cell L. There is a teleporter here.",
        choices: ['K', 'P', '#teleporter']
      },
      {
        id: 'M',
        tags: ['teleporter'],
        optionText: function(model, host) {
          if (host.hasTag('teleporter')) {
            return 'Teleport to M';
          } else {
            return 'Go to M';
          }
        },
        content: "You are standing in cell M. There is a teleporter here.",
        choices: ['N', '#teleporter']
      },
    /* ... */
  ]
})

If you're an experienced JavaScript programmer you might want to consolidate that logic a bit. Go right ahead!

Here are the changes in action. You can walk to cell D by visiting cells AEFBCGHD, where you can see the teleporter options.

Remembering things

Let's design a new maze. It will be physically smaller, but it will require the player to find a key to solve.

+-------+-------+
|       |       |
|  key  | exit   
|       |       |
+-     -+- door-+
|       |       |
| start   empty |
|       |       |
+-------+-------+

We want our logic to look something like this:

  • When the player enters the 'key' room,
    • Then remember that the player has the key.
    • Once the player has the key, stop showing the 'key' room.
  • When the player enters the empty room,
    • Only show the door choice if they have the key.

To accomplish this task, we need to use three new features of situations. Each one is a function you can provide which is called by Jumbo Grove when something happens or a decision needs to be made.

  • situation.getCanSee(model, host) may prevent the host situation from displaying this situation as a choice by returning false.
  • situation.getCanChoose(model, host) may let the player see a choice, but not take it. This is so they know there is an option they haven't yet unlocked.
  • situation.enter(model, ui, fromSituation) is called when the player enters a situation, after content has been shown.

And finally, we need to make use of the model. The model object does a lot of things. What we care about right now is the model.globalState object.

model.globalState

This object may contain anything you like, as long as it is safe too convert it to JSON. globalState is part of your game's save file.

Things that can be converted to JSON include:

  • Numbers
  • Strings ("hello")
  • Booleans (true, false)
  • Lists or objects containing numbers, strings, or booleans

Everything else is unsafe to use.

Tiny maze example

Here's our tiny maze game in full:

jumbogrove.jumbogrove('#game', {
  id: 'prison-escape',
  // You can specify the initial value of globalState like this
  globalState: {
    playerHasKey: false,
  },
  situations: [
    {
      id: 'start',
      optionText: 'Return to your bedroom',
      content: `
        You are standing in a cold, damp cell. A straw mattress lies in
        the corner.
      `,
      choices: ['key-room', 'door-room'],
    },

    {
      id: 'key-room',
      // In the ASCII map I drew this as a whole room, but the flavor
      // text just calls it a hole in the wall.
      optionText: 'Inspect hole in the wall',
      getCanSee: function(model, host) {
        // only visible if player doesn't already have the key
        return model.globalState.playerHasKey === false;
      },
      enter: function(model, ui, from) {
        model.globalState.playerHasKey = true;
      },
      content: `
        There is a key hidden in the hole!
      `,
      choices: ['start']
    },

    {
      id: 'door-room',
      optionText: 'Walk to the other end of the room',
      content: `
        There is a locked door here.
      `,
      choices: ['start', 'win-the-game']
    },

    {
      id: 'win-the-game',
      optionText: 'Unlock the door',
      getCanChoose: function(model, host) {
        // only pickable if player DOES have the key
        return model.globalState.playerHasKey === true;
      },
      content: `
        You walk out into the hallway. You're free!

        # Game Over
      `
    }

  ]
});

Priority

Imagine you're making a game about a high school crush. The player is at a party, and may talk to anyone. But when their crush walks in the room, all they can do is stammer and stare.

We could write a getCanSee function for every person you talk to, but it's much simpler to use situation.priority!

When a situation's choices have different priorities, it only shows choices of situations with the highest priority.

So if situations A, B, and C have priority: 0, but situations E and F have priority: 1, then only E and F will be presented.

All situations have priority 0 by default.

Here's a small demonstration:

jumbogrove.jumbogrove('#game', {
  id: 'party-crush',
  globalState: {
    turnsUntilCrushEnters: 2,
    hasTalkedToJeff: false,
    hasTalkedToXiao: false,
    hasTalkedToMegan: false,
    hasTalkedToMarta: false
  },
  situations: [
    {
      id: 'start',
      content: "You have entered a pretty chill party.",
      choices: ['#talk-to-someone'],
    },

    {
      id: 'jeff', tags: ['talk-to-someone'],
      optionText: "Talk to Jeff",
      getCanSee: function(model) { return !model.globalState.hasTalkedToJeff; },
      enter: function(model) {
        model.globalState.hasTalkedToJeff = true;
        model.globalState.turnsUntilCrushEnters -= 1;
      },
      content: "You catch up with Jeff. He aced his math test.",
      choices: ['#talk-to-someone']
    },

    {
      id: 'xiao', tags: ['talk-to-someone'],
      optionText: "Talk to Xiao",
      getCanSee: function(model) { return !model.globalState.hasTalkedToXiao; },
      enter: function(model) {
        model.globalState.hasTalkedToXiao = true;
        model.globalState.turnsUntilCrushEnters -= 1;
      },
      content: "You catch up with Xiao. He scored the winning goal at a football game.",
      choices: ['#talk-to-someone']
    },

    {
      id: 'megan', tags: ['talk-to-someone'],
      optionText: "Talk to Megan",
      getCanSee: function(model) { return !model.globalState.hasTalkedToMegan; },
      enter: function(model) {
        model.globalState.hasTalkedToMegan = true;
        model.globalState.turnsUntilCrushEnters -= 1;
      },
      content: "You catch up with Megan. She tells you about a short story she wrote.",
      choices: ['#talk-to-someone']
    },

    {
      id: 'marta', tags: ['talk-to-someone'],
      optionText: "Talk to Marta",
      getCanSee: function(model) { return !model.globalState.hasTalkedToMarta; },
      enter: function(model) {
        model.globalState.hasTalkedToMarta = true;
        model.globalState.turnsUntilCrushEnters -= 1;
      },
      content: "You catch up with Marta. She just beat her personal best deadlift.",
      choices: ['#talk-to-someone']
    },

    {
      id: 'crush', tags: ['talk-to-someone'],
      optionText: "Your crush is here",
      priority: 1,
      getCanSee: function(model) {
        return model.globalState.turnsUntilCrushEnters <= 0;
      },
      content: `
      You see your crush enter the party. You immediately forget how to speak.
      You stammer uncontrollably, trying to excuse yourself while you escape
      out the back door.

      # The End
      `
    }

  ]
});

Templates

A situation doesn't always have to have the same text every time the player sees it. You can use Nunjucks template syntax to do all kinds of cool stuff!

The Nunjucks documentation is pretty good, so this part of the tutorial won't really dive into the ins and outs of writing templates, but there are a few interesting topics to cover:

  • Basic usage
  • Template context: what variables are accessible in templates?
  • Custom filters
  • How Jumbo Grove uses both Nunjucks and Markdown to render text
  • Advanced Markdown + CSS
  • How to use another template engine instead of Nunjucks

World's Simplest Puzzle

Here's a quick demonstration of an if-statement that reads a value from model.globalState. The template context includes model.globalState for convenience, so you can check it really easily.

jumbogrove.jumbogrove('#game', {
  id: 'simple-puzzle',
  globalState: { hasPulledLever: false },
  situations: [

    { id: 'start',
      optionText: 'OK',
      content: `
      You are standing in a room with a thick shag carpet and purple velvet walls.

      {% if hasPulledLever %}
      The north wall has opened up into a huge cavern.
      {% else %}
      There is a lever on the west wall.
      {% endif %}
      `,
      choices: ['pull-lever']
    },

    { id: 'pull-lever',
      optionText: 'Pull the lever',
      getCanSee: function(model) { return !model.globalState.hasPulledLever; },
      enter: function(model) {
        model.globalState.hasPulledLever = true;
      },
      content: `
        You pull the lever. A rumbling sound starts beneath your feet.
      `,
      choices: ['start']
    },

  ]
})

Template Context

The template context is the set of variables accessible directly in a template.

model and ui are always accessible in the template context, so this will always work:

{% if model.globalState.someVar %}some text{% endif %}

Here's how the template context is created each time a template is rendered:

Custom Filters

You can add custom filters to templates using the init hook. This function is called as soon as Jumbo Grove has parsed your game.

Here's an example of adding a | yesNo filter to boolean values, so you can write {{ myVal|yesNo }} to get a "yes" or "no" in your text instead of "true" or "false":

jumbogrove.jumbogrove('#game', {
  id: 'custom-filter-demo',
  globalState: { isAwesome: true },
  init: function(model, ui) {
    ui.nunjucks.addFilter('yesNo', function(val) {
      if (val) { return "yes"; } else { return "no"; }
    });
  },
  situations: [
    {
      id: 'start',
      // Am I awesome? YES!
      content: "Am I awesome? {{ isAwesome|yesNo|upper }}!"
    }
  ]
});

For more on Nunjucks filters, check out the Nunjucks docs.

With Markdown & Whitespace

Any time you write the content for a situation, or the navHeader or asideHeader, text is processed like this:

  1. Remove left indentation
  2. Run Nunjucks
  3. Run MarkdownIt

Normally this will have straightforward results. But you have to be careful not to add indentation when you don't mean to, because Markdown treats indented text as a code block if you use four or more spaces:

jumbogrove.jumbogrove('#game', {
  id: 'whitespace-example-1',
  situations: [
    {
      id: 'start',
      content: `
      Hello.

      {% if true %}
          This will show up as code!
      {% endif %}

      {% if true %}
      This is a normal paragraph.
      {% endif %}

      {% if true %}
        This only uses 2 spaces and is also a normal paragraph.
      {% endif %}
      `
    }
  ]
});

You can "eat" the whitespace before a {% or after a %} by adding a dash character ({%- / -%}):

jumbogrove.jumbogrove('#game', {
  id: 'whitespace-example-2',
  situations: [
    {
      id: 'start',
      content: `
      Hey, my name is
      {% if name == 'brad' -%}
        Brad
      {%- else -%}
        Jeff
      {%- endif %}.
      `
    }
  ]
});

Adding CSS

Jumbo Grove makes games for web browsers. It would be a shame if you couldn't take advantage of all those great web styling features. Fortunately, Jumbo Grove includes a MarkdownIt extension called markdown-it-attrs, which lets you add CSS classes to things!

You can add additional MarkdownIt extensions in your init callback using ui.md, a MarkdownIt instance.

jumbogrove.jumbogrove('#game', {
  id: 'markdown-it-attrs-demo',
  situations: [
    {
      id: 'start',
      content: `
      The last word is **pink**{.demo-pink}! Use your browser's
      inspector tool to look at the CSS on it.
      `
    }
  ]
});

Template Comment Syntax

Because Nunjucks's comment syntax ({# blah blah blah %}) conflicts with markdown-it-attrs's HTML ID syntax ({#some-id}), Nunjucks is configured by default to use {## blah blah blah ##} for comments (two hashes instead of one on each side).

Changing Template Engines

It is possible to do this, but it is not in this guide because it should only be done if you really, really know what you are doing.

Built-in Filters

  • character|quality(qualityId) formats the current value of a character's quality. (See next guide for what that is.)
  • character|qualityName(qualityId) shows the name value of the character's quality.

Actions

In this guide:

  • What are actions?
  • Writing your own actions
  • Built-in actions; snippets
  • Multiple actions in one link

What are actions?

So far, this manual has talked about situations and choices. There has been a simple game loop: read a situation, make a decision, repeat.

But we can do so much more with hypertext! Actions let you add some nonlinearity and interactivity to your situations.

In Jumbo Grove, an action is when you write a link like this:

Blah blah blah [link text](>action_name:action_argument)

You add a link to your Markdown and have the link target start with >. When Jumbo Grove renders your text, it looks over the output to find specially formatted links like this. (You may recall that you can do something similar with situation links, if you use @.)

You can add as many arguments as you want, or no arguments:

[link text](>action_name)  <!-- no arguments -->
[link text](>action_name:arg1:arg2)  <!-- two arguments arguments -->

When the user clicks the link, Jumbo Grove calls the action function you define, or a built-in action.

Here's a simple example. The player is in a gym and they can work out on the various machines.

Here's the source code for that example:

jumbogrove.jumbogrove('#game', {
  id: 'workout',
  init: function(model, ui) {
    ui.nunjucks.addFilter('formatSwoleness', function(str) {
      const n = parseInt(str, 10);
      if (!n) return 'Do you even lift';
      switch (n) {
        case 1: return 'Average';
        case 2: return 'Swole';
        case 3: return 'Mad swole';
        default: return 'Jacked';
      }
    });
  },
  globalState: {
    swoleness: 0,
  },
  situations: [
    {
      id: 'start',
      content: `
      You are in Sgt. McBeefy's Gym for People Who Want To Get Strong, because you
      want to get strong.

      There are some [dumbbells](>workout:dumbbells).

      There is a [leg press machine](>workout:leg_press).

      There is a [pull-up bar](>workout:pull_ups).

      There is a [squat rack](>workout:squat_rack).
      `,
      actions: {
        // after 'model' and 'ui', function arguments come from
        // the link in the original Markdown.
        workout: function(model, ui, whichMachine) {
          console.log("go");
          model.globalState.swoleness += 1;

          switch (whichMachine) {
            // You can write stuff to the end of the transcript manually with
            // `ui.write()`. These strings include an extra `\n`
            // character because otherwise Jumbo Grove won't add a surrounding
            // <p> tag.
            case 'dumbbells':
              ui.write("You lift some dumbbells.\n"); break;
            case 'leg_press':
              ui.write("You use the leg press.\n"); break;
            case 'pull_ups':
              ui.write("You use the pull-up bar.\n"); break;
            case 'squat_rack':
              ui.write("You do some squats.\n"); break;
          }

          ui.write(`
          Your strength level is: **{{ swoleness|formatSwoleness }}**
          `);

          if (model.globalState.swoleness >= 4) {
            ui.write(`
            [Hit the showers](@end)
            `);
          }
        }
      }
    },
    {
      id: 'end',
      content: "Congrats on getting jacked!"
    }
  ]

});

Built-in Actions

There is a popular kind of interactive fiction presentation where you click links and they expand text or just write text to the end of the transcript, or some specific point. Jumbo Grove has built-in actions for all of these cases.

Here is a quick example:

Here's how it works:

jumbogrove.jumbogrove('#game', {
  id: 'woods',
  situations: [
    {
      id: 'start',
      content: `
      You are [standing](>replaceself:be_quiet) under a [tree](>write:hear_the_wind).

      A *blue*{#make_the_bird_red} [bird](>replace:make_the_bird_red) sits on a
      branch above you.
      `,
      snippets: {
        // Snippets may contain more actions!
        be_quiet: `standing [quietly](>replaceself:be_noisy)`,
        be_noisy: `noisily`,
        hear_the_wind: `
          The wind rustles the leaves above your head.
        `,
        make_the_bird_red: `*red*`,
      }
    }
  ]
});

You can get really fancy with these things. You could write a whole game with just one situation!

The built-in actions use the special snippets object. It's a mapping of IDs to Markdown templates.

It's OK to put action links, situation links, and template syntax in your snippets!

write

The write action appends a snippet to the transcript.

If you have some text like this:

I am listening to [the wind](>write:hear_the_wind)

And a snippet like this:

snippets: {
  hear_the_wind: "The wind rustles the leaves."
}

Then when you click the link, the link will turn into plain text, and the text "The wind rustles the leaves" will be added to the end of your content.

replaceself

The replaceself action replaces the link with the contents of a snippet. If you have some text like this:

Demo of [a snippet](>replaceself:be_cool)

and you have a snippet like this:

snippets: {
  be_cool: "a really cool snippet"
}

Then when you click the link "a snippet", it will be replaced with non-linked text that says "a really cool snippet".

replace

This is a tricky one. The replaceself action replaces the DOM element with the same ID as the snippet, with the contents of the snippet.

So if you have some text like this:

This paragraph will be replaced. {#replacement_example}

[Replace the paragraph](>replace:replacement_example)

And a snippet like this:

snippets: {
  replacement_example: "This is the new text!"
}

Then when you click "Replace the paragraph", the first paragraph will be replaced with the contents of the snippet.

In the Markdown part of the example, the {#replacement_example} part is what assigns the ID attribute to the paragraph element. For details about using this syntax, read the markdown-it-attrs docs.

Multiple Actions, One Link

It is possible to trigger multiple actions from one link. Just separate them with a ; character.

jumbogrove.jumbogrove('#game', {
  id: 'multi-action',
  situations: [
    {
      id: 'start',
      content: `
      There is a [red](>replaceself:blue;>write:fly_away) bird here.
      `,
      snippets: {
        blue: 'blue',
        fly_away: `
        It flies away.
        `
      }
    }
  ]
});

You can use this technique to save yourself having to write a lot of custom action JavaScript if all you want to do is replace some text and go to another situation.

Here's a rewrite of the gym example using this technique:

Source:

jumbogrove.jumbogrove('#game', {
  id: 'workout-2',
  init: function(model, ui) {
    ui.nunjucks.addFilter('formatSwoleness', function(str) {
      const n = parseInt(str, 10);
      if (!n) return 'Do you even lift';
      switch (n) {
        case 1: return 'Average';
        case 2: return 'Swole';
        case 3: return 'Mad swole';
        default: return 'Jacked';
      }
    });
  },
  globalState: {
    swoleness: 0,
  },
  situations: [
    {
      id: 'start',
      content: `
      You are in Sgt. McBeefy's Gym for People Who Want To Get Strong, because you
      want to get strong.

      There are some [dumbbells](>write:dumbbells;>show_progress).

      There is a [leg press machine](>write:leg_press;>show_progress).

      There is a [pull-up bar](>write:pull_ups;>show_progress).

      There is a [squat rack](>write:squat_rack;>show_progress).
      `,
      snippets: {
        dumbbells: "You lift some dumbbells.\n",
        leg_press: "You use the leg press.\n",
        pull_ups: "You use the pull-up bar.\n",
        squat_rack: "You do some squats.\n"
      },
      actions: {
        show_progress: function(model, ui) {
          model.globalState.swoleness += 1;

          // no more gross switch statement!

          ui.write(`
          Your strength level is: **{{ swoleness|formatSwoleness }}**
          `);

          if (model.globalState.swoleness >= 4) {
            ui.write(`
            [Hit the showers](@end)
            `);
          }
        }
      }
    },
    {
      id: 'end',
      content: "Congrats on getting jacked!"
    }
  ]

});

Characters

In this guide:

  • What is a character?
  • What is a quality?
  • Working with characters and qualities

Characters and Qualities

It's possible to use only model.globalState to store your game's state, sometimes even desirable, but Jumbo Grove includes another way: characters.

A character is a collection of qualities. A quality is a value (string, number, or boolean) with a human-readable name.

As an example, consider a game about a group's survival in a zombie apocalypse. Here's what each character's qualities would look like:

  • Integer value 1-10 representing hunger, displayed as one of these words: (stuffed, full, satisfied, not hungry, a little hungry, very hungry, super hungry, famished, ravenous, starving)
  • Boolean value for whether they are sleeping or not

All characters must be defined at the start of the game. It is not currently possible to add or remove characters.

Here's a quick example of working with characters in a function:

Here's the code for the example:

jumbogrove.jumbogrove('#game', {
  id: 'character-example',
  showAside: true,
  asideHeader: "Qualities:",
  showNav: true,
  navHeader: "Qualities: the game!",
  characters: [
    {
      // 'player' is a special character accessible at model.player
      id: 'player',  
      name: 'You',
      // this is like globalState but just for this character.
      state: {},  

      qualities: {
        // Qualities are always grouped.
        main: {
          name: 'Main',  // optional group heading
          priority: 0,   // higher priority = higher in list
          hidden: false, // default true; if false, not shown in sidebar
          hunger: {
            name: "Hunger",
            type: 'wordScale',
            words: [
              'stuffed', 'full', 'satisfied', 'not hungry',
              'a little hungry', 'very hungry', 'super hungry', 'famished',
              'ravenous', 'starving'
            ],
            offset: -1,  // wordScale is 0-indexed, but value is 1-indexed, so
                          // subtract 1 when looking up words
            initialValue: 4,  // "not hungry"
          }
        }
      }
    }
  ],

  situations: [
    {
      id: 'start',
      enter: function(model, ui) {
        model.character('player').addToQuality('hunger', 1);
        ui.write(`
        After adding 1 to player hunger, player is now:
        `);
        ui.write(model.player.formatQuality('hunger'));
      },
      content: `
        Quality name: {{ model.character('player')|qualityName('hunger') }}

        Quality value: {{ model.character('player')|quality('hunger') }}
      `,
      choices: ['eat'],
    },
    {
      id: 'eat',
      enter: function(model, ui) {
        model.character('player').addToQuality('hunger', -1);
      },
      content: `
      Look at the sidebar; the value changed
      `
    }
  ]
});

Since this feature requires more JavaScript knowledge, this guide won't dig in to qualities, since you can probably figure it out from the API. Please open a GitHub issue if you have trouble.

Character State

You can store whatever JSON-safe values you like on the .state attribute of a character. These values are restored when you save and load.

Saving & Loading

Jumbo Grove games are saved to browser local storage. If you players use Incognito Mode/Private Browsing, it won't work.

The game only saves when entering situations with autosave: true.

When loading a game, the whole transcript is not replayed. The game picks up where the player left off, without showing the whole history.

Version Numbers

In addition to an id, you should define a version for your game, because the game's savefile location is based on the version number.

Resetting the Game

There is a special

Quick Example

Try navigating to a new scene and reloading the page.

jumbogrove.jumbogrove('#game', {
  id: 'save-example',
  showNav: true,
  gameSaveMessage: '> Game saved.',
  navHeader: `
  ### A Game You Can Save

  [Start over](>resetGame)
  `,
  situations: [
    {
      id: 'start',
      autosave: true,
      content: `
      You are at START.
      `,
      choices: ['the-other-scene']
    },
    {
      id: 'the-other-scene',
      autosave: true,
      content: `
      You are at THE OTHER SCENE.
      `,
      choices: ['start']
    }
  ]
};

Gamepad Support

If a gamepad is detected with the HTML5 gamepad API, then the player will be able to use it to navigate between links.

It doesn't really work on this documentation site because there are multiple instances on most pages, but it should work fine if there's just one instance.