DEV Community

Cover image for Elm vs HyperScript - A Wordle implementation
lucamug
lucamug

Posted on • Updated on

Elm vs HyperScript - A Wordle implementation

HyperScript: Demo, The source code is in the HTML Page Source.

Elm: Demo, Source Code, Playground


A few days ago I came across a simple Wordle implementation in HyperScript, a new event-oriented front-end scripting language, inspired by HyperTalk and a companion of htmx.

Not sure exactly what a "companion of htmx" stands for here. It could be that both are sponsored by the same company or that they complement each other on the same page with the common goal of avoiding JavaScript.

The HyperScript implementation of Wordle is written in 57 total lines of code.

The code is in the source of the HTML page and it looks quite impressive that the entire implementation can be so compact.

So I decided to rewrite it in Elm and see how it compares:

Image description

The DOM is the state

The HyperScript version is built around the DOM. The DOM is used as a global mutable variable that stores the state of the application, similar to how web applications were written using JQuery.

In detail, there are three classes .bg-muted, .bg-warning, and .bg-success which are written and read throughout the life of the app, to change the state of blocks, both in the gameboard and on the keyboard.

Then there is the class .guess that is used to mark the latest row typed by the user.

The HyperScript is partially written within the <script> element and partially inlined inside HTML, using the _ attribute:

<script type="text/hyperscript">
    init fetch words.json as json then set $word to (random in it).toUpperCase() then log $word

    def submit()
        set remaining to $word
        repeat for ch in children of first .guess index i
            set key to <[letter=${ch.innerText}]/>
            add .bg-muted to key
            if ch.innerText == $word[i] then
                add .bg-success to ch add .bg-success to key
                set remaining to remaining.replace(ch.innerText, '')
            end
        end
        repeat for ch in children of first .guess
            set key to <[letter=${ch.innerText}]/>
            if remaining.includes(ch.innerText) then add .bg-warning to ch add .bg-warning to key end
            set remaining to remaining.replace(ch.innerText, '')
        end
        if (.bg-success in first .guess).length == 5 then remove .guess from .guess
        otherwise remove .guess from first .guess
    end

    behavior kbdRow(keys) 
        on click[target[@letter]] send keyup(keyCode: (target[@letter]).charCodeAt(0)) to <body/>
        init repeat for ch in keys 
            append `<button letter='${ch}' class='secondary ma1 pa2 w2'>${ch}</button>` to me
    end
</script>

<body _="
    on keyup(keyCode)[keyCode >= 65 and keyCode <= 90] put String.fromCharCode(keyCode) into first <:empty/> in first .guess
    on keyup(keyCode)[keyCode == 8] put '' into last <.guess > :not(:empty)/>
    on keyup(keyCode)[keyCode == 13 and (<:not(:empty)/> in first .guess).length == 5] call submit()
    ">
    <div class="container">
        <h1 class="tc">Hyperwordle</h1>
        <div _="on load set h to my innerHTML then repeat 5 times put h at the end of me">
            <div class="guess contrast flex ttu tc b f3 w-100 justify-center">
                <span class="w3 h3 ma1 pa3 bg-muted"></span>
                <span class="w3 h3 ma1 pa3 bg-muted"></span>
                <span class="w3 h3 ma1 pa3 bg-muted"></span>
                <span class="w3 h3 ma1 pa3 bg-muted"></span>
                <span class="w3 h3 ma1 pa3 bg-muted"></span>
            </div>
        </div>
        <div class="flex justify-center ttu tc" _="install kbdRow(keys: 'QWERTYUIOP')"></div>
        <div class="flex justify-center ttu tc" _="install kbdRow(keys: 'ASDFGHJKL')"></div>
        <div class="flex justify-center ttu tc">
            <button class="secondary ma1 pa2 w-auto" _="on click send keyup(keyCode: 13) to <body/>">Enter</button>
            <div class="flex" _="install kbdRow(keys: 'ZXCVBNM')"></div>
            <button class="secondary ma1 pa2 w-auto" _="on click send keyup(keyCode: 8) to <body/>"></button>
        </div>
    </div>
</body>
Enter fullscreen mode Exit fullscreen mode

The Elm version uses instead The Elm Architecture where the state of the app is stored in an immutable data structure called Model. What shows up on the page is then just a representation of this Model converted to HTML by the view function.

In this implementation, I adopted the idea that if some data can be calculated from others, it should not be stored in the Model. This is usually a good approach as it assure that the model doesn't contain any stale calculation. For example, all the information about which letter has been guessed or not, and also the notion that the game is over or player won the game, is calculated just-in-time, when is needed.

This is the model:

type alias Model =
    { currentGuess : List Char
    , pastGuesses : List (List Char)
    , wordToGuess : List Char
    , error : Maybe String
    }
Enter fullscreen mode Exit fullscreen mode

It only stores what the user typed and what is the secret word to guess, plus a possible error from loading asynchronously the list of words to guess.

This is the entire code of the Elm version. This is an Ellie version of it.

Compactness

The HyperScript compactness is a combination of several things, but mainly the idea of DOM-oriented syntax. The language is deeply integrated with the DOM. Adding/removing classes is a breeze. For example on click toggle .red on me. How can it get more direct and shorter than this?

Let's analyze another line of HyperScript code:

init fetch words.json as json then set $word to (random in it).toUpperCase() then log $word
Enter fullscreen mode Exit fullscreen mode

