Building Hangman with Hyperapp - Part 3
Adam Dawkins
Posted on January 21, 2020
And... Action
In Part 1, we introduced the basics of Hyperapp, and in Part 2, we did a rough sketch of the game of Hangman in code. But we're not going to win any awards just yet - the user can't actually do anything.
To handle interactions with the user, or any other form of event, Hyperapp gives us Actions.
Let's quickly check our spec again:
- The Computer picks a random word for us to guess - hard-coded for now
- The Player inputs letters to guess the word
- Like the paper version, correct letters get inserted into the word, incorrect letters get listed elsewhere
- 8 incorrect guesses and the Player loses - done
- If the Player fills in the word correctly, they win. - done
Before anything else, we're going to want to use the Object rest/spread syntax in Javascript quite a bit with Hyperapp, and we need to add that to our build so parcel can use it.
'Object rest/spread' syntax allows us to want all of the existing properties of an object, and then override the ones we want to change. It reads nicely, but it's also an important way of doing things - whenever we change the state in Hyperapp we actually need to create a new state object, and object spread does just that.
Here's a quick example:
const cat = {
name: 'Larry',
legs: 4,
sound: 'meow',
};
const dog = {
...cat, // <-- this is the spread we want to support
sound: 'woof',
};
console.log(dog); // => { name: 'Larry', legs: 4, sounds: 'woof' }
Here our dog
kept the name
and legs
properties of cat
, but had it's own sound
. We'll use this syntax when we want to return a new version of our state
. Let's get it setup.
yarn add babel-plugin-transform-object-rest-spread -d
Put the following in a file called .babelrc
:
{
"plugins": ["transform-object-rest-spread"]
}
Now that's out of the way, we'll start by building a form for our user to enter letters. I've included some basic styling on the input.
import {
div,
h1,
h2,
ul,
li,
span,
input,
label,
form,
button,
} from '@hyperapp/html';
// ...
// VIEWS
// ...
const UserInput = () =>
form({}, [
label({for: 'letter'}, 'Your guess:'),
,
input({
type: 'text',
id: 'letter',
maxlength: 1,
style: {
border: '2px solid black',
fontSize: '36px',
width: '1.5em',
margin: '0 1em',
textAlign: 'center',
},
}),
button({type: 'submit'}, 'Guess!'),
]);
// THE APP
app({
init: {
word: 'application'.split(''),
guesses: [],
},
view: state =>
div(
{},
isGameOver(state)
? h1({}, `Game Over! The word was "${state.word.join('')}"`)
: isVictorious(state)
? [h1({}, 'You Won!'), Word(state)]
: [UserInput(), Word(state), BadGuesses(state)],
),
node: document.getElementById('app'),
});
Nothing happens... Let's change that with an action.
Actions take the current state, an optional argument and return a new state.
For now, we just want to get our action working when we submit the form, so we'll hardcode the letter 'z' into the guess.
// ACTIONS
const GuessLetter = state => ({
...state,
guesses: state.guesses.concat(['z']),
});
NB: We use concat
here instead of push
because Hyperapp always wants a new state object, not a change to the existing one. To put it formally, state in Hyperapp is immutable.
When the GuessLetter
action is called, we return the current state, with the letter 'z' added to the guesses.
We want to call this when the user submits the form, or on the submit
event.
form({ onSubmit: GuessLetter } // ...
```
This is the gist of it, but it won't actually work yet, because by default, submit events change the URL and refresh the page. We need to stop the default behaviour. We can do that manually, by calling `event.preventDefault()`.
```js
form(
{
onSubmit: (state, event) => {
event.preventDefault();
return GuessLetter;
},
},
This works, but it introduces a lot of extra boilerplate code all over our view. After all, Javascript UIs are all about events, or we'd just be building in plain HTML. Hyperapp has a @hyperapp/events
package that has some useful helper functions for this sort of thing.
Introducing Events
Let's install the package:
yarn add @hyperapp/events
And we'll use the preventDefault
helper function from there to stop our form refreshing the page.
import {preventDefault} from '@hyperapp/events';
// ...
// VIEWS
const UserInput = letter =>
form(
{onSubmit: preventDefault(GuessLetter)},
// ...
);
```
Now we can repeatedly guess the letter 'z' when we submit the form. Let's take it where we need to go, and capture the user input.
### Capturing User Input
A key concept in Hyperapp is that there's only one state, and changing the state refreshes our 'loop' around the application. As such, we need to store the user's guessed letter before we submit the form so that we know which letter they've guessed within our `GuessLetter` action.
This is where we want our `GuessLetter` action to go:
```js
const GuessLetter = state => ({
...state,
guesses: state.guesses.concat([state.guessedLetter]),
guessedLetter: '', // reset the letter after the user has guessed it
});
So, let's add a guessedLetter
to our state, set the input to be the same value as it, and change it whenever the value of the input changes.
- Add the
guessedLetter
to our initial state.
// THE APP
app({
init: {
word: 'application'.split(''),
guesses: [],
guessedLetter: '',
},
// ...
});
- Pass the letter to our
UserInput
view, and set it as the value of theinput
so that we can display it:
// VIEWS
const UserInput = letter =>
form({onSubmit: preventDefault(GuessLetter)}, [
label({for: 'letter'}, 'Your guess:'),
,
input({
value: letter,
// ...
},
}),
button({type: 'submit'}, 'Guess!'),
]);
// THE APP
app({
// ...
view: // ...
[UserInput(state.guessedLetter), Word(state), BadGuesses(state)],
// ...
});
- Change
state.guessedLetter
when the input changes.
The onInput
event we have takes two arguments, the current state, passed in automatically from Hyperapp, and the event that was triggered, so we can use that to to do this action in line:
input({
value: letter,
onInput: (state, event) => ({...state, guessedLetter: event.target.value}),
// ...
},
});
And, just like that, we can now make guesses with an input. We have Hangman.
Getting Better
There's still more work to be done though, we need to make the word random, and we can tidy up some of the user experience. We'll look at those in the next part.
Before you go, let's tidy this up a bit.
- We'll take the styling out into a stylesheet:
style.css
.input {
border: 2px solid black;
font-size: 36px;
width: 1.5em;
margin: 0 1em;
text-align: center;
}
<!-- ... -->
<head>
<link rel="stylesheet" href="./style.css">
</head>
<!-- ... -->
// VIEWS
const UserInput = letter =>
form({/* ... */}, [
// ...
input({
// ...
class: 'input', // add the class 'input'
// remove the style: block
// ...
}),
// ...
]);
- Get rid of the inline action.
// ACTIONS
const SetGuessedLetter = (state, letter) => ({
...state,
guessedLetter: letter,
});
// VIEWS
input({
// ...
onInput: (_, event) => [SetGuessedLetter, event.target.value],
});
This is better, but another helper from @hyperapp/events
allows us to abstract this pattern of using the event target value
import {preventDefault, targetValue} from '@hyperapp/events';
// VIEWS
input({
// ...
onInput: [SetGuessedLetter, targetValue],
});
So there we are, 101 lines of code, and we have a working Hangman. Let's make it better by introduction random words - in Part 4.
This tutorial was originally posted to adamdawkins.uk on 7th October 2019
Posted on January 21, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.