Clojure Bites - Mazeboard 3 - core.async to update the UI layer

by FPSD — 2023-12-09


In the last post we have started decoupling game and UI logic in order to be able to run the two layers in different processes; we are not there yet and it will take some time to get to the final client/server setup, but we will get there, eventually :)

We have moved some logic and the shared state to the game layer, even if the UI is still using the state atom from the namespace, actions are sent to game layer using core.async instead of calling the update functions directly; it is not much but it is honest work.

Goal of this post is to further decouple UI and game logic by having a separate state for both layers and propagate changes from the game layer to the UI via core.async.

Recap of the previous changes

We started from something like this:

And we ended up with the new setup:

The UI still listen to the game state atom directly, and we are going to fix this.

The commit is available here.

Two layers, two state atoms

We want to use two distinct state atoms for:

The most obvious thing to do would be to keep the pre existing game state atom in the namespace and a dedicated state atom to mazeboard.ui.core namespace so that the two layers can happily live their independent lives :)

This is not enough, because we want to be able to update the UI state after something happened in the game layer; this must somehow be communicated to the UI layer via core.async channels but given that now we are directly "assoc"ing or "update(-in)"ing directly to the game state map, it is not easy to transfer the intended changes between layers.

Let's do a step back, or sideways, to try do define a data format that can describe state changes that can be transferred between layers and applied accordingly to both states.

Abstracting state changes

We can start by having a look at how the action that handles player's movement is implemented:

(defn action-move-player
  [{:keys [current-player players] :as game} {:keys [direction target]}]
  (let [old-pos (get-in players [target :position])
        new-pos (update-position (get-in game [:players target :position]) direction)]
    (-> game
        (assoc-in [:players target :position] new-pos)
        (assoc :current-player (mod (inc current-player) (count players)))
        (update-in [:board :tiles old-pos :players] disj target)
        (update-in [:board :tiles new-pos :players] conj target)
        (update :round-number inc))))

All it is doing is to take the game map, and applying assoc(-in) and update(-in) functions to it; update and update-in can be replaced by their assoc forms if handled correctly so, if we can replace the direct calls to assoc(-in) functions to data then we then can abstract away what we want to do using just data in a form the looks something like:

[[:assoc-in [:players target :position] new-pos]
 [:assoc-in [:board :tiles old-pos :players] old-tile-players]
 [:assoc-in [:board :tiles new-pos :players] new-tile-players]
 [:assoc :current-player (mod (inc current-player) (count players))]
 [:assoc :round-number (inc round-number)]]

We can then have a function that takes a data structure, the requested changes and that can spit out a new, updated data structure:

(defn apply-commands
  [state commands]
   (fn [game command]
     (match command
            [:assoc key value] (assoc game key value)
            [:assoc-in path value] (assoc-in game path value)))
    state commands))

With all these building blocks in place what we have left to do is to update our code to:

The final result can be seen here

Changes can be summarized as following:


We have started with a tightly coupled core and UI logic code base, slowly we have pushed to the edges the concerns that are more suited to each layer; communication between layers is based on core.async to help us to reason about intents (user actions or state changes) and we have closed the circle with a setup that leverages the abstractions that we have built to give us the possibility to reason about game and UI independently.

It is not 100% evident at this point, but the UI and game state are still sharing way too much structure:

This will be addressed in the next post, but for now I am happy with what we have achieved!

Mazeboard related posts