I think that modeling the game interactions between players and rules is best done by a coroutine. There is the computation that represents the rule enforcement of a given game from start to completion. But that computation must pause at some points waiting for input from players, aka moves. So I think it perfectly fits in the framework of an interruptible computation where the game rules run until a pause point in which it passes control to the invoker of the computation. So unless I’m wrong I think that using coroutines is the way to go.
So say that I’m writing an online server for playing Tic Tac Toe. Since there will be multiple games running at once, I think using some form of concurrency will be necessary. For this thought experiment each game will live within an Actor that will provide the necessary concurrency and state encapsulation. I will use the stm-actor but anything IO()
could also be used if you are not familiar with actors.
Several design possibilities appear. For the coroutines I will use the package
1. Completely control the coroutine from the Actor code
Here the approach is to have a pure Coroutine as:
data AskMove = PickFreeCell [Int]
data Move = Move Int
data TTTGameState = -- Data Structure to hold symbols in the 9x9 grid
type GameMonad = Coroutine (Request AskMove Move) (State TTTGameState)
data Result = X | O | Draw
rules :: GameMonad Result
rules = do
-- Here are the rules to run a game of Tic Tac Toe
-- plus the occasional
-- request askMove from monad-coroutine
With this the actor just needs to be peeling off the coroutine explicitly:
data Env = Env {
gs :: IORef TTTGameState
---
--- logging, db connections, and other effects...
}
gameActor :: ActorT Move (ReaderT Env IO) Result
gameActor = step initialGame rules
where
step gs cont = case runState (resume cont) gs of
Left ((Request (PickFreeCell cells) f), newGs) -> do
-- Use the cells to perform IO communicaiton with the user
theMoveAsResponse <- doIOWithUserAskingFor cells
-- Once we get the result we iterate
step newGs (f theMoveAsResponse)
Right winner -> return winner
to me this sounds as the most reasonable/sound. It allows me to completely write any game rules without considering where that monad is going to be run in. On the other hand, it makes me feel a bit uneasy not to use any of the machinery in the monad-coroutine
package for running coroutines like pogoStick
or bounce
.
2. Make the coroutine wrap the entire Actor
So the idea here is that the actor is an interruptible computation. The whole type would be:
type GameMonad = Coroutine (Request Ask Move) (ActorT Move (ReaderT Env IO))
One of the advantages that I see here is that the ActorT
could have a dynamic behaviour (ie. act differently to incoming messages) based on what the request generated.
My concerns are two fold:
- It sounds a bit weird that an IO that is running a thread will be “interruptible”, although maybe I’m mixing contexts within the hard interruption of hardware and the conceptual interruption of the coroutine
- From a developer ergonomics point of view, when you unwrap the coroutine in order to get a final
ActorT
I don’t know if the function that we will pass to thepogoStick
function will have a lot of nested lifts to pass levelsActorT, ReaderT, and final State
3. Make the coroutine the inner monad of the actor
Here the type would be
type InnerCoroutine = Coroutine (Request Ask Move) (ReaderT Env IO)
type GameMonad = ActorT Move InnerCoroutine
With this setup I don’t even know what does it mean to do a suspension within the actor. When I do:
program :: ActorT Move InnerRoutine Result
program = do
response <- lift (request askMove)
I understand that there will be an interpreter that when I am peeling of the ActorT but compared to the first approach I think I’m losing the ability to modify the behaviour of the ActorT and the coroutine won’t know anything about the environment it is running in.
These are my initial thoughts, what do you think is the most promising design path? I can explore more but each seems too daunting to explore all three at the same time.