This post is the sixth in the series of posts focused on the design and implementation of a port in ClojureScript of the game named Tribolo. You can try the game at the following address.
Our first post described the game and identified the following responsibilities, each of them will be developed in a different post:
- Game logic: the rules governing transitions between game states
- Rendering: transforming the current game state into HTML and SVG
- Store: the in-memory data-base holding the application state
- Game loop: coordinates and control of the different aspects
- AI logic: selection of the best next move for an AI player
Reminder on the target architecture
Our different responsibilities are interconnected through the following high level architecture (you can refer to our first post for more details):
Store ----------------> Rendering ---------------> Reagent ^ (reaction) ^ (send hiccup) | | | | (listen) | (update) | \-------------------- Game loop | | ,-------------------------| (use to update store) | | v v Game logic <-------------- AI logic
The store represents our application state. The application state is kept inside a Reagent ratom. We can use Reagent reaction macro to create views on the main ratom.
Each of these views will be automatically refreshed when the ratom they dereference changes. Rendering functions dereferencing a ratom or a reaction are implicitly wrapped in a reaction. They will be refreshed upon changes of their sources.
The game loop is the reactive component that connects the different components of the application. It listens to all the events of the application and triggers the appropriate responses. In particular, the game loop will be responsible for:
- Listening for the user interface events
- Triggering the artificial intelligence actions
- Updating the application state in response to these events
We did not develop the artificial intelligence yet, so we will assume the existence of a function find-best-move. This function will take as input a game and returns the coordinate for the current player to play at. We can provide it a nice spec to specify it more precisely:
|:args (s/cat :game ::game/game)|
The application state is the place where every piece of state-full data is put in. We need to identify the data we need to store and then organize it. Fortunately, our application is pretty simple. We need to store the game and whether the suggestion feature is on. A simple map will do:
In case the data to store is more complex, there might be better solution than storing map inside the application state, for example libraries such as DataScript.
The application data is definitively not at rest. Most of our user interactions will update it. Since consumers are generally interested in parts of the application state only, they will not be interested by most updates.
To accommodate the needs of the application data consumers, and avoid spamming them to much, we define a bunch of Reagent reactions. For instance, the game reaction zooms on the :game keyword of our store and the current-turn further zooms on the current turn of the game.
We list below the most useful reactions for our user interface:
|(def game (reaction (:game @app-state)))|
|(def current-turn (reaction (game/current-turn @game)))|
|(def ai-player? (reaction (player/is-ai? (:player @current-turn))))|
We can also define reactions holding functions. For instance, the suggestions reaction will hold a function to provide to the rendering. This function will allow identifying whether a cell should be highlighted as an available move for the human player.
If the current player is either an AI or if the suggestion feature is disabled, the reactive function will always return false. Otherwise, the reactive function will do a search inside the set of coordinates leading to a valid transition.
|(if (and (:help @app-state) (not @ai-player?))|
|#(contains? (turn/transitions @current-turn) %)|
The game loop is in charge of triggering the updates of the store in response to events. Our store will define some convenience functions to help with these updates:
- swap-game! applies a function on the game of the store to update it
- toggle-help! switches the suggestion feature on and off
|[update-fn & args]|
|(apply swap! app-state update :game update-fn args))|
|(swap! app-state update :help not))|
The game loop is the bridge between the events received in the user interface and the update of the application state. The game loop is also responsible for the scheduling of events such as triggering the artificial intelligence computation and play.
Our Tic Tac Toe did not need any game loop. Updating the application state was done directly inside the callbacks listening the user interface events. What is it exactly that make us want a game loop?
Our Tribolo game has become complex enough to make the approach of directly updating the application state less practical. In particular, the following time constraints will require us to do something more involved than direct updates:
The artificial intelligence will require a bit of time to compute its next move. During that time, the human player shall not be able to play. To avoid any weird interactions, the best is to ignore all play commands from the human player when the AI plays.
The other events (such as restart or undo) triggered during the AI turns should however still be listened to. Such events will completely change the state of the game, and will thus need to discard any on-going AI computation. New AI computations might have to be triggered instead.
The timings and animations also participate in augmenting complexity. The AI might take variable time to compute its move. We would like to avoid the human player to witness instantaneous moves. This would make the ongoing game hard to understand, especially if the moves are chained faster than the animations.
To summarize, the need for the game loop comes from the increased complexity of the pattern of interactions with the user.
We can translate the various aspects we listed in our previous section into requirements for our game loop:
- Menu events should drop any ongoing AI computations
- The human player play events should be ignored during AI computations
- AI computations should not intervene during human player turn
- We impose a delay of 1.5 seconds before an AI can play its move
Core Async to the rescue
We will rely on this library for our game loop. You might have an easier time following the remaining of this post if you are familiar with the concept of Communicating Sequential Processes on which is based core.async.
As a refresher, CSP is built around the concept of lightweight processes synchronising and exchanging data through the use of channels. Channels are things you can send a message into or receive a message from.
In Clojure, a go block is such a lightweight unit of computation and chan allows to build channels. These processes run concurrently and park when they wait for inputs from a channel, or wait for an output channel to have some space to write into.
One interesting aspect of go blocks is that they return a channel. This channel will be sent the result of the last expression executed in the go block. It means we can create the equivalent of futures by just wrapping an expression inside a go macro.
The following example demonstrates some of the power of core.async. Thanks to it, we are able to deal with what would be otherwise annoying callbacks interactions triggered using js/setTimeout.
The ai-computation function starts an AI computation asynchronously. The goal is to keep the UI responsive to events during the computation. We use async/timeout to create channels that will delay the result computation for at most 1.5 seconds before releasing it. This means the AI will play at most once every 1.5 second.
|(def animation-delay 500)|
|(def ai-move-delay 1000)|
|"Run the computation of the AI asynchronously:|
|* Wait 500ms to start (animation might be frozen otherwise)|
|* Wait 1s to play the move (avoid moves being played too fast)"|
|(<! (async/timeout animation-delay))|
|(let [ai-chan (go (ai/find-best-move game))]|
|(<! (async/timeout ai-move-delay))|
Note: We split the delay in two parts, 500 ms followed by 1 second. The first delay is used to explicitly give control to the browser for the rendering of the transitions. The user interface would otherwise experience some lag.
The game loop will receive two different kinds of events. It might receive play events which are the events that move the game forward. It might also receive game events which are the other kind of event that affect the game: restart, undo and new game.
In in the play events we can further distinguish those coming from the user interface (the human player) and those coming from the asynchronous AI computation we described in the previous section.
We can summarize this in the following ASCII Art diagram:
Click on the board | | v Game events Play event <-----\ | | | | | | \----------.----------/ | | | (feeds) | | | | v | Game loop | | | (triggers) | | | | v | AI Computation --------------/
The clicks on the board should be discarded when the current turn is the one of an AI. We can use a transducer on a channel to act as a latch that disable or enable the channel. Depending on whether the current player is an AI or not, the game loop will also need to select the appropriate play event channel to listen to.
This process of selection and filtering is encapsulated inside the start-game-loop function which creates the loop and returns the two input channels of the game loop:
- The play-events channel in which clicks on the board will be sent to
- The game-events channel for the restart, new game and undo events
|"Manage transitions between player moves, ai moves, and generic game events"|
|(let [play-events (chan 1 (filter #(not @store/ai-player?)))|
|game-events (chan 1)]|
|(let [play-chan (if @store/ai-player?|
|game-events ([msg] (handle-menu-event! msg))|
|play-chan ([coord] (store/swap-game! game/play-at coord))|
This function makes use of the handle-game-event! responsible for dealing with the restart, new game and undo events:
|:new-game (fn [_] (game/new-game))|
Game loop public API
Until now, all the functions we described are private and describe the implementation of the game loop. In particular, start-game-loop implements the creation of the loop.
Since our game only needs one game loop, we will create a game-loop definition to get the result of start-game-loop. We then provide the send-play-event! and send-game-event! functions to send the events to the corresponding channels.
The public API of our game loop is thus limited to the three following definitions:
|(defonce game-loop (start-game-loop))|
|(defn send-play-event! [e] (put! (game-loop :play-events) e))|
|(defn send-game-event! [e] (put! (game-loop :game-events) e))|
Plugging it all together
The core namespace of the Tribolo is responsible for plugging everything together. In particular, it plugs:
- The appropriate reactions to the main-frame rendering function
- The game and play events to the channels of the game loop
- The toggle suggestion event directly to the store toggle-help! function
- The whole into the reagent/render function to start the rendering
|(defn triboard |
|(frame/main-frame @store/current-turn @store/suggestions|
|(on-new-game [_] (loop/send-game-event! :new-game))|
|(on-toogle-help [_] (store/toggle-help!))|
|(on-restart [_] (loop/send-game-event! :restart))|
|(on-undo [_] (loop/send-game-event! :undo))|
|(on-player-move [_ x y] (loop/send-play-event! [x y]))|
Conclusion and what’s next
We are done with state and time management for our Tribolo game. Using Reagent and core.async, we were able to deal with this task rather easily.
The last remaining part of our game to implement is the artificial intelligence. This will be the subject of the the next post.