Lowbrow Haskell: A Quick Tetris Clone

A 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:

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
  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!