A game loop in a core.async goroutine
Growing up I had an old Commodore 64 with an assortment of games on 5¼" floppy disks. One of them was an election game, possibly President Elect by Strategic Simulations, Inc, but at the time I was too young to understand it, and I never played much. Last year I wanted to teach my kids about the electoral college, so I made my own election game, one aimed more at a third grader. I omitted real-world parties, issues, and polling data, and focused instead on a tight loop of nudging states' preferences for or against two different candidates. There's a little geography, and a lot of puns. I called it Electravaganza. You can play here.

It's implemented in ClojureScript, the UI is rendered with Replicant, events handled with Nexus, and game data stored in DataScript. The interface is mostly a map of the United States; the player's controls are on the left side. In the original implemenation, each component had buttons which triggered a Replicant event when clicked, then Nexus routed the event to an action that produced an effect and updated the DB, often by setting a new UI component in the DB. Then the renderer would update the UI with the new component.
That's a fairly standard rendering loop for Replicant, but putting a UI component in the DB was an experiment. I didn't end up liking it. It made the game loop hard to follow through the code. I had to cross back and forth between code in two different files to trace the event fired by a component's button to the action handler that specified the next UI component, then find that component and its button(s). Even if all the logic had been in action handlers, the code would have been split into small pieces that required lots of jumping around in the file, goto style.
Also, using Nexus actions for the game loop made it hard to change the rules. The first version of the game gave the player a random hand of action cards to choose from, then chose a random action for the computer opponent, then gave the human player a totally new set of cards. That was good enough to get the basic mechanics working, but it didn't allow for any strategy across turns. I wanted to tinker with the game loop, but having the logic split between components and action handlers meant that changing the sequence of steps required rewriting the actions. That was tedious and error-prone.
To address both problems, I introduced a proper state machine. While I briefly toyed with a high-level, data-oriented definition of states and transitions, the need for a loop with a turn counter smelled like a programming language, so first I tried a different form of state machine: a core.async goroutine.
I hadn't used core.async in a while. Though I appreciate Go's model of communicating sequential processes, it's overkill for most of the helper scripts and small apps I write. In this case, I didn't need to execute code in parallel, but for something to serve as a goroutine's namesake, a pausable coroutine. After some refactoring, I ended up with a game loop defined like this:
(defn basic-game [conn turns]
(let [chans [(async/chan) (async/chan)]]
(async/go
(let [[player1 player2] (<! (choose-party chans))]
(<! (choose-hairdo chans @conn player1))
(<! (choose-hairdo chans @conn player2))
(loop [turns-left turns]
(<! (set-turns-left chans turns-left))
(if (pos? turns-left)
(do
(<! (choose-action chans @conn player1))
(<! (choose-action chans @conn player2))
(recur (dec turns-left)))
(<! (results chans))))))
chans))
The syntactic gynmastics of core.async chan, go, and <! make it a bit noisy, but it did centralize the logic of the game loop. The helper functions provided a domain-level grammar of the game's sequence.
The game initializes this goroutine during setup, stores the returned channels in the DB, sends Nexus effects keyed with :effect/send to the input channel, and spawns a second goroutine that listens for output messages and dispatches actions:
That requires each of the helper functions used by the goroutine to have some of the same syntactic overhead as the main loop, littered with go and <! and >!. Maybe that boilerplate could be cleaned up with macros. But a perfect DSL wasn't my main concern. I was more interested in having a game loop defined in a compact and comprehensible chunk, as well as one that could be easily swapped out for a different loop. A goroutine gave me that. I could fiddle with the loop so each player started with random cards then discarded played cards and kept the rest for the next turn:
(defn hand-of-actions [conn turns actions-per-turn]
(let [chans [(async/chan) (async/chan)]
hand-limit actions-per-turn]
(async/go
(let [[player1 player2] (<! (choose-party chans))]
(<! (choose-hairdo chans @conn player1))
(<! (choose-hairdo chans @conn player2))
(loop [turns-left turns
hand1 (db/random-actions @conn hand-limit)
hand2 (db/random-actions @conn hand-limit)])
(<! (set-turns-left chans turns-left))
(if (pos? turns-left)
(let [[played1 hand1] (<! (choose-actions chans @conn player1 hand1))
[played2 hand2] (<! (choose-actions chans @conn player2 hand2))]
(recur (dec turns-left)
(replace-action @conn hand1 hand-limit)
(replace-action @conn hand2 hand-limit)))
(<! (results chans)))))
chans))
I didn't have to add features to a custom state machine evaluator, but instead used Clojure's built-in language constructs to keep state between passes through the loop. A minimum viable implementation.
The end result is more complicated, with more moving parts—a game-loop goroutine running in the background, a goroutine listening for output messages, channels saved to the database—but it grouped logic according to its responsibilities and made the individual parts easier to reason about.
That's the important part. A functional language like Clojure usually avoids the kind of imperative logic above, but sometimes the clearest expression of a solution is step-by-step. Especially for a game, which is inherently imperative. Do this, then do that. The goal isn't to always avoid side effects and only use pure functions. My original Nexus handlers were pure functions. The goal is to make code understandable and to facilitate new ways of using it. Having clusters of tiny pure functions can undermine those goals.
Both the code and the game have plenty of room for improvement, but using a core.async goroutine allowed me to define different new game loops easily enough to arrive at some rules my son found fun to play, and hopefully one that taught him a little about the electoral college and geography (and wordplay).
If you have suggestions for the game, or the code, feel free to email me.