Clojure Bites - Mazeboard 1 - Dumdom event handler

by FPSD — 2023-11-30


Intro

In the previous post I've introduced the game I am working on, presenting the stack and the general code structure I'd like to follow, I've closed the post with a couple of screenshot of a static game board, now it is time to make it a bit more interactive.

The goal is to let the user to click on one of the arrows (which represent the available actions, more actions to come later) and move the player icon to correct new position.

The game UI state

The UI state is a map that holds the data that drives the rendering of the components, and includes information about:

The map will be updated after player actions and changes will be reflected in the interface by re-rendering what have changed. To achieve this goal the UI state will be stored in an atom, attaching the rendering function to the atom watch list, something like:

(ns mazeboard.ui.core
  (:require [dumdom.core :as d]
            [mazeboard.test-data :as test-data]
            [mazeboard.ui.components.app :as app]))

(defonce state_ (atom {}))

(defn render [state]
  (d/render (app/App state)
            (js/document.getElementById "app")))

(defn init []
  (add-watch state_ :app (fn [_ _ _ new-state] (render new-state)))

  (swap! state_ assoc :game (test-data/create-game)))

create-game returns a map which is an implementation detail and can be ignored. With this setup, each time the state_ atom will be mutated, the rendering function will be called with the new state, causing a change in the UI. At this time the only way to make changes to the UI state is to manually swap! or reset! the atom, so not ideal for a real game, but it is enough get started; this approach can be useful to manually test ideas in the REPL until we are sure how something will be implemented, creating a nice interactive development environment.

Triggering actions on click events

After few iterations, playing with the game UI (man, I suck at CSS), I can shows where the players are in the board and what the player "One" can do i.e. move south or east, here is how it looks currently:

Board

Clicking on one of the arrows should trigger an action that will update the player's position, change the current player, update the round number and update the UI to reflect what has changed. First step is to handle clicks on the arrows.

Dumdom provides two ways to attach event handlers to DOM elements, specifying the key :on-click in the element configuration map:

The global event handler must be registered with dumdom.core/set-event-handler! whose only parameter is a function that will receive the target element and whatever data has been specified in the :on-click value of the element. The event handler function have a signature like (fn [target data]).

Both methods (fn or data) have use cases that can be more or less suitable depending on your specific needs, in this case I have opted for the data driven approach for two reasons:

Lets re-write the init method to setup an event handler:

(defn init []
  (add-watch state_ :app (fn [_ _ _ new-state] (render new-state)))
  
  (d/set-event-handler!
   (fn [_e actions]
     (doseq [action actions]
       (prn action))))

  (swap! state_ assoc :game (test-data/create-game)))

And here is how the Tile component looks like, with data driven :on-click trigger:

(defcomponent Tile [{:keys [actions position end-position? players]} all-players]
  (cond-> [:div.tile {:class "tile-background"
                      :key position}

           (for [{:keys [on-click action-class]} actions]
             [:div.action {:on-click on-click :class action-class}])

           (for [player players]
             (let [{:keys [class name]} (nth all-players player)]
               [:div.player {:class class} name]))]

    end-position?
    (conj [:div.end-position])))

;; example of component's actions
#_[{:on-click [[:move-player {:direction :east :target 0}]]
                :action-class "action-move-east"}
   {:on-click [[:move-player {:direction :south :target 0}]]
                :action-class "action-move-south"}]

Dumdom does not impose any structure to the data that can be associated to an element's event handler so I have taken the examples in Parens of the dead and I will structure the actions as a vector of vectors, with the first element of the sub vectors describing the action as a keyword and the second element as the options/parameters map of the action itself.

After setting up the global event handler, if we click on one the arrows, for example to go east, in the browser console log we should see [:move-player {:direction :east :target 0}], meaning that the event handler is working properly. But still, nothing is happening in the game UI, lets fix that.

Dispatching actions and state updates

We are now able to trigger actions and we need something that can update the game state and reflect the changes to screen. In this case I think it is better jump straight to the code that will move a player in Mazeboard:

(ns mazeboard.actions
  (:require [clojure.core.match :refer [match]]))

(defn update-position
  [position direction]
  (case direction
    :north (update position :row dec)
    :east (update position :col inc)
    :south (update position :row inc)
    :west (update position :col dec)))

(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))))

(defn dispatch
  [game action]
  (prn "Triggered action" action)
  (match action
         [:move-player opts] (action-move-player game opts)))

The code must be read bottom up, first thing to look at is the dispatch function that will match the requested action to a specific function calling it; the fact the here clojure.core.match is being used is an implementation detail, you may prefer a different approach (for example defmulti is another good candidate). This is quite simple, we try to match the requested action to something that we know how to handle, in this case moving the player. action-move-player will, of course, update the game (and UI) state, returning it.

There is still one bit missing, who is calling dispatch and how this will update the global state atom? We have to update the global handler to connect actions to state updates, here is how the init fn is looking now:

(defn init []
  (d/set-event-handler!
   (fn [_e actions]
     (doseq [action actions]
       (swap! state_ update :game actions/dispatch action))))

  (add-watch state_ :app (fn [_ _ _ state] (render state)))

  (swap! state_ assoc :game (test-data/create-game)))

After these changes, when clicking one of the arrows the player icon will be placed correctly in the next tile, in this screenshot we can see the effect of moving south

Moved south

Together with the updated player "One" position we can also observe that the round number has changed and that now it is the turn of player "Two"; updating the tiles to reflect what the other user can do will be the content of another post.

Future improvements

At this point we have a working application that can react to user actions and render the updated state accordingly. What I don't like about the current approach is that it is coupling the game logic and the UI logic, for example knowing which actions are available and how to render them is a concern of the UI state and not of the game state, but now everything is mixed together and soon managing both the game and the UI will be a complete mess and hard to maintain. To make a concrete example here is how the tile is represented at the moment:

(def tile {:actions [{:on-click [[:move-player {:direction :east :target 0}]]
                      :action-class "action-move-east"}
                     {:on-click [[:move-player {:direction :south :target 0}]]
                      :action-class "action-move-south"}]
           :walls [:closed :open :open :closed]
           :end-position? true
           :players [0]})

The :actions, :end-position? and :players keys are closely related to the UI, instead the :walls key is more on the game logic side.

By separating game and UI I hope to get to a setup were:

A bonus benefit of this kind of separation of concerns is that unit testing the game logic and the event generation that will feed the UI will cover most of the application! (Thanks Parens of the dead for this idea).

Conclusion

In this post we have seen how to model the UI state (even if coupled with game state), how to dispatch actions from DOM elements and how to handle them to update the game.

The whole setup is quite primitive but good enough for a starting point. I am very excited to finally decouple game and UI concerns, it will make it easier to build game and UI logic separately.

Discuss

Sources

Mazeboard source code is available here.

Mazeboard related posts