We are finally in the last step of this project. We are going to create a fully abstracted version of our code. The way we are doing this is by creating classes for each component of our code. Let me recall the general architecture:
Let's summarize what we have:
- Two external devices:
- The keyboard input
- The screen
- Two external services communicating with the external devices
- The
EventQueue
(read from the keyboard) - The renderer device. In our case, it is the console, but it could be a SDL2's pixel buffer or a OpenGL Data Structure
- The
- One main thread which read from the
EventQueue
and send to the renderer device. The main thread is separated in:- The GameState
- The RenderState
Notice that the core of the software is the main thread. In theory we could change all external components without touching the logic at all. Examples of this are:
- We use ncurses frontend
- We read events from terminal but using ncurses built-in Event system
- We render to the console but using ncurses TUI capabilities.
- We use SDL frontend
- Instead of reading directly inputs from console, we use SDL's built-in event queue
- Instead of rendering to console we use SDL's surface buffer which is rendered using the GPU.
This refactoring is separated in one Step with two task.
- Step 1: Make your code abstract.
- Task 1.1: Abstract the read only environment
- Task 1.2: create type classes following the ideas above.
The idea is that we are going to define an App
type which will have instances for many type classes expressing the components above. If we'd like to change frontends, we will just create new type AppSDL
and create the convenient instances.
- In
RenderState.hs
create a type classHasBoardInfo env
which has one method calledgetBoardInfo :: env -> BoardInfo
. - Substitute in
RenderState.hs
andGameState.hs
any constraints ofMonadReader BoardInfo m
byMonadReader env m, HasBoardInfo env
- modify
App.hs
so yourApp
type has now an environment with theBoardInfo
and theEventQueue
.
data AppState = AppState GameState RenderState
data Env = Env BoardInfo EventQueue
newtype App m a = App {runApp :: ReaderT Env (StateT AppState m) a}
deriving (Functor , Applicative, Monad, MonadState AppState, MonadReader Env, MonadIO)
You may wonder why using an environment with the EventQueue
on it, when the event queue is not part of the core logic (only the events it contains are). Certainly, we could pass the EventQueue
as a parameter to all the functions that need it, but this is a little inconvenient. Also, you may notice that we are putting a mutable asynchronous queue into a read-only environment. That doesn't look like the things Haskell promises!! Precisely, Haskell is famous for not having global mutable states, or mutable variables out of the box. This pattern of pushing mutable structures into a read-only environment is actually quite common, and it is called the ReaderT
pattern. Strictly speaking, we are not using such a pattern here, but we are taking some of the ideas of this pattern and applying them to our piece of software.
- Define instances for
Env
so it can use functions inGameState
andRenderState
modules.
instance HasBoardInfo Env where
getBoardInfo = undefined
instance HasEventQueue Env where
getEventQueue = undefined
- Define the following type classes. If you look closely, we only define what is strictly necessary for the core logic. We need to pull events from somewhere, we need to render somehow, and we need to update both state sending messages.
class Monad m => MonadQueue m where
pullEvent :: m Event -- ^Pull an Event from the queue
class Monad m => MonadSnake m where
updateGameState :: Event -> m [RenderMessage]
updateRenderState :: [RenderMessage] -> m ()
class Monad m => MonadRender m where
render :: m ()
You may be surprised updateRenderState
does not produce a Builder
as we do in the RenderState
module. The reason for that is because we don't want to be bounded to a specific implementation of the rendering. Builder
just works to render into the console as we do now, but it is useless if we'd like to change frontends.
- Now we need to define instances for
App
monad
instance (???) => MonadQueue (App m) where -- you will need to fill the ???
pullEvent = undefined
instance (???) => MonadSnake (App m) where -- you will need to fill the ???
updateGameState = undefined
updateRenderState = undefined
instance (???) => MonadRender (App m) where -- you will need to fill the ???
render = undefined
- last, let's define the logic of the game.
-- This set the the speed of the game on the score. Notice the constraint give access to all the components.
setSpeedOnScore :: (MonadReader env m, HasEventQueue env, MonadState state m, HasRenderState state, MonadIO m) => m Int
setSpeedOnScore = undefined
-- This is one step of the logic: read from the queue and-then update the game state and-then update the render state and-then render
gameStep :: (MonadQueue m, MonadSnake m, MonadRender m) => m ()
gameStep = undefined -- Try just using the operator >>= which you can read as "(m >>= f) do m and-then f"
-- The game loop implementation is provided. To pretty much can read in english.
gameloop :: (MonadQueue m, MonadSnake m, MonadRender m, MonadState state m, HasRenderState state, MonadReader env m, HasEventQueue env, MonadIO m) => m ()
gameloop = forever $ do
w <- setSpeedOnScore
liftIO $ threadDelay w
gameStep
isGameOver <- gets (gameOver . getRenderState)
unless isGameOver gameloop
-- Run the application as usual
run :: Env -> AppState -> IO ()
run env app = runApp gameloop `runReaderT` env `evalStateT` app
- Last step. Fix compiler errors.
You've completed the snake-fury
challenge. Hopefully all the code deletions, refactorings, etc... has give you a good understanding on how Haskell code is structured and how monads play a fundamental role on it.
Of course, there is a lot of missing topics. From here, you can try to write a SDL frontend using sdl2
bindings or ncurses frontend with the brick
Other topics you should explore, are free-monads and effect systems, the ReaderT pattern, type level programming, and many many more.
If you've found any refactoring too difficult or badly introduced, feel free to open an issue.