diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Changelog
+
+## Master
+
+## v0.1.0
+
+-   Initial release.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,7 @@
+# Contributing
+
+1. Fork it.
+2. Create your feature branch (`git checkout -b my-feature`).
+3. Commit your changes (`git commit -a -m 'Add feature'`).
+4. Push to the branch (`git push origin my-feature`).
+5. Create new pull request.
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2014 Taylor Fausak <taylor@fausak.me>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# [hs2048][1]
+
+[![Build Status][2]][3]
+
+A [2048][4] clone in Haskell.
+
+![Screenshot](http://i.imgur.com/utw0O1h.png)
+
+## Setup
+
+``` sh
+$ vagrant up
+$ vagrant ssh
+```
+
+``` sh
+$ cd /vagrant
+$ make
+$ make run
+```
+
+## Others
+
+- <https://github.com/DavidMihola/2048>
+- <https://github.com/Javran/h2048>
+- <https://github.com/YawarRaza7349/2048Game.hs>
+- <https://github.com/badamson/2048.hs>
+- <https://github.com/chrismwendt/2048>
+- <https://github.com/egonSchiele/ones>
+- <https://github.com/godel9/2048-hs>
+- <https://github.com/jgallag88/2048>
+- <https://github.com/mitchellwrosen/hs2048-free>
+- <https://github.com/uncleverone/haskell2048>
+
+[1]: https://github.com/tfausak/hs2048
+[2]: https://travis-ci.org/tfausak/hs2048.svg?branch=master
+[3]: https://travis-ci.org/tfausak/hs2048
+[4]: https://github.com/gabrielecirulli/2048
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/benchmark/Bench.hs b/benchmark/Bench.hs
new file mode 100644
--- /dev/null
+++ b/benchmark/Bench.hs
@@ -0,0 +1,29 @@
+module Main (main) where
+
+import           Criterion.Main        (bgroup, defaultMain)
+import qualified Hs2048.BoardBench
+import qualified Hs2048.DirectionBench
+import qualified Hs2048.GameBench
+import qualified Hs2048.MainBench
+import qualified Hs2048.PointBench
+import qualified Hs2048.RendererBench
+import qualified Hs2048.SettingsBench
+import qualified Hs2048.TileBench
+import qualified Hs2048.VectorBench
+import qualified Hs2048Bench
+-- HASKELETON: import qualified New.ModuleBench
+
+main :: IO ()
+main = defaultMain
+    [ bgroup "Hs2048" Hs2048Bench.benchmarks
+    , bgroup "Hs2048.Board" Hs2048.BoardBench.benchmarks
+    , bgroup "Hs2048.Direction" Hs2048.DirectionBench.benchmarks
+    , bgroup "Hs2048.Game" Hs2048.GameBench.benchmarks
+    , bgroup "Hs2048.Main" Hs2048.MainBench.benchmarks
+    , bgroup "Hs2048.Point" Hs2048.PointBench.benchmarks
+    , bgroup "Hs2048.Renderer" Hs2048.RendererBench.benchmarks
+    , bgroup "Hs2048.Settings" Hs2048.SettingsBench.benchmarks
+    , bgroup "Hs2048.Tile" Hs2048.TileBench.benchmarks
+    , bgroup "Hs2048.Vector" Hs2048.VectorBench.benchmarks
+    -- HASKELETON: , bgroup "New.Module" New.ModuleBench.benchmarks
+    ]
diff --git a/executable/Main.hs b/executable/Main.hs
new file mode 100644
--- /dev/null
+++ b/executable/Main.hs
@@ -0,0 +1,7 @@
+module Main (main) where
+
+import           Hs2048        (new, play)
+import           System.Random (getStdGen)
+
+main :: IO ()
+main = getStdGen >>= play . new
diff --git a/hs2048.cabal b/hs2048.cabal
new file mode 100644
--- /dev/null
+++ b/hs2048.cabal
@@ -0,0 +1,207 @@
+author:
+    Taylor Fausak
+bug-reports:
+    https://github.com/tfausak/hs2048/issues
+build-type:
+    Simple
+cabal-version:
+    >= 1.18
+category:
+    Game
+copyright:
+    2014 Taylor Fausak <taylor@fausak.me>
+description:
+    A <https://github.com/gabrielecirulli/2048 2048> clone in Haskell.
+    .
+    This implements the game logic as well as a console interface for playing
+    it.
+extra-source-files:
+    CHANGELOG.md
+    CONTRIBUTING.md
+    README.md
+homepage:
+    https://github.com/tfausak/hs2048
+license-file:
+    LICENSE.txt
+license:
+    MIT
+maintainer:
+    taylor@fausak.me
+name:
+    hs2048
+synopsis:
+    A 2048 clone in Haskell.
+tested-with:
+    GHC == 7.6.3
+version:
+    0.1.0
+
+source-repository head
+    location:
+        git://github.com/tfausak/threase.git
+    type:
+        git
+
+flag documentation
+    default:
+        False
+
+library
+    build-depends:
+        base < 5
+      , random == 1.*
+    default-language:
+        Haskell2010
+    exposed-modules:
+        Hs2048
+        Hs2048.Board
+        Hs2048.Direction
+        Hs2048.Game
+        Hs2048.Main
+        Hs2048.Point
+        Hs2048.Renderer
+        Hs2048.Settings
+        Hs2048.Tile
+        Hs2048.Vector
+        -- HASKELETON: New.Module
+    ghc-prof-options:
+        -auto-all
+        -prof
+    hs-source-dirs:
+        library
+
+    if flag(documentation)
+        build-depends:
+            hscolour == 1.*
+
+executable hs2048
+    build-depends:
+        base < 5
+      , hs2048
+      , random == 1.*
+    default-language:
+        Haskell2010
+    ghc-prof-options:
+        -auto-all
+        -prof
+    hs-source-dirs:
+        executable
+    main-is:
+        Main.hs
+
+test-suite hspec
+    build-depends:
+        base < 5
+      , hs2048
+      , hspec == 1.*
+          , HUnit
+          , QuickCheck
+      , random == 1.*
+    default-language:
+        Haskell2010
+    ghc-options:
+        -fhpc
+        -Wall
+        -Werror
+    hs-source-dirs:
+        library
+        test-suite
+    main-is:
+        Spec.hs
+    other-modules:
+        Hs2048
+        Hs2048.Board
+        Hs2048.Direction
+        Hs2048.Game
+        Hs2048.Main
+        Hs2048.Point
+        Hs2048.Renderer
+        Hs2048.Settings
+        Hs2048.Tile
+        Hs2048.Vector
+        -- HASKELETON: New.Module
+        Hs2048Spec
+        Hs2048.BoardSpec
+        Hs2048.DirectionSpec
+        Hs2048.GameSpec
+        Hs2048.MainSpec
+        Hs2048.PointSpec
+        Hs2048.RendererSpec
+        Hs2048.SettingsSpec
+        Hs2048.TileSpec
+        Hs2048.VectorSpec
+        -- HASKELETON: New.ModuleSpec
+    type:
+        exitcode-stdio-1.0
+
+test-suite doctest
+    build-depends:
+        base < 5
+      , Glob == 0.*
+      , doctest == 0.*
+    default-language:
+        Haskell2010
+    hs-source-dirs:
+        test-suite
+    main-is:
+        DocTest.hs
+    type:
+        exitcode-stdio-1.0
+
+test-suite hpc
+    build-depends:
+        base < 5
+      , process == 1.*
+      , regex-compat == 0.*
+    default-language:
+        Haskell2010
+    hs-source-dirs:
+        test-suite
+    main-is:
+        HPC.hs
+    type:
+        exitcode-stdio-1.0
+
+test-suite haddock
+    build-depends:
+        base < 5
+      , process == 1.*
+      , regex-compat == 0.*
+    default-language:
+        Haskell2010
+    hs-source-dirs:
+        test-suite
+    main-is:
+        Haddock.hs
+    type:
+        exitcode-stdio-1.0
+
+test-suite hlint
+    build-depends:
+        base
+      , hlint == 1.*
+    default-language:
+        Haskell2010
+    hs-source-dirs:
+        test-suite
+    main-is:
+        HLint.hs
+    type:
+        exitcode-stdio-1.0
+
+benchmark benchmarks
+    build-depends:
+        base < 5
+      , hs2048
+      , criterion == 0.6.*
+          , hastache < 0.6
+          , statistics < 0.11
+      , random == 1.*
+    default-language:
+        Haskell2010
+    hs-source-dirs:
+        benchmark
+    main-is:
+        Bench.hs
+    type:
+        exitcode-stdio-1.0
diff --git a/library/Hs2048.hs b/library/Hs2048.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048.hs
@@ -0,0 +1,17 @@
+{- |
+    Top-level module. Re-exports things from all other modules.
+-}
+module Hs2048 (module Hs2048) where
+
+import           Hs2048.Board     as Hs2048
+import           Hs2048.Direction as Hs2048 hiding (render)
+import           Hs2048.Game      as Hs2048
+import           Hs2048.Main      as Hs2048
+import           Hs2048.Point     as Hs2048
+import           Hs2048.Renderer  as Hs2048
+import           Hs2048.Settings  as Hs2048
+import           Hs2048.Tile      as Hs2048 hiding (empty, parse, render,
+                                             score)
+import           Hs2048.Vector    as Hs2048 hiding (canShift, empty, parse,
+                                             render, score, set, shift)
+-- HASKELETON: import New.Module as Hs2048
diff --git a/library/Hs2048/Board.hs b/library/Hs2048/Board.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048/Board.hs
@@ -0,0 +1,160 @@
+-- | Types and functions for manipulating boards.
+module Hs2048.Board
+    ( Board
+    , canMove
+    , canShift
+    , empty
+    , emptyPoints
+    , move
+    , parse
+    , render
+    , rotate
+    , rotateFrom
+    , rotateTo
+    , score
+    , set
+    , shift
+    ) where
+
+import           Data.List        (transpose)
+import qualified Hs2048.Direction as D
+import qualified Hs2048.Point     as P
+import qualified Hs2048.Tile      as T
+import qualified Hs2048.Vector    as V
+
+{- |
+    Represents the game board. By convention, it is row-major.
+-}
+type Board = [V.Vector]
+
+{- |
+    Determines if the board can be moved in the given direction. See 'move'.
+
+    >>> canMove [[Nothing, Just 2]] D.West
+    True
+-}
+canMove :: Board -> D.Direction -> Bool
+canMove b d = move b d /= b
+
+{- |
+    Determines if the board can be shifted. See 'shift'.
+
+    >>> canShift [[Nothing, Just 2]]
+    True
+-}
+canShift :: Board -> Bool
+canShift b = shift b /= b
+
+{- |
+    Returns an empty board of the given size.
+
+    >>> empty 2 1
+    [[Nothing,Nothing]]
+-}
+empty :: Int -- ^ Width
+    -> Int -- ^ Height
+    -> Board
+empty = flip replicate . V.empty
+
+{- |
+    Returns the points that don't contain tiles.
+
+    >>> emptyPoints [[Nothing, Just 2]]
+    [(0,0)]
+-}
+emptyPoints :: Board -> [P.Point]
+emptyPoints b = zip [0 ..] (fmap V.emptyIndexes b) >>= go
+  where
+    go (x, ys) = fmap ((,) x) ys
+
+{- |
+    Moves the board in the given direction.
+
+    >>> move [[Nothing, Just 2]] D.West
+    [[Just 2,Nothing]]
+-}
+move :: Board -> D.Direction -> Board
+move b d = rotateFrom (shift (rotateTo b d)) d
+
+{- |
+    Parses a string as a board. This is the inverse of 'render'.
+
+    >>> parse "- 2\n4 -\n"
+    [[Nothing,Just 2],[Just 4,Nothing]]
+-}
+parse :: String -> Board
+parse = fmap V.parse . lines
+
+{- |
+    Renders a board as a string. This is the inverse of 'parse'.
+
+    >>> render [[Nothing, Just 2], [Just 4, Nothing]]
+    "- 2\n4 -\n"
+-}
+render :: Board -> String
+render = unlines . fmap V.render
+
+{- |
+    Rotate the board 90 degrees clockwise.
+
+    >>> rotate [[Nothing, Just 2], [Just 4, Nothing]]
+    [[Just 4,Nothing],[Nothing,Just 2]]
+-}
+rotate :: Board -> Board
+rotate = fmap reverse . transpose
+
+{- |
+    Rotates the board so that 'Hs2048.Direction.West' is at the left, assuming
+    the given direction is currently at the left. This is the inverse of
+    'rotateTo'.
+
+    >>> rotateFrom [[Just 4, Nothing], [Nothing, Just 2]] D.South
+    [[Nothing,Just 2],[Just 4,Nothing]]
+-}
+rotateFrom :: Board -> D.Direction -> Board
+rotateFrom b d = head (drop n gs)
+  where
+    n = 1 + fromEnum (maxBound :: D.Direction) - fromEnum d
+    gs = iterate rotate b
+
+{- |
+    Rotates the board so that the given direction is at the left. This is the
+    inverse of 'rotateFrom'
+
+    >>> rotateTo [[Nothing, Just 2], [Just 4, Nothing]] D.South
+    [[Just 4,Nothing],[Nothing,Just 2]]
+-}
+rotateTo :: Board -> D.Direction -> Board
+rotateTo b d = head (drop n gs)
+  where
+    n = fromEnum d
+    gs = iterate rotate b
+
+{- |
+    Calculates the score of a board.
+
+    >>> score [[Nothing, Just 2], [Just 4, Just 8]]
+    20
+-}
+score :: Board -> Int
+score = sum . fmap V.score
+
+{- |
+    Sets a tile at the given point in the board.
+
+    >>> set [[Nothing, Just 2], [Just 4, Nothing]] (Just 8) (1, 1)
+    [[Nothing,Just 2],[Just 4,Just 8]]
+-}
+set :: Board -> T.Tile -> P.Point -> Board
+set b t p = zipWith go [0 ..] b
+  where
+    go i v = if i == P.x p then V.set v t (P.y p) else v
+
+{- |
+    Shifts a board toward the head. See 'Vector.shift'.
+
+    >>> shift [[Nothing, Just 2], [Just 4, Nothing]]
+    [[Just 2,Nothing],[Just 4,Nothing]]
+-}
+shift :: Board -> Board
+shift = fmap V.shift
diff --git a/library/Hs2048/Direction.hs b/library/Hs2048/Direction.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048/Direction.hs
@@ -0,0 +1,38 @@
+-- | Types and functions for manipulating directions.
+module Hs2048.Direction
+    ( Direction (..)
+    , directions
+    , render
+    ) where
+
+{- |
+    Represents a direction that the game board can be moved in. The cardinal
+    directions are used to avoid collisions with 'Left' and 'Right'.
+-}
+data Direction
+    = West
+    | South
+    | East
+    | North
+    deriving (Bounded, Enum, Eq, Show)
+
+{- |
+    Returns all of the directions.
+
+    >>> directions
+    [West,South,East,North]
+-}
+directions :: [Direction]
+directions = [minBound ..]
+
+{- |
+    Renders a direction as a string.
+
+    >>> render West
+    "\8592"
+-}
+render :: Direction -> String
+render West = "\x2190"
+render South = "\x2193"
+render East = "\x2192"
+render North = "\x2191"
diff --git a/library/Hs2048/Game.hs b/library/Hs2048/Game.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048/Game.hs
@@ -0,0 +1,118 @@
+-- | Functions for playing the game.
+module Hs2048.Game
+    ( addRandomTile
+    , addRandomTiles
+    , hasWon
+    , isOver
+    , new
+    , randomEmptyIndex
+    , randomEmptyPoint
+    , randomTile
+    ) where
+
+import           Data.Maybe       (fromJust)
+import qualified Hs2048.Board     as B
+import qualified Hs2048.Direction as D
+import qualified Hs2048.Point     as P
+import qualified Hs2048.Settings  as S
+import qualified Hs2048.Tile      as T
+import qualified Hs2048.Vector    as V
+import qualified System.Random    as R
+
+{- |
+    Adds a random tile to the board.
+
+    >>> addRandomTile [[Nothing], [Nothing]] (R.mkStdGen 0)
+    ([[Nothing],[Just 2]],1346387765 2103410263)
+-}
+addRandomTile :: R.RandomGen r => B.Board -> r -> (B.Board, r)
+addRandomTile b r = case p of
+    Nothing -> (b, r)
+    _ -> (b', r'')
+  where
+    b' = B.set b t (fromJust p)
+    (p, r') = randomEmptyPoint b r
+    (t, r'') = randomTile r'
+
+{- |
+    Adds some random tiles to the board.
+
+    >>> addRandomTiles 2 [[Nothing], [Nothing]] (R.mkStdGen 0)
+    ([[Just 2],[Just 2]],2127568003 1780294415)
+-}
+addRandomTiles :: R.RandomGen r => Int -> B.Board -> r -> (B.Board, r)
+addRandomTiles 0 b r = (b, r)
+addRandomTiles n b r = addRandomTiles (n - 1) b' r'
+  where
+    (b', r') = addRandomTile b r
+
+{- |
+    Determines if the game has been won. See 'Hs2048.Settings.maxTile'.
+
+    >>> hasWon [[Just 2048]]
+    True
+-}
+hasWon :: B.Board -> Bool
+hasWon = any (any (maybe False (>= S.maxTile)))
+
+{- |
+    Determines if the game is over. The game is over if there are no available
+    moves and no empty points.
+
+    >>> isOver [[Just 2]]
+    True
+-}
+isOver :: B.Board -> Bool
+isOver b = cantMove && haveNoEmptyPoints
+  where
+    cantMove = not (any (B.canMove b) D.directions)
+    haveNoEmptyPoints = null (B.emptyPoints b)
+
+{- |
+    Creates a new game by making an empty board and adding some random tiles to
+    it. See 'Hs2048.Settings.width', 'Hs2048.Settings.height', and
+    'Hs2048.Settings.tiles'.
+
+    >>> new (R.mkStdGen 0)
+    ([[Just 2,Nothing,Nothing,Nothing],[Nothing,Nothing,Nothing,Nothing],[Nothing,Nothing,Nothing,Nothing],[Nothing,Just 2,Nothing,Nothing]],2127568003 1780294415)
+-}
+new :: R.RandomGen r => r -> (B.Board, r)
+new = addRandomTiles S.tiles (B.empty S.width S.height)
+
+{- |
+    Selects an empty index at random from a vector.
+
+    >>> randomEmptyIndex [Nothing, Nothing] (R.mkStdGen 0)
+    (Just 1,40014 40692)
+-}
+randomEmptyIndex :: R.RandomGen r => V.Vector -> r -> (Maybe Int, r)
+randomEmptyIndex v r = if null is then (Nothing, r) else (Just i, r')
+  where
+    i = is !! x
+    (x, r') = R.randomR (0, length is - 1) r
+    is = V.emptyIndexes v
+
+{- |
+    Selects an empty point at random from a board.
+
+    >>> randomEmptyPoint [[Nothing],[Nothing]] (R.mkStdGen 0)
+    (Just (1,0),40014 40692)
+-}
+randomEmptyPoint :: R.RandomGen r => B.Board -> r -> (Maybe P.Point, r)
+randomEmptyPoint b r = if null ps then (Nothing, r) else (Just p, r')
+  where
+    p = ps !! x
+    (x, r') = R.randomR (0, length ps - 1) r
+    ps = B.emptyPoints b
+
+{- |
+    Creates a random tile.
+
+    >>> randomTile (R.mkStdGen 0)
+    (Just 2,1601120196 1655838864)
+-}
+randomTile :: R.RandomGen r => r -> (T.Tile, r)
+randomTile r = (Just n, r')
+  where
+    n = if (x :: Float) < 0.9 then 2 else 4
+    (x, r') = R.random r
diff --git a/library/Hs2048/Main.hs b/library/Hs2048/Main.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048/Main.hs
@@ -0,0 +1,72 @@
+-- | Main entry point for the console interface to the game.
+module Hs2048.Main
+    ( direction
+    , getChars
+    , getMove
+    , play
+    ) where
+
+import qualified Hs2048.Board     as B
+import qualified Hs2048.Direction as D
+import qualified Hs2048.Game      as G
+import           Hs2048.Renderer  (renderGame)
+import           System.IO        (BufferMode (NoBuffering), hSetBuffering,
+                                   hSetEcho, stdin)
+import qualified System.Random    as R
+
+{- |
+    Converts a string into a direction.
+
+    >>> direction "\ESC[D"
+    Just West
+    >>> direction "<"
+    Nothing
+-}
+direction :: String -> Maybe D.Direction
+direction "\ESC[D" = Just D.West
+direction "\ESC[B" = Just D.South
+direction "\ESC[C" = Just D.East
+direction "\ESC[A" = Just D.North
+direction _ = Nothing
+
+{- |
+    Gets up to three characters from standard input. If the input corresponds
+    to an arrow key, it will be returned. Otherwise 'Nothing' will be returned.
+    Will only consume enough input to determine if the input is an arrow key or
+    not.
+-}
+getChars :: IO (Maybe String)
+getChars = do
+    a <- getChar
+    if a /= '\ESC' then return Nothing else do
+        b <- getChar
+        if b /= '[' then return Nothing else do
+            c <- getChar
+            return $ if c `elem` "ABCD"
+                then Just [a, b, c]
+                else Nothing
+
+{- |
+    Reads from standard input and converts it into a direction. See 'getChars'.
+-}
+getMove :: IO (Maybe D.Direction)
+getMove = fmap (maybe Nothing direction) getChars
+
+{- |
+    Plays the game.
+-}
+play :: R.RandomGen r => (B.Board, r) -> IO ()
+play (b, r) = do
+    hSetBuffering stdin NoBuffering
+    hSetEcho stdin False
+
+    putStr (renderGame b)
+
+    if G.hasWon b then putStrLn "You won!" else do
+        if G.isOver b then putStrLn "You lost." else do
+            m <- getMove
+            case m of
+                Nothing -> putStrLn "Unknown move." >> play (b, r)
+                Just d -> if B.canMove b d
+                    then putStrLn (D.render d) >> play (G.addRandomTile (B.move b d) r)
+                    else putStrLn "Invalid move." >> play (b, r)
diff --git a/library/Hs2048/Point.hs b/library/Hs2048/Point.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048/Point.hs
@@ -0,0 +1,30 @@
+-- | Types and functions for manipulating points.
+module Hs2048.Point
+    ( Point
+    , x
+    , y
+    ) where
+
+{- |
+    Represents a point on a game board. The top-left corner is (0, 0) with x
+    increasing left-to-right and y increasing top-to-bottom.
+-}
+type Point = (Int, Int)
+
+{- |
+    Returns the x part of a point.
+
+    >>> x (1, 2)
+    1
+-}
+x :: Point -> Int
+x = fst
+
+{- |
+    Returns the y part of a point.
+
+    >>> y (1, 2)
+    2
+-}
+y :: Point -> Int
+y = snd
diff --git a/library/Hs2048/Renderer.hs b/library/Hs2048/Renderer.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048/Renderer.hs
@@ -0,0 +1,93 @@
+-- | Functions for pretty-printing games to the console.
+module Hs2048.Renderer
+    ( center
+    , color
+    , renderBoard
+    , renderGame
+    , renderTile
+    ) where
+
+import           Data.Monoid  ((<>))
+import qualified Hs2048.Board as B
+import qualified Hs2048.Tile  as T
+
+{- |
+    Centers a string in the given number of characters, using spaces for
+    padding.
+
+    >>> center 3 "x"
+    " x "
+    >>> center 2 "x"
+    " x"
+-}
+center :: Int -> String -> String
+center w s = prefix <> s <> suffix
+  where
+    prefix = replicate (ceiling n) ' '
+    suffix = replicate (floor n) ' '
+    n = fromIntegral (w - length s) / (2 :: Float)
+
+{- |
+    Calculates the color code for a tile. See
+    <http://en.wikipedia.org/wiki/ANSI_escape_code#Colors>.
+
+    >>> color Nothing
+    30
+    >>> color (Just 2)
+    31
+    >>> color (Just 2048)
+    45
+-}
+color :: T.Tile -> Int
+color t = 30 + x
+  where
+    x = if r > 6 then r + 4 else r
+    r = T.rank t
+
+{- |
+    Renders a board with colorized, centered tiles. See 'renderTile' and
+    'center'.
+
+    >>> renderBoard [[Nothing, Just 2]]
+    "\ESC[30m-\ESC[0m \ESC[31m2\ESC[0m\n"
+    >>> renderBoard [[Nothing, Just 16]]
+    " \ESC[30m-\ESC[0m \ESC[34m16\ESC[0m\n"
+-}
+renderBoard :: B.Board -> String
+renderBoard b = unlines (fmap unwords vs')
+  where
+    vs' = fmap (fmap (center l)) vs
+    vs = fmap (fmap renderTile) b
+    l = maximum ls
+    ls = vs >>= fmap length
+
+{- |
+    Renders a board along with its score. See 'renderBoard'.
+
+    >>> renderGame [[Nothing, Just 2]]
+    "Score: 0\n\ESC[30m-\ESC[0m \ESC[31m2\ESC[0m\n"
+-}
+renderGame :: B.Board -> String
+renderGame b = concat
+    [ "Score: "
+    , show (B.score b)
+    , "\n"
+    , renderBoard b
+    ]
+
+{- |
+    Renders a colorized tile. See 'color'.
+
+    >>> renderTile Nothing
+    "\ESC[30m-\ESC[0m"
+    >>> renderTile (Just 2)
+    "\ESC[31m2\ESC[0m"
+-}
+renderTile :: T.Tile -> String
+renderTile t = concat
+    [ "\ESC["
+    , show (color t)
+    , "m"
+    , T.render t
+    , "\ESC[0m"
+    ]
diff --git a/library/Hs2048/Settings.hs b/library/Hs2048/Settings.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048/Settings.hs
@@ -0,0 +1,43 @@
+-- | Game settings.
+module Hs2048.Settings
+    ( height
+    , maxTile
+    , tiles
+    , width
+    ) where
+
+{- |
+    Returns the height of the game board.
+
+    >>> height
+    4
+-}
+height :: Int
+height = 4
+
+{- |
+    Returns the maximum tile value.
+
+    >>> maxTile
+    2048
+-}
+maxTile :: Int
+maxTile = 2048
+
+{- |
+    Returns the number of starting tiles.
+
+    >>> tiles
+    2
+-}
+tiles :: Int
+tiles = 2
+
+{- |
+    Returns the width of the game board.
+
+    >>> width
+    4
+-}
+width :: Int
+width = 4
diff --git a/library/Hs2048/Tile.hs b/library/Hs2048/Tile.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048/Tile.hs
@@ -0,0 +1,79 @@
+-- | Types and functions for manipulating tiles.
+module Hs2048.Tile
+    ( Tile
+    , empty
+    , parse
+    , rank
+    , render
+    , score
+    ) where
+
+{- |
+    Represents a tile on the game board. Can be empty (@Nothing@) or can have a
+    value (@Just n@). By convention, a tile's value is always a power of 2.
+-}
+type Tile = Maybe Int
+
+{- |
+    Returns the empty tile.
+
+    >>> empty
+    Nothing
+-}
+empty :: Tile
+empty = Nothing
+
+{- |
+    Parses a string as a tile. This is the inverse of 'render'.
+
+    >>> parse "-"
+    Nothing
+    >>> parse "2"
+    Just 2
+-}
+parse :: String -> Tile
+parse "-" = Nothing
+parse s = Just (read s)
+
+{- |
+    Calculates the rank of a tile.
+
+    >>> rank Nothing
+    0
+    >>> rank (Just 2)
+    1
+    >>> rank (Just 2048)
+    11
+-}
+rank :: Tile -> Int
+rank Nothing = 0
+rank (Just n) = floor (logBase b n')
+  where
+    b = 2 :: Double
+    n' = fromIntegral n
+
+{- |
+    Renders a tile as a string. This is the inverse of 'parse'.
+
+    >>> render Nothing
+    "-"
+    >>> render (Just 2)
+    "2"
+-}
+render :: Tile -> String
+render Nothing = "-"
+render (Just n) = show n
+
+{- |
+    Calculates the score of a tile.
+
+    >>> score Nothing
+    0
+    >>> score (Just 2)
+    0
+    >>> score (Just 2048)
+    20480
+-}
+score :: Tile -> Int
+score Nothing = 0
+score t@(Just n) = n * (rank t - 1)
diff --git a/library/Hs2048/Vector.hs b/library/Hs2048/Vector.hs
new file mode 100644
--- /dev/null
+++ b/library/Hs2048/Vector.hs
@@ -0,0 +1,113 @@
+-- | Types and functions for manipulating vectors.
+module Hs2048.Vector
+    ( Vector
+    , canShift
+    , empty
+    , emptyIndexes
+    , parse
+    , render
+    , score
+    , set
+    , shift
+    ) where
+
+import           Data.List   (group)
+import           Data.Maybe  (isJust, isNothing)
+import           Data.Monoid ((<>))
+import qualified Hs2048.Tile as T
+
+{- |
+    Represents a row or column on the game board. By convention, a vector has
+    4 tiles.
+-}
+type Vector = [T.Tile]
+
+{- |
+    Determines if the vector can be shifted.
+
+    >>> canShift [Nothing, Just 2, Nothing, Nothing]
+    True
+-}
+canShift :: Vector -> Bool
+canShift v = shift v /= v
+
+{- |
+    Returns an empty vector of the given size.
+
+    >>> empty 4
+    [Nothing,Nothing,Nothing,Nothing]
+-}
+empty :: Int -> Vector
+empty = flip replicate T.empty
+
+{- |
+    Returns the indexes that don't contain tiles.
+
+    >>> emptyIndexes [Nothing, Just 2, Nothing, Nothing]
+    [0,2,3]
+-}
+emptyIndexes :: Vector -> [Int]
+emptyIndexes = fmap fst . filter (isNothing . snd) . zip [0 ..]
+
+{- |
+    Parses a string as a vector. This is the inverse of 'render'.
+
+    >>> parse "- 2 - -"
+    [Nothing,Just 2,Nothing,Nothing]
+-}
+parse :: String -> Vector
+parse = fmap T.parse . words
+
+{- |
+    Renders a vector as a string. This is the inverse of 'parse'.
+
+    >>> render [Nothing, Just 2, Nothing, Nothing]
+    "- 2 - -"
+-}
+render :: Vector -> String
+render = unwords . fmap T.render
+
+{- |
+    Calculates the score of a vector.
+
+    >>> score [Nothing, Just 2, Just 4, Just 8]
+    20
+-}
+score :: Vector -> Int
+score = sum . fmap T.score
+
+{- |
+    Sets a tile at the given index in the vector.
+
+    >>> set [Nothing, Nothing, Nothing, Nothing] (Just 2) 1
+    [Nothing,Just 2,Nothing,Nothing]
+-}
+set :: Vector -> T.Tile -> Int -> Vector
+set v t i = zipWith go [0 ..] v
+  where
+    go i' t' = if i' == i then t else t'
+
+{- |
+    Shifts a vector toward the head. The output vector will be the same size as
+    the input vector, padded with @Nothing@.
+
+    >>> shift [Nothing, Just 2, Nothing, Nothing]
+    [Just 2,Nothing,Nothing,Nothing]
+
+    Like tiles will be combined.
+
+    >>> shift [Just 2, Nothing, Just 2, Just 2]
+    [Just 4,Just 2,Nothing,Nothing]
+
+    Any number of tiles can be combined in one shift.
+
+    >>> shift [Just 2, Just 2, Just 4, Just 4]
+    [Just 4,Just 8,Nothing,Nothing]
+-}
+shift :: Vector -> Vector
+shift v = take n (v' <> empty n)
+  where
+    n = length v
+    v' = group (filter isJust v) >>= go
+    go (Just a : Just b : ts) = Just (a + b) : go ts
+    go ts = ts
diff --git a/test-suite/DocTest.hs b/test-suite/DocTest.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/DocTest.hs
@@ -0,0 +1,7 @@
+module Main (main) where
+
+import           System.FilePath.Glob (glob)
+import           Test.DocTest         (doctest)
+
+main :: IO ()
+main = glob "library/**/*.hs" >>= doctest
diff --git a/test-suite/HLint.hs b/test-suite/HLint.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/HLint.hs
@@ -0,0 +1,17 @@
+module Main (main) where
+
+import           Language.Haskell.HLint (hlint)
+import           System.Exit            (exitFailure, exitSuccess)
+
+arguments :: [String]
+arguments =
+    [ "benchmark"
+    , "executable"
+    , "library"
+    , "test-suite"
+    ]
+
+main :: IO ()
+main = do
+    hints <- hlint arguments
+    if null hints then exitSuccess else exitFailure
diff --git a/test-suite/HPC.hs b/test-suite/HPC.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/HPC.hs
@@ -0,0 +1,31 @@
+module Main (main) where
+
+import           Data.List      (genericLength)
+import           Data.Maybe     (catMaybes)
+import           System.Exit    (exitFailure, exitSuccess)
+import           System.Process (readProcess)
+import           Text.Regex     (matchRegex, mkRegex)
+
+arguments :: [String]
+arguments =
+    [ "report"
+    , "dist/hpc/tix/hspec/hspec.tix"
+    ]
+
+average :: (Fractional a, Real b) => [b] -> a
+average xs = realToFrac (sum xs) / genericLength xs
+
+expected :: Fractional a => a
+expected = 80
+
+main :: IO ()
+main = do
+    output <- readProcess "hpc" arguments ""
+    if average (match output) >= expected
+        then exitSuccess
+        else putStr output >> exitFailure
+
+match :: String -> [Int]
+match = fmap read . concat . catMaybes . fmap (matchRegex pattern) . lines
+  where
+    pattern = mkRegex "^ *([0-9]*)% "
diff --git a/test-suite/Haddock.hs b/test-suite/Haddock.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Haddock.hs
@@ -0,0 +1,30 @@
+module Main (main) where
+
+import           Data.List      (genericLength)
+import           Data.Maybe     (catMaybes)
+import           System.Exit    (exitFailure, exitSuccess)
+import           System.Process (readProcess)
+import           Text.Regex     (matchRegex, mkRegex)
+
+arguments :: [String]
+arguments =
+    [ "haddock"
+    ]
+
+average :: (Fractional a, Real b) => [b] -> a
+average xs = realToFrac (sum xs) / genericLength xs
+
+expected :: Fractional a => a
+expected = 100
+
+main :: IO ()
+main = do
+    output <- readProcess "cabal" arguments ""
+    if average (match output) >= expected
+        then exitSuccess
+        else putStr output >> exitFailure
+
+match :: String -> [Int]
+match = fmap read . concat . catMaybes . fmap (matchRegex pattern) . lines
+  where
+    pattern = mkRegex "^ *([0-9]*)% "
diff --git a/test-suite/Hs2048/BoardSpec.hs b/test-suite/Hs2048/BoardSpec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048/BoardSpec.hs
@@ -0,0 +1,146 @@
+module Hs2048.BoardSpec (spec) where
+
+import           Hs2048.Board
+import qualified Hs2048.Direction as D
+import           Test.Hspec
+
+spec :: Spec
+spec = do
+    describe "canMove" $ do
+        it "returns False for []" $ do
+            canMove [] D.West `shouldBe` False
+
+        it "can move West" $ do
+            canMove [[Nothing, Just 2]] D.West `shouldBe` True
+
+        it "can move South" $ do
+            canMove [[Just 2], [Nothing]] D.South `shouldBe` True
+
+        it "can't move East" $ do
+            canMove [[Nothing, Just 2]] D.East `shouldBe` False
+
+        it "can't move North" $ do
+            canMove [[Just 2], [Nothing]] D.North `shouldBe` False
+
+    describe "canShift" $ do
+        it "returns False for []" $ do
+            canShift [] `shouldBe` False
+
+        it "returns True if any of the vectors can be shifted" $ do
+            canShift [[Nothing, Nothing], [Nothing, Just 2]] `shouldBe` True
+
+    describe "empty" $ do
+        it "returns [] for 0 0" $ do
+            empty 0 0 `shouldBe` []
+
+        it "returns [[Nothing], [Nothing]] for 1 2" $ do
+            empty 1 2 `shouldBe` [[Nothing], [Nothing]]
+
+    describe "emptyPoints" $ do
+        it "returns [] for []" $ do
+            emptyPoints [] `shouldBe` []
+
+        it "returns the points without tiles" $ do
+            emptyPoints [[Nothing, Just 2], [Just 4, Nothing]] `shouldBe`
+                [(0, 0), (1, 1)]
+
+    describe "move" $ do
+        it "moves West" $ do
+            move [[Nothing, Just 2], [Just 4, Nothing]] D.West `shouldBe`
+                [[Just 2, Nothing], [Just 4, Nothing]]
+
+        it "moves South" $ do
+            move [[Nothing, Just 2], [Just 4, Nothing]] D.South `shouldBe`
+                [[Nothing, Nothing], [Just 4, Just 2]]
+
+        it "moves East" $ do
+            move [[Nothing, Just 2], [Just 4, Nothing]] D.East `shouldBe`
+                [[Nothing, Just 2], [Nothing, Just 4]]
+
+        it "moves North" $ do
+            move [[Nothing, Just 2], [Just 4, Nothing]] D.North `shouldBe`
+                [[Just 4, Just 2], [Nothing, Nothing]]
+
+    describe "parse" $ do
+        it "returns [] for \"\"" $ do
+            parse "" `shouldBe` []
+
+        it "returns [[Nothing, Just 2], [Just 4, Just 8]] for \"- 2\n4 8\n\"" $ do
+            parse "- 2\n4 8\n" `shouldBe` [[Nothing, Just 2], [Just 4, Just 8]]
+
+    describe "render" $ do
+        it "renders []" $ do
+            render [] `shouldBe` ""
+
+        it "renders a board" $ do
+            render [[Nothing, Just 2], [Just 4, Just 8]] `shouldBe`
+                "- 2\n4 8\n"
+
+    describe "rotate" $ do
+        it "returns [] for []" $ do
+            rotate [] `shouldBe` []
+
+        it "rotates clockwise" $ do
+            rotate [[Nothing, Just 2], [Just 4, Just 8]] `shouldBe`
+                [[Just 4, Nothing], [Just 8, Just 2]]
+
+    describe "rotateFrom" $ do
+        it "rotates West" $ do
+            rotateFrom [[Nothing, Just 2], [Just 4, Just 8]] D.West `shouldBe`
+                [[Nothing, Just 2], [Just 4, Just 8]]
+
+        it "rotates South" $ do
+            rotateFrom [[Nothing, Just 2], [Just 4, Just 8]] D.South `shouldBe`
+                [[Just 2, Just 8], [Nothing, Just 4]]
+
+        it "rotates East" $ do
+            rotateFrom [[Nothing, Just 2], [Just 4, Just 8]] D.East `shouldBe`
+                [[Just 8, Just 4], [Just 2, Nothing]]
+
+        it "rotates North" $ do
+            rotateFrom [[Nothing, Just 2], [Just 4, Just 8]] D.North `shouldBe`
+                [[Just 4, Nothing], [Just 8, Just 2]]
+
+    describe "rotateTo" $ do
+        it "rotates West" $ do
+            rotateTo [[Nothing, Just 2], [Just 4, Just 8]] D.West `shouldBe`
+                [[Nothing, Just 2], [Just 4, Just 8]]
+
+        it "rotates South" $ do
+            rotateTo [[Nothing, Just 2], [Just 4, Just 8]] D.South `shouldBe`
+                [[Just 4, Nothing], [Just 8, Just 2]]
+
+        it "rotates East" $ do
+            rotateTo [[Nothing, Just 2], [Just 4, Just 8]] D.East `shouldBe`
+                [[Just 8, Just 4], [Just 2, Nothing]]
+
+        it "rotates North" $ do
+            rotateTo [[Nothing, Just 2], [Just 4, Just 8]] D.North `shouldBe`
+                [[Just 2, Just 8], [Nothing, Just 4]]
+
+    describe "score" $ do
+        it "returns 0 for []" $ do
+            score [] `shouldBe` 0
+
+        it "returns 20 for [[Nothing, Just 2], [Just 4, Just 8]]" $ do
+            score [[Nothing, Just 2], [Just 4, Just 8]] `shouldBe` 20
+
+    describe "set" $ do
+        it "sets the tile at the point" $ do
+            set [[Nothing, Nothing], [Nothing, Nothing]] (Just 2) (0, 0) `shouldBe`
+                [[Just 2, Nothing], [Nothing, Nothing]]
+
+    describe "shift" $ do
+        it "returns [] for []" $ do
+            shift [] `shouldBe` []
+
+        it "shifts toward the heads" $ do
+            shift [[Nothing, Just 2], [Nothing, Just 4]] `shouldBe`
+                [[Just 2, Nothing], [Just 4, Nothing]]
+
+        it "combines like tiles" $ do
+            shift [[Just 2, Just 2]] `shouldBe` [[Just 4, Nothing]]
+
+        it "only combines pairs" $ do
+            shift [[Just 2, Just 2, Just 2, Just 2]] `shouldBe`
+                [[Just 4, Just 4, Nothing, Nothing]]
diff --git a/test-suite/Hs2048/DirectionSpec.hs b/test-suite/Hs2048/DirectionSpec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048/DirectionSpec.hs
@@ -0,0 +1,46 @@
+module Hs2048.DirectionSpec (spec) where
+
+import           Hs2048.Direction
+import           Test.Hspec
+
+spec :: Spec
+spec = do
+    describe "Direction" $ do
+        it "is an instance of Bounded" $ do
+            minBound `shouldBe` West
+            maxBound `shouldBe` North
+
+        it "is an instance of Enum" $ do
+            succ West `shouldBe` South
+            pred North `shouldBe` East
+            toEnum 1 `shouldBe` South
+            fromEnum East `shouldBe` 2
+            enumFrom North `shouldBe` [North]
+            enumFromThen West East `shouldBe` [West, East]
+            enumFromTo West West `shouldBe` [West]
+            enumFromThenTo West East North `shouldBe` [West, East]
+
+        it "is an instance of Eq" $ do
+            West == West `shouldBe` True
+            West /= West `shouldBe` False
+
+        it "is an instance of Show" $ do
+            show West `shouldBe` "West"
+            showList [West] "" `shouldBe` "[West]"
+
+    describe "directions" $ do
+        it "returns the directions" $ do
+            directions `shouldBe` [West, South, East, North]
+
+    describe "render" $ do
+        it "renders West" $ do
+            render West `shouldBe` "\x2190"
+
+        it "renders South" $ do
+            render South `shouldBe` "\x2193"
+
+        it "renders East" $ do
+            render East `shouldBe` "\x2192"
+
+        it "renders North" $ do
+            render North `shouldBe` "\x2191"
diff --git a/test-suite/Hs2048/GameSpec.hs b/test-suite/Hs2048/GameSpec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048/GameSpec.hs
@@ -0,0 +1,112 @@
+module Hs2048.GameSpec (spec) where
+
+import           Hs2048.Game
+import           System.Random (mkStdGen)
+import           Test.Hspec
+
+spec :: Spec
+spec = do
+    let r = mkStdGen 0
+
+    describe "addRandomTile" $ do
+        it "returns id for []" $ do
+            fst (addRandomTile [] r) `shouldBe` []
+
+        it "adds a random tile" $ do
+            fst (addRandomTile [[Nothing]] r) `shouldBe` [[Just 2]]
+
+        it "returns id when there are no empty tiles" $ do
+            fst (addRandomTile [[Just 2]] r) `shouldBe`
+                [[Just 2]]
+
+    describe "addRandomTiles" $ do
+        it "returns id for 0" $ do
+            fst (addRandomTiles 0 [[Nothing]] r) `shouldBe`
+                [[Nothing]]
+
+        it "adds a random tile" $ do
+            fst (addRandomTiles 1 [[Nothing]] r) `shouldBe`
+                [[Just 2]]
+
+        it "adds some random tiles" $ do
+            fst (addRandomTiles 2 [[Nothing, Nothing]] r) `shouldBe`
+                [[Just 2, Just 2]]
+
+        it "returns id when there are no empty tiles" $ do
+            fst (addRandomTiles 1 [[Just 2]] r) `shouldBe`
+                [[Just 2]]
+
+    describe "hasWon" $ do
+        it "returns False for []" $ do
+            hasWon [] `shouldBe` False
+
+        it "returns False without any tiles" $ do
+            hasWon [[Nothing]] `shouldBe` False
+
+        it "returns False with no tiles above 2048" $ do
+            hasWon [[Just 1024]] `shouldBe` False
+
+        it "returns True with tiles equal to 2048" $ do
+            hasWon [[Just 2048]] `shouldBe` True
+
+        it "returns True with tiles above 2048" $ do
+            hasWon [[Just 4096]] `shouldBe` True
+
+    describe "isOver" $ do
+        it "returns True for []" $ do
+            isOver [] `shouldBe` True
+
+        it "returns False if there are empty tiles" $ do
+            isOver [[Nothing]] `shouldBe` False
+
+        it "returns False if there are moves" $ do
+            isOver [[Just 2, Just 2]] `shouldBe` False
+
+        it "returns True if there are no empty tiles and no moves" $ do
+            isOver [[Just 2]] `shouldBe` True
+
+    describe "new" $ do
+        it "returns a new game" $ do
+            fst (new r) `shouldBe`
+                [ [Just 2, Nothing, Nothing, Nothing]
+                , [Nothing, Nothing, Nothing, Nothing]
+                , [Nothing, Nothing, Nothing, Nothing]
+                , [Nothing, Just 2, Nothing, Nothing]
+                ]
+
+    describe "randomEmptyIndex" $ do
+        it "returns Nothing for []" $ do
+            fst (randomEmptyIndex [] r) `shouldBe` Nothing
+
+        it "returns an empty index" $ do
+            let g = mkStdGen 0
+                v = [Nothing, Nothing]
+            fst (randomEmptyIndex v g) `shouldBe` Just 1
+
+        it "returns an empty index" $ do
+            let g = mkStdGen 53668
+                v = [Nothing, Nothing]
+            fst (randomEmptyIndex v g) `shouldBe` Just 0
+
+    describe "randomEmptyPoint" $ do
+        it "returns Nothing for []" $ do
+            fst (randomEmptyPoint [] r) `shouldBe` Nothing
+
+        it "returns an empty point" $ do
+            let g = mkStdGen 0
+                b = [[Nothing, Nothing], [Nothing, Nothing]]
+            fst (randomEmptyPoint b g) `shouldBe` Just (0, 1)
+
+        it "returns an empty point" $ do
+            let g = mkStdGen 2
+                b = [[Nothing, Nothing], [Nothing, Nothing]]
+            fst (randomEmptyPoint b g) `shouldBe` Just (1, 1)
+
+    describe "randomTile" $ do
+        it "returns a 2 tile" $ do
+            let g = mkStdGen 0
+            fst (randomTile g) `shouldBe` Just 2
+
+        it "returns a 4 tile" $ do
+            let g = mkStdGen 1
+            fst (randomTile g) `shouldBe` Just 4
diff --git a/test-suite/Hs2048/MainSpec.hs b/test-suite/Hs2048/MainSpec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048/MainSpec.hs
@@ -0,0 +1,32 @@
+module Hs2048.MainSpec (spec) where
+
+import qualified Hs2048.Direction as D
+import           Hs2048.Main
+import           Test.Hspec
+
+spec :: Spec
+spec = do
+    describe "direction" $ do
+        it "returns Nothing" $ do
+            direction "" `shouldBe` Nothing
+
+        it "returns Just West" $ do
+            direction "\ESC[D" `shouldBe` Just D.West
+
+        it "returns Just South" $ do
+            direction "\ESC[B" `shouldBe` Just D.South
+
+        it "returns Just East" $ do
+            direction "\ESC[C" `shouldBe` Just D.East
+
+        it "returns Just North" $ do
+            direction "\ESC[A" `shouldBe` Just D.North
+
+    describe "getChars" $ do
+        it "is" pending
+
+    describe "getMove" $ do
+        it "is" pending
+
+    describe "play" $ do
+        it "is" pending
diff --git a/test-suite/Hs2048/PointSpec.hs b/test-suite/Hs2048/PointSpec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048/PointSpec.hs
@@ -0,0 +1,16 @@
+module Hs2048.PointSpec (spec) where
+
+import           Hs2048.Point
+import           Test.Hspec
+
+spec :: Spec
+spec = do
+    let p = (1, 2)
+
+    describe "x" $ do
+        it "returns the first value" $ do
+            x p `shouldBe` 1
+
+    describe "y" $ do
+        it "returns the second value" $ do
+            y p `shouldBe` 2
diff --git a/test-suite/Hs2048/RendererSpec.hs b/test-suite/Hs2048/RendererSpec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048/RendererSpec.hs
@@ -0,0 +1,31 @@
+module Hs2048.RendererSpec (spec) where
+
+import           Hs2048.Renderer
+import           Test.Hspec
+
+spec :: Spec
+spec = do
+    describe "center" $ do
+        it "centers a string" $ do
+            center 3 "x" `shouldBe` " x "
+
+    describe "color" $ do
+        it "returns the color code" $ do
+            color Nothing `shouldBe` 30
+
+        it "returns the color code" $ do
+            color (Just 2048) `shouldBe` 45
+
+    describe "renderBoard" $ do
+        it "renders a board" $ do
+            renderBoard [[Nothing, Just 16]] `shouldBe`
+                " \ESC[30m-\ESC[0m \ESC[34m16\ESC[0m\n"
+
+    describe "renderGame" $ do
+        it "renders a game" $ do
+            renderGame [[Nothing, Just 2]] `shouldBe`
+                "Score: 0\n\ESC[30m-\ESC[0m \ESC[31m2\ESC[0m\n"
+
+    describe "renderTile" $ do
+        it "renders a tile" $ do
+            renderTile Nothing `shouldBe` "\ESC[30m-\ESC[0m"
diff --git a/test-suite/Hs2048/SettingsSpec.hs b/test-suite/Hs2048/SettingsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048/SettingsSpec.hs
@@ -0,0 +1,22 @@
+module Hs2048.SettingsSpec (spec) where
+
+import           Hs2048.Settings
+import           Test.Hspec
+
+spec :: Spec
+spec = do
+    describe "height" $ do
+        it "returns 4" $ do
+            height `shouldBe` 4
+
+    describe "maxTile" $ do
+        it "returns 2048" $ do
+            maxTile `shouldBe` 2048
+
+    describe "tiles" $ do
+        it "returns 2" $ do
+            tiles `shouldBe` 2
+
+    describe "width" $ do
+        it "returns 4" $ do
+            width `shouldBe` 4
diff --git a/test-suite/Hs2048/TileSpec.hs b/test-suite/Hs2048/TileSpec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048/TileSpec.hs
@@ -0,0 +1,39 @@
+module Hs2048.TileSpec (spec) where
+
+import           Hs2048.Tile
+import           Test.Hspec
+import           Test.Hspec.QuickCheck
+
+spec :: Spec
+spec = do
+    describe "empty" $ do
+        it "is Nothing" $ do
+            empty `shouldBe` Nothing
+
+    describe "parse" $ do
+        it "parses \"-\"" $ do
+            parse "-" `shouldBe` Nothing
+
+        prop "parses \"n\"" $ \ n ->
+            parse (show n) == Just n
+
+    describe "rank" $ do
+        it "ranks Nothing" $ do
+            rank Nothing `shouldBe` 0
+
+        prop "ranks Just n" $ \ n ->
+            rank (Just n) == floor (logBase (2 :: Double) (fromIntegral n))
+
+    describe "render" $ do
+        it "renders Nothing" $ do
+            render Nothing `shouldBe` "-"
+
+        prop "renders Just n" $ \ n ->
+            render (Just n) == show n
+
+    describe "score" $ do
+        it "scores Nothing" $ do
+            score Nothing `shouldBe` 0
+
+        prop "scores Just n" $ \ n ->
+            score (Just n) == n * (rank (Just n) - 1)
diff --git a/test-suite/Hs2048/VectorSpec.hs b/test-suite/Hs2048/VectorSpec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048/VectorSpec.hs
@@ -0,0 +1,59 @@
+module Hs2048.VectorSpec (spec) where
+
+import qualified Hs2048.Tile           as T
+import           Hs2048.Vector
+import           Test.Hspec
+import           Test.Hspec.QuickCheck
+
+spec :: Spec
+spec = do
+    let v1 = replicate 4 Nothing
+        v2 = replicate 4 (Just 2)
+
+    describe "canShift" $ do
+        it "returns false if the vector can't be shifted" $ do
+            canShift v1 `shouldBe` False
+
+        it "returns true if the vector can be shifted" $ do
+            canShift v2 `shouldBe` True
+
+    describe "empty" $ do
+        prop "returns n empty tiles" $ \ n ->
+            -- TODO: There has to be a better way to set bounds.
+            (n > 127) || (empty n == replicate n T.empty)
+
+    describe "emptyIndexes" $ do
+        it "returns the empty indexes" $ do
+            emptyIndexes v1 `shouldBe` [0 .. length v1 - 1]
+
+        it "doesn't return indexes with tiles" $ do
+            emptyIndexes v2 `shouldBe` []
+
+    describe "parse" $ do
+        it "parses \"- - - -\"" $ do
+            parse "- - - -" `shouldBe` v1
+
+        prop "parses \"n n n n\"" $ \ n ->
+            parse (unwords (replicate 4 (show n))) == replicate 4 (Just n)
+
+    describe "render" $ do
+        it "renders [Nothing, Nothing, Nothing, Nothing]" $ do
+            render v1 `shouldBe` "- - - -"
+
+        prop "renders [Just n, Just n, Just n, Just n]" $ \ n ->
+            render (replicate 4 (Just n)) == unwords (replicate 4 (show n))
+
+    describe "score" $ do
+        it "scores [Nothing, Nothing, Nothing, Nothing]" $ do
+            score v1 `shouldBe` 0
+
+        prop "scores [Just n, Just n, Just n, Just n]" $ \ n ->
+            score (replicate 4 (Just n)) == 4 * T.score (Just n)
+
+    describe "set" $ do
+        it "sets the tile at the index" $ do
+            set v1 (Just 2) 0 `shouldBe` [Just 2, Nothing, Nothing, Nothing]
+
+    describe "shift" $ do
+        it "shifts the vector" $ do
+            shift v2 `shouldBe` [Just 4, Just 4, Nothing, Nothing]
diff --git a/test-suite/Hs2048Spec.hs b/test-suite/Hs2048Spec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Hs2048Spec.hs
@@ -0,0 +1,7 @@
+module Hs2048Spec (spec) where
+
+import           Hs2048     ()
+import           Test.Hspec
+
+spec :: Spec
+spec = it "is" pending
diff --git a/test-suite/Spec.hs b/test-suite/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test-suite/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
