JavaScript + Ink:
- Part 1: Ink for Web
- Part 2: Story API
- Part 3: Getting and Setting Variables
- Part 4: Calling Ink Functions
- Part 5: Building an Interface
ink is a scripting language for creating interactive narratives. It can be written using the editor Inky. The inkjs NPM module is a JavaScript port of the ink engine.
Building an Interface
In the previous part, the function EvaluateFunction() was used to call Ink functions directly, having Ink check and then change values. This was seen as a better alternative than changing values directly via the variablesState properties.
Back in the first part, the main.js file generated by the Ink for Web functionality showed a way of using Ink in a browser, using the same APIs as the examples developed from using the inkjs NPM module.
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8"> | |
<title>Example Story</title> | |
</head> | |
<body> | |
<div id="story"></div> | |
<script src="ink.js"></script> | |
<script src="ExampleStory.js"></script> | |
<script src="main.js"></script> | |
</body> | |
</html> |
Pulling functionality and code examples from main.js from Ink for Web, its function usage can be copied and used to create a simple interface
var story = new inkjs.Story(storyContent); | |
var storyContainer = document.getElementById("story"); | |
function continueStory() { | |
while(story.canContinue) { | |
// Get ink to generate the next paragraph | |
var paragraphText = story.Continue(); | |
// Create paragraph element (initially hidden) | |
var paragraphElement = document.createElement('p'); | |
paragraphElement.innerHTML = paragraphText; | |
storyContainer.appendChild(paragraphElement); | |
// Create HTML choices from ink choices | |
story.currentChoices.forEach(function(choice) { | |
// Create paragraph with anchor element | |
var choiceParagraphElement = document.createElement('p'); | |
choiceParagraphElement.classList.add("choice"); | |
choiceParagraphElement.innerHTML = `<a href='#'>${choice.text}</a>` | |
storyContainer.appendChild(choiceParagraphElement); | |
// Click on choice | |
var choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0]; | |
choiceAnchorEl.addEventListener("click", function(event) { | |
// Don't follow <a> link | |
event.preventDefault(); | |
// Remove all existing choices | |
removeAll("p.choice"); | |
// Tell the story where to go next | |
story.ChooseChoiceIndex(choice.index); | |
// Loop | |
continueStory(); | |
}); | |
}); | |
} | |
} | |
// Remove all elements that match the given selector. Used for removing choices after | |
// you've picked one, as well as for the CLEAR and RESTART tags. | |
function removeAll(selector) | |
{ | |
var allElements = storyContainer.querySelectorAll(selector); | |
for(var i=0; i<allElements.length; i++) { | |
var el = allElements[i]; | |
el.parentNode.removeChild(el); | |
} | |
} | |
continueStory(); |
VAR x = 20 | |
VAR y = 20 | |
VAR water = 20 | |
The desert was vast. My steps were erased soon after I placed them, but I had to continue. I knew that with every movement on the map, I would drink water — and I was quickly running out. | |
-> Choices | |
=== Choices === | |
{ water < 1: -> Out_Of_Water } | |
+ Move north? | |
{ setY(y – 1) } | |
{ drinkWater() } | |
-> Choices | |
+ Move south? | |
{ setY(y + 1) } | |
{ drinkWater() } | |
-> Choices | |
+ Move west? | |
{ setX(x – 1) } | |
{ drinkWater() } | |
-> Choices | |
+ Move east? | |
{ setX(x + 1) } | |
{ drinkWater() } | |
-> Choices | |
=== Out_Of_Water === | |
I shook my canteen, but could tell it was empty. This… this was the last step in my journey. | |
-> DONE | |
=== function setX(newX) === | |
{ | |
– newX > 100: | |
~ x = 100 | |
– newX < 0: | |
~ x = 0 | |
– else: | |
~ x = newX | |
} | |
=== function setY(newY) === | |
{ | |
– newY > 100: | |
~ y = 100 | |
– newY < 0: | |
~ y = 0 | |
– else: | |
~ y = newY | |
} | |
=== function currentPosition() === | |
(Currently at {x} and {y}.) | |
=== function drinkWater() === | |
~ water = water – 1 | |
=== function currentWater() === | |
(Remaining water {water}) |

