- Application Overview
- Custom Types
- Movement Lenses
- Final Touches
┌───────────── Solitaire ──────────────┐ │┌──┐│┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐│┌ ┐│ Score: 0 ││λ=││┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐│7♠││ │ │└──┘│┌──┐┌──┐┌──┐┌──┐┌──┐│K♥│└──┘│└ ┘│ Moves: 0 │┌──┐│┌──┐┌──┐┌──┐┌──┐│J♣│└──┘ │┌ ┐│ ││3♠││┌──┐┌──┐┌──┐│6♦│└──┘ │ │ [New] │┌──┐│┌──┐┌──┐│9♣│└──┘ │└ ┘│ ││3♥││┌──┐│Q♠│└──┘ │┌ ┐│ [Undo] │┌──┐││4♠│└──┘ │ │ ││7♦││└──┘ │└ ┘│ │└──┘│ │┌ ┐│ │ │ │ │ │ │ │└ ┘│ │ │ │ │ └──────────────────────────────────────┘
I’d wanted to write an implementation of Solitaire a.k.a. Patience, Klondike, etc in Haskell ever since I learned about brick, a library for programming terminal user interfaces (TUIs). I liked it because, as the docs say, it
…exposes a declarative API. Unlike most GUI toolkits which require you to write a long and tedious sequence of “create a widget, now bind an event handler”,
brickjust requires you to describe your interface using a set of declarative combinators. Then you provide a function to transform your application state when input or other kinds of events arrive.
The other component of this project involved learning about lenses. Lenses are a Template Haskell solution to the record problem, which concerns the difficulty of reading from, writing to, and editing in-place deeply-nested record variables. Although Haskell is an immutable language, sometimes in-place modification is simply too convenient to abandon. Lenses are an elegant set of combinators for working around this.
As discussed above,
brick lets us define
app :: App State Event ()application state object, and
appEvent :: State -> Event e -> EventM () (Next State)event handler and that’s almost entirely it. There’s a bit more business for styling and click region detection, but the core of the game takex place in the event loop within
In the above, some keys
halt the game, but most of them
continue the game
- with the state
- with the state
smodified by some function (
Rules of Solitaire
Before we continue let’s just speak briefly about Solitaire.
+-------+----------------------+ | Stock | | | +-------+ Tableau | Foundation | | Waste | | | +-------+----------------------+
- Cards start either facedown in the
stockor in seven piles of lengths 1, 2,.. in the
- The stock is always facedown, but can be dealt three at a time to the
- The piles in the
tableauare splayed downwards, and start with only their top card visible.
- Nothing starts in the
foundation, but cards can accumulate there face-up.
Cards can be moved like so:
+-------+----------------------+ | Stock | | | | ^ | <-- | +-- | --+ Tableau | Foundation | | v | --> | | Waste -> | | +-------+----------------------+
Some more rules:
- in the
tableauonly a King can go on an empty pile, but any card can go on any other card as long as it has a different color and is of exactly one rank less.
- in the
foundationonly an Ace can go on an empty pile, and any card can go on a foundation pile as long as it matches the base suit and is of exactly one rank more.
I’m not sure Solitaire is a very interesting game to play, but abstracting the core ideas of cards, displaycards, piles, lists of piles, and operations between them was a lot of fun.
I think Haskell is fairly readable, so it might be best to just look at the
just as a quick overview, we define:
Card(rank and suit),
DCard, a display-card which wraps a
Cardand contains a preference for being displayed face-up or face-down
Pile, which is a list of
DCards with an opinion on what sort of card can go at its base (for example, only a King, or only an Ace) as well as a preference for its cards being displayed stacked or splayed out.
GSt, a game state which wraps the stock, waste, tableau, and foundation, as well as containing the current score, the elapsed move count, a random seed, and a history of prior fields and scores.
We can make our own type instance of a few of the above custom typeclasses by defining what it means to
We want to define our record fields with underscores like so:
So that the
Lens library can, at compile time, create functions like
facedir which can be called on
DCard objects, like so:
(^.) is a getter and
.~ is a setter (sorta). For more read the lens
By the same convention, a deeply-nested object could be accessed with
which makes it super easy for us to just pass around the
Field or the
gamestate and modify it at any level. Thanks, Lenses!
Just as we wrote a set of abstract data types above which can be composed into
Piles, etc., we want to write a set of abstract render functions which
can be composed to draw a
Pile, or a
DCard, or whatever. Brick wants us to define our
app like so:
drawUI :: GSt -> [Widget ()] handles every part of the program, from
the field to the score counters. It is a pure function of the game state and
doesn’t need callbacks or promises or event handlers at all, except that we
can name certain regions so that, when clicked, Brick handles a
(Vty.EvMouseDown col row _ _) where
extents = map extentName $
findClickedExtents (col,row) lets us interpet the observed column and row and
get a list of clicked extents. We can report a named extent by wrapping it in
Brick provides some primitive combinators for stacking widgets (rectangles) next to (
<+>) or above (
<=>) each other, as well as some primitive widgets for displaying text (
str :: String -> Widget ()), wrapping widgets in styled borders, (
withBorderStyle unicodeRounded $ borderWithLabel (str "title") $ myWidget), etc. As before, the code is fairly readable, so I’ll just cover some interesting mechanics here briefly before moving on.
A typical card looks like this: a string
7♦ wrapped in a
┌──┐ │7♦│ └──┘
but we want to be able to draw custom border too, in the case of our empty piles:
┌ ┐ └ ┘
Brick lets us define custom borderstyles like so:
Where those unicode
0x.... codes are just various box-drawing characters, and
bsHorizontal codes are (intentionally) spaces.
Once we have a
drawCard function, we can stack the cards by cropping their bottom or right borders as necessary, with more of the card cropped if it is meant to be face-down than if it is meant to be face-up. For example,
stacked face-up | stacked face-down ┌──┐ │┌──┐ │3♥│ │┌──┐ ┌──┐ ││4♠│ │7♦│ │└──┘ └──┘ │
Render.hs is mostly composing existing Brick primitives in easy
OK, here’s where the complexity of the game begins to shine through.
In computer solitaire, we typically expect to be able to click on a card and it will, it possible, move to the next open position. Thus, whenever a card gets clicked on, we should be able to figure out the next valid pile it could be moved to and move it there.
We can do so with lenses – and we can define our own lenses with independent getters and setters to make doing so easier.
For example, here’s a
lens which writes to or reads from the stock. Its type is
stockL :: Lens' Field [DCard], and it either reads and returns a list of
DCards or accepts a list of
DCards and writes them to the stock. The syntax is
Actually, let’s use this opportunity to flip the cards at read/write-time. We can use
each to iterate over each of the returned or processed objects and apply some transformation with
you can see custom lenses for the stock, the waste, as well as two lens
generators for the tableau and foundation which instead a) return Piles
[DCard]s, and b) accept an integer index for which tableau pile
/ foundation pile to return. They are of type
tableLN :: Int -> Lens' Field
N is a convention for indexed generators.
Eventually we want to be able to, upon reading a list of
extents from a
clicked region, continue with our game by calling
hasWon :: GSt -> Bool later, but for now let’s write
doMove :: GSt -> [Ext] -> GSt, which tries to move the clicked card and, if successful, returns a changed
GSt with incremented
moves ticker, mutated
score, and augmented
Here we can see chained
operator chaining for the first time, which is pretty snazzy. Here,
tryMove function which will return not just the new field, but a
score mutator (+5, -10,
Before we write
tryMove, we should talk about Extents and what they look like
in practice. They end up being lists of clicked extents where the innermost
extents are first. We can wrap our stock, waste, etc. in
etc. extent labels, and we can report the
DCard directly with a
wrapper. Later we’ll use the
ActionX wrapper to report something of type
Finally, we can use
IdX Int as a wrapper for a row/col index.
For now let’s write
tryMove by pattern-matching on the reported extents. Each region will have a different shape so we should be able to striate our regions fairly easily.
[StockX]: reporting just an empty
StockXregion means there are no cards, so we should refresh it from the waste.
[_, StockX]: reporting a non-empty stock means we want to take three cards from the stock and move them to the waste.
[DCX dc, IdX 0, WasteX]: reporting from the top of the waste stack means we should try to move it. We don’t pattern-match on just any index in the waste since none of the others are actionable.
[DCX dc, Idx row, FoundX]: reporting from any row in the foundation means we should try and move its topmost card.
[DCX dc, Idx row, Id col, TableX]: reporting from the tableau means we expect a row and column index, which tells us where in the tableau structure to try and read from. Uniquely, we can read a card or a stack of cards at a time from the tableau and move them all as a unit, as long as the stack of cards doesn’t leave the tableau.
One more thing to do before we can write
Eventually, we want to be able to write (pseudocode below):
oldLocation lenses will have to be deduced from
Let’s get a list of our tableau and foundation lenses:
These are almost of type
Lens' Field Pile, but not quite. We define them
differently here because they aren’t setters or getters yet. If we defined them
Lens' types, they’d be polymorphic, and the process of evaluating them
filter cond ls mechanism would solidify them as setters, when ideally
we want to be able to later turn around and use them as getters.
Each location can provide its own set of candidate lenses (usually either
inTableau, but it will depend) and evaluate them
findSpot, which takes a list of lenses and a card and a field and
returns the index of the first matching lens, if possible.
canPlace is a manual bit of pattern-matching which lives in `Utils.hs and runs through the types of piles and types of cards to provide a true/false.
In practice it is convenient to provide two helpers to
We can use the first in
canMove, and the second in
us whether there is a spot for a card to go elsewhere in the field, and
mkMoveL will, assuming there is a spot, return both a lens to that spot and
the piletype of the spot the card can go to.
OK, let’s write
tryMove. If you don’t like lenses, the above was the worst of
tryMove to have the form:
tryMove [StockX] Moving from the Stock (i)
Let’s write the
tryMove [StockX] function first, since it is the simplest:
Even for Haskell, this is pretty esoteric. we read a load from the waste using
wasteL custom lens, reverse it, and prepend it (++) to the stock by using
the in-place mutation operator (
%~), while overwriting the waste with an
empty list (
.~). We return that new field
f' and a
keeps the score as-is.
tryMove [_, StockX] Moving from the Stock (ii)
This is pretty similar to the last function, except that we are reading from
the stock and writing to the waste instead. We drop 3 and take 3 at a time.
Remember that the need to flip our cards over is handled innately in the
tryMove [DCX dc, IdX 0, WasteX] Moving from the Waste
We’ve already solved the hardest subproblem, that if determining whether or not
a card can move anywhere else in the field. So we can just use
rowIndex displaycard field to decide whether or not to try to evaluate
f'. If we do, it lazily evaluates
mkMoveL, which returns the
which we can use to write one card to the location in question. We also use the
PileType to inform our scoring mutator.
tryMove [DCX dc, IdX row, FoundX] Moving from the Foundation
This is starting to feel familiar; we know how to move one card at a time. The only difficulty comes when it’s time to move from the tableau; we could be moving one card or more at a time.
tryMove [DCX dc, IdX row, Idx col, TableX] Moving from the Tableau
OK, this was definitely the most difficult one, But we’ve built ourselves a nice set of primitives, so that flipping the underlying card, or dropping
n cards from the tableau row in question become pretty readable.
This is great! A well-formed
tryMove mean we can write
doMove, and the core
of our game is finished.
Now that we know how to use lenses, a lot of the remaining functions are pretty simple:
Now we can finally finish
appEvent and our app is done!
I think a good UI framework is one where the bulk of the difficulty of writing the app is the internal logic of the underlying app itself, not fighting with the framework. Brick fit right into my functional understanding of frameworks like React or Angular, and although there was some learning curve for lenses, the final product is, like all Haskell, surprisingly short and readable.
If you’ve never played with Haskell before, I think Brick is an excellent place to start. I encourage you to download and play Solitiare if you’re interested in getting a feel for the ecosystem, or just reading through some of the code if you’re interested in how a comparatively large app feels at a low level.