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 mazeboard.game
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
.
We started from something like this:
And we ended up with the new setup:
maeboard.ui.core
to mazeboard.game
namespacecore.async
channel which the game layer listens to, to break the direct call dependencyThe UI still listen to the game state atom directly, and we are going to fix this.
The commit is available here.
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 mazeboard.game
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.
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]
(reduce
(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:
:players
key in the game map, given that it does not make any use of itThis will be addressed in the next post, but for now I am happy with what we have achieved!