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:
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:
state
atom: holds the game state and the UI, for now, will subscribe to its changesinit
fn: fills the state
atom with game data and returns two core.async
channels to communicate between the two layersgame-loop
fn: will listen for actions sent to the actions-chan
channel and will update the game state accordingly, using the mazeboard.actions/dispatch
fn introduced in the previous postevents-chan
channel is not used at the moment but, in future, listeners of this channel will be able to update their state after changes of the game stateThe namespace mazeboard.ui.core
requires few changes:
actions-chan
channel instead of calling the dispatch
action directlyHere 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.
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.