Create a news game with Ink, React and Redux: Part II, playing your game on the web

Ændra Rininsland
Journocoders
Published in
12 min readMar 15, 2018

This is the second half of a two-part series about creating news games using Ink, React and Redux. We wrote our game script using Inky in the first part; if you don’t have a game to render online, you might have some difficulty with this section!

This is the second part of a two-part tutorial. The first part focused on the Ink language, this part looks at integrating React and Redux.

First off, though, check out the finished project here. You can either following along below using NodeJS locally, or you can remix this Glitch starter project I’ve set up.

This half of the tutorial might get a bit advanced and long in the tooth — you don’t really need to do any of this to play the game we created in Part I; feel free to mess with it a bit in the Inky app first. Or save this part for another time once you have a really ballin’ game to show to the world. Or just use the “Export For Web…” option below if you want to get rolling quickly with a minimum of fuss.

There are a few ways you can use Ink in a web context. The easiest is to just use Inky’s “Export For Web…” file menu option.

Easy as pie.

This will generate a few JavaScript files, a HTML file and a stylesheet. If you inspect main.js, you’ll see a function called continueStory(), which is where InkJS (a JavaScript library for rendering Ink stories) does most of the work. I’ve truncated it a bit below, read the comments to get a sense what each bit does:

var story = new inkjs.Story(storyContent);
// ... truncated
function continueStory() {
// Generate story text - loop through available content
while(story.canContinue) {
// Get ink to generate the next paragraph
var paragraphText = story.Continue();

/**
... Code for inserting paragraphText into DOM ... **/
}

// Create HTML choices from ink choices
story.currentChoices.forEach(function(choice) {
/**
... Code for inserting choices into the DOM goes here ... This eventually calls story.ChooseChoiceIndex(choice.index);
and then continueStory().
**/
});

This recursive looping idiom is what we used in The Uber Game to good effect. If you just want to get something out there, by all means feel free to use it, it’s straight-forward enough for simple games.

In all reality, however, you probably want to manage state in an easier-to-debug capacity than recursive functions that are blocked by while loops, particularly when dealing with how the game renders to your players. Luckily, the last few years have brought forth a plethora of ways to manage state in JavaScript, one of the most widely used being Redux.

You don’t need to use React to use Redux — Redux on its own is a very useful idiom for managing state in complex projects. But it’s super good with React, and given we’re wanting some way of manipulating the DOM in a component-y fashion anyway for this project, we’ll be using React as well. In order to make initial setup as painless as possible, we’ll use Create React App (CRA) to scaffold out our project. If you’ve never used React or Redux before, this might be a bit of a tough journey, but I’ll do my best to keep it simple.

I’m assuming you have at least version 8 of NodeJS installed on your development environment. If you’re using the Glitch environment, you don’t have to worry about any of this stuff (though you need to add "inkjs": "^1" to the dependencies section of package.json in the starter project).

Start by running CRA in whichever directory you keep your projects:

$ npx create-react-app ink-tutorial

This will create a basic project layout. Change into your project directory:

$ cd ink-tutorial

We now need to install a few extra things:

$ npm install inkjs@^1 react-redux@^5 redux@^3 \
redux-devtools-extension --save

Next thing we need to do is save a JSON version of our Ink story. In Inky, select “Export To JSON…” from the File menu:

Save this as cyberian-bot-farmer.json in ink-tutorial/src .

We’ll start by setting up all of our state management. We use the React Redux Provider component to let us propagate state down to our child components. Add the Redux Provider to src/index.js like so:

import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import "./index.css";
import App from "./App";
import store from "./state";
import registerServiceWorker from "./registerServiceWorker";
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
registerServiceWorker();

I’ve bolded the bits above that you’ll have to add.

We are going to create new directory in src/ , src/state/ , and then populate that with a index.js file, which you may have noticed we import above. Add the following to src/state/index.js:

import { createStore } from "redux";
import { devToolsEnhancer } from "redux-devtools-extension";
import inkGame, { INITIAL_STATE } from "./reducers";
export default createStore(inkGame, INITIAL_STATE, devToolsEnhancer());

This is where we create our Redux store. In Redux, all state propagates downward and is immutable, so any changes to state travel down the component hierarchy whenever a change is made. Unfortunately, we also have to contend with a totally separate state machine — the InkJS Story class—meaning keeping state synchronised between that and your React components is crucial.

We add redux-devtools-extension here because it allows us to jump around in our state live in the browser, which is kind of cool and useful, though a bit limited for reasons I’ll get into later. We then create our store from our initial state and set of reducers, which we’ll create next.

In src/state/reducers.js:

import { gameLoop, MAKE_CHOICE } from "./actions";export const INITIAL_STATE = {
ending: false,
...gameLoop()
};
export default (state = INITIAL_STATE, { type, ...action }) => {
switch (type) {
case MAKE_CHOICE:
return {
...state,
...action
};
default:
return state;
}
};

If you’ve never used Redux before, a reducer is a function that updates state from the result of an action. We populate our initial state by running the game loop once, then use that state to inform our reducers.

We have a total of one Reducer in this entire thing. If you were building a serious React app, you would likely have many more. All this particular Reducer does is merge the updated action values into the state tree. The real magic is done in src/state/actions.js, our next stop. Here is where things get a bit weird:

import { Story } from "inkjs";
import storyContent from "../cyberian-bot-farmer.json";
export const ink = new Story(storyContent);export const MAKE_CHOICE = "MAKE_CHOICE";

We instantiate the Ink engine using InkJS and our JSON file from Inky.app. We also start creating constants to use in our Action makers. Before we get into that, we first need a few helper functions. Add the following to src/state/actions.js:

export const getGlobalVars = variablesState =>
Object.keys(variablesState._globalVariables).reduce(
(acc, key) => ({
...acc,
[key]: variablesState[key]
}),
{}
);
export const getTags = tags =>
tags.reduce(
(acc, tag) => ({ ...acc,
[tag.split(": ")[0]]: tag.split(": ")[1] }),
{}
);

InkJS surfaces both Ink variables and tags in a bit of a confusing way: global variables are attached to ES6 proxies and all kinds of madness, and tags are returned as strings much like how you type them in Inky. The above simply reduces both to JavaScript Objects.

Still in src/state/actions.js, add the following::

export const gameLoop = () => {
const sceneText = [];
let currentTags = [];
while (ink.canContinue) {
sceneText.push(ink.Continue());
currentTags = currentTags.concat(ink.currentTags);
}
const { currentChoices, variablesState } = ink;if (!ink.canContinue && !currentChoices.length)
throw new GameOverError("no more choices");
return {
globals: getGlobalVars(variablesState),
tags: getTags(currentTags),
currentChoices,
sceneText,
currentTags
};
};

Ugh, that while loop looks really familiar… Unfortunately, InkJS makes it really difficult to get the tags for any individual knot (or even know what the name of the knot you’re on is called, which is particularly annoying — you generally have to use tags for all current scene metadata), because they’re available on the ink.currentTags property only during the first line of any particular series of dialogue. This means we have to use ink.Continue(), which delivers one line of story text at a time, instead of ink.ContinueMaximally(), which delivers all story text lines up until the story can no longer continue. Regardless, what we’re doing here is telling InkJS to give us the current game state, which we then return.

Hey ladies and gentlemen, do you want to talk about immutability and state machines for a little while? No? Not even the slightest?! Don’t blame you; skip on to the next code block. …Still here? Really? Cool.

Effectively, the fundamental issue with using Redux as a state management approach is that the Redux Store is supposed to be the sole source of truth, but in this particular instance, it can’t be — the InkJS engine acts in that capacity. Normally in Redux, you can “time travel” back through state, much in the same way you’re able to in Inky.app. However, if you do this, the Redux state will get overwritten by InkJS’ current state the next time the MAKE_CHOICE action happens, because Redux can’t rewind the state of InkJS the same way it can itself. I honestly don’t know how to get around this, and I’d love for somebody to share some ideas in the comments. Regardless, there’s still value in using Redux for your frontend components in this context, so long as you remember that the Redux Store and InkJS engine are two totally different state machines, and all Redux does is push values from InkJS downwards to your front end components whenever the gameLoop() runs (generally when the MAKE_CHOICE action is called, but also during the initial state population). Regardless, if you use your ✨ i m a g i n a t i o n ✨, this isn’t too different conceptually from communicating with a web API. REALLYYYYYYY.

Still with me? Don’t worry if the above makes zero sense, the tl;dr version is that player decisions cause InkJS state changes, the results of which get propagated downwards to your components.

Alright, finally time for the actual makeChoice() action. Still in src/state/actions.js:

export const makeChoice = choiceIdx => {
ink.ChooseChoiceIndex(choiceIdx);
try {
const gameData = gameLoop();
return {
type: MAKE_CHOICE,
...gameData
};
} catch (e) {
if (e instanceof GameOverError
&& e.reason === "no more choices") {
return {
type: MAKE_CHOICE,
ending: true
};
}
throw e;
}
};

We pass the choice index to InkJS, run the game loop, the pass the values down. In the game loop, we throw a custom Error object, GameOverError, if we cannot continue and have no more choices, which we catch here and update the ending state property. This will let us use different components for the ending than we use for the game.

Finally, we need that custom Error object, which is the last thing in src/state/actions.js:

function GameOverError(reason = "", ...rest) {
var instance = new Error(`Game Over, ${reason}`, ...rest);
instance.reason = reason;
Object.setPrototypeOf(instance, Object.getPrototypeOf(this));
if (Error.captureStackTrace) {
Error.captureStackTrace(instance, GameOverError);
}
return instance;
}
GameOverError.prototype = Object.create(Error.prototype, {
constructor: {
value: Error,
enumerable: false,
writable: true,
configurable: true
}
});
if (Object.setPrototypeOf) {
Object.setPrototypeOf(GameOverError, Error);
} else {
GameOverError.__proto__ = Error;
}

I’m sorry for how verbose the above is; there is a far more succinct ES6 way to write this, but oddly it does not work at all with Babel. I’m not going to dwell on what any of the above does (read the MDN article if you’re curious), other than it lets us emit a special type of error that halts everything and show an ending screen when the -> END directive in Ink occurs.

We’re done with Redux for now. All that’s left to do is create our React components and CSS styles. Let’s get the latter out of the way because ain’t nobody reading this for any of that Flexbox sickness.

Update src/index.css to resemble the following:

html,
body,
.App,
#root {
width: 100%;
height: 100%;
}
body {
margin: 0;
padding: 0;
font-family: sans-serif;
display: flex;
flex-direction: column;
}

