DEV Community

Adam Dawkins
Adam Dawkins

Posted on • Edited on

Building Hangman with Hyperapp - Part 5

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

Top comments (0)