Building Hangman with Hyperapp - Part 5
Adam Dawkins
Posted on January 21, 2020
Finishing touches
Let's start to tidy this up. First we'll add some styling. Hyperapp elements can take a style
object, much like React, but for our simple styling purposes, we'll just add a stylesheet and some classes.
/* style.css */
body {
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, sans-serif;
padding: 1rem 2rem;
background: #f0f0f0;
}
h1 {
font-size: 5rem;
margin: 1rem 0;
}
.subtitle {
font-size: 2rem;
}
.word {
font-size: 4rem;
display: flex;
justify-content: center;
}
.accent {
color: #fccd30;
}
.input {
border: 2px solid black;
font-size: 36px;
width: 1.5em;
margin: 0 1em;
text-align: center;
}
.guesses {
font-size: 2rem;
display: flex;
}
.guess {
margin: 0 .5em;
}
.linethrough {
text-decoration: line-through;
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
}
Staying Alive
Before we add the classes, I wanted to show the number of lives left to the user as part of the displaying of the bad guesses, just using a simple heart emoji.
To this, firstly, I renamed badGuesses
to getBadGuesses
for clarity, and then passed just the guesses to our BadGuesses
view instead of the whole state:
// HELPERS
const getBadGuesses = state =>
state.guesses.filter(guess => !isInWord(guess, state));
const isGameOver = state => getBadGuesses(state).length >= MAX_BAD_GUESSES;
// VIEWS
const BadGuesses = guesses => [
h2({}, "Your Guesses:"),
ul(
{ class: "guesses" },
guesses.map(guess => li({ class: "guess" }, guess))
)
];
// THE APP
app({
//....
view: state =>
//...
BadGuesses(getBadGuesses(state));
});
With that done, we now need to count how many lives are left and output that many hearts, replacing the lost lives with the bad guesses:
// UTILITIES
// returns an array of all the numbers between start and end.
// range(2, 5) => [2, 3, 4, 5]
const range = (start, end) => {
const result = [];
let i = start;
while (i <= end) {
result.push(i);
i++;
}
return result;
};
// VIEWS
const BadGuesses = guesses =>
div({ class: "guesses" }, [
range(1, MAX_BAD_GUESSES - guesses.length).map(() =>
span({ class: "guess" }, "♥️")
),
guesses.map(guess => span({ class: "guess linethrough" }, guess))
]);
Now we should see our lives output before the guesses. Let's add the rest of the classes now, with a bit of re-arrangement.
// VIEWS
const WordLetter = (letter, guessed) =>
span({ class: "letter" } // ...
const Word = state =>
div(
{ class: "word" },
// ....
);
// THE APP
app({
init: [
{
word: [],
guesses: [],
guessedLetter: ""
},
getWord()
],
view: state =>
div({}, [
div({ class: "header" }, [
div([h1("Hangman."), h2({ class: "subtitle" }, "A hyperapp game")]),
div({}, BadGuesses(getBadGuesses(state)))
]),
isGameOver(state)
? h2({}, `Game Over! The word was "${state.word.join("")}"`)
: isVictorious(state)
? [h2({}, "You Won!"), Word(state)]
: [Word(state), UserInput(state.guessedLetter)]
]),
node: document.getElementById("app")
});
There, things are looking much better.
A bug
We have a tiny bug to fix. When the page refreshes, you can see the 'You Won!' message for a split-second. This has come in because our word is being retrieved remotely. It's a simple fix, we just check the word is there first.
app({
// ...
view: state =>
div({}, [
div({ class: "header" }, [
div([h1("Hangman."), h2({ class: "subtitle" }, "A hyperapp game")]),
div({}, BadGuesses(getBadGuesses(state)))
]),
state.word.length > 0 &&
(isGameOver(state)
? h2({}, `Game Over! The word was "${state.word.join("")}"`)
: isVictorious(state)
? [h2({}, "You Won!"), Word(state)]
: [Word(state), UserInput(state.guessedLetter)])
]),
//...
})
By putting this under our header, we don't give the user the illusion of delay, it's fast enough, and the flash is gone.
A Key Ingredient
This is a perfectly serviceable Hangman game in just 131 generous lines of Hyperapp, with an HTTP service being called to get the word.
But one thing could lead to a much better user experience. Why do we need an input field? We could just ask the user to type a letter and take that as their guess.
Let's change the UI first, and then work out how to implement that.
We just need to replace our UserInput
with the instruction to type a letter:
: [
Word(state),
p(
{ style: { textAlign: "center" } },
"Type a letter to have a guess."
)
])
Don't forget to subscribe
To respond to key presses anywhere in our application we need to look at the last tool in our core toolset from Hyperapp: Subscriptions. Subscriptions respond to global events and call actions for our app. Examples of subscriptions include:
- timers
- intervals (to fetch things from servers)
- global DOM events.
We'll be subscribing to the keyDown
event and calling our GuessLetter
Action every time the event is fired.
import { onKeyDown, targetValue, preventDefault } from "@hyperapp/events";
Subscriptions get added to our app
function:
app({
init: /* ... */,
view: /* ... */,
subscriptions: () => [onKeyDown(GuessLetter)],
node: document.getElementById("app")
});
We need to make some changes to GuessLetter
for this to work. Currently it looks like this:
const GuessLetter = state => ({
...state,
guesses: state.guesses.concat([state.guessedLetter]),
guessedLetter: ""
});
It takes state
, gets our gussedLetter
from the state
, (we were setting that onInput
on our text field) and then adding it to state.guesses
.
We don't need that interim step of setting a guessedLetter
anymore, so we can remove our SetGuessedLetter
Action, and guessedLetter
from our initial state.
Ok, so, what's going to get passed GuessedLetter
from our onKeyDown
subscription? Our current state, and a keyDown
event object:
const GuessedLetter = (state, event) =>
We can get the actual key off the event and append it straight to our guesses:
const GuessLetter = (state, event) => ({
...state,
guesses: state.guesses.concat([event.key])
})
Return to sender
It works! But we have a bit of a problem, every key we press is being counted as a guess: numbers, punctuation, even Control and Alt.
Let's check that we have a letter before guessing:
const GuessLetter = (state, event) =>
// the letter keycodes range from 65-90
contains(range(65, 90), event.keyCode)
? {
...state,
guesses: state.guesses.concat([event.key])
}
: state;
We leave our state
untouched if the key that's pressed isn't a letter.
More fixes and enhancements
There are just a couple more enhancements and bug fixes we need to make before we're done:
- Give the user a way to play again.
- Stop letters being guessed after the game has finished
- Don't let the user guess the same letter twice - we'll do this simply by ignoring it.
Rinse & repeat.
One of the real joys of working with Hyperapp is that we only have one state going on. To allow a user to play again, we just need to reset the state.
Because we'll want to show our 'play again' button for both victory and game over states, I'm going to put it in it's own View:
// VIEWS
const PlayAgain = () => button({ onclick: ResetGame }, "Play again");
Our ResetGame
action just sets everything back to the start, and calls getWord()
again to get a new word:
// ACTIONS
const ResetGame = () => [
{
guesses: [],
word: []
},
getWord()
];
Now we add our PlayAgain
view to the UI and we're golden:
app({
init: /* ... */,
view: state =>
div({}, [
div({ class: "header" }, [
div([h1("Hangman."), h2({ class: "subtitle" }, "A hyperapp game")]),
div({}, BadGuesses(getBadGuesses(state)))
]),
state.word.length > 0 &&
(isGameOver(state)
? [
h2({}, `Game Over! The word was "${state.word.join("")}"`),
PlayAgain()
]
: isVictorious(state)
? [h2({}, "You Won!"), PlayAgain(), Word(state)]
: [
Word(state),
p(
{ style: { textAlign: "center" } },
"Type a letter to have a guess."
)
])
]),
subscriptions: /* ... */,
node: /* ... */
});
A quick refactor
For me, a downside of using @hyperapp/html
over jsx
is that visualing changes to UI becomes quite difficult. One way to get around this is not to try and treat it like HTML, but as the functions they actually are.
I'm going to split the victorious and game over UIs into their own Views.
// VIEWS
// ...
const GameOver = state => [
h2({}, `Game Over! The word was "${state.word.join("")}"`),
PlayAgain()
];
const Victory = state => [h2({}, "You Won!"), PlayAgain(), Word(state)];
// THE APP
app({
//...
view: state =>
div({}, [
div({ class: "header" }, [
div([h1("Hangman."), h2({ class: "subtitle" }, "A hyperapp game")]),
div({}, BadGuesses(getBadGuesses(state)))
]),
state.word.length > 0 &&
(isGameOver(state)
? GameOver(state)
: isVictorious(state)
? Victory(state)
: [
Word(state),
p(
{ style: { textAlign: "center" } },
"Type a letter to have a guess."
)
])
]),
//...
});
While we're at it, let's move some other parts out into Views that make sense as well:
// THE VIEWS
const Header = state =>
div({ class: "header" }, [
div([h1("Hangman."), h2({ class: "subtitle" }, "A hyperapp game")]),
div({}, BadGuesses(getBadGuesses(state)))
]);
const TheGame = state => [
Word(state),
p({ style: { textAlign: "center" } }, "Type a letter to have a guess.")
];
// THE APP
app({
//...
view: state =>
div({}, [
Header(state),
state.word.length > 0 &&
(isGameOver(state)
? GameOver(state)
: isVictorious(state)
? Victory(state)
: TheGame(state))
]),
//...
});
There's another refactor you may have noticed here. Our ResetGame
action looks exactly the same as our app.init
:
const ResetGame = () => [
{
word: [],
guesses: []
},
getWord()
];
init: [
{
word: [],
guesses: []
},
getWord()
],
Let's move that out and make it clearer than ResetGame
literally returns us to our initial state:
// HELPERS
const getInitialState = () => [
{
guesses: [],
word: []
},
getWord()
];
// ACTIONS
const ResetGame = getInitialState();
// THE APP
app({
init: getInitialState(),
// ...
});
Stop guessing!
Our game has three states it can be in: Playing
, Lost
, and Won
. At the moment we're testing for two of these on the whole state with isGameOver()
and isVictorious()
.
We can use these in GuessLetter
to see if we should keep accepting guesses, but there might be a better way. Let's start there anyway, and refactor afterwards:
const GuessLetter = (state, event) =>
isGameOver(state) ||
isVictorious(state) ||
// the letter keycodes range from 65-90
!contains(range(65, 90), event.keyCode)
? state
: {
...state,
guesses: state.guesses.concat([event.key])
};
This stops extra guesses being accepted, but I'm not sure it's going to be clearest as to what's going on. We could make this clearer by being more explicit about the state of the game after each guess.
I'd normally do this by setting up a constant that represents all of the states:
const GAME_STATE = {
PLAYING: 1,
LOST: 2,
WON: 3
}
But in this case, we already have two of these states working nicely with our isGameOver()
and isVictorious()
helpers. For an application this small, I don't think we need can justify all the extra overhead. Let's just add some more helpers to be more explicit about our intentions here.
Expressing it in plain English, we want to allow a guess if the user is still playing and the key they pressed is a letter:
const GuessLetter = (state, event) =>
isPlaying(state) && keyCodeIsLetter(event.keyCode)
? {
...state,
guesses: state.guesses.concat([event.key])
}
: state;
That's clearer. And for the helpers...
const isPlaying = state => !(isGameOver(state) || isVictorious(state));
const keyCodeIsLetter = keyCode => keyCode >= 65 && keyCode <= 90;
Our last part of this then is to stop duplicate letters. We'll take the same approach and write in the helper function we'd want in here and then write the actual helper after.
isPlaying(state) &&
keyCodeIsLetter(event.keyCode) &&
isNewLetter(state, event.key)
// HELPERS
const isNewLetter = (state, letter) => !contains(state.guesses, letter);
That's a wrap
And there we have it, Hangman in Hyperapp. If you have any questions or comments you can reach me on Twitter at @adamdawkins or email at adam@dragondrop.uk
This tutorial was originally posted on adamdawkins.uk on 3rd December 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.