It has been awhile since I’ve done much front end development (I’m the guy who thinks vi is peak UI, so it makes sense that people are reluctant to put me in charge of their product’s user experience), so I thought I’d dust off my skills and throw together a web game to see what the state of the art looks like these days. I also haven’t done too much with Clojure recently so I figured I would use ClojureScript to build a single page application.
You can find the code for this project here.
The Game
The game I implemented was based on the classic n-back cognitive performance test. You are presented with a sequence of letters and click the letter if it is the same as the letter n (starting at two) letters back. So if n is 2 and the sequence is
- A
- C
- D
- C
…you would click the second C.
This is easy at first but every 10 correct clicks n is increased, so it quickly gets more challenging.
You can play the game here.
Re-Frame
To implement it, I used the re-frame library. Based on the popular React project, it provides a “data-oriented” interface to building single page applications. Application state is stored in an internal “database” (really just a JavaScript object) which is then interacted with through subscriptions and events.
When I first started using it, it seemed to make things much more complicated. It probably is overkill for simple applications. To interact with a single field stored in the DB, you have to register an event, provide an event handler that saves the field to the DB, register a subscription that pulls the field from the database, and finally have your UI component subscribe to it. At the very least this could be made easier with syntactic sugar.
Now advocates for re-frame may well point out that such trivial use cases as “getting and setting a property” are not where most of the difficulties of building a UI sit. And if you find yourself doing that a lot, that may be because you are trying to break down complicated tasks sub-optimally. And that may be true, and I did see that as I built Echo. But that is how people start using the library, when they are building a simple “Hello World” app that just stores and retrieves a name.
Initial Design
My initial approach centered around a tick
event. That fit the gameplay pretty well. Every few seconds, a tick event is kicked off, which handles the game’s rules. It checks to see if the player had clicked the number (via a property set by an event generated by the onclick function), and whether or not the number was supposed to be clicked. If both are true, it increments the score, and possibly the value indicating how far back to look if the user advanced a level. If one is true and the other isn’t, the player loses. If neither is true, it just continues on. It then updates the sequence by choosing another letter, weighing the “echo” letter and other recent letters more.
This is what the tick function looked like
(defn tick
"Perform a tick"
[{:keys [s n clicked? score] :as db}
{:keys [is-n is-recent options points-per-level]}]
(let [echo? (game/is-echo? s n)
scored? (and clicked? echo?)
new-seq (conj s (game/choose-next s options n is-n is-recent))
new-score (if scored? (inc score) score)
advance? (and scored? (zero? (mod new-score points-per-level)))
lost? (or (and clicked? (not echo?))
(and (not clicked?) echo?))]
{:db (assoc db
:s new-seq
:scored? scored?
:score new-score
:lost? lost?
:running? (not lost?)
:level-change? advance?
:n (if advance? (inc n) n)
:clicked? false
:fade? false)
:next-turn (not lost?)}))
If you don’t understand Clojure code, some of that may be confusing. Clojure is a Lisp, which makes it a bit unusual to those used to C style languages. But it’s pretty simple. It defines a function tick
that takes in two arguments, the database and a config. Those two arguments are deconstructed into the set of properties the function needs. From the DB it gets current state properties including the current sequence (s
), the n
value to evaluate if there was an “echo” and a click is expected, whether or not there actually was a click (clicked?
, in Clojure it is a standard practice to name Boolean values with a ‘?’, that isn’t any sort of special syntax), and the current score. From the config it pulls out is-n
and is-recent
, two values which determine what percentage of the time the next value should be the n-back value or a different recent value, the list of options
for the sequence (for instance the letters of the alphabet), and the number of correct clicks needed to advance to the next level (points-per-level
).
The let
expression then defines the values needed to move to the next turn. Whether or not there was an echo (echo?
), whether or not the player scored (scored?
), the new score (new-score
), whether or not the player advanced to the next level (advance?
), whether or not the player lost (lost?
), and the new sequence (new-seq
). It then uses those to update the db
object that is returned as the new state.
This function works. But there are a couple of issues. First, that function is doing a lot. It determines the next sequence, whether or not the player won or lost, the new score and level, etc. Not only does that result in some inelegant code, but it also makes it less responsive than I would like. After clicking the letter, you have to wait until the next tick to determine whether or not you scored or lost.
A Better Version
My initial approach came from thinking of the game as a big loop. Maybe that’s because I’ve done so much backend programming where that is often what you are dealing with. Once you finish one task you pull the next one off the queue. There is no need to wait for some user to click a button. But that is not the approach Re-Frame (or React for that matter) seems to want.
So I instead decided to separate things out into more events. Instead of having the click event just set a database property, the new click function goes ahead and evaluates whether or the player scored or lost. And depending on that answer, it then either dispatches a score event or game-over event.
(defn click
[{:keys [s n]}]
{:dispatch [(if (game/is-echo? s n)
::score
::game-over)]})
This resulted in a simplified tick function, as shown here.
(defn tick
"Perform a tick"
[{:keys [s n scored?] :as db}
{:keys [is-n is-recent options period]}]
(let [echo? (game/is-echo? s n)
new-seq (conj s (game/choose-next s options n is-n is-recent))
lost? (and (not scored?) echo?)]
{:db (assoc db
:s new-seq
:clicked? false
:scored? false
:fade? false)
:fx [[:dispatch-later {:ms 500 :dispatch [::fade]}]
(if lost?
[:dispatch [::game-over]]
[:dispatch-later {:ms period :dispatch [::tick]}])]}))
Ok, it’s not really that much shorter. But part of that is because I got rid of the next-turn
event which was no longer needed after I figured out to use :dispatch-later
for events to take place in the future. It still handles one loss condition, when the player fails to click an echo. But it is more responsive. And this paradigm did make it easier to handle other asynchronous actions like flashing the score display and level displays when they increase.
Of course that asynchrony comes at a price. I had other changes needed to fix bugs such as extra clicks being generated, or ticks from a previous game continuing after the player starts a new game. Neither of those were particularly difficult to deal with, though.
Conclusion
I can certainly see the value re-frame brings to UI development. It does help simplify development of UIs that need to quickly respond to events. It may be that this was overkill for such as simple project. That’s ok, my purpose wasn’t to build a million dollar app (but if the New York Times figures people are bored with Wordle and wants something else, let me know). This was something I played with on nights over the weekend to learn a new technology. And if I have to build a front end in the future I can see this coming in handy.