This is a remarkable small piece of code that does many things:

  • On initialization of the app, send a GET HTTP request to fetch "words.json"
  • Interpret it as JSON data
  • Extract a random element from the JSON list (The secret word to guess) and assign it to $word
  • Log this value into the console

This is something that in Elm require more structured work:

  • In the init function we start sending out the command Random.generate NewRandom (Random.float 0 1) instructing the Elm runtime that we would like to have a random number. This is pure functional code, so no impure functions are allowed, this is one of these things that require some mindset-shift. Consider Elm as your assistant that does things for you. You just need to ask.
  • Once the random number is ready we will get a message. At that point we can ask the Elm runtime to send out a GET request to get words.json and that we expect it to have a certain structure.
  • Once words.json data is ready, we will get another message informing us of the result of the operation, including all possible sort of failure (network down, not a JSON file, not a data structure we expected, etc.)
  • At this point, we can use the random number and the list of words to peak what secret word and save in the Model.

What happens in the HyperScript version if something goes wrong with the JSON file as described above?

The app just keeps running and lets you type the first row of characters. After that, it stops working.

Note that the HyperScript version generates errors in the console also during the normal execution. For example, continuing to type after reaching the five characters in a row, generates errors. The same when continuing to type after a solution is found. Probably this has been done on purpose, to make the code shorter. To avoid these errors, some extra code would be necessary.

Elm instead forces us to consider all the edge cases so that these types of errors became impossible. So, in case something goes wrong with the GET request, I display a message on the screen.

Typos

In my projects, I try to repeat only once all the strings. This is because the content of strings is not checked by the compiler and typos can be deadly.

For example, there are three possible state for a block: .bg-muted, .bg-warning, and .bg-success. These are classes added to the DOM and have these meanings:

  • .bg-muted (light gray background) is used for when a key in the keyboard has been used,
  • .bg-warning (light yellow background) is for when a character is used but it is in the wrong position
  • .bg-success (green background) when the character is a perfect match

In Elm I replaced them with a custom type:

type CharState
    = Muted
    | Warning
    | Success
Enter fullscreen mode Exit fullscreen mode

This guarantees that no typos can go into the code. Then I have a conversion function, used to inject the classes in the DOM:

charStateToClass : CharState -> String
charStateToClass charState =
    case charState of
        Muted ->
            "bg-muted"

        Warning ->
            "bg-warning"

        Success ->
            "bg-success"
Enter fullscreen mode Exit fullscreen mode

These strings appear only here in the entire code, so the possibility of introducing errors is minimized.

The HyperScript code instead has these strings used multiple times, for example:

add .bg-success to ch add .bg-success to key
Enter fullscreen mode Exit fullscreen mode

Any typo in the string bg-success will go undetected and just causes a malfunction in the app, without any apparent error notification. So only ad-hoc tests can detect these issues.

Code and bundle sizes

The Elm implementation, which tries to mimic the original HyperScript implementation (there is probably a more "functional" way to implement the Wordle game itself), is around 260 lines of code. Around five times the original HyperScript version.

I would argue that the Elm version is more readable and simpler to be expanded with additional features but here we enter into a biased territory caused by my knowledge of Elm.

The HyperScript version, in addition to what is in the source of the page, requires an HyperScript runtime library that is 86KB minified and 24KB zipped.

The Elm language compiles to JavaScript and it doesn't need any separated runtime library as it is already included in the compiled JavaScript. The output of this Wordle implementation written in Elm is 33KB minified and 14KB zipped.

So this means that concerning the sizes of stuff to load, Elm is around half the size of HyperScript.


There are many more differences, but this is all that I have for this article. It has been a fun exercise. Have a look at the respective codes to learn more about these two languages.

There are also alternative implementation of Wordle in Elm here, here and here.

❤️


HyperScript: Demo, The source code is in the HTML Page Source.

Elm: Demo, Source Code, Playground


Note: In the past, there has been some disagreement about the choice of "vs" as the title in this series of articles. I agree with the criticism: there is "plenty of room for everyone". So please consider "vs" in the sense of a "comparison" rather than a "deathmatch".

Top comments (3)

Collapse
 
jmpavlick profile image
John Pavlick

Yet another shining example that "LOC is a bad metric for anything" - in either direction.

Also worth noting:

<div _="on load set h to my innerHTML then repeat 5 times put h at the end of me">
Enter fullscreen mode Exit fullscreen mode

While often touted as a benefit, I would argue that language grammars that "read like English" are a barrier to readability and writeability.

From reading that line of code, I know what it's supposed to do, but I have no idea what it's actually doing. How many of those tokens are keywords? How many of them are ignored by the runtime? How many of them are functions, and what are their arguments?

Good language grammars expose a form, a shape, such that the operations performed by the nouns and verbs in that grammar are obviously different one from another; this allows the user to infer what the language is actually doing. It's easier to build a mental model of what the language is supposed to be doing from what it's actually doing than it is to reason in the opposite direction.

Just my $0.02.

if some data can be calculated from others, it should not be stored in the Model

is one of those things that should be patently obvious, but it's great to see it in black and white every now and then.

Collapse
 
abdurrahmaanj profile image
Abdur-Rahmaan Janhangeer

The docs explains clearly what each part does. It's just that we are not used to having optional constructs and keyword aliases.

Collapse
 
konung profile image
konung

Just came across this post. I'd like to mention a couple of tools that may be helpful in evaluating _hyperscript, especially for someone coming over from elm: