Haskell roguelike - Structure
Code structure
Even with everything we have implemented so far the game code is still fairly small somewhere around 1400 lines of Haskell. Its also still pretty manageable with the basic structure of engine, core, levels and hosting. But as your code base starts getting more complex you may feel that you need a bit more structure.
Here I’m very briefly going to look at implementing Matt Parsons’ Three Layer Haskell Cake.
This is entirely optional and I’m certainly not even going to attempt explain things better than Matt already has. So if any of this does not make sense feel free to rather skip to the next chapter
Identifying the layers
- Layer 1 / orchestration: The code that initialises the client and loops waiting for commands from the client
- Layer 2 / external services: The external service is the web socket connection
- Layer 3 / pure functional: Everything else
The majority of the code is already fairly basic and pure, which is good news. Most of the functionality already fits nicely into layer 3. All the code that deals with the TVar will be moved into layer 1 or 2.
I’ve not changed the hosting code to fit into the layers, so I’ve just called it layer 0. I could have made it part of layer 1, either by making a HostT or alternatively by having Apt store a Maybe World and Connection. If my hosting code got any more complicated I’d do something like that.
Layer 0
All the code in GameHost is layer zero as is runGame and manageConnection from GameEngine. manageConnection starts the AppT pipeline (initialise and startGame)
21_console/src/GameEngine.hs (41 to 69)
----------------------------------------------------------------------------------------------------------------
-- L0 Hosting
----------------------------------------------------------------------------------------------------------------
runGame :: (Levels -> Level) -> IO ()
runGame getLevel =
Host.runHost $ manageConnection getLevel
manageConnection :: (Levels -> Level) -> Host.Connection -> IO ()
manageConnection getLevel conn = do
initCmd <- conn ^. conReceiveText
case parseCommand initCmd of
Just ("init", cmdData) -> do
std <- Rnd.getStdGen
case initialiseConnection conn cmdData std getLevel of -- initialiseConnection is L3
Right world -> do
app <- atomically $ AppState <$> newTVar conn <*> newTVar world
-- Start the AppT pipeline
runReaderT (unAppT $ initialiseConfig >> startGame) app
Left e ->
runReaderT (sendHostError e) conn
_ ->
pass
----------------------------------------------------------------------------------------------------------------
Layer 1
Layer 1 defines AppState and AppT. It has the initialiseConfig and startGame functions. startGame gets commands from the web sockets and starts layer two’s handleCommand
21_console/src/GameEngine.hs (74 to 115)
----------------------------------------------------------------------------------------------------------------
-- L1 App (orchestration)
----------------------------------------------------------------------------------------------------------------
data AppState = AppState { appConnection :: !(TVar Host.Connection)
, appWorld :: !(TVar World)
}
newtype AppT m a = AppT { unAppT :: ReaderT AppState m a
} deriving (Functor, Applicative, Monad, MonadReader AppState, MonadTrans)
askApp :: (AppState -> TVar a) -> AppT IO a
askApp getter = do
app <- ask
lift . atomically . readTVar $ getter app
modifyApp :: (AppState -> TVar a) -> (a -> a) -> AppT IO ()
modifyApp getter modify' = do
app <- ask
lift . atomically $ modifyTVar' (getter app) modify'
----
initialiseConfig :: AppT IO ()
initialiseConfig = do
conn <- askApp appConnection
world <- askApp appWorld
app <- ask
lift $ runReaderT (sendConfig $ world ^. wdConfig) (conn, appWorld app)
startGame :: AppT IO ()
startGame = do
app <- ask
conn <- askApp appConnection
lift . forever $ do
t <- conn ^. conReceiveText
r <- runReaderT (handleCommand t) (conn, appWorld app)
case r of
Nothing -> pass
Just e -> putText e
----------------------------------------------------------------------------------------------------------------
Layer 2
Layer 2 acts as a bridge to external services. The only external service here is the web socket connection. The MonadHost class abstracts actions on this connection. There are two instances of the type class. One for the normal case where we have the connection and a World and one for when we only have a connection. I could have chosen to use a Maybe World and only have one instance but I think this was is clearer.
Since there are two instances and they share most of their code, the type class has a default implementation for most of the functions.
21_console/src/GameEngine.hs (120 to 178)
----------------------------------------------------------------------------------------------------------------
-- L2 (bridge / external services)
----------------------------------------------------------------------------------------------------------------
class (Monad m) => MonadHost m where
receiveHostText :: m Text
sendHostData :: Text -> m ()
sendHostLog :: Text -> m ()
sendHostError :: Text -> m ()
sendHostUiConfig :: UiConfigData -> m ()
compressData :: Text -> m BSL.ByteString
--default implementation
sendHostLog err = sendHostData $ Ae.encodeText $ UiMessage "log" err
sendHostError err = sendHostData $ Ae.encodeText $ UiMessage "error" err
sendHostUiConfig config = sendHostData . Ae.encodeText $ UiConfig "config" config
compressData = pure . Bz.compress . BSL.fromStrict . TxtE.encodeUtf8
instance MonadHost (ReaderT (Host.Connection, TVar World) IO) where
receiveHostText = do
(conn, _) <- ask
lift $ conn ^. conReceiveText
sendHostData t = do
(conn, _) <- ask
lz <- compressData t
lift $ conn ^. conSendData $ lz
instance MonadHost (ReaderT Host.Connection IO) where
receiveHostText = do
conn <- ask
lift $ conn ^. conReceiveText
sendHostData t = do
conn <- ask
lz <- compressData t
lift $ conn ^. conSendData $ lz
class (Monad m) => MonadWorld m where
askWorld :: m World
putWorld :: World -> m ()
modifyWorld :: (World -> World) -> m ()
debugPrint :: Text -> m ()
instance MonadWorld (ReaderT (Host.Connection, TVar World) IO) where
askWorld = do
(_, wt) <- ask
w <- lift . atomically $ readTVar wt
pure w
putWorld w = do
(_, wt) <- ask
lift . atomically $ writeTVar wt w
modifyWorld fn = do
(_, wt) <- ask
lift . atomically $ modifyTVar' wt fn
debugPrint = putText
Layer 2 has handleCommand, runCmd and sendConfig. The functions that need to deal with the external service and call functions from level 3.
21_console/src/GameEngine.hs (183 to 266)
handleCommand :: (MonadHost m, MonadWorld m) => Text -> m (Maybe Text)
handleCommand t =
case parseCommand t of
Nothing -> pure . Just $ "error parsing: " <> t
Just (cmd, cmdData) -> do
runCmd cmd cmdData
pure Nothing
runCmd :: (MonadHost m, MonadWorld m) => Text -> [Text] -> m ()
runCmd cmd cmdData =
case cmd of
"redraw" ->
case parseScreenSize cmdData of
Nothing -> sendHostError "missing / invalid screen size"
Just (sx, sy) -> do
updatePlayer (plScreenSize .~ (sx, sy))
world <- askWorld
sendHostData $ Ae.encodeText (drawAndSend world)
sendHostLog "draw"
"key" -> do
-- Handle the key press
modifyWorld (\w ->
-- Do the actions as if they will succeed
let pendingWorld = runActions w $ handleKey w cmdData in
-- Apply, if the move is allowed
-- Cost is hard-coded to 100 for now, this will be fixed later
playerMoving 100 pendingWorld w
)
-- Get the updated world
w2 <- askWorld
-- Handle the annotations
-- This is not terribly pretty as its doing a select for update, but its good enough for debugging
-- the annotation code can be removed once everything is working
let annotations = w2 ^. wdUtilBrainAnnotations
modifyWorld (\w -> w & wdUtilBrainAnnotations .~ [])
--printAnnotations annotations
-- Draw
w3 <- askWorld
sendHostData $ Ae.encodeText (drawAndSend w3)
_ ->
sendHostError $ "Unknown command: " <> cmd
where
updatePlayer f = modifyWorld (\w -> w & wdPlayer %~ f)
printAnnotations as = do
debugPrint ""
debugPrint ""
debugPrint ""
debugPrint ""
debugPrint "***** Utility Annotations **************"
traverse_ printAnnotation as
debugPrint "****************************************"
debugPrint ""
printAnnotation (e, assess, top) = do
debugPrint ""
debugPrint $ "-----------------------" <> show e
debugPrint " -- assess --"
debugPrint . Txt.intercalate "\n" $ showEntries <$> assess
debugPrint ""
debugPrint " -- top --"
debugPrint . Txt.intercalate "\n" $ showEntries <$> top
debugPrint "-----------------------"
showEntries :: UtilAnnotationEntry -> Text
showEntries e =
case e of
UeAt a -> " At: " <> a
UeSelectTopNone n -> " No utils: " <> n
UeSelectTopAbove f -> " Top above: " <> showF f
UeSelectTopOne val n i d -> " Select top one: " <> n <> ", impulse=" <> show i <> ", score=" <> showF val <> "," <> d
UeNote n -> " Note: " <> n
sendConfig :: (MonadHost m) => Config -> m ()
sendConfig config =
sendHostData . Ae.encodeText $ UiConfig "config" (buildConfig config)
Level 3
Level 3 has everything else.
Structure done
I think this new structure nicely separates the different concerns. It also makes it clear what should be pure code and its nice to see how much of the code is pure.
Adopting this structure is not required but I think it is worth considering as your game gets more complicated
Chapters
Changes
src/GameEngine.hs
diff -w -B -a -d -u -b -r --new-file 19_story/src/GameEngine.hs 20_structure/src/GameEngine.hs
--- 19_story/src/GameEngine.hs
+++ 20_structure/src/GameEngine.hs
@@ -2,6 +2,8 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module GameEngine where
@@ -20,8 +22,10 @@
import qualified System.Random as Rnd
import Control.Lens (at, _1, (^.), (.~), (%~))
import qualified Control.Arrow as Ar
+import Control.Monad.Trans (lift)
+import Control.Monad.Reader (ask, runReaderT, ReaderT, MonadReader, MonadTrans)
import Control.Monad.Writer.Strict (runWriter)
-import Control.Concurrent.STM (atomically, readTVar, newTVar, modifyTVar', TVar)
+import Control.Concurrent.STM (atomically, readTVar, writeTVar, newTVar, modifyTVar', TVar)
import qualified Memory as M
import GameCore
@@ -33,8 +37,13 @@
import qualified UtilityBrain as UB
+
+----------------------------------------------------------------------------------------------------------------
+-- L0 Hosting
+----------------------------------------------------------------------------------------------------------------
runGame :: (Levels -> Level) -> IO ()
-runGame getLevel = Host.runHost (manageConnection getLevel)
+runGame getLevel =
+ Host.runHost $ manageConnection getLevel
manageConnection :: (Levels -> Level) -> Host.Connection -> IO ()
@@ -45,26 +54,223 @@
Just ("init", cmdData) -> do
std <- Rnd.getStdGen
- case initialiseConnection conn cmdData std getLevel of
+ case initialiseConnection conn cmdData std getLevel of -- initialiseConnection is L3
Right world -> do
- worldV <- atomically $ newTVar world
- sendConfig conn $ world ^. wdConfig
- runConnection worldV
+ app <- atomically $ AppState <$> newTVar conn <*> newTVar world
+
+ -- Start the AppT pipeline
+ runReaderT (unAppT $ initialiseConfig >> startGame) app
+
Left e ->
- sendError conn e
+ runReaderT (sendHostError e) conn
_ ->
pass
+----------------------------------------------------------------------------------------------------------------
- where
- runConnection worldV =
- forever $ do
+
+
+
+----------------------------------------------------------------------------------------------------------------
+-- L1 App (orchestration)
+----------------------------------------------------------------------------------------------------------------
+data AppState = AppState { appConnection :: !(TVar Host.Connection)
+ , appWorld :: !(TVar World)
+ }
+
+newtype AppT m a = AppT { unAppT :: ReaderT AppState m a
+ } deriving (Functor, Applicative, Monad, MonadReader AppState, MonadTrans)
+
+askApp :: (AppState -> TVar a) -> AppT IO a
+askApp getter = do
+ app <- ask
+ lift . atomically . readTVar $ getter app
+
+modifyApp :: (AppState -> TVar a) -> (a -> a) -> AppT IO ()
+modifyApp getter modify' = do
+ app <- ask
+ lift . atomically $ modifyTVar' (getter app) modify'
+----
+
+initialiseConfig :: AppT IO ()
+initialiseConfig = do
+ conn <- askApp appConnection
+ world <- askApp appWorld
+ app <- ask
+ lift $ runReaderT (sendConfig $ world ^. wdConfig) (conn, appWorld app)
+
+
+startGame :: AppT IO ()
+startGame = do
+ app <- ask
+ conn <- askApp appConnection
+
+ lift . forever $ do
t <- conn ^. conReceiveText
+ r <- runReaderT (handleCommand t) (conn, appWorld app)
+
+ case r of
+ Nothing -> pass
+ Just e -> putText e
+----------------------------------------------------------------------------------------------------------------
+
+
+
+----------------------------------------------------------------------------------------------------------------
+-- L2 (bridge / external services)
+----------------------------------------------------------------------------------------------------------------
+class (Monad m) => MonadHost m where
+ receiveHostText :: m Text
+ sendHostData :: Text -> m ()
+ sendHostLog :: Text -> m ()
+ sendHostError :: Text -> m ()
+ sendHostUiConfig :: UiConfigData -> m ()
+ compressData :: Text -> m BSL.ByteString
+
+ --default implementation
+ sendHostLog err = sendHostData $ Ae.encodeText $ UiMessage "log" err
+ sendHostError err = sendHostData $ Ae.encodeText $ UiMessage "error" err
+ sendHostUiConfig config = sendHostData . Ae.encodeText $ UiConfig "config" config
+ compressData = pure . Bz.compress . BSL.fromStrict . TxtE.encodeUtf8
+
+instance MonadHost (ReaderT (Host.Connection, TVar World) IO) where
+ receiveHostText = do
+ (conn, _) <- ask
+ lift $ conn ^. conReceiveText
+
+ sendHostData t = do
+ (conn, _) <- ask
+ lz <- compressData t
+ lift $ conn ^. conSendData $ lz
+
+instance MonadHost (ReaderT Host.Connection IO) where
+ receiveHostText = do
+ conn <- ask
+ lift $ conn ^. conReceiveText
+
+ sendHostData t = do
+ conn <- ask
+ lz <- compressData t
+ lift $ conn ^. conSendData $ lz
+
+
+class (Monad m) => MonadWorld m where
+ askWorld :: m World
+ putWorld :: World -> m ()
+ modifyWorld :: (World -> World) -> m ()
+ debugPrint :: Text -> m ()
+
+instance MonadWorld (ReaderT (Host.Connection, TVar World) IO) where
+ askWorld = do
+ (_, wt) <- ask
+ w <- lift . atomically $ readTVar wt
+ pure w
+
+ putWorld w = do
+ (_, wt) <- ask
+ lift . atomically $ writeTVar wt w
+
+ modifyWorld fn = do
+ (_, wt) <- ask
+ lift . atomically $ modifyTVar' wt fn
+
+ debugPrint = putText
+
+-------------------
+
+
+handleCommand :: (MonadHost m, MonadWorld m) => Text -> m (Maybe Text)
+handleCommand t =
case parseCommand t of
- Nothing -> putText $ "error parsing: " <> t
- Just (cmd, cmdData) -> runCmd conn worldV cmd cmdData
+ Nothing -> pure . Just $ "error parsing: " <> t
+ Just (cmd, cmdData) -> do
+ runCmd cmd cmdData
+ pure Nothing
+
+
+runCmd :: (MonadHost m, MonadWorld m) => Text -> [Text] -> m ()
+runCmd cmd cmdData =
+ case cmd of
+ "redraw" ->
+ case parseScreenSize cmdData of
+ Nothing -> sendHostError "missing / invalid screen size"
+ Just (sx, sy) -> do
+ updatePlayer (plScreenSize .~ (sx, sy))
+ world <- askWorld
+ sendHostData $ Ae.encodeText (drawAndSend world)
+ sendHostLog "draw"
+ "key" -> do
+ -- Handle the key press
+ modifyWorld (\w ->
+ -- Do the actions as if they will succeed
+ let pendingWorld = runActions w $ handleKey w cmdData in
+ -- Apply, if the move is allowed
+ -- Cost is hard-coded to 100 for now, this will be fixed later
+ playerMoving 100 pendingWorld w
+ )
+
+ -- Get the updated world
+ w2 <- askWorld
+
+ -- Handle the annotations
+ -- This is not terribly pretty as its doing a select for update, but its good enough for debugging
+ -- the annotation code can be removed once everything is working
+ let annotations = w2 ^. wdUtilBrainAnnotations
+ modifyWorld (\w -> w & wdUtilBrainAnnotations .~ [])
+ printAnnotations annotations
+
+ -- Draw
+ w3 <- askWorld
+ sendHostData $ Ae.encodeText (drawAndSend w3)
+
+ _ ->
+ sendHostError $ "Unknown command: " <> cmd
+
+ where
+ updatePlayer f = modifyWorld (\w -> w & wdPlayer %~ f)
+
+ printAnnotations as = do
+ debugPrint ""
+ debugPrint ""
+ debugPrint ""
+ debugPrint ""
+ debugPrint "***** Utility Annotations **************"
+ traverse_ printAnnotation as
+ debugPrint "****************************************"
+ debugPrint ""
+
+ printAnnotation (e, assess, top) = do
+ debugPrint ""
+ debugPrint $ "-----------------------" <> show e
+ debugPrint " -- assess --"
+ debugPrint . Txt.intercalate "\n" $ showEntries <$> assess
+ debugPrint ""
+ debugPrint " -- top --"
+ debugPrint . Txt.intercalate "\n" $ showEntries <$> top
+ debugPrint "-----------------------"
+
+ showEntries :: UtilAnnotationEntry -> Text
+ showEntries e =
+ case e of
+ UeAt a -> " At: " <> a
+ UeSelectTopNone n -> " No utils: " <> n
+ UeSelectTopAbove f -> " Top above: " <> showF f
+ UeSelectTopOne val n i d -> " Select top one: " <> n <> ", impulse=" <> show i <> ", score=" <> showF val <> "," <> d
+ UeNote n -> " Note: " <> n
+
+
+sendConfig :: (MonadHost m) => Config -> m ()
+sendConfig config =
+ sendHostData . Ae.encodeText $ UiConfig "config" (buildConfig config)
+
+----------------------------------------------------------------------------------------------------------------
+
+
+----------------------------------------------------------------------------------------------------------------
+-- L3 (Business logic, pure code only)
+----------------------------------------------------------------------------------------------------------------
parseCommand :: Text -> Maybe (Text, [Text])
parseCommand t =
case Txt.splitOn "|" t of
@@ -162,91 +367,6 @@
}
-runCmd :: Host.Connection -> TVar World -> Text -> [Text] -> IO ()
-runCmd conn worldV cmd cmdData =
- case cmd of
- "redraw" ->
- case parseScreenSize cmdData of
- Nothing -> sendError conn "missing / invalid screen size"
- Just (sx, sy) -> do
- updatePlayer (plScreenSize .~ (sx, sy))
- w <- atomically $ readTVar worldV
- drawAndSend w
- sendLog conn "draw"
-
- "key" -> do
- -- Handle the key press
- atomically $ modifyTVar' worldV (\w ->
- -- Do the actions as if they will succeed
- let pendingWorld = runActions w $ handleKey w cmdData in
- -- Apply, if the move is allowed
- -- Cost is hard-coded to 100 for now, this will be fixed later
- playerMoving 100 pendingWorld w
- )
- -- Get the updated world
- w2 <- atomically $ readTVar worldV
-
- -- Handle the annotations
- -- This is not terribly pretty as its doing a select for update, but its good enough for debugging
- -- the annotation code can be removed once everything is working
- let annotations = w2 ^. wdUtilBrainAnnotations
- atomically $ modifyTVar' worldV (\w -> w & wdUtilBrainAnnotations .~ [])
- printAnnotations annotations
-
- -- Draw
- drawAndSend w2
-
- _ ->
- sendError conn $ "Unknown command: " <> cmd
-
- where
- updatePlayer f = atomically $ modifyTVar' worldV (\w -> w & wdPlayer %~ f)
-
- printAnnotations as = do
- putText ""
- putText ""
- putText ""
- putText ""
- putText "***** Utility Annotations **************"
- traverse_ printAnnotation as
- putText "****************************************"
- putText ""
-
- printAnnotation (e, assess, top) = do
- putText ""
- putText $ "-----------------------" <> show e
- putText " -- assess --"
- putText . Txt.intercalate "\n" $ showEntries <$> assess
- putText ""
- putText " -- top --"
- putText . Txt.intercalate "\n" $ showEntries <$> top
- putText "-----------------------"
-
- showEntries :: UtilAnnotationEntry -> Text
- showEntries e =
- case e of
- UeAt a -> " At: " <> a
- UeSelectTopNone n -> " No utils: " <> n
- UeSelectTopAbove f -> " Top above: " <> showF f
- UeSelectTopOne val n i d -> " Select top one: " <> n <> ", impulse=" <> show i <> ", score=" <> showF val <> "," <> d
- UeNote n -> " Note: " <> n
-
-
-sendLog :: Host.Connection -> Text -> IO ()
-sendLog conn err =
- sendData conn $ Ae.encodeText $ UiMessage "log" err
-
-
-sendError :: Host.Connection -> Text -> IO ()
-sendError conn err =
- sendData conn $ Ae.encodeText $ UiMessage "error" err
-
-
-sendConfig :: Host.Connection -> Config -> IO ()
-sendConfig conn config =
- sendData conn . Ae.encodeText $ UiConfig "config" (buildConfig config)
-
-
buildConfig :: Config -> UiConfigData
buildConfig cfg =
UiConfigData { udKeys = buildKeys (cfg ^. cfgKeys)
@@ -258,12 +378,6 @@
buildKey (s, a) = UiKey s a
-sendData :: Host.Connection -> Text -> IO ()
-sendData conn t = do
- let lz = Bz.compress . BSL.fromStrict . TxtE.encodeUtf8 $ t
- conn ^. conSendData $ lz
-
-
parseScreenSize :: [Text] -> Maybe (Int, Int)
parseScreenSize cmd = do
(tx, ty) <- case cmd of
@@ -275,15 +389,14 @@
pure (x, y)
-drawAndSend :: World -> IO ()
-drawAndSend world = do
- let layers = drawTilesForPlayer world (world ^. wdMap)
+drawAndSend :: World -> UiDrawCommand
+drawAndSend world =
+ let layers = drawTilesForPlayer world (world ^. wdMap) in
- let cmd = Ae.encodeText UiDrawCommand { drCmd = "draw"
+ UiDrawCommand { drCmd = "draw"
, drScreenWidth = world ^. wdPlayer ^. plScreenSize ^. _1
, drMapData = mkDrawMapData <<$>> (Map.toList <$> layers)
}
- sendData (world ^. wdPlayer ^. plConn) cmd
where
mkDrawMapData :: (PlayerPos, Tile) -> (Int, Int, Int)
src/GameHost.hs
diff -w -B -a -d -u -b -r --new-file 19_story/src/GameHost.hs 20_structure/src/GameHost.hs
--- 19_story/src/GameHost.hs
+++ 20_structure/src/GameHost.hs
@@ -1,6 +1,8 @@
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module GameHost ( runHost
, Connection(..)
@@ -18,6 +20,9 @@
import qualified Network.Wai.Handler.WebSockets as WaiWs
import qualified Network.WebSockets as WS
+----------------------------------------------------------------------------------------------------------------
+-- L0 Hosting
+----------------------------------------------------------------------------------------------------------------
data Connection = Connection { _conSendData :: BSL.ByteString -> IO ()
, _conReceiveText :: IO Text
}
@@ -55,11 +61,12 @@
wsapp :: (Connection -> IO ()) -> WS.ServerApp
-wsapp startHost pending = do
+wsapp runConnection pending = do
conn <- WS.acceptRequest pending
WS.forkPingThread conn 30
- startHost Connection { _conSendData = WS.sendBinaryData conn
+ let h = Connection { _conSendData = WS.sendBinaryData conn
, _conReceiveText = WS.receiveData conn
}
-
+ runConnection h
+----------------------------------------------------------------------------------------------------------------