Yawn. Update App.css to look like this:

.App {
text-align: center;
flex-direction: column;
display: flex;
}
.scene {
flex: 3;
background-size: cover;
background-position: center center;
}
.story-text {
flex: 1;
padding: 2em;
}
.choices {
position: absolute;
right: 0;
top: 0;
width: auto;
background: rgba(255, 255, 255, 0.9);
padding: 1em;
}
.choices ul {
padding: 0;
}
.choices li {
padding: 0.5em 1em;
margin: 0.5em 0;
background: rgb(175, 171, 251);
cursor: pointer;
list-style-type: none;
}
.ending {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
height: 100%;
font-size: 4em;
}

This is all pretty basic Flexbox stuff: we‘re going to have a context-specific background image and a container for story text sized in a 3:1 ratio. A list of currently available choices will float in the upper right corner. Probably you should add a media query or whatever to tweak that so that it looks halfway decent on mobile, I guess? ¯\_(ツ)_/¯

Cool. Let’s update App.js to contain what will eventually be our components:

import React from "react";
import { connect } from "react-redux";
import Scene from "./Scene";
import Choices from "./Choices";
import Story from "./Story";
import { makeChoice } from "./state/actions";
import "./App.css";
const App = props =>
props.ending ? (
<div className="ending">🎉🎉 YOU WIN! 🎉🎉</div>
) : (
<div className="App">
<Scene tags={props.tags} />
<Story sceneText={props.sceneText} />
<Choices choices={props.currentChoices} makeChoice={props.makeChoice} />
</div>
);

