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:
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>
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
}
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
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 commandRandom.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
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"
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
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)
Yet another shining example that "LOC is a bad metric for anything" - in either direction.
Also worth noting:
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.
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.
The docs explains clearly what each part does. It's just that we are not used to having optional constructs and keyword aliases.
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: