Advice for coroutine design and modelling

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:

  1. 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
  2. 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 the pogoStick function will have a lot of nested lifts to pass levels ActorT, 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.

1 Like

If I had to choose, I’d say approach 1 is the way to go. However:

Indeed, I think coroutines might be overkill. I would start by trying to keep it simple and model the game as a function Move -> State -> State.

The concurrency structure is simply to run all games concurrently, so I’d again start by keeping it simple and just forkIO-ing every new game request.

But I’d love to know if there are any specific reasons to use coroutines or actors in this case. I guess it depends on how you expect your app to evolve in the future.

1 Like

I’m the author of monad-coroutine. Don’t worry too much about using pogoStick or bounce, they were meant more for education than for industrial use.

Regarding the options you put on offer, the best choice depends on the needs of your game, and I doubt anybody can choose for you. My advice is to pick one without much worry. The important thing is to make it easy to change your mind later. Factor out all functions used by actors for switching coroutines and give them meaningful names, like pickFreeCell. That will keep the actor code stable and let you switch out the architecture later. Haskell excels at this kind of large-scale refactoring, keep that power in mind and rely on it.

2 Likes