If the ending prop is true, then show an exciting ending screen (in reality you’d probably create a totally separate bunch of components for this). Otherwise, show our main React components.

We still need to connect this container component to the Redux store. Add the following to src/App.js:

const stateToProps = state => ({
tags: state.tags,
currentChoices: state.currentChoices,
sceneText: state.sceneText,
ending: state.ending
});
const dispatchToProps = dispatch => ({
makeChoice: idx => dispatch(makeChoice(idx))
});
export default connect(stateToProps, dispatchToProps)(App);

What we do here is use React Redux’s connect() function to provide our component with the ability to both receive state changes and dispatch actions. We do the former with stateToProps(), the latter with dispatchToProps(). This means that the tags, currentChoices, sceneText and ending Redux state properties will be available as props on the React component, as will the makeChoice() function, which can be used to dispatch player choices to the InkJS state machine and then the Redux state tree.

Finally we need to make our Scene, Choices and Story components:

src/Scene.js:

import React from "react";const backgroundImages = {
beginning: "https://cdn.glitch.com/475e1d65-6dfb-4a37-92b8-ffd178d08d8c%2Fbeginning.jpg?1520817106330",
farm: "https://cdn.glitch.com/475e1d65-6dfb-4a37-92b8-ffd178d08d8c%2Ffarm.jpg?1520817107140",
barn: "https://cdn.glitch.com/475e1d65-6dfb-4a37-92b8-ffd178d08d8c%2Fbarn.jpg?1520817110937",
city: "https://cdn.glitch.com/475e1d65-6dfb-4a37-92b8-ffd178d08d8c%2Fcity.jpg?1520817106054",
bar: "https://cdn.glitch.com/475e1d65-6dfb-4a37-92b8-ffd178d08d8c%2Fbar.jpg?1520817105083",
store: "https://cdn.glitch.com/475e1d65-6dfb-4a37-92b8-ffd178d08d8c%2Fstore.jpg?1520817110669"
};
const defaultImage = "";
export default ({ tags }) => {
return (
<section
className="scene"
style={{
backgroundImage: `url(${
tags && backgroundImages[tags.background]
? backgroundImages[tags.background]
: defaultImage
})`
}}
/>
);
};

