Clojure Bites - Mazeboard 4 - From commands to events for a better separation of concerns

by FPSD — 2023-12-19


Intro

Last post was about the separation of game and UI logic, where game and UI state have been made independent by turning user actions to commands that would have updated both game and UI state. That was possible because both state maps share the same structure; even if convenient initially (you use the same code for both) it can be limiting in the long run, who knows how the next UI will be rendered tomorrow? It is also a matter of separation of concerns, the current schema has data that is not needed for the game logic, for example some element classes, and the same applies to the UI layer.

From commands to events

Given that the final goal is to have independent state schema in the game and UI logic the approach based on shared commands which will update both states would not work anymore; the new approach instead will be based on events which will be responsible for describing what happened and then each layer will handle them in a way that best fits their needs (for example updating the view, triggering animations etc in the UI layer). We will transition from a game loop like the following:

To something like:

The event schema can be quite simple, for example a tuple with the event keyword and an optional map holding the information attached to it:


(def player-moved-event [:player-moved {:target 0 :old-position {:row 0 :col 0} :new-position {:row 0 :col 1}}])

Easy to build, easy to destructure.

One example

Let's have a look at a concrete example, currently the only available action is :move-player so yeah we don't much choice :)

The action itself is simple:


[:move-player {:direction :east :target 0}]

It means that the user 0 can move to east, amazing! If the game logic will receive this action it will generate two events, :player-moved to signal the new position of the user and round-started to signal that a new round has started. For now the function that translates actions to events looks like this:


(defn action->events
  [game action]
  (prn "Triggered action" action)
  (match action
         [:move-player opts] [(player-moved-event game opts)
                              (round-started-event game)]))

Each event will describe what happened, the game logic will do its best to reflect the change to its state and the UI will interpret the event to show the change to the user; it is more of a mindset that is applied to both layers and this helps to keep both worlds separated. Maybe it is worth having a look at how these first two event generators are implemented:


(defn player-moved-event
  [{:keys [players]}
   {:keys [direction target]}]
  (let [old-pos (get-in players [target :position])
        new-pos (update-position old-pos direction)]
    [:player-moved {:target target
                    :old-position old-pos
                    :new-position new-pos}]))

(defn round-started-event
  [{:keys [turn players]}]
  (let [{:keys [current-player round-number]} turn]
    [:round-started {:current-player (mod (inc current-player) (count players))
                     :round-number (inc round-number)}]))

Now that we have events these must be interpreted to reflect these changes to the respective states, let's start with the game side:


(defn handle-event-player-moved
  [game {:keys [target new-position]}]
  (assoc-in game [:players target :position] new-position))

(defn handle-round-started
  [game turn]
  (assoc game :turn turn))

(defn handle-event
  [game event]
  (prn "Received event " event)
  (match event
         [:player-moved opts] (handle-event-player-moved game opts)
         [:round-started opts] (handle-round-started game opts)))

handle-event will dispatch to the proper function which then will update the game state. One thing I like about this approach is that it makes state transitions quite straightforward at every layer.

p.s. Yes yes, the name of the functions is not consistent at all, I will take care of this in future.

How does it look on the frontend side? Different! Which is the point of this change, here is how player-moved is handled in the UI:


(defn event--player-moved
  [game {:keys [target old-position new-position]}]
  (-> game
      (update-in [:board :tiles old-position :players] disj target)
      (update-in [:board :tiles new-position :players] conj target)
      ))

So what is the difference? At the game logic layer we just need to update the position in the player map and at the UI layer we "put" the player id in the new tile and we remove it from the previous position.

Conclusions

In this short post we have explored how to make the game and UI states really independent of each other. Still, the initial map is the same but then it will evolve following two distinct paths. It is not just a cosmetic change but it creates the baseline for different client implementations, for example I am curious to try PixiJS (again) or even Three.js and this setup would make it possible to just work on the UI layer without touching the game logic.

Discuss

Code

This post include changes made in two different merge requests:

Mazeboard related posts