My first Elm program (hangman game)

Hi, Just to get the hang of things, I started out with the simplest interesting project I could come up with - a hangman game.

Here’s my code, I’ve probably done some things sub optimally, and would love to hear what experienced Elm programmers have to say.

Edit: updated version - formatted code, as per suggestion (also added a separation between the letters).

5 Likes

Congrats on finishing your first Elm game! One suggestion when using Ellie, you can press this button Screenshot 2020-11-18 at 13.34.39
and it will automatically format your code.

1 Like

awesome. i think there should be a bit of space between each underscore

Nice work! I love that you included both clickable buttons and keyboard support for letters.

I notice that you run two commands for to first randomly generate an index for the category and then to randomly generate the index for the word. One of the nice things about Elm is that it allows you to combo generators together such that you only to make a single call to Random.generate.

In this program for example, you might create a Generator (String, String) that generates a tuple of a random category name and a random word from within that category.

Single generator

wordGen : Generator (String, String)
wordGen =
  Random.int 0 (Array.length categories_and_words - 1)
    |> Random.andThen (\categoryIdx ->
      case Array.get categoryIdx categories_and_words of
        Just { category, words } ->
          Random.int 0 (Array.length words)
            |> Random.andThen (\wordIdx ->
              case Array.get wordIdx words of
                Just word ->
                   Random.constant (category, word)
                Nothing -> 
                  Random.constant ("", "")
            )
        Nothing ->
          Random.constant ("", "")
    )

While it does the job, this is a pretty intimidating piece of code! :cold_sweat: :scream: :see_no_evil:

We could clean it up by breaking it up into smaller functions but that won’t address the underlying issues: Handling those Maybe values and needing to index into an Array.

Using List instead of Array

If you store the categories and words as a List rather than an Array, that allows the use of a convenient helper function Random.uniform that will majorly simplify the generator.

wordGen : Generator (String, String)
wordGen =
    Random.uniform { category = "", words = [] } categoriesAndWords
      |> Random.andThen (\{category, words} ->
        Random.uniform "" words
          |> Random.andThen (\word -> Random.constant (category, word))
      )

Much cleaner! No more messing around with indices, and handling cases where we couldn’t find a word or category is mostly gone.

Many languages build their programs around arrays + indices. In Elm that style of programming tends to be much rarer in favor of other constructs provided by the language.

The one sort-of ugly thing left is that first argument to Random.uniform that’s there as a default just in case the list is empty. However, since we’re working with hard-coded data, we know the list will never be empty. If only there were a way to convince the complier…

Using List.NonEmpty

There are a variety of third-party packages that provide a non-empty list type. These guarantee at least one item and thus getting the head of the list does not return a Maybe. I’m a fan of turboMaCk/non-empty-list-alias.

With such a type, we could write a variation of uniform that doesn’t require a default:

uniformNonEmpty : List.NonEmpty a -> Generator a
uniformNonEmpty (head, rest) =
  Random.uniform head rest

using this we can simplify the wordGen again:

wordGen : Generator (String, String)
wordGen =
    uniformNonEmpty categoriesAndWords
      |> Random.andThen (\{category, words} ->
        uniformNonEmpty words
          |> Random.andThen (\word -> Random.constant (category, word))
      )

You may or may not want to bring in a third-party package for this project. If so, the List + Random.uniform example shown earlier will work just fine.

2 Likes

Actually, the first argument in Random.uniform has equal probability of being chosen as any member of the list.

Using a Nonempty list is great!

1 Like

Thanks!

The non empty list solution is great, it would probably be better than using regular string even if I didn’t hard code the list - just return a Maybe List.NonEmpty.

I didn’t know that a function could deconstruct a list into its arguments. I would have expected the syntax to resemble the case deconstruction (x :: xs), or did it require some tinkering from the NonEmpty list’s code? Will such deconstruction work for regular lists?

Doesn’t randomly choosing from a list have a complexity O(N), while choosing randomly from an array has the complexity O(1)? In this particular case it doesn’t matter, as there aren’t many elements, but if we scaled it up to contain e.g. all the entries in wikipedia, we might start to see a difference.

The second version (see edit) has space between each underscore :slight_smile:.

Well done! I also did a hangman game some years ago, as I was learning Elm. If you want to check it out (for comparison, not that it’s any good), the source is at https://github.com/runarfu/elm-hangman and it’s playable at https://runarfu.github.io/elm-hangman/. It only has one secret sentence, but my friend made some awesome graphics for it, and I would encourage pressing all wrong buttons, as it’s the best part of the game :slight_smile:

1 Like

Well done!

About better representation, you could represent the remaining tries as a stack (which is trivially implemented as a list in elm), each element in the list being the drawing displayed. The number of remaining tries simply is the length of the list, and every time the player gives a wrong answer, you pop the first element of the list.

1 Like

Thanks, is this what you mean?

yeah, totally!

You can be a more elegant with destructuring the list (instead of calling List.head AND List.drop):

    Letter ch ->
        if Set.member ch model.letters_left then
                ( { model | letters_left = Set.remove ch model.letters_left }, Cmd.none )

            else
                case model.remaining_filed_stage_graphics of
                           first :: others ->
                                 ( { model | current_fail_stage_graphics = first
                                                , remaining_filed_stage_graphics  = others
                                   }
                                , Cmd.none)
                         [] -> (model, Cmd.none) --this shouldn't happen

By the way, the convention in elm is to use camelCase, so we’d write: remainingFieldStageGraphics.

1 Like

Good catch. I should have said that the first argument is there to guarantee at least one item

1 Like

Elm allows you to destructure directly in the arguments if that type has only a single possible pattern. Lists have two patterns [] and x :: xs so you need an explicit case expression to cover both branches.

List.NonEmpty is an alias for a tuple:

type alias NonEmpty a =
  (a, List a)

so what I’m doing in the argument is pattern-matching on a tuple :slightly_smiling_face:

1 Like

Yes, choosing randomly from a list would be O(n). Technically arrays are implemented as trees with a high branching factor so they’d be something like O(logBase64(n)) which is effectively O(1) for the sizes we tend to work with.

When looking at performance in elm, DOM changes tend to massively outweigh any other kind of calculation in terms of computation cost. This means that practically speaking using an O(1) operation vs an O(n) operation on a list doesn’t make a measurable impact on the performance of your Elm code even for large lists. Additionally, because Elm apps run in the browser, the lists they tend to work stay small.

All this to say that in general, you can ignore time complexity when working with collections in your app code and instead optimize for readability and idiomatic approaches.

2 Likes