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
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
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.
- Only show the
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 returningfalse
.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, aftercontent
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:
- Add everything from
model.globalState
- Add
player
, if there is a player character - Add everything you put in ui.addTemplateGetters()
- Add everything you put in ui.addTemplateContext()
- Add
model
andui
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:
- Remove left indentation
- Run Nunjucks
- 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 thename
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']
}
]
};