diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for ca-patterns
+
+## 0.1.0.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,20 @@
+Copyright (c) Owen Bechtel
+
+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,2 @@
+# ca-patterns
+Data types for life-like cellular automata, functions for creating and parsing RLE files
diff --git a/ca-patterns.cabal b/ca-patterns.cabal
new file mode 100644
--- /dev/null
+++ b/ca-patterns.cabal
@@ -0,0 +1,40 @@
+cabal-version: 2.4
+name:          ca-patterns
+version:       0.1.0.0
+author:        Owen Bechtel
+maintainer:    ombspring@gmail.com
+
+category: Data, Text, Parsing
+synopsis: Manipulate patterns in cellular automata, create and parse RLE files
+
+description:
+  This package contains a Pattern type for working with 2-dimensional 2-state
+  cellular automata. It also has functions for creating and parsing RLE
+  files. RLE is a textual representation of CA patterns used in applications
+  such as Golly and LifeViewer.
+
+homepage:           https://github.com/UnaryPlus/ca-patterns
+bug-reports:        https://github.com/UnaryPlus/ca-patterns/issues
+license:            MIT
+license-file:       LICENSE.md
+extra-source-files: CHANGELOG.md, README.md
+
+source-repository head
+  type:     git
+  location: https://github.com/UnaryPlus/ca-patterns.git
+
+library
+    exposed-modules:
+      Data.CA.Pattern,
+      Data.CA.List,
+      Text.RLE
+
+    build-depends:
+      base >= 4.11 && < 5,
+      vector >= 0.7 && < 0.13,
+      text >= 0.7 && < 2.1,
+      parsec >= 3.1.6 && < 3.2
+
+    default-language: Haskell2010
+    ghc-options:      -Wall
+    hs-source-dirs:   src
diff --git a/src/Data/CA/List.hs b/src/Data/CA/List.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/CA/List.hs
@@ -0,0 +1,44 @@
+module Data.CA.List (withDimensions, combinations) where
+
+import qualified Control.Applicative as Ap
+
+import qualified Data.CA.Pattern as Pat
+import Data.CA.Pattern (Pattern, Cell(..))
+
+composeN :: Int -> (a -> a) -> a -> a
+composeN n f
+  | n <= 0 = id
+  | otherwise = f . composeN (n - 1) f
+
+possible1 :: Int -> [[Cell]]
+possible1 n = composeN n (Ap.liftA2 (:) [Dead, Alive]) [[]]
+
+possible2 :: Int -> Int -> [[[Cell]]]
+possible2 h w = let
+  rows = possible1 w
+  in composeN h (Ap.liftA2 (:) rows) [[]]
+
+{-|
+A list of every possible h by w pattern. This function
+is necessarily exponential in both arguments, so it's only
+practical if the dimensions are very small.
+-}
+withDimensions
+  :: Int -- ^ h
+  -> Int -- ^ w
+  -> [Pattern]
+withDimensions h w = map Pat.fromRectList (possible2 h w)
+
+{-|
+Combine two patterns in multiple ways. Useful for creating
+a list of spaceship / still life collisions.
+
+See 'Pat.combine'.
+-}
+combinations
+  :: (Int, Int) -- ^ min and max vertical offset
+  -> (Int, Int) -- ^ min and max horizonal offset
+  -> Pattern -> Pattern -> [Pattern]
+combinations (yMin, yMax) (xMin, xMax) pat1 pat2 = let
+  combine y x = Pat.combine y x pat1 pat2
+  in Ap.liftA2 combine [yMin .. yMax] [xMin .. xMax]
diff --git a/src/Data/CA/Pattern.hs b/src/Data/CA/Pattern.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/CA/Pattern.hs
@@ -0,0 +1,325 @@
+{-|
+The functions in this module allow you to create, transform,
+and combine CA patterns.
+-}
+
+{-# LANGUAGE BlockArguments, LambdaCase, OverloadedStrings #-}
+
+module Data.CA.Pattern
+  ( -- * Cells
+    Cell(..), isDead, isAlive
+    -- * Patterns
+  , Pattern, lookup, generate, height, width, dimensions, valid
+    -- * Vector conversions
+  , fromRectVector, fromVector, toVector
+    -- * List conversions
+  , fromRectList, fromList, toList
+    -- * Text and string conversions
+  , toText, toString
+    -- * Trimming
+  , trimTop, trimBottom, trimLeft, trimRight, trim
+    -- * Cropping and padding
+  , setHeight, setWidth, setDimensions
+    -- * Transformations
+  , reflectX, reflectY, rotateL, rotateR
+    -- * Combining
+  , combine
+  ) where
+
+import Prelude hiding (lookup)
+import qualified Data.Maybe as Maybe
+import Data.String (IsString)
+
+import qualified Data.Vector as Vec
+import Data.Vector (Vector, (!?))
+
+import qualified Data.Text as Text
+import Data.Text (Text)
+
+padEnd :: Int -> a -> Vector a -> Vector a
+padEnd n val row = row <> Vec.replicate (n - length row) val
+
+dropWhileEnd :: (a -> Bool) -> Vector a -> Vector a
+dropWhileEnd f row
+  | Vec.null row = Vec.empty
+  | f (Vec.last row) = dropWhileEnd f (Vec.init row)
+  | otherwise = row
+
+dropFirst :: Vector a -> Vector a
+dropFirst = Vec.drop 1
+
+dropLast :: Vector a -> Vector a
+dropLast row
+  | Vec.null row = Vec.empty
+  | otherwise = Vec.init row
+
+firstCell :: Vector Cell -> Cell
+firstCell row
+  | Vec.null row = Dead
+  | otherwise = Vec.head row
+
+lastCell :: Vector Cell -> Cell
+lastCell row
+  | Vec.null row = Dead
+  | otherwise = Vec.last row
+
+{-|
+The state of a cell.
+-}
+data Cell = Dead | Alive
+  deriving (Eq, Ord, Show)
+
+isDead :: Cell -> Bool
+isDead = \case { Dead -> True; Alive -> False }
+
+isAlive :: Cell -> Bool
+isAlive = \case { Dead -> False; Alive -> True }
+
+{-|
+A pattern in a 2-dimensional 2-state cellular automaton.
+-}
+newtype Pattern = Pattern (Vector (Vector Cell))
+
+transform :: (Vector (Vector Cell) -> Vector (Vector Cell)) -> Pattern -> Pattern
+transform f (Pattern rows) = Pattern (f rows)
+
+{-|
+Get the state of one of the cells in a pattern. @lookup 0 0@
+returns the cell in the upper-left corner. If the row
+or column number is out of range, this function will
+return 'Dead'.
+-}
+lookup
+  :: Int -- ^ row
+  -> Int -- ^ column
+  -> Pattern -> Cell
+lookup r c (Pattern rows) = Maybe.fromMaybe Dead (rows !? r >>= (!? c))
+
+{-|
+Generate a pattern from a function.
+-}
+generate
+  :: Int -- ^ height
+  -> Int -- ^ width
+  -> (Int -> Int -> Cell) -- ^ function taking row and column
+  -> Pattern
+generate h w f = Pattern (Vec.generate h \r -> Vec.generate w \c -> f r c)
+
+height :: Pattern -> Int
+height (Pattern rows) = Vec.length rows
+
+width :: Pattern -> Int
+width (Pattern rows)
+  | Vec.null rows = 0
+  | otherwise = Vec.length (Vec.head rows)
+
+{-|
+Get the height and width of a pattern.
+-}
+dimensions :: Pattern -> (Int, Int)
+dimensions pat = (height pat, width pat)
+
+{-|
+Test if a pattern is valid, i.e. rectangular.
+Some of the functions in this module only behave
+properly on rectangular patterns.
+-}
+valid :: Pattern -> Bool
+valid pat@(Pattern rows) = let
+  w = width pat
+  validRow row = Vec.length row == w
+  in all validRow rows
+
+{-|
+Convert a vector of rows into a pattern, assuming
+the rows are all the same length.
+-}
+fromRectVector :: Vector (Vector Cell) -> Pattern
+fromRectVector = Pattern
+
+{-|
+Convert a vector of rows into a pattern. If
+the rows are not all the same length, they will
+padded with dead cells until the pattern is
+rectangular.
+-}
+fromVector :: Vector (Vector Cell) -> Pattern
+fromVector rows = let
+  maxWidth = foldr (max . Vec.length) 0 rows
+  padded = fmap (padEnd maxWidth Dead) rows
+  in Pattern padded
+
+{-|
+Convert a pattern into a vector of rows.
+-}
+toVector :: Pattern -> Vector (Vector Cell)
+toVector (Pattern rows) = rows
+
+{-|
+Convert a list of rows into a pattern, assuming
+the rows are all the same length.
+-}
+fromRectList :: [[Cell]] -> Pattern
+fromRectList = fromRectVector . Vec.fromList . map Vec.fromList
+
+{-|
+Convert a list of rows into a pattern. If
+the rows are not all the same length, they will
+padded with dead cells until the pattern is
+rectangular.
+-}
+fromList :: [[Cell]] -> Pattern
+fromList = fromVector . Vec.fromList . map Vec.fromList
+
+{-|
+Convert a pattern into a list of rows.
+-}
+toList :: Pattern -> [[Cell]]
+toList (Pattern rows) = map Vec.toList (Vec.toList rows)
+
+toSomeString :: (Monoid s, IsString s) => (Vector Char -> s) -> Char -> Char -> Pattern -> s
+toSomeString makeStr dead alive (Pattern rows) = let
+  toChar = \case { Dead -> dead; Alive -> alive }
+  makeLine row = makeStr (fmap toChar row) <> "\n"
+  in foldMap makeLine rows
+
+{-|
+Convert a pattern into text. For example, @toText \'.' \'Z'@
+will replace each dead cell with a @.@ and each live cell
+with a @Z@.
+-}
+toText
+  :: Char -- ^ dead cell
+  -> Char -- ^ live cell
+  -> Pattern -> Text
+toText = let
+  add c text = Text.singleton c <> text
+  in toSomeString (foldr add "")
+
+{-|
+Convert a pattern into a string.
+-}
+toString
+  :: Char -- ^ dead cell
+  -> Char -- ^ live cell
+  -> Pattern -> String
+toString = toSomeString Vec.toList
+
+{-|
+Remove rows of dead cells from the top of a pattern.
+-}
+trimTop :: Pattern -> Pattern
+trimTop = transform (Vec.dropWhile (all isDead))
+
+{-|
+Remove rows of dead cells from the bottom of a pattern.
+-}
+trimBottom :: Pattern -> Pattern
+trimBottom = transform (dropWhileEnd (all isDead))
+
+trimLeftV :: Vector (Vector Cell) -> Vector (Vector Cell)
+trimLeftV rows
+  | all (isDead . firstCell) rows = trimLeftV (fmap dropFirst rows)
+  | otherwise = rows
+
+trimRightV :: Vector (Vector Cell) -> Vector (Vector Cell)
+trimRightV rows
+  | all (isDead . lastCell) rows = trimRightV (fmap dropLast rows)
+  | otherwise = rows
+
+{-|
+Remove columns of dead cells from the left side of a pattern.
+-}
+trimLeft :: Pattern -> Pattern
+trimLeft = transform trimLeftV
+
+{-|
+You get the idea.
+-}
+trimRight :: Pattern -> Pattern
+trimRight = transform trimRightV
+
+{-|
+A composition of 'trimTop', 'trimBottom', 'trimLeft', and 'trimRight'.
+Removes as many dead cells from the pattern as possible while
+keeping it rectangular.
+-}
+trim :: Pattern -> Pattern
+trim = trimTop . trimBottom . trimLeft . trimRight
+
+{-|
+Force a pattern to have the given height by removing rows
+from the bottom or by adding rows of dead cells.
+-}
+setHeight :: Int -> Pattern -> Pattern
+setHeight h pat =
+  case compare h (height pat) of
+    LT -> transform (Vec.take h) pat
+    EQ -> pat
+    GT -> let
+      row = Vec.replicate (width pat) Dead
+      in transform (padEnd h row) pat
+
+{-|
+Force a pattern to have the given width by remove columns
+from the right or by adding columns of dead cells.
+-}
+setWidth :: Int -> Pattern -> Pattern
+setWidth w pat =
+  case compare w (width pat) of
+    LT -> transform (fmap (Vec.take w)) pat
+    EQ -> pat
+    GT -> transform (fmap (padEnd w Dead)) pat
+
+{-|
+Set the height and width of a pattern.
+-}
+setDimensions :: Int -> Int -> Pattern -> Pattern
+setDimensions h w = setHeight h . setWidth w
+
+{-|
+Reflect vertically, switching the top and the bottom.
+-}
+reflectY :: Pattern -> Pattern
+reflectY = transform Vec.reverse
+
+{-|
+Reflect horizontally, switching the left and the right.
+-}
+reflectX :: Pattern -> Pattern
+reflectX = transform (fmap Vec.reverse)
+
+{-|
+Rotate counterclockwise by a quarter turn.
+-}
+rotateL :: Pattern -> Pattern
+rotateL pat = let
+  (h, w) = dimensions pat
+  in generate w h \r c -> lookup c (w - r - 1) pat
+
+{-|
+Rotate clockwise by a quarter turn.
+-}
+rotateR :: Pattern -> Pattern
+rotateR pat = let
+  (h, w) = dimensions pat
+  in generate w h \r c -> lookup (h - c - 1) r pat
+
+{-|
+Combine two patterns given a vertical and horizontal offset,
+which describe the displacement of the second pattern relative
+to the first one.
+-}
+combine
+  :: Int -- ^ vertical offset
+  -> Int -- ^ horizontal offset
+  -> Pattern -> Pattern -> Pattern
+combine y x pat1 pat2 = let
+  (y1, y2) = if y < 0 then (-y, 0) else (0, y)
+  (x1, x2) = if x < 0 then (-x, 0) else (0, x)
+  h = max (height pat1 + y1) (height pat2 + y2)
+  w = max (width pat1 + x1) (width pat2 + x2)
+  in generate h w \r c -> let
+    cell1 = lookup (r - y1) (c - x1) pat1
+    cell2 = lookup (r - y2) (c - x2) pat2
+    in max cell1 cell2
diff --git a/src/Text/RLE.hs b/src/Text/RLE.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/RLE.hs
@@ -0,0 +1,211 @@
+{-|
+The RLE (run length encoded) format is a common way of representing
+patterns in life-like cellular automata. RLE files consist of three
+sections:
+
+  1. Zero or more comment lines beginning with @#@
+
+  2. A header of the form @x = [width], y = [height], rule = [rule]@.
+  @[width]@ and @[height]@ are natural numbers, and @[rule]@ is the
+  rule the pattern is meant to be run in, such as @B36/S23@. The "rule" field
+  is optional.
+
+  3. The content of the pattern. @b@ represents a dead cell, @o@ represents
+  a live cell, and @$@ denotes the end of a row. A run of identical
+  characters can be abbreviated with a number, e.g. @4o@ is short for
+  @oooo@ (hence the name "run length encoded"). This section must be
+  terminated by a @!@ character.
+
+A glider in the Game of Life could be represented like so:
+
+> #N glider
+> x = 3, y = 3, rule = B3/S23
+> 3o$2bo$bo!
+
+See this [LifeWiki article](https://conwaylife.com/wiki/Run_Length_Encoded)
+for more information.
+-}
+
+{-# LANGUAGE BlockArguments, LambdaCase, OverloadedStrings, FlexibleContexts #-}
+
+module Text.RLE (Rule, parse, make, printAll) where
+
+import qualified Data.Char as Char
+import qualified Data.String as String
+import qualified Control.Applicative as Ap
+import Data.Functor.Identity (Identity)
+import Data.String (IsString)
+
+import Text.Parsec
+  ( Parsec, Stream, runParser
+  , (<|>), try, many, many1, manyTill, satisfy
+  , anyChar, digit, string, spaces
+  )
+
+import qualified Data.Text.IO as IO
+
+import qualified Data.CA.Pattern as Pat
+import Data.CA.Pattern (Pattern)
+
+{-|
+A string representing a cellular automaton, such as @"B36/S23"@ or
+@"B3/S2-i34q"@.
+-}
+type Rule = String
+
+data RunType = Dead | Alive | Newline
+  deriving (Eq)
+type Run = (Int, RunType)
+
+data Header = Header Int Int (Maybe Rule)
+type RLEData = (Header, [Run])
+
+type Parser s = Parsec s ()
+
+natural :: (Stream s Identity Char) => Parser s Int
+natural = do
+  num <- many1 digit
+  spaces
+  return (read num)
+
+symbol :: (Stream s Identity Char) => String -> Parser s ()
+symbol sym = string sym >> spaces
+
+parseWidth :: (Stream s Identity Char) => Parser s Int
+parseWidth = symbol "x" >> symbol "=" >> natural
+
+parseHeight :: (Stream s Identity Char) => Parser s Int
+parseHeight = symbol "," >> symbol "y" >> symbol "=" >> natural
+
+parseRule :: (Stream s Identity Char) => Parser s Rule
+parseRule = do
+  symbol "," >> symbol "rule" >> symbol "="
+  rule <- many1 (satisfy (not . Char.isSpace))
+  spaces
+  return rule
+
+parseHeader :: (Stream s Identity Char) => Parser s Header
+parseHeader = Ap.liftA3 Header parseWidth parseHeight maybeRule
+  where maybeRule = fmap Just parseRule <|> return Nothing
+
+parseRunType :: (Stream s Identity Char) => Parser s RunType
+parseRunType = (symbol "b" >> return Dead)
+  <|> (symbol "o" >> return Alive)
+  <|> (symbol "$" >> return Newline)
+
+parseRun :: (Stream s Identity Char) => Parser s Run
+parseRun = Ap.liftA2 (,) (natural <|> return 1) parseRunType
+
+parseData :: (Stream s Identity Char) => Parser s RLEData
+parseData = do
+  rle <- Ap.liftA2 (,) parseHeader (many1 parseRun)
+  symbol "!"
+  return rle
+
+parseComment :: (Stream s Identity Char) => Parser s ()
+parseComment = symbol "#" >> manyTill anyChar newline >> return ()
+  where newline = try (symbol "\n")
+
+parseRLE :: (Stream s Identity Char) => Parser s RLEData
+parseRLE = spaces >> many parseComment >> parseData
+
+updateRows :: [Run] -> [[Pat.Cell]] -> [[Pat.Cell]]
+updateRows = curry \case
+  ([], rows) -> rows
+  ((0, _) : runs, rows) -> updateRows runs rows
+
+  ((n, rt) : runs, rows) -> let
+    add cell = \case
+      [] -> [[cell]]
+      (row : rest) -> (cell : row) : rest
+
+    runs' = (n - 1, rt) : runs
+
+    rows' = case rt of
+      Dead -> add Pat.Dead rows
+      Alive -> add Pat.Alive rows
+      Newline -> [] : rows
+
+    in updateRows runs' rows'
+
+toPattern :: RLEData -> (Maybe Rule, Pattern)
+toPattern (Header h w rule, runs) = let
+  rows = updateRows (reverse runs) []
+  pat = Pat.setDimensions h w (Pat.fromList rows)
+  in (rule, pat)
+
+{-|
+Parse an RLE file, returning a 'Rule' (if it exists) and a 'Pattern'.
+The argument can be 'String', 'Text', or any other type with a 'Stream'
+instance.
+
+This parser is fairly liberal. Whitespace is allowed everywhere except
+in the middle of a number or rulestring, and there need not be a
+newline after the header. Also, text after the final @!@ character is
+ignored.
+-}
+parse :: (Stream s Identity Char) => s -> Maybe (Maybe Rule, Pattern)
+parse str =
+  case runParser parseRLE () "" str of
+    Left _ -> Nothing
+    Right rle -> Just (toPattern rle)
+
+updateRuns :: [[Pat.Cell]] -> [Run] -> [Run]
+updateRuns = let
+  add rt = \case
+    (n, rt') : runs | rt == rt' -> (n + 1, rt) : runs
+    runs -> (1, rt) : runs
+
+  in curry \case
+  ([], runs) -> reverse runs
+  ([] : rows, runs) -> updateRuns rows (add Newline runs)
+  ((Pat.Dead : row) : rows, runs) -> updateRuns (row : rows) (add Dead runs)
+  ((Pat.Alive : row) : rows, runs) -> updateRuns (row : rows) (add Alive runs)
+
+fromShow :: (Show a, IsString s) => a -> s
+fromShow = String.fromString . show
+
+showRunType :: (IsString s) => RunType -> s
+showRunType = \case
+  Dead -> "b"
+  Alive -> "o"
+  Newline -> "$"
+
+showRun :: (Semigroup s, IsString s) => Run -> s
+showRun = \case
+  (1, rt) -> showRunType rt
+  (n, rt) -> fromShow n <> showRunType rt
+
+showRuns :: (Semigroup s, IsString s) => Int -> [Run] -> s
+showRuns = curry \case
+  (_, []) -> ""
+  (0, runs) -> "\n" <> showRuns 30 runs
+  (n, run : runs) -> showRun run <> showRuns (n - 1) runs
+
+{-|
+Convert a 'Pattern' into an RLE. If the first argument is 'Nothing',
+the generated RLE will have no "rule" field in its header.
+-}
+make :: (Semigroup s, IsString s) => Maybe Rule -> Pattern -> s
+make rule pat = let
+  x = "x = " <> fromShow (Pat.width pat)
+  y = ", y = " <> fromShow (Pat.height pat)
+  r = case rule of
+    Nothing -> ""
+    Just str -> ", rule = " <> String.fromString str
+  runs = updateRuns (Pat.toList pat) []
+  in x <> y <> r <> "\n" <> showRuns 30 runs <> "!\n"
+
+{-|
+Convert a list of patterns into RLEs and print them. Example:
+
+> import qualified Text.RLE as RLE
+> import Data.CA.List (withDimensions)
+> main = RLE.printAll (Just "B3/S23") (withDimensions 4 4)
+
+The program above will print the RLE of every possible pattern
+contained in a 4 by 4 square. This data can then be piped into another
+program such as apgsearch.
+-}
+printAll :: Maybe Rule -> [Pattern] -> IO ()
+printAll rule = mapM_ (IO.putStrLn . make rule)