This component receives a tags prop from the Redux store, which is used to look up a background image in a static object (in a bigger project, this could be read from a JSON file or whatever). Otherwise it displays the default image (at this point, nothing).

We’re going to do the Story component next. It’s really simple.

src/Story.js:

import React from "react";const Story = ({ sceneText }) => (
<section className="story-text">
{sceneText.map((text, idx) => <p key={idx}>{text}</p>)}
</section>
);
export default Story;

This uses Array.prototype.map to create a new paragraph for every line of text in the sceneText state property, which is provided as a prop by Redux to this component.

Lastly, we have our Choices component, src/Choices.js:

import React from "react";const Choices = ({ choices, makeChoice }) => (
<section className="choices">
<h3>Make a decision...</h3>
<ul>
{choices.map(choice => (
<li key={choice.index}
onClick={() => makeChoice(choice.index)}>
{choice.text}
</li>
))}
</ul>
</section>
);
export default Choices;

Pretty similar to the last component, in that we receive the choices state property from Redux and then use Array.prototype.map to create a new element for each item in an array. However, we also receive the makeChoice() function from dispatchToProps in src/App.js, which we use as the onClick event handler.

💥💥💥 That’s all folks! Go start it up!

$ npm start

This will open a browser at http://localhost:3000 with your game awaiting you. Enjoy!

Ændrew Rininsland is on Twitter at @aendrew and recently redid his blog using React, Gatsby and p5.js to look totally sickening.

He is a senior developer with the Graphics team at Financial Times and any ridiculous antics, shady depictions of a particular foreign power, or otherwise poor takes of any sort, are clearly his alone and not that of his employer.

He also recently did a book about D3, which has been variously described as “not for newbies” and “impenetrable”.

--

--

Ændra Rininsland
Journocoders

Newsroom developer with Interactive Graphics at @ft. she/her or ze/hir. Rather queer. Opinions are mine, not employers. I'm hackers.town/@aendra on Mastodon.