Learning (Some) Haskell by Building Tetris
Table of Contents
- Beginning at the End
- What This Is
- What This Isn’t
- Prelude
- Strategy
- Imports and Dependencies
- Establishing the Grid
- Making Some Tetrominos
- Rotations
- Placing Pieces on the Grid
- Representing the Game State
- The Introduction of Time and Logic
- Operating on the Game
- Super Advanced Tetris AI (SATAI)
Beginning at the End
This is what we’ll build over the course of this post1.
What This Is
This post is a hands-on introduction to Haskell via the implementation of a little-known game involving falling blocks, because that’s how I first learnt the basics. I’ll try explain Haskell-specific concepts in detail, such that an audience of competent programmers with no Haskell or even functional programming familiarity could follow it and end up with a passing understanding of how to build a simple Haskell application.
I’ll explicitly try to overexplain everything, either in prose or in comments. I’m also going to purposefully try to use a variety of different styles of Haskell programming.
We’ll end up with a minimal terminal implementation of Tetris, and a simple agent playing using beam search.
What This Isn’t
I won’t touch on package management or project structure - in fact, this post is a literate2 Haskell file, and the concatenated code blocks (if written to tetris.hs
) can be run via runhaskell tetris.hs
. There are plenty of tutorials on package managers like Stack and Cabal, and on general project management out there - for now, all you need is whatever Haskell distribution your machine uses. GHCup is as good a place to start as any. You might already even have runhaskell
on your machine.
We’ll try to use as few external dependencies as possible, and won’t use any language extensions.
There are a lot of ways one could write this code more cleanly and performantly - avoiding passing around explicit state using monad transformers like StateT
, being more careful around the use of strictness versus laziness, and so on - I’m considering this out of scope and will try keep it as simple as I can. There will be no catamorphisms, hylomorphisms, or other such morphisms here.
Prelude
I watched the Tetris movie this week. There’s this almost certainly apocryphal scene where Alexey Patjinov is demoing his creation to a publisher, who has a “drop the ‘the’” moment and suggests all completed rows should vanish at once, rather than one at a time, enabling the achievement of the four-lines-at-once namesake move. They swiftly hack the feature together on a tiny monochrome display, and I was reminded how lucky I am to live in an era of rich tooling, expressive languages, and 4K monitors.
When I was first learning Haskell, though, it felt like punching holes in cards. I couldn’t get my head around the interplay between the purity of the language and the need to interact with the real world. A long while before, I’d grokked Gary Bernhardt’s Functional Core, Imperative Shell message, but how does this apply in a world where, supposedly, everything is functional? As we’ll see, the Haskell equivalent is something like “functional core, IO
shell” - but we’re getting ahead of ourselves. I wrote my own toy implementation as a way of getting to grips with the language, and thought I’d revisit it, rewriting it piece-by-piece in notebook style.
Please note that I myself am a kind of “expert beginner” - I love the language but I’m sure (in fact I know) there’s a lot here that could be improved upon, even with the constraints of targetting a beginner audience. My email is in the footer and I welcome errata.
Strategy
- We’ll build up from a play area, to the tetrominos, to the game logic, to user input, and finally to a self-playing bot.
- We’ll represent the play area as a
Map
(think: a tree-backed dictionary) whose keys are coordinates, and whose values are the contents of a grid cell, where the y-coordinate grows from top to bottom. - We’ll make this 10x24, to allow for a 4-row buffer at the top in which to place new pieces.
- Pieces themselves will begin life in a 4x4 grid, and remain that way until they get fixed to the board.
- This lets us implement rotation, collision detection and bounds checks on falling pieces by stepping forward (either by rotating, by translation or by gravity), looking for overlap, and simply rejecting the new game state if we have overlapping blocks.
- We’ll build logic to move the game forward one “step” (apply gravity, fix blocks when they hit bottom, delete full rows, update the score, etc.)
- We’ll finally implement a simple bot that looks a few blocks ahead and optimises for keeping the grid as low as possible.
- Later, when I revisit this post to include human input, we’ll have a playable game with three threads running:
- One to progress the game state
- One to draw the game to the screen
- One to accept user input and act on it
Imports and Dependencies
We’ll start with the imports we need. Haskell is “batteries included” in so far as there is a rich collection of widely used, canonical core libraries on Hackage - but they don’t come with the compiler. You need to make them available on your system. For example, we’ll be using Map
a lot, which is part of the containers
package. The glorious Glasgow Haskell Compiler needs you to install these libraries. There are myriad ways of doing this, but simplest might just be running cabal install --lib <libname>
.
The full list of packages we need here are:
base
containers
random
random-shuffle
extra
If you’re following along, you’ll want to install them all:
cabal install --lib base containers random random-shuffle extra
3
Versioning is a whole other topic. We aren’t using any unstable features of these packages, so I’ve not suggested pinning any particular versions, but just know it’s often useful to do so do avoid dependency hell in a real project. A good package manager4 (Cabal, Stack, Nix, others) will help you here.
Alright, so say we’ve got our tetris.hs
blank slate. This is going to be a single-file program, so we’ll put everything into a monolithic Main
module. This isn’t great practice for serious projects, but for our purposes we can keep everything in Main
.
I’ll spell out each import we’re using explicitly5:
Establishing the Grid
Now let’s think about how we’ll represent the game state, the entities within it, and the actions we can take.
We’ll need a 2D grid of cells, each of which can be empty or filled with a block, and that block . Whenever you have state in this “one-of-many” form, where you might reach for an enum, in Haskell you can define a sum type:
Now we’re ready to set up our grid:
And our first function, a simple constructor:
Let’s get some output going. We’re going to want to be able to pretty-print a bunch of our entities (our grids, our scoreboard) - when we want to implement the same broad concept across multiple disparate types, we draw for a typeclass (similar to a trait in Rust, or maybe an interface in Go). We’ll define a Pretty
typeclass - any type that implements this will be convertable to a nicely formatted String
6 which we can later print to the screen7.
Here a
is a placeholder for the type that will implement the Pretty
class. We’re simply saying that anything prettifiable must define a pretty
function that spits out a nice String
representation. Very hand-wavily, Haskell’s type signatures are written this way as all functions can be partially applied and are curried by default; for now, a function with a signature of foo :: a -> b -> c -> d
can be thought of as a three argument function taking an a
, a b
, a c
and returning a d
.
We can make Cell
an instance of this typeclass simply by associating each cell with a character. We can use Haskell’s pattern-matching to have pretty
behave differently depending on whether it’s given an Empty
cell or a Block
cell. We can also cheat a little, and make the Pretty
representation of a Colour
be a terminal escape code we can use to give colour to the blocks by using it as a prefix.
The <>
is shorthand for mconcat
- a member of the Monoid
typeclass, which roughly represents things that can be empty, and can be joined together. String
is a Monoid
so <>
just concatenates them.
Since an empty grid is going to be quite boring to print, let us make a way of adding a border to a grid. We can use BlockChar
with Unicode line and corner chars to surround a grid. Let’s make this a typeclass too! That way, we can add borders to regular grid, but also to UI elements.
We’re ready to prettify our Grid
. Since we’re operating over collections of things, we can start using higher-order functions; in Haskell, fmap
from the Functor
typeclass lets you apply a function to the inhabitants of any instance of a given Functor
. A list is an instance of Functor
, and so for some list xs
, fmap f xs
just operates like the map(f, xs)
function you find over lists in most other languages.
Helper functions and intermediate values defined in where
blocks are available in the above scope. Type signatures are optional, but I’ve included them for clarity - they can also help the compiler tell you when you’ve gone off track. I’ve included some alternative equivalent implementations of prettyRow
here; I won’t keep doing this, but it gives you a sense of the different ways one can construct functions.
We use M.!
to look up keys in our grid; this is unsafe, and can throw an error. A nicer way would be to use M.lookup
, which returns a Maybe Cell
here, meaning we’d have to handle the Nothing
case (i.e. out of bounds) and the Just cell
case separately. We know we’re within bounds here, so we’ll keep it simple, but it’s worth knowing.
Here we’ve converted back from our Map
representation of the Grid
to a List
-based one, in order to more easily convert it to a list of String
that we can join (intercalate
in Haskell) together with newlines inbetween.
We can finally print our grid! It’s nothing special, but here we go:
┌──────────┐
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────┘
Alright!
We’ll hide the top four rows later on. For now it’s useful to print the whole grid, as we’ll use this to display our tetrominos too.
Making Some Tetrominos
Let’s make the pieces. We’ll represent them as a product type with a colour and coordinates, and take advantage of Haskell’s laziness to construct an infinite stream of pieces, in chunks of seven, where each of the seven chunks is a shuffled collection containing every piece (per the official rules). This’ll let us easily draw the next piece, as well as enabling a simple lookahead for a next-piece preview.
We’ll encode the actual shapes by the coordinates of their full blocks, letting us specify their colour as well. We’ll use some helpers to let us quickly set coloured blocks on an empty grid. Eventually we’ll have a function that transforms a Grid
into a copy of itself containing one new coloured block - we can then fold
this function, using an empty 4x4 grid as the initial state, over the coordinates of the piece, which will add the blocks one by one, giving us the finished piece.
Now we can specify piece properties using simple pattern-matched functions:
And now we can generate our infinite stream of pieces lazily:
We will also need some notion of a falling piece; something combining colour and location:
Now we need some functions for composing an ActivePiece
and a Grid
, both for inspection and later, for placing tetrominos on the playing field.
Notice how we take our grid as an argument, and return ostensibly a new one; in some languages this would be expensive, but Haskell’s functional data structures make this a cheap operation, and let us pass around and create updated versions of state without needing to worry about mutation. We can just think in terms of pure transformations of our entities.8
Whew, okay. Let’s give ourselves a nice way of inspecting these pieces - we’ll use this for things like next-piece preview. We can just pretty-print the containing grid; here we use point-free style to omit the argument. The (.)
operator composes functions right-to-left, so since we want to first convert to a grid, and then pretty-print, we can write:
Let’s see if we got that right by pretty-printing these pieces. First we’ll just print one:
┌────┐
│ │
│ █ │
│ █ │
│ ██ │
└────┘
For fun, we’ll implement Monoid
for Grid
; this just means defining what it means for a Grid
to be empty, and how to stitch two grids together. However, just like Int
(which can be combined multiple ways - summing, multiplying), there’s no unique way to combine two grids - so let’s implement both horizontal and vertical stitching. This will require some newtype
wrappers - for example, we can’t just do 2 <> 3 == ???
in Haskell, as it doesn’t know which Monoid
to use for the concatenation; instead we either:
Sum 2 <> Sum 3 == Sum 5
Product 2 <> Product 3 == Product 6
There’s a practical use here; we’ll use these Monoid
instances to compose UI elements like the grid, the next piece preview, and the display of the held piece. When we concatenate two grids along an edge, we’ll grow the shorter grid to match it. This is a design choice; if we didn’t do this, we’d still have a lawful Monoid
9, but it wouldn’t be as useful for us.
A detail; a Semigroup
is something that can be associatively combined - that’s where the <>
comes from (shorthand for mconcat
). A Monoid
is a Semigroup
with an identity element (e.g. the empty grid - something you can combine either on the left or right, and get the same thing back). So to make something a Monoid
, we first make it a Semigroup
, then simply define what an empty one looks like. It goes like this:
There’s quite a bit going on here; essentially, we construct a new empty grid of combined height, and wide enough to accomodate both grids. The unHGrid
named member just lets us easily unwrap this type later on.
Then we M.union
with the original grid, copying over its elements.
Finally, we copy over the second grid - but this time, we increase all y-coordinates by the height of the first grid by first creating a partial function that increments the second member of a tuple (second (+heightA))
) and using an M.mapKeys
to bump all y-coordinates of the second grid to the correct locations.
Note that we use backticks to inline the function, since it’s kind of standing in place of the fmap
operator (<$>)
10.
Let’s just test this quickly:
┌────┐
│ │
│ █ │
│ █ │
│ ██ │
└────┘
┌────┐
│ │
│ ██ │
│ █ │
│ █ │
└────┘
┌────┐
│ │
│ ██ │
│██ │
│ │
└────┘
Now the same for the VGrid
:
Again, always worth testing:
┌────┐┌────┐┌────┐
│ ││ ││ │
│ █ ││ ██ ││ ██ │
│ █ ││ █ ││██ │
│ ██ ││ █ ││ │
└────┘└────┘└────┘
Now we can generate some batches of seven pieces, and stitch them together like so:
┌──────────────────────────────────────────┐
│┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐│
││ ││ ││ ││ ││ ││ ││ ││
││ █ ││██ ││ ██ ││ █ ││ ██ ││ ││ ██ ││
││███ ││ ██ ││██ ││ █ ││ █ ││ ││ ██ ││
││ ││ ││ ││ ██ ││ █ ││████││ ││
│└────┘└────┘└────┘└────┘└────┘└────┘└────┘│
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐│
││ ││ ││ ││ ││ ││ ││ ││
││ █ ││ ██ ││ ││ █ ││ ██ ││██ ││ ██ ││
││ █ ││ █ ││ ││███ ││ ██ ││ ██ ││██ ││
││ ██ ││ █ ││████││ ││ ││ ││ ││
│└────┘└────┘└────┘└────┘└────┘└────┘└────┘│
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐│
││ ││ ││ ││ ││ ││ ││ ││
││ ██ ││ ██ ││ ██ ││ █ ││██ ││ ││ █ ││
││ █ ││ ██ ││██ ││ █ ││ ██ ││ ││███ ││
││ █ ││ ││ ││ ██ ││ ││████││ ││
│└────┘└────┘└────┘└────┘└────┘└────┘└────┘│
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐│
││ ││ ││ ││ ││ ││ ││ ││
││ ││ █ ││ ██ ││ ██ ││ ██ ││██ ││ █ ││
││ ││ █ ││ ██ ││ █ ││██ ││ ██ ││███ ││
││████││ ██ ││ ││ █ ││ ││ ││ ││
│└────┘└────┘└────┘└────┘└────┘└────┘└────┘│
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐│
││ ││ ││ ││ ││ ││ ││ ││
││ █ ││██ ││ ██ ││ ██ ││ ││ ██ ││ █ ││
││███ ││ ██ ││██ ││ █ ││ ││ ██ ││ █ ││
││ ││ ││ ││ █ ││████││ ││ ██ ││
│└────┘└────┘└────┘└────┘└────┘└────┘└────┘│
└──────────────────────────────────────────┘
Looks good to me - each batch of seven represents all pieces, and each is separately shuffled. But where’s our colour?! In a terminal, those ANSI control codes would show up just fine.
We introduced a number of new concepts here; we secretly entered a monad (IO
, specifically), enabling the do
-notation you see above, and giving us the ability to enact the useful side effect of being able to print to the screen. In fact, we’ve been doing this all along with every call to putStrLn
. We’ll get into IO
more later when we start dealing with user input and multiprocessing.
We also introduced uncurry
- we wanted to pass the tuples of form f (1, batch1)
we’d created via zip
into a function that wanted arguments f 1 batch1
- uncurry
will convert a function that wants two arguments into a function that wants a tuple of those two arguments11.
Rotations
While we’re here, let’s implement piece rotation. We’d like to handle a single coordinate at a time, which means we’ll also need to pass in information about the bounding box within which we’re rotating.
Now we can rotate coordinates, but we want to rotate pieces themselves.
Let’s take a look at these rotations with a helper:12
First clockwise:
┌────┐┌────┐┌────┐┌────┐
│ ││ ││ ██ ││ │
│ █ ││███ ││ █ ││ █│
│ █ ││█ ││ █ ││ ███│
│ ██ ││ ││ ││ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ ││ █ ││ │
│ ██ ││███ ││ █ ││ █ │
│ █ ││ █ ││ ██ ││ ███│
│ █ ││ ││ ││ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ ││ ││ │
│ ██ ││ ██ ││ ██ ││ ██ │
│ ██ ││ ██ ││ ██ ││ ██ │
│ ││ ││ ││ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ █ ││ ││ │
│ ██ ││ ██ ││ ██││ █ │
│██ ││ █ ││ ██ ││ ██ │
│ ││ ││ ││ █ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ █ ││ ││ │
│██ ││ ██ ││ ██ ││ █ │
│ ██ ││ █ ││ ██││ ██ │
│ ││ ││ ││ █ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ █ ││ ││ │
│ █ ││ ██ ││ ███││ █ │
│███ ││ █ ││ █ ││ ██ │
│ ││ ││ ││ █ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││█ ││████││ █│
│ ││█ ││ ││ █│
│ ││█ ││ ││ █│
│████││█ ││ ││ █│
└────┘└────┘└────┘└────┘
And counterclockwise:
┌────┐┌────┐┌────┐┌────┐
│ ││ ││ ██ ││ │
│ █ ││ █││ █ ││███ │
│ █ ││ ███││ █ ││█ │
│ ██ ││ ││ ││ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ ││ █ ││ │
│ ██ ││ █ ││ █ ││███ │
│ █ ││ ███││ ██ ││ █ │
│ █ ││ ││ ││ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ ││ ││ │
│ ██ ││ ██ ││ ██ ││ ██ │
│ ██ ││ ██ ││ ██ ││ ██ │
│ ││ ││ ││ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ ││ ││ █ │
│ ██ ││ █ ││ ██││ ██ │
│██ ││ ██ ││ ██ ││ █ │
│ ││ █ ││ ││ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ ││ ││ █ │
│██ ││ █ ││ ██ ││ ██ │
│ ██ ││ ██ ││ ██││ █ │
│ ││ █ ││ ││ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ ││ ││ █ │
│ █ ││ █ ││ ███││ ██ │
│███ ││ ██ ││ █ ││ █ │
│ ││ █ ││ ││ │
└────┘└────┘└────┘└────┘
┌────┐┌────┐┌────┐┌────┐
│ ││ █││████││█ │
│ ││ █││ ││█ │
│ ││ █││ ││█ │
│████││ █││ ││█ │
└────┘└────┘└────┘└────┘
I’m almost sure it’s not Regulation Tetris Rotation Rules, but it’ll do.
Placing Pieces on the Grid
Let’s start by placing a piece in that buffer zone at the top of the grid (which we’ll eventually hide).
We want it to be anchored to the bottom, so that it immediately starts to become visible as it falls, so we’ll translate it based on its lowest y-coordinate.
And let’s test this, as ever:
┌──────────┐
│ │
│ ██ │
│ ██ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────┘
Looks solid - one step of gravity after this, and the piece will become visible.
Representing the Game State
Now we’ll create the type we’ll be using to store all state about the ongoing game. Note that we still keep this outside of IO
, requiring that a source of randomness is piped in to create this state.
We’re going to implement piece holding - since there might not be a held piece, we’ll represent this using Maybe
. This is a Haskell staple, defined as data Maybe a = Just a | Nothing
. It’s like Rust’s Option<a>
and there are analogues in most languages. It forces you to consider both cases when you may or may not have a value.
As we pull pieces from the infinite lazy list pieces
, we’ll create new Game
objects that contain the remainder of the lazy list.
Note each field of this record type (essentially a Haskell product type with named members) creates a function of the same name, which you can call on inhabitants of this datatype to retrieve the field value. So score game
will return the score of a game, and so on. This can cause all kinds of namespace clashes and there are a lot of ways around it, but for now we’re just going to use these default record accessors.
Alright - now we’re in a position to render our rudimentary UI by stitching these things together. On the left we’ll have our grid, and on the right we’ll have our next piece on the top, and our held piece on the bottom:
We’ll need a way of adding string labels to our UI:
And a way of hiding the buffer zone:
Now finally we can put it all together:
We can preview this as so:
┌────────┐
│Score: 0│
└────────┘
┌──────────┐┌─────┐
│ ││Next:│
│ ││ │
│ ││ ██ │
│ ││ █ │
│ ││ █ │
│ │└─────┘
│ │┌─────┐
│ ││Held:│
│ ││ │
│ ││ ██ │
│ ││██ │
│ ││ │
│ │└─────┘
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────┘
This is looking a bit like Tetris! We can no longer see the buffer zone at the top with the falling piece, but we can see the next piece displayed on the right hand side, and below that we’ve artificially inserted a held square piece, and as we can see it’s all composing nicely.
The Introduction of Time and Logic
Let’s ignore user input for now and focus solely on advancing time.
To make this work, we’ll need a way to:
- Advance the current piece downwards
- Fix pieces in place when they hit the bottom
- Pulls a new piece from the infinite stream and places it at the top
To do all this in a carefree way, we’d like a way of checking if a game is in a valid state (at first just to stop pieces from falling through the floor).
A valid Game
is one where there are no out of bound blocks, we haven’t spilled over the top, and the current ActivePiece
is not overlapping with any of the existing blocks. By induction, if we start with a valid Game
, and only place pieces in valid places, we only need to check the currently active piece:
Now we’re able to use this for a simple implementation of gravity:
So let’s test this out a few times - for now we’ll represent the passage of time horizontally, so we’ll make a few game states, pull out the grids, and stitch them side by side. We’d like to keep applying applyGravity
over and over - but each time we take a Game
to a Maybe Game
. We want some way of chaining these iterations together - and that’s where the fact that Maybe
belongs to the Monad
typeclass comes in.
This is not a Monad
tutorial but it’s useful to know that this is what’s powering the composition13 of instances of this applyGravity
function together in a type-consistent way.
Here we unsafely unwrap the Maybe String
since we know it’s going to be a Just
, but bear in mind that’s not great practice in production:
┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐┌───────────────────┐
│┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ ││┌────────┐ │
││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │││Score: 0│ │
│└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ ││└────────┘ │
│┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐││┌──────────┐┌─────┐│
││ ██ ││Next:││││ █ ││Next:││││ █ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││││ ││Next:││
││ ││ ││││ ██ ││ ││││ █ ││ ││││ █ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││
││ ││ ██ ││││ ││ ██ ││││ ██ ││ ██ ││││ █ ││ ██ ││││ █ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││││ ││ ██ ││
││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ██ ││ █ ││││ █ ││ █ ││││ █ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││
││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ██ ││ █ ││││ █ ││ █ ││││ █ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││││ ││ █ ││
││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ ██ │└─────┘│││ █ │└─────┘│││ █ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│
││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ ██ │┌─────┐│││ █ │┌─────┐│││ █ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│││ │┌─────┐│
││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ██ ││Held:││││ █ ││Held:││││ █ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││││ ││Held:││
││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ██ ││ ││││ █ ││ ││││ █ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││
││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ██ ││ ││││ █ ││ ││││ █ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││
││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ██ ││ ││││ █ ││ ││││ █ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││
││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ██ ││ ││││ █ ││ ││││ █ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││││ ││ ││
││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ ██ │└─────┘│││ █ │└─────┘│││ █ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│││ │└─────┘│
││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ ██ │ │││ █ │ │││ █ │ │││ │ │││ │ │││ │ │││ │ │
││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ ██ │ │││ █ │ │││ █ │ │││ │ │││ │ │││ │ │
││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ ██ │ │││ █ │ │││ █ │ │││ │ │││ │ │
││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ ██ │ │││ █ │ │││ █ │ │││ │ │
││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ ██ │ │││ █ │ │││ █ │ │
││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ ██ │ │││ █ │ │
││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ │ │││ ██ │ │
│└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ ││└──────────┘ │
└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘└───────────────────┘
Sick, we hit the bottom and then we stop.
This horizontal time axis thing is a bit of a rough way to display game progression. Let’s write a bit of magic to make this easier on the eyes, and animate our outputs:
Let’s test this out:
Who needs ncurses
when you have hacks like these?
Let’s create a way to fix our active pieces to the grid - simple, because we can just take the union of the coordinates. We’ll simultaneously draw a new piece from the stream, too - and this would be the time to check for any complete lines, and remove them from the grid. We’ll implement simple scoring (no T-spins here, although they will be actually be possible).
Let’s find which line indices are completely full:
Now we can remove them from the grid. This is a little inefficient; we’ll remove them one by one, shifting the rest of the grid above it down, ensuring that we re-fill with empty space at the top.
Let’s write a way to test this out real quick:14
This should give us a side by side comparison:
Full lines detected: [21,23]
┌───────────────────┐┌───────────────────┐
│┌────────┐ ││┌──────────┐ │
││Score: 0│ │││Score: 300│ │
│└────────┘ ││└──────────┘ │
│┌──────────┐┌─────┐││┌──────────┐┌─────┐│
││ ││Next:││││ ││Next:││
││ ││ ││││ ││ ││
││ ││ ██ ││││ ││ ██ ││
││ ││ █ ││││ ││ █ ││
││ ││ █ ││││ ││ █ ││
││ │└─────┘│││ │└─────┘│
││ │┌─────┐│││ │┌─────┐│
││ ││Held:││││ ││Held:││
││ ││ ││││ ││ ││
││ ││ ││││ ││ ││
││ ││ ││││ ││ ││
││ ││ ││││ ││ ││
││ │└─────┘│││ │└─────┘│
││ │ │││ │ │
││ │ │││ │ │
││ │ │││ │ │
││ │ │││ │ │
││██████████│ │││ │ │
││██████ │ │││ │ │
││██████████│ │││██████ │ │
│└──────────┘ ││└──────────┘ │
└───────────────────┘└───────────────────┘
Seems legit to me, and the score went up appropriately too. Now we can finally fix our pieces in place:
Now we can continually apply gravity, and when we reach an invalid state, we can fix the piece instead. The call to applyGravity
lets us look one step ahead and respond accordingly. However, if after fixing a piece, we’re still invalid (i.e. we’ve reached the top of the grid), we can return Nothing
again.
And so now when we go to print this:
Aight! We’ve got rudimentary collision detection, game over detection and we can see that the piece preview works. Now we need some sort of way to “play the game”.
Operating on the Game
We’ll need to give our bot a way to operate on a game. Let’s define a set of operations - later, we could just map these to keyboard inputs to play the game ourselves, but this is trickier in the medium of a blog.
Let’s start by defining the possible operations:
Now we’ll implement the application of these operations to a Game
. If they result in an invalid game state (moving out of bounds, or impossible rotations), we’ll just return Nothing
.
Holding a piece is relatively simple:
To forcibly drop a piece, we can just move it down until it’s no longer a valid move. This should also trigger fixing the piece.
Now we can implement the actual application of operations:
We can test this out with a short animation:
I reckon we can do better than this. Time for a bot.
Super Advanced Tetris AI (SATAI)
Ultimately, we want something that can look at a game and decide what operation to perform in order to maximise some heuristic. I think it’s a bit ambitious to optimise for score here, since the lookahead required can be quite far, so let’s just start by keeping the grid ceiling as low as possible.
To make a decision, we’ll simulate all possible operations15 that we can perform in one turn. We’ll cheat, and give the bot as much time to think about each move as possible - a “move” will therefore be some combination of left or right movements and rotations followed by a drop.
We can use Applicative
syntax and the Monad
instance of lists to (somewhat) neatly generate all of these possible future states:
We can test this out just by generating an animation of all the possible states at the start of a given game:
Let’s implement a simple heuristic - the higher the grid ceiling, the worse it is:
We can now implement one-step lookahead:
Let’s see how it gets on:
Hey, 1000! Not bad, but obviously we’d benefit from arbitrary lookahead. We’ll implement a kind of beam search, where we take the n
best states and expand them out, up to some predefined depth. Obviously the wider the beam, the faster this space grows, and the deeper we go the more computation we’ll need.
We use a common pattern here where we’d like to track some state in our recursion, but don’t want to expose it. We use this go
helper to, here, track the first move we made, so that we can return it at the end.
And finally, let’s see how it gets on with a width and depth of 1:
Sweet, this replicates our simple one-step lookahead. Finally, let’s expand the beam out.
Alright! I’ll take 5300. I’m going to stop here, as this post has started to take minutes to compile into HTML… needless to say, a wider beam and a deeper search can result in some surprisingly good play.
Footnotes
1 Okay, for now this is actually a version I build ages ago. I’m rewriting this from scratch for this post, so ours will look a little different, and hopefully better!
2 Okay, not quite. I’m writing this in Emacs, where org-babel
will run each block in GHCi, a Haskell interpreter, with set +m
enabled to allow multiline blocks. The whole thing gets compiled to Markdown via org-jekyll
. The end result is the same, more or less, as writing actual literate code, with some of the advantages of a Jupyter-style workflow.
3 Note that in general this is a terrible idea and gave me all kinds of headaches writing this post. Using Cabal in a global manner like this is inviting trouble. Pick and learn a package manager (could still be Cabal, but in the context of a project, not a blog post)
4 I use Cabal’s Nix integration for anything serious.
5 Also because for whatever reason, I can’t get org-babel
to accept more than one import per code block and I really want to be able to run this entire post as a single notebook-style program.
6 You’ll typically be recommended to eschew String
(which is a linked list of characters) for the more efficient Text
type; we don’t need to worry about this for a toy application.
7 There’s already the Show
typeclass that does exactly this, and which can be automatically derived for many types, but I tend to think of it as for debugging and inspection purposes - I prefer a separate typeclass for representations intended to be user-facing.
8 The use of foldl'
here does two things: we fold from the left (irrelevant in this case, but important sometimes), and we fold strictly - that is, we don’t accumulate a load of unevaluated thunks and overflow the stack. Again, never going to happen in our toy example, but worth knowing.
9 That is, associative, and with a left and right identity (the empty grid in both cases).
10 Note that when referring to operators both in code and prose, it’s typical to refer to them in parentheses. (+) 1 2
is the same as 1 + 2
.
11 It gets more complex when you’re dealing with more arguments - uncurry3 f (a, b c) = f a b c
and so on exist but there’s no way to write generic uncurryN
without resorting to TemplateHaskell
to the best of my knowledge. Tweet at me if I’m wrong please.
12 The lambda syntax used here twice nested makes e.g. (\a b -> a + b)
equivalent to (+)
.
13 In this case, Kleisli composition; the (>=>)
operator composes a -> m b
and b -> m c
into a -> m c
.
14 Note that here I’m being explicit that we’re building something of type IO ()
, roughly meaning a thing that can have real-world side effects like printing to the screen, but doesn’t return anything (or rather, returns the unit value ()
).
15 Well, not all. We don’t implement T-spins, or slotting into holes halfway down the grid, for example, which might end up being the optimal move.