Clojure Bites - Mazeboard 2 - Using core.async to decouple game and UI logic

by FPSD — 2023-12-05


Intro

The previous post ended by looking at the shortcomings of having the game and UI logic completely coupled, which is not ideal in a client/server game setup. We want to setup our code to make it easy to reason about the two layers separately, focusing on their specificity and, of course, we want to prepare it make it possible for the two layers to communicate over a network connection.

Here is the plan:

Move slowly without breaking everything

Baby steps. I am not Meta and I cannot move fast and break everything ;)

One thing to keep in mind is that everything is still running inside the same process in the browser, but we want to slowly separate client and server concerns so that when the server will run in a remote process then the client will not need any fundamental changes to keep it working.

The current setup has a global state that represents the game (players, board with the tiles, current turn and possible actions) and it is used to render the UI components and handle the game logic altogether; before splitting the two concerns lets move this state elsewhere so that we can start to identify the dependencies between the two layers. Here is the new namespace that will hold the game logic, mazeboard.game:

(ns mazeboard.game
  (:require [cljs.core.async :as async]
            [mazeboard.actions :as actions]
            [mazeboard.test-data :as test-data])
  (:require-macros [cljs.core.async.macros :refer [go-loop]]))

(defonce state (atom {}))

(defn game-loop
  [actions-chan]
  (go-loop []
    (let [action (async/<! actions-chan)]
      (swap! state update :game actions/dispatch action))
    (recur)))

(defn init []
  (let [actions-chan (async/chan)
        events-chan (async/chan)]

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

    (game-loop actions-chan)

    [events-chan actions-chan]))

The namespace contains a little bit more that just moving the state from the UI layer to the logic layer, let's break it down:

The namespace mazeboard.ui.core requires few changes:

Here is how the event handler setup look like now:

  (let [[events-chan actions-chan] (game/init)]
    (d/set-event-handler!
     (fn [_e actions]
       (doseq [action actions]
         (put! actions-chan action)))))

Instead of subscribing to the mazeboard.game/state atom directly it would have been cleaner to return it from maeboard.game/init fn and subscribe to that but this setup is going to be changed soon so we can close an eye this time ;)

After these changes the game renders and actions are handled correctly, baby steps! Unfortunately there is still this one shared state that we want to get rid of, but this will be covered in the next post.

The full change set for this post is available here.

Conclusions

In this short post we have started the decoupling process of UI and game logic layers, even if not completely, by using core.async as an abstraction of a communication layer. The UI is still subscribed to a shared state (atom) to render updates but at least the user actions are now sent "somewhere" instead of a direct function call, preparing it for the next step of receiving game state updates using a similar approach.

Discuss

Mazeboard related posts