Using a combination of canContinue, Continue() and currentChoices, the current text can be returned and the choices shown to the user. However, in using the EvaluateFunction() function, this can be improved.
Instead of showing choices, HTML buttons could be used to drive the story. These could call the internal Ink functions and change values.
Changing the interface means also changing the Ink code. If the HTML interface is going to drive the experience, there is little need for the choices to be in the Ink code.
VAR x = 20 | |
VAR y = 20 | |
VAR water = 20 | |
The desert was vast. My steps were erased soon after I placed them, but I had to continue. I knew that with every movement on the map, I would drink water — and I was quickly running out. | |
-> Choices | |
=== Choices === | |
{ water < 1: -> Out_Of_Water } | |
+ [Keep going] | |
-> Choices | |
=== Out_Of_Water === | |
I shook my canteen, but could tell it was empty. This… this was the last step in my journey. | |
-> DONE | |
=== function goNorth() === | |
{ setY(y – 1) } | |
{ drinkWater() } | |
=== function goSouth() === | |
{ setY(y + 1) } | |
{ drinkWater() } | |
=== function goWest() === | |
{ setX(x – 1) } | |
{ drinkWater() } | |
=== function goEast() === | |
{ setX(x + 1) } | |
{ drinkWater() } | |
=== function setX(newX) === | |
{ | |
– newX > 100: | |
~ x = 100 | |
– newX < 0: | |
~ x = 0 | |
– else: | |
~ x = newX | |
} | |
=== function setY(newY) === | |
{ | |
– newY > 100: | |
~ y = 100 | |
– newY < 0: | |
~ y = 0 | |
– else: | |
~ y = newY | |
} | |
=== function currentPosition() === | |
(Currently at {x} and {y}.) | |
=== function drinkWater() === | |
~ water = water – 1 | |
=== function currentWater() === | |
(Remaining water {water}) |
In the new code, the other choices have been removed to create a simple looping structure where “Keep going” is the first, non-removed choice. Using HTML buttons, the previous Ink choices could be used to drive the code via calls to EvaluateFunction(), making a HTML interface to the Ink interface functions.
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8"> | |
<title>Example Story</title> | |
</head> | |
<body> | |
<div id="story"></div> | |
<button id="north">Go North</button> | |
<button id="south">Go South</button> | |
<button id="east">Go East</button> | |
<button id="west">Go West</button> | |
<div id="water"></div> | |
<div id="position"></div> | |
<script src="ink.js"></script> | |
<script src="ExampleStory.js"></script> | |
<script src="main.js"></script> | |
</body> | |
</html> |
This requires re-writing the main.js code to support this new interface where, instead of using currentChoices, the same choice is used over and over again whenever a BUTTON is clicked.
var story = new inkjs.Story(storyContent); | |
var storyContainer = document.getElementById("story"); | |
var waterElement = document.getElementById("water"); | |
var positionElement = document.getElementById("position"); | |
var northElement = document.getElementById("north"); | |
northElement.addEventListener("click", function(event) { | |
story.EvaluateFunction("goNorth"); | |
story.ChooseChoiceIndex(0); | |
continueStory(); | |
}); | |
var southElement = document.getElementById("south"); | |
southElement.addEventListener("click", function(event) { | |
story.EvaluateFunction("goSouth"); | |
story.ChooseChoiceIndex(0); | |
continueStory(); | |
}); | |
var westElement = document.getElementById("west"); | |
westElement.addEventListener("click", function(event) { | |
story.EvaluateFunction("goWest"); | |
story.ChooseChoiceIndex(0); | |
continueStory(); | |
}); | |
var eastElement = document.getElementById("east"); | |
eastElement.addEventListener("click", function(event) { | |
story.EvaluateFunction("goEast"); | |
story.ChooseChoiceIndex(0); | |
continueStory(); | |
}); | |
function refreshWater() { | |
waterElement.innerHTML = story.EvaluateFunction("currentWater", [], true).output; | |
} | |
function refreshPosition() { | |
positionElement.innerHTML = story.EvaluateFunction("currentPosition", [], true).output; | |
} | |
function continueStory() { | |
// Get ink to generate the next paragraph | |
var paragraphText = story.Continue(); | |
// Create paragraph element (initially hidden) | |
var paragraphElement = document.createElement('p'); | |
paragraphElement.innerHTML = paragraphText; | |
storyContainer.appendChild(paragraphElement); | |
refreshWater(); | |
refreshPosition(); | |
} | |
continueStory(); |
