Building Hangman - Sketching with code
Now that we've got Hyperapp installed, we're ready to try and make some steps towards our goal: Hangman.
Let's remind ourselves of our brief from Part 1:
- The Computer picks a random word for us to guess
- 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
- If the Player fills in the word correctly, they win.
Sketching with code
When first starting on a new problem, (that's what interface is really, a set of problems needing solution), it's important not to get too precious about how your application will end. It's important to make a start.
By "Sketching with code", we'll do the coding equivalent of a quick visual mockup of a design, let's get some things in place without being too precious about it, working in broad strokes.
We know our app()
function in Hyperapp takes 3 things: init
, view
and node
. We solved node
in Part 1, so we have a choice. The State or the View?
When I'm building a website, I nearly always start with the View. Let me put my elements on the page, and then go from there. However, in this case, I'm not entirely sure how I want to take input, or how I'm going to display guesses - so let's think about State.
The Loop
In Hyperapp, we can think of our UI as a function that gets called again, and again, and again in response to outside events: user interaction, time, whatever we want.
This function takes one thing, state
, which is all the information it needs to display the UI correctly. When we respond to events in the world (e.g. somebody entering a letter), we trigger an Action that changes the State.
State In, UI out. State in, UI out.
Knowing that we have this loop going on really helps us think about our State. What information do we need to be able to show the user in a game of hangman?
- The word they're guessing.
- The letters they've guessed.
That's it. We can get everything we need to know to display a game of hangman from these two pieces of information.
Let's sketch that in code.
Our initial state looks like this:
{
word: 'application',
guesses: [],
}
A word, and an array waiting to receive guesses.
We'll put that in our init
, and output the word onto the screen in our view:
import {app} from 'hyperapp';
import {div, h1, h2} from '@hyperapp/html';
app({
init: {
word: 'application',
guesses: [],
},
view: state => div({}, [
h1({}, state.word), // we output our word from the state we passed into `init`
h2({}, 'Your Guesses:')
]),
node: document.getElementById('app'),
});
Hurray, the state we define in init
becomes available to us in view
. When we change state, we can change how our UI reacts to that change in view
.
Making things a bit more 'Hangmany'
Seeing the word on the screen reminds me of a crucial part of Hangman's UI: you're not meant to be able to see the word you're guessing! You're also meant to display lines for letters you haven't guessed, and fill in the letters you have.
We also need to show our incorrect guesses. Phew! A fair bit to do.
Let's start by putting some guesses into our state and outputting them in our view.
import {app} from 'hyperapp';
import {div, h1, h2, ul, li} from '@hyperapp/html';
app({
init: {
word: 'application',
guesses: ['a', 'b', 'c', 'd'],
},
view: state =>
div({}, [
h1({}, state.word),
h2({}, 'Your Guesses:'),
ul(
{class: 'guesses'},
state.guesses.map(guess => li({class: 'guess'}, guess)),
),
]),
node: document.getElementById('app'),
});
We can now see our guesses on the screen, and we only really need to hide some information to have a genuine Hangman state - a good place to be in!
What the map?!
Let's quickly make sure we understand what's going on here where we display our guesses:
state.guesses.map(guess => li({ class: 'guess' }, guess))
.map
applies a function to each element in an array, and then returns that array. Because all of our UI is just functions - We're turning our array of guesses:
[
'a',
'b',
'c',
'd'
]
into an array of li
functions:
[
li({ class: 'guess' }, 'a'),
li({ class: 'guess' }, 'b'),
li({ class: 'guess' }, 'c'),
li({ class: 'guess' }, 'd')
]
And of course, we know li
is just a short-hand for h('li',...)
- and it's h()
that is adding these elements to our Virtual DOM. Remembering it's all functions will help us tidy this up later on. But for now, back to Hangman.
Hiding the answer
So, let's hide the answer, and only show our bad guesses, and we're well on our way.
To start with, our word isn't that easy to deal with is as string, so let's turn it into an array.
init: {
word: 'application'.split(''),
guesses: ['a', 'b', 'c', 'd'],
},
and now let's output the mdash
character for each letter of the word.
(In HTML: the mdash is output with —
, in Javascript we have to use a mysterious unicode ¯_(ツ)_/¯)
// import ...
// ...
const mdash = '\u2014';
// ...
app({
//...
view: state =>
div({}, [
h1({}, state.word.map(() => span({class: 'letter'}, mdash))),
h2({}, 'Your Guesses:'),
ul(
{class: 'guesses'},
state.guesses.map(guess => li({class: 'guess'}, guess)),
),
]),
//...
});
NB: Don't forget to import the span
function from @hyperapp/html
here, I won't keep including that line, you'll be able to work out when to add these.
Great, we're hiding our word, but we've gone too far. We need to show the letter for letters we got right, and only show bad guesses underneath.
Time to Refactor
We want to keep moving fast, but we're duplicating a few things, and there's some definite sections to our UI: The Word and the Incorrect Guesses. Let's write some helpers and views to tidy things up.
As we need to work with arrays here, let's write a nice helper function to let us know if an array contains a thing we give it:
const contains = (list, item) => list.indexOf(item) > -1;
This way, instead of having indexOf
all over the place, we can check if a letter has been guessed like this:
contains(guesses, letter)
Or if a guess is in the word:
contains(word, guess)
Let's put it into action.
div({}, [
h1(
{},
state.word.map(letter =>
span({class: 'letter'}, contains(state.guesses, letter) ? letter : mdash),
),
),
h2({}, 'your guesses:'),
ul(
{class: 'guesses'},
state.guesses.map(guess => li({class: 'guess'}, guess)),
),
]);
We can see our guesses in the word now. If the letter has been guessed, we display it, if not we show a dash.
We'll do the same for the guesses and only show the bad ones:
app({
// ...
view: state =>
div({}, [
h1(
{},
state.word.map(letter =>
span(
{class: 'letter'},
contains(state.guesses, letter) ? letter : mdash,
),
),
),
h2({}, 'Incorrect Guesses:'),
ul(
{class: 'guesses'},
state.guesses
.filter(guess => !contains(state.word, guess))
.map(guess => li({class: 'guess'}, guess)),
),
]),
//...
});
This time we add a filter, which only keeps the elements in an array when the filter function is true. If the word doesn't contain this guess, we output it in our bad guesses
Tidy Up Time
Ok good, we can see how hangman would work from here, we just need a way of updating the guesses, which will be our next lesson. Before then, we can do various bits of tidy up to make this look a lot easier to manage.
Putting things in drawers
I like to organize my Hyperapp applications into six drawers:
- Utility Functions - general purpose functions that help us operate clearly, we wrote the
contains()
utility function in this part. - Helper functions - functions specific to our application that help explain our intentions for the app.
- Effects (we'll use those later)
- Actions (we'll use those later)
- Views
- The
app()
call
I keep these six headings in my index.js file, and use the same file for as long as possible, especially when I'm still sketching with code.
There are a couple of helpers we can write already, tidying up both of our filters.
// HELPERS
const isGuessed = (letter, state) => contains(state.guesses, letter);
const isInWord = (letter, state) => contains(state.word, letter);
// THE APP
app({
//...
view: state =>
div({}, [
h1(
{},
state.word.map(letter =>
span({class: 'letter'}, isGuessed(letter, state) ? letter : mdash),
),
),
h2({}, 'Incorrect Guesses:'),
ul(
{class: 'guesses'},
state.guesses
.filter(guess => !isInWord(guess, state))
.map(guess => li({class: 'guess'}, guess)),
),
]),
//...
});
This describes what we're trying to do a bit better, but we can take it further.
Remember, in Hyperapp, all views are functions. this h1
can become a function (view) called Word, and can have WordLetter views within it - moving all the detail about whether or not to display a letter or a dash somewhere else.
const WordLetter = (letter, guessed) =>
span({class: 'letter'}, guessed ? letter : mdash);
So, first we have a WordLetter
view, which is going to take a letter, and a guessed
boolean, so we know whether or not show it.
Then, we want to move the whole Word
into a view as well.
We need to pass state
in here, because to say whether or not a letter is guessed, we need to access state.guesses
(via our isGuessed
helper)
Word
looks like this:
const Word = state =>
h1(
{},
state.word.map(letter => WordLetter(letter, isGuessed(letter, state))),
);
and now, we can put that back into our view
:
app({
//...
view: state =>
div({}, [
Word(state),
h2({}, 'Incorrect Guesses:'),
ul(
{class: 'guesses'},
state.guesses
.filter(guess => !isInWord(guess, state))
.map(guess => li({class: 'guess'}, guess)),
),
]),
//...
});
Let's do something similar with the incorrect guesses, and we can move on.
// HELPERS:
// throwing `!isInWord` around was getting ugly
const badGuesses = state =>
state.guesses.filter(guess => !isInWord(guess, state));
// ...
// VIEWS
const BadGuesses = state => [
h2({}, 'Incorrect Guesses:'),
ul(
{class: 'guesses'},
badGuesses(state).map(guess => li({class: 'guess'}, guess)),
),
];
// APP
app({
init: {
word: 'application'.split(''),
guesses: ['a', 'b', 'c', 'd'],
},
view: state => div({}, [Word(state), BadGuesses(state)]),
node: document.getElementById('app'),
});
Game Over
To finish our first sketch of hangman, we need to think about the two end states: Game Over and Victory.
Let's start with victory, we know the user has won if they have guessed all of the letters in the word before 8 tries. In other words:
const isVictorious = state =>
state.word.every(letter => isGuessed(letter, state))
Array.every
returns true if every element in the array passes the test. Our test here is whether not "every" letter "isGuessed".
We can hard code a victory and use this in our view:
app({
init: {
word: 'application'.split(''),
guesses: ['a', 'p', 'l', 'i', 'c', 't', 'o', 'n'],
},
view: state =>
div(
{},
isVictorious(state)
? [h1({}, 'You Won!'), Word(state)]
: [Word(state), BadGuesses(state)],
),
node: document.getElementById('app'),
});
We already have the helper we need for gameover, badGuesses
. Let's just write in code somewhere how many guesses you're allowed: 7.
const MAX_BAD_GUESSES = 7; // this doesn't change, so we don't need to store it in State.
// HELPERS
const isGameOver = state => badGuesses(state).length >= MAX_BAD_GUESSES;
app({
init: {
word: 'application'.split(''),
guesses: ['a', 'p', 'l', 'i', 'c', 't', 'o', 'n'],
},
view: state =>
div(
{},
isGameOver(state)
? h1({}, `Game Over! The word was "${state.word.join('')}"`)
: isVictorious(state)
? [h1({}, 'You Won!'), Word(state)]
: [Word(state), BadGuesses(state)],
),
node: document.getElementById('app'),
});
This gives us a working application, in theory. We just need to allow the User to interact with our application and change the guesses in the state. That's our next episode.
This tutorial was originally posted to adamdawkins.uk on 7th October 2019
Top comments (0)