How a Haskell programmer wrote a tris in Purescript
This was written as a response to shiraeeshi's "How a Java Programmer Wrote Console Tetris in Haskell". The point was to show how you could do this from scratch in Haskell. I also ended up trying out Purescript for the first time.
You can play it here if it works on your browser, it responds to keyboard arrow keys after you click it:
Download the source code: tris.zip
There's a little lie in the title of this post. I'm not a Haskell programmer. Though, we start the work with Haskell and finish it in Purescript. I did this because wanted to show the results in front of this post and Haskell itself doesn't translate well to Javascript whereas Purescript is meant to run on Javascript.
Right after I finished the work I felt like shitting bricks. Purescript has tried to do things "better" than Haskell but they fall short of it in several ways that ends up being infuriating.
2020-08-24 update: Miles Frain told me that the Tris would be a fun example to include in purescript-cookbook and provided me a pull request to look at before committing the code in. I have approved the use. The example is "SignalTrisJs" and demonstrates, well, how to write a tris in Purescript. I saw it uses signals and a random number generator with a seed instead of what I did here. The code there is much cleaner than here and addresses some of the issues I've picked up.
10/10, I would criticize again, though from now on I promise to be much more gentle than I was here. I learned that good criticism takes responsibility for recipient's feelings.
Purescript issues
2020-08-08: There is something that I should've written right from the beginning here. I really appreciate that I have something to criticize here. I appreciate the work you've put into to make Purescript work. There were plenty of things right for things to get into this point. If you keep reading, read with that in mind.
The first thing that caught me off guard is Purescript's lack of tuples. They got a "Data.Tuple", but there's no infix syntax for declaring tuples. I ended up rewriting everything in records and that resulted in pages-long type errors that required me to scroll upwards while Purescript's emitted warnings about implicitly imported variables before them.
Also, what's the deal with number literals?
In Haskell the type signatures for number literals 3 and 3.0
are Num t => t and Fractional t => t.
In purescript they are Int and Number...
I continue and try this with other things.
> :t 3Int> :t 3.0Number> :t "foo"String> :t [1,2]Array Int
You're serious about this? Overloadable literals are some of the best stuff that Haskell has to offer and you did not implement them into this language? Come on! Why would anybody want to use it in this way?!!
> :t (+)forall a. Semiring a => a -> a -> a
At least they got...
Semiring NumberI feel pissed. When you write "Semiring", it means it must satisfy certain rules for semirings. Floating point addition is not associative. Why did you do this when you didn't abstract the literals? This is translating to Javascript's double right? "What Every Computer Scientist Should Know About Floating-Point Arithmetic", just wow.
The another thing I really don't like is how much stuff is off the Prelude. Look at all this stuff I had to import into my program.
module Main whereimport Data.Array ((..), index)import Data.Either (Either(..))import Data.Foldableimport Data.FoldableWithIndeximport Data.Int (toNumber)import Data.Map (Map, insert, union, intersection, keys, empty)import Data.Maybe (Maybe(..))import Data.Number.Format (toString)import Data.Set (size, filter)import Data.Traversable (sequence, sequence_)import Effect.Aff (Aff, delay, Milliseconds(..), launchAff_)import Effect.Class (liftEffect)import Effect.Console (log)import Effect (Effect)import Effect.Random (randomInt)import Effect.Ref (Ref, new, read, write)import Graphics.Canvasimport Preludeimport Web.DOM.Document (toNonElementParentNode)import Web.DOM.Element (toEventTarget)import Web.DOM.NonElementParentNodeimport Web.Event.Eventimport Web.Event.EventTargetimport Web.HTML.HTMLDocument (toDocument)import Web.HTML (window)import Web.HTML.Window (Window, document, requestAnimationFrame)import Web.UIEvent.KeyboardEvent
How come I need to import arrays, number formatting, foldables, traversables, either or maybe? Why the rest of the stuff is divided into so many modules? There are like more than 5 modules for accessing HTML elements. Also I'm a bit annoyed when I see things like this:
addEventListener :: EventType -> EventListener -> Boolean -> EventTarget -> Effect UnitSo.. I need to convert things into EventTarget when I use this function. Also how come EventType doesn't determine the EventListener's shape? I got some slight Elm vibes from this. At least there are typeclasses but why does this seem otherwise so conservative when it comes to type level stuff?
There's also a thing that I don't like at all about in Purescript. It's the foreign function interface. This is how you expose stuff to Purescript. First you write a module in Javascript:
exports.diagonal = function(w) {return function(h) {return Math.sqrt(w * w + h * h);};};
Then you describe this for the Purescript:
foreign import diagonal :: Number -> Number -> NumberWhy are the types Purescript when it's Javascript on the other side? What I suspect is happening is that "imports" are doing nothing here. It's just exposing how purescript compiles to javascript. It might happen like this, for instance:
codegen :: Term env type -> Javascriptcodegen (Var index) = lookup indexcodegen (Lambda fn) = do(arg, body) <- abstract (codegen fn)pure ("function(" <> arg <> "){" <> block body <> "}")codegen (App f x)= codegen f <> "(" <> codegen x <> ")"
This is what you might like to do if you want to get some abstract machine, such as Javascript, to evaluate lambda calculus. It's inevitable that the implementation produces some type there but why is it exposed? Also is that why there are record types with row polymorphism? Did you do the dumbest thing you can do there and copy the implementation details into the syntax of your language? It seems a lot like it:
Purescript | Javascript-----------+-----------Boolean | BooleanString | StringInt,Number | NumberArray | ArrayRecord | Object
There's an additional layer that'd belong here you know...
- Javascript's values are just valid structures that your language can carry around. There's no need to pretend that you couldn't pass plain untyped Javascript values as such.
- Your source language's types should not mix with Javascript's types. They are separate entities and you aren't supposed to reveal how they're implemented. It varies by how the language is implemented.
- You can construct structures that relate javascript values into your language's types. It would be much more pleasant to use.
This isn't too hard to do if you're building a new language. For instance, you could do it all through a separate module that you import to declare things.
import FFI.Javascript (Function, Double, Inline)diagonal :: Number -> Number -> Numberforeign diagonal (Function [Double, Double] Double)(Inline "function(x,y){ return Math.sqrt(x*x + y*y); }")
Also don't expect that we'd want only one implementation!
import FFI.C (Function, Double)import FFI.C.So (SharedObject, Win32, Linux)foreign diagonal (Function [Double, Double] Double)SharedObject[Linux "libDiagonal.so", Win32 "libDiagonal.dll"]"diagonal"
When it comes to typed languages it would be almost like I am the only one who understands this: The datatypes presented by the language do not need to correspond to hardware datatypes or implementation datatypes. They also do not need to stay same representation during the code generation step.
Overall the experience of Purescript is not bad enough that I'd have stopped and forgotten about this whole thing. That's already an achievement.
Lets get to trying this thing. It compiles to Javascript. That's neat?
What's a tris?
Tris is a tile-matching video game where a player moves tiles to a board in order to make them disappear. Tiles are commonly tetrominoes, mathematical shapes arranged by placing 4 square tiles together. Tiles drop in one-by-one and fuse to the board when it cannot move further down. Player loses when the tiles eventually reach the top. There are conditions for when a player may destroy tiles on a board, if he does so he scores. The game speeds up as it's being played.
Why reiterate this even if the game's already running above for you? Well this is how you write any game: Just like when logic programming, you ask yourself what is that thing you're trying to write. We start with Haskell here so that the title has some justification.
Mathematical model of a tris
Boards and tiles are sets of points, decorated with something on top. I don't see why to distinguish between anything so I call everything a board. There's a neat structure for constructing sets with decorations called a map.
type Point = (Integer, Integer)type Board a = Map Point a
a is going to stand for a decoration,
in our case it'll be a String, but we don't let it affect the game mechanics.
Guess what, we get bunch of interesting commands straight away!
The union and intersection are granted.
The 'union' can be used directly.
When a tile is fused to the board we can do it like this:
union tile boardThe union prioritizes values from the left side, so the tile ends up "above" the board.
The intersection is also directly useful, we can combine it with 'null' to check whether two boards collide.
null (intersection tile board)Tiles are moving elements, so we're still short of a command to move them. But guess what else we can do? We can transform boards by mapping their keys.
mapKeys (transform x y) tileThe transform is the first function that needs to be defined:
transform :: Point -> Point -> Pointtransform (ix,iy) (x,y) = (x+ix, y+iy)
The player needs to rotate tiles and some people who know their linear algebra, or are game developers (though included in the guys who know linear algebra), can guess what we do next. You may recall a rotation is a linear transform, which means it can be represented with a matrix.
type Matrix = Point -> PointI doubt the evaluator is able to fold this but you're not going to rotate the piece so many times that it'd become a problem.
Matrices have an identity element, you may guess that's an identity function. Function composition works well as the matrix multiplication.
The rotation can be simply represented as y moves to the left and x moves upwards. We take those transforms and sum them.
rotate90d :: Matrixrotate90d (x,y) = (-y, x)
Here's how we can now rotate a piece:
angle . rotate90dNow we're able to represent the tile in player's hand:
mapKeys (transform x y . angle) tileNow we're ready to model the game. There's usually some environment that tells which pieces the game may give.
newtype GameEnv a = GameEnv {getPieces :: [Board a]}When the game's running, we got player's state, the point is where player's board is, the matrix tells how to rotate it, and finally we got a board at hand. The second board stores the next board shown in a small box.
type GameState a = Either (GameOver a) (Running a)type Running a = (Player a, Board a, Integer)type Player a = (Point, Matrix, Board a, Board a)type GameOver a = (Board a, Integer)
When the game's not running, we leave the board and score around.
Next lets sketch the rules of the game. We got player motions to shift and rotate the pieces.
stepShift :: Integer -> GameEnv a -> Running a -> Running astepRotate :: GameEnv a -> Running a -> Running a
These are carried through only if the motion is noncolliding with anything. To represent that we got a way to check whether the run is affordable. Affordability requires checking that the thing's inside the playing area.
affordable :: Running a -> Boolin_board :: Integer -> Integer -> Point -> Bool
Next there's the only way to end the game. The step-down fuses pieces to the board if it can't move them ahead. Player may also step-down with the down key himself.
stepDown :: GameEnv a -> Running a -> GameState aThere's also the condition for clearing the board. I did it by counting items on each line and removing rows that ended up being filled.
That would be about 80 lines of fairly easy to understand code. We are going to fix that next.
Purescript - game code
We are outright in a bad situation because we were using tuples for everything. First they're translated to records because why not leak some implementation details into your code?
type GameEnv a ={ pieces :: Array (Board a), width :: Int, height :: Int }type GameState a = Either (GameOver a) (Running a)type Running a ={ player :: Player a, board :: Board a, score :: Int }type Player a ={ offset :: Point, orient :: Matrix, tile :: Board a, nextTile :: Board a }type GameOver a ={ board :: Board a, score :: Int }type Point = {x :: Int, y :: Int}type Matrix = Point -> Pointtype Board a = Map Point atransform :: Point -> Point -> Pointtransform i j = {x: i.x + j.x, y: i.y + j.y}rotate90d :: Matrixrotate90d p = {x: -p.y, y: p.x}
The game requires bit of randomness that we're going to get through Javascript. Mainly we have to choose the next tile randomly.
randomTile :: forall a. GameEnv a -> Effect (Board a)randomTile env = doi <- randomInt 0 (length env.pieces - 1)case index env.pieces i ofJust tile -> pure tileNothing -> pure empty
To break the game rather than bug out I'll return an empty tile when the list is empty, because why not make it a hassle to use partial functions when false total functions are so convenient to create?
The next part is game initialization, well, the abstract parts. The initPlayer is separate because it's used to initiate a new turn.
initPlayer :: forall a. GameEnv a -> Board a -> Effect (Player a)initPlayer env tile = donextTile <- randomTile envpure{ offset: {x: 6, y: 0}, orient: (\i -> i), tile: tile, nextTile: nextTile }initGame :: forall a. GameEnv a -> Effect (Running a)initGame env = doplayer <- randomTile env >>= initPlayer envpure { player: player, board: empty, score: 0 }
Next the stepShift, stepRotate and stepDown like it was planned. There's a minor change in the stepDown because we had to randomize the results whenever the game progresses.
stepShift :: forall a. Int -> GameEnv a -> Running a -> Running astepShift x env prev = letrun = prev { player = prev.player { offset = transform {x:x,y:0} prev.player.offset } }in if affordable env run then run else prevstepRotate :: forall a. GameEnv a -> Running a -> Running astepRotate env prev = letrun = prev { player = prev.player { orient = prev.player.orient >>> rotate90d } }in if affordable env run then run else prevstepDown :: forall a. GameEnv a -> Running a -> Either (GameOver a) (Effect (Running a))stepDown env prev = letrun = {player: { offset: transform {x:0,y:1} prev.player.offset, orient: prev.player.orient, tile: prev.player.tile, nextTile: prev.player.nextTile }, board: prev.board, score: prev.score }in (if affordable env run then pure (pure run) else nextTurn)where nextTurn = letfl = flushBoard env.width env.height$ { board: union (currentTile prev.player) prev.board, score: 1 }in if any (\p -> p.y <= 2) (keys fl.board)then (Left {board: fl.board, score: prev.score + fl.score})else pure $ doplayer <- initPlayer env prev.player.nextTile(pure {player: player, board: fl.board, score: prev.score + fl.score})
The board flushing mechanims was a bit more convoluted than I thought. The flushBoard goes through the whole playing field, applying flushRow to each row. The countRow takes care of counting the items on a row and then collapse removes a row.
flushBoard :: forall a. Int -> Int-> {board::Board a, score::Int}-> {board::Board a, score::Int}flushBoard w h = foldl (>>>) (\a -> a) (map (flushRow w) (2 .. (h-1)))flushRow :: forall a. Int -> Int-> {board::Board a, score::Int}-> {board::Board a, score::Int}flushRow w y bs = if w == countRow y bs.boardthen {board: mapKeys' (collapse y) bs.board, score: bs.score+w}else bscountRow :: forall a. Int -> Board a -> IntcountRow y board = size (filter (\p -> p.y == y) (keys board))collapse :: Int -> Point -> Maybe Pointcollapse y p | (p.y == y) = Nothingcollapse y p | (p.y < y) = Just {x: p.x, y: p.y + 1}collapse y p = Just p
The affordable and in_board came out like planned.
affordable :: forall a. GameEnv a -> Running a -> Booleanaffordable env run = lettile = currentTile run.playerin null (intersection tile run.board)&& all (in_board env.width env.height) (keys tile)in_board :: Int -> Int -> Point -> Booleanin_board w h p =(0 <= p.x) && (p.x < w)&& (0 <= p.y) && (p.y < h)
In addition I have a convenience function to retrieve the current player's tile pre-transformed, and a little function to retrieve the transform for the player.
currentTile :: forall a. Player a -> Board acurrentTile player = mapKeys (pt player) player.tilept :: forall a. Player a -> Point -> Pointpt player = transform player.offset <<< player.orient
Next we look at the game initialization.
Purescript - game environment
Color hexadecimal codes are coming in here. The piece is an assistive function to build the tetrominoes list.
initGameEnv :: GameEnv StringinitGameEnv = { pieces: tetrominoes, width: 14, height: 25 }tetrominoes :: Array (Board String)tetrominoes =[ piece[ {x: 0, y: 0}, {x: 1, y: 0}, {x: 2, y: 0}, {x: -1, y: 0}] "#800", piece[ {x: 0, y: 0} , {x: 1, y: 0} , {x: 0, y: 1} , {x: 1, y: 1}] "#080", piece[ {x: -1, y: 0} , {x: 0, y: 0} , {x: 1, y: 0} , {x: 1, y: 1}] "#00C", piece[ {x: -1, y: 0} , {x: 0, y: 0} , {x: 1, y: 0} , {x: 0, y: 1}] "#088", piece[ {x: -1, y: 0} , {x: 0, y: 0} , {x: 1, y: 1} , {x: 0, y: 1}] "#808"]piece :: Array {x :: Int, y :: Int} -> String -> Board Stringpiece indices color = foldl(\m p -> insert p color m)emptyindices
Purescript - Entry point
The following code sets up the game display, stepping and key events. Note: The game environment is initialized into a reference and passed around into every routine that's doing something wit it.
main :: Effect Unitmain = dow <- windowd <- document wlet env = initGameEnvgameRef <- initGame env >>= (new <<< Right)keyEvent <- eventListener (onKeyEvent env gameRef <<< fromEvent)canvas_elem <- getCanvasElementById "tris"case canvas_elem ofJust canvas -> doctx <- getContext2D canvas_ <- requestAnimationFrame (read gameRef >>= display w ctx gameRef) wlaunchAff_ (gameStep env gameRef)pure unitNothing -> dopure unitkbElem <- getElementById "tris" (toNonElementParentNode (toDocument d))case kbElem ofJust kb -> doaddEventListener (EventType "keydown") keyEvent true (toEventTarget kb)Nothing -> dopure unit
I especially love the horrors of setting up event listeners in Purescript. They figured Javascript's object-oriented event interface would be neat to use if you had difficulty juggling with representations of objects. It took several hours to figure out how to get this main entry point and the stuff below to work, especially with code scantily clad of documentation. We also can't afford using typeclasses in addEventListener, nope.
onKeyEvent :: GameEnv String -> Ref (GameState String) -> Maybe KeyboardEvent -> Effect UnitonKeyEvent env gameRef (Just ev) = case key ev of"ArrowLeft" -> dogame <- read gameRefwrite (map (stepShift (-1) env) game) gameRefpreventDefault (toEvent ev)"ArrowRight" -> dogame <- read gameRefwrite (map (stepShift 1 env) game) gameRefpreventDefault (toEvent ev)"ArrowUp" -> dogame <- read gameRefwrite (map (stepRotate env) game) gameRefpreventDefault (toEvent ev)"ArrowDown" -> dogame <- read gameRefgame' <- sequence (game >>= stepDown env)write game' gameRefpreventDefault (toEvent ev)_ -> pure unitonKeyEvent env gameRef Nothing = pure unit
The gamestepping here is actually something better than you'd get in javascript, except that it's like telling your face looks prettier than weeks old roadkill.
gameStep :: GameEnv String -> Ref (GameState String) -> Aff UnitgameStep env gameRef = dospeed <- liftEffect $ map gameSpeed (read gameRef)delay $ Milliseconds speedgame <- liftEffect $ read gameRefgame' <- liftEffect $ sequence (game >>= stepDown env)liftEffect $ write game' gameRefcase game' ofLeft _ -> pure unitRight _ -> gameStep env gameRef
Lets define the game speedup as you score, the produced number is a milliseconds between the game steps. This is probably badly selected table, also note that I was lazy and Vimed it out.
gameSpeed :: forall a. GameState a -> NumbergameSpeed (Right g) | g.score < 20 = 700.0gameSpeed (Right g) | g.score < 40 = 600.0gameSpeed (Right g) | g.score < 60 = 500.0gameSpeed (Right g) | g.score < 80 = 400.0gameSpeed (Right g) | g.score < 100 = 300.0gameSpeed (Right g) | g.score < 120 = 200.0gameSpeed (Right g) | g.score < 140 = 100.0gameSpeed (Right g) | g.score < 160 = 90.0gameSpeed (Right g) | g.score < 180 = 80.0gameSpeed (Right g) | g.score < 200 = 70.0gameSpeed (Right g) | g.score < 220 = 60.0gameSpeed g = 50.0
Purescript - Display on canvas
Now it's time to show the mess on the screen. Both the game over and the game running. Here's the display routine.
display :: Window-> Context2D-> Ref (GameState String)-> GameState String-> Effect Unitdisplay w ctx game (Left over) = dodisplayBackground ctx over.boardsetTextAlign ctx AlignLeftsetFillStyle ctx "#FFF"fillText ctx (toString (toNumber over.score)) (3.0 * 16.0) 425.0setTextAlign ctx AlignLeftfillText ctx ("game over") (3.0 * 16.0) 440.0display w ctx game (Right run) = dodisplayBackground ctx run.boarddisplayPlayer ctx run.playerdisplayNextTile ctx run.player.nextTilesetFillStyle ctx "#FFF"setTextAlign ctx AlignLeftfillText ctx (toString (toNumber run.score)) (3.0 * 16.0) 425.0_ <- requestAnimationFrame (read game >>= display w ctx game) wpure unitdisplayBackground :: Context2D -> Board String -> Effect UnitdisplayBackground ctx board = dosetFillStyle ctx "#333"fillPath ctx $ rect ctx{ x: 0.0, y: 0.0, width: 320.0, height: 480.0}setFillStyle ctx "#000"fillPath ctx $ rect ctx{ x: 3.0 * 16.0, y: 0.0, width: 16.0 * 14.0, height: 16.0 * 25.0}setFillStyle ctx "#000"fillPath ctx $ rect ctx{ x: 12.0 * 16.0, y: 26.0 * 16.0, width: 16.0 * 5.0, height: 16.0 * 2.0}foldlWithIndex(\p m c -> m >>= \_ -> plot ctx p c)(pure unit)boarddisplayPlayer :: Context2D -> Player String -> Effect UnitdisplayPlayer ctx player = dofoldlWithIndex(\p m c -> m >>= \_ -> plot ctx (pt player p) c)(pure unit)player.tiledisplayNextTile :: Context2D -> Board String -> Effect UnitdisplayNextTile ctx tile = dofoldlWithIndex(\p m c -> m >>= \_ -> plot ctx (transform {x:11,y:26} p) c)(pure unit)tileplot :: Context2D -> Point -> String -> Effect Unitplot ctx p color = dosetFillStyle ctx colorfillPath ctx $ rect ctx{ x: toNumber (p.x+3) * 16.0 + 1.0, y: toNumber p.y * 16.0 + 1.0, width: 14.0, height: 14.0}
The main trick there was to convert the maps into folds so they could be plot-drawn to the screen. Overall that was not too bad.
Purecript - stuff that was supposed to be out there but wasn't
I thought the pursuitfursuit had the mapKeys but I didn't manage to import it. Instead I had to write it out as a fold that rebuilds the map. Likewise I added function that'd allow me to discard keys and named it mapKeys' in lack of better name.
mapKeys :: forall j k v. Ord j => Ord k => (k -> j) -> Map k v -> Map j vmapKeys f m = foldlWithIndex(\k out v -> insert (f k) v out)emptymmapKeys' :: forall j k v. Ord j => Ord k => (k -> Maybe j) -> Map k v -> Map j vmapKeys' f m = foldlWithIndex(\k out v -> case f k ofJust k' -> insert k' v outNothing -> out)emptym
Fortunately they were easy to build up after some digging around.
That's how you do it
That's how you'd do it if you were to start straight off from Haskell or Purescript. Here's a recap:
- Think about what you're supposed to do.
- Turn it to propositions of what the result or outcome is supposed to be like, eg. graph that has no cycles innit.
- Convert the propositions into mathematical logic, those become your types.
- Insult your tools (can be omitted by a beginner, this a difficult task to do well)
- Spend hours to days trying to find out which way you should do the simplest things such as drawing to a canvas, catching events from your keyboard or setting things off after a set duration.
The results were achieved by going from abstract to concrete. Hardened-enough wizard buttocks shape even the hardest mediums and leave lasting indents.