Lowbrow Haskell: A Quick Tetris Clone
2016-01-18A short overview of a TTY retro game re-implemented in Haskell.
In this post, I'd like to showcase an entertaining hack in Haskell: a clone of the classic arcade game Tetris. As this was my first Haskell project in almost two years, I decided to keep things simple and minimal. The project's only external dependency is stm.
Design Goals
I set out with the following goals in mind:
- The game should run in the TTY
- It should be playable, and at least a little bit fun
- It may not have curses-like library dependencies
- The game state must be modeled fully in pure code
Defining these up front helped me fight back feature creep and establish a point at which I could consider the project "finished". As usual, there are many things that need improvement, but I believe I've reached the above goals.
Modus Operandi
The development process was quite ad-hoc, as the repo's git history will reveal. I started from the bottom-up, modeling Tetris 'boards' and their various rotations. As usual with bottom-up strategies, I initially missed the mark by a wide margin, but gained some intuition about the kinds of models I'd be working with.
For the first couple of iterations, the entire game was contained within two
files: apps/Main.hs
and src/Tetris.hs
, with some rudimentary tests in
test/Spec.hs
. Main.hs
was responsible for accepting keyboard input and
drawing blocks, while Tetris.hs
housed the representation of blocks and
functions to rotate them.
Out of this mud-ball, the MVC pattern slowly emerged. I did not set out with any interaction paradigm in mind, but the models and their relations naturally suggested MVC. More on this later.
The Architecture
The final working architecture consists of:
1. The top-level loop
mainLoop :: Game -> InputSource -> IO ()
mainLoop g@Game{gameOver=False} is =
drawGame g >> getInputEvent is >>= mainLoop is . flip updateGame g
Where the InputSource
doubles as a pace-maker, returning an input event only
after a certain time interval has passed. mainLoop
will terminate when
an application of updateGame
sets the gameOver
flag to True
.
2. Game logic in Tetris.Model.Game
This module exposes a minimal API, encapsulating all game state changes:
module Tetris.Model.Game (Game(..), InputEvent(..), freshGame, updateGame)
updateGame :: InputEvent -> Game -> Game
updateGame ev = supplyNewBlock . dropBlock . updateTick . applyInput ev
Each of the four functions making up the body of updateGame
has the type
Game -> Game
, which makes them smelly, but also very easy to tweak.
Particular low-level decisions about the legality of board positions are made
in the Tetris.Game.Board
module, which knows all about the entities that
inhabit the game board:
module Tetris.Model.Board
(Board(..), Block(..), BlockType(..), Cell
,board, blocks, clearLines
,emptyCell, fullCell
,overlapsAt, rotated, spliceBoardAt
)
3. Player input accepted and parsed in Tetris.Controller
module Tetris.Controller(InputSource, getInputEvent, startInputLoop)
This module's functionality was, to me, the most interesting component of the
project. While the Tetris.Model.Game
module has no concept of time, the
Controller
is all about time. It exposes two functions to the main driver loop:
startInputLoop
launches a thread which will buffer keyboard input, while
getInputEvent
blocks for a set interval and returns an opaque InputEvent
, to
be used in the pure updateGame
function.
data InputSource = InputSource (TVar (Maybe Char))
startInputLoop :: IO InputSource
startInputLoop = do
is <- InputSource <$> newTVarIO Nothing
forkIO (inputLoop is)
return is
inputLoop :: InputSource -> IO ()
inputLoop i@(InputSource tvar) =
getChar >>= atomically . writeTVar tvar . Just >> inputLoop i
This starts a process that updates the STM variable (TVar
), hidden inside the
InputSource
, whenever a key is pressed. Notice that this function overwrites
the value.
When the main loop asks for the most recent InputEvent
, we sleep a
while, reset the Tvar
to Nothing
, and return whatever was in the TVar
before we cleared it.
getInputEvent :: InputSource -> IO InputEvent
getInputEvent (InputSource tvar) = do
tick
c <- readTVarIO tvar
when (isJust c) (atomically (writeTVar tvar Nothing))
return (inputEvent c)
What's tick
? It makes sure our game runs at a constant refresh rate. The main
loop will not progress until it gets its InputEvent
. We leverage this fact
to slip in the below code:
tick :: IO ()
tick = threadDelay $ (1000 `div` 12) * 1000
I told you that this module was all about time!
4. Drawing things in Game.View
This last bit of the MVC puzzle is extremely simple. Its only responsibility is
to take a Game
and render it as a String
, so the main loop can dump it
to the TTY.
module Tetris.View (showGame) where
showGame :: Game -> String
Closing Thoughts
I had a ton of fun getting back into Haskell with this project. It was my
first time using stack
, the new Haskell project management tool, and also my
first time writing a real-time UI in Haskell. Here are my main takeaways:
1. STM is a pleasure to use
Concurrency is fun, but it's also complex. I've spent the last two years coding almost exclusively in Erlang, where concurrent, loosely-coupled processes are the norm. With the awesome freedom comes the pain of managing an infinite space of incoming message formats and timings. Contrasted with the double-edged power of unconstrained message passing, typed STM feels like simple made easy again.
2. I don't need no stinkin' Curses
gnome-terminal
produces no visible artifacts while refreshing the entire
terminal window smoothly at up to 24 times a second. Drawing a game frame to
the screen is done in one line:
drawGame g = clearTerm >> putStr (showGame g)
This is very convenient, and not having to deal with cursor addressing is bliss. Should I need more involved layouts, I'd rather put some work into composable screen regions – perhaps something like the picture language from SICP or Joey Hess' console region manager.
3. Buzzword-compliance is hard. Modeling counts
Having had a look around the Haskell blogosphere and watched some talks, I noticed that both Free Monads and FRP are steadily building a following. I intentionally kept this project simple to avoid turning it into an exercise in implementing a specific Cool Thing that's making the rounds.
It turns out that by going low-brow and avoiding upfront abstraction, I painted
myself into a corner. Having modeled the game as an IO-based core loop with
pure game logic in a dedicated data structure, I'd inadvertently committed to a
discrete and state-based architecture. While this code naturally broke apart
into the Model, View and (the most interesting) Controller abstractions, I was
left with a bunch of functions typed Game -> Game
, which give off the
distinctive smell of global state.
I see no obvious way of implementing the same game within an FRP framework, but given another chance, I'd like to try my hand at modeling the state transitions as a DSL inside the Free monad. Perhaps a topic for another blog post?
Subscribe to the RSS's feed and stay tuned!