Arrange the grey squares so they appear from brightest to darkest going left to right, top to bottom.

We write code that works with the vanilla GHC compiler, the Haste compiler, and the Asterius compiler.

Haste provides a library for common front-end tasks such as interacting with the HTML DOM, drawing on a canvas, and handling events:

{-# LANGUAGE CPP #-}
#ifdef __HASTE__
import Data.IORef
import Data.List
import Haste
import Haste.DOM
import Haste.Events
import Haste.Graphics.Canvas
#endif

Asterius is more Spartan:

#ifdef ASTERIUS
import Data.IORef
import Data.List
import Asterius.Types
#endif

The pure part of our code shuffles the board until we find a solvable 15-puzzle position.

We ought to use uniformR instead of randomR but we’re supporting older compilers.

For picking a random permutation uniformly, we could import System.Random.Shuffle, but instead we implement our own to illustrate a point. The typical imperative solution repeatedly swaps memory contents chosen from certain ranges and runs in linear time. Although pure code is forbidden from such swaps, we can replace an array of memory cells with a Data.Map that maps memory indexes to data contents. This sort of thing is based on trees, thus the running time increases by a logarithmic factor.

Thus in general, we can construct a persistent version of a data structure by paying at most a logarithmic factor.

Hopefully it is clear our shuffling routine has $$O(N \log N)$$ complexity. This matches System.Random.Shuffle, but likely has a far higher constant factor because a Data.Map is intended for more than just shuffling.

On a related note, a C programmer would represent a 2D board with an array. A straightforward translation to Haskell is inefficient, because any array update creates a fresh copy of the entire array. Better to use a map; to update a map of $$N$$ entries only costs $$O(\log N)$$ more space.

import qualified Data.Map.Strict as M
import Data.Map.Strict (Map, (!))
import System.Random

type Board = Map (Int, Int) Int

shuf m g = M.fromList $zip (M.keys m)$ shufElems m g
shufElems m g = case M.size m of
0 -> []
n -> let
(i, g') = randomR (0, n - 1) g
(_, v) = M.elemAt i m
in v : shufElems (M.deleteAt i m) g'

side = 4
hole = side^2

goal = M.fromList $zip ((,) <$> [0..side-1] <*> [0..side-1]) [1..]

parity []     = 0
parity (x:xs) = length (filter (x>) xs) + parity xs
+ if x == hole then uncurry (+) $divMod (length xs) side else 0 gen :: IO Board gen = do z <- shuf goal <$> newStdGen
if even $parity$ M.elems z then pure z else gen

Most of the code is taken by the front end, especially since we’re animating tiles. For Asterius, we define wrappers for JavaScript functions, including some we wrote that can be studied by viewing the source of this page.

#ifdef ASTERIUS
foreign import javascript "fillrect($1,$2,$3,$4,$5,$6)" fillrect :: JSVal -> Int -> Int -> Int -> Int -> JSString -> IO ()
foreign import javascript "setKeydown($1)" js_onKeyDown :: JSFunction -> IO () foreign import javascript "document.getElementById($1).onmousedown=$2" js_onMouseDown :: JSString -> JSFunction -> IO () foreign import javascript "document.getElementById($1).innerText=$2" js_setInner :: JSString -> JSString -> IO () foreign import javascript "$1.keyCode" keyCode :: JSVal -> Int
foreign import javascript "$1.offsetX" offsetX :: JSVal -> Int foreign import javascript "$1.offsetY" offsetY :: JSVal -> Int
foreign import javascript "wrapper" mkCallback :: IO () -> IO JSFunction
foreign import javascript "wrapper" mkCallback1 :: (JSVal -> IO ()) -> IO JSFunction
foreign import javascript "wrapper oneshot" mkOnceCallback :: IO () -> IO JSFunction
foreign import javascript "setTimeout($1,$2)" js_setTimeout :: JSFunction -> Int -> IO ()
foreign import javascript "document.getElementById('canvas').getContext('2d')" mustCanvas :: IO JSVal

rgb r g b = toJSString $concat ["rgb(", show r, ",", show g, ",", show b, ")"] delayCall n f = flip js_setTimeout n =<< mkOnceCallback f onKeyDown f = js_onKeyDown =<< mkCallback1 f onMouseDown s f = js_onMouseDown (toJSString s) =<< mkCallback1 f setInner elemId s = js_setInner (toJSString elemId) (toJSString s) #endif The Haste equivalents use the library functions bundled with the compiler: #ifdef __HASTE__ delayCall n f = void$ setTimer (Once n) f
rgb = RGB
onKeyDown f = void $onEvent documentBody KeyDown f onMouseDown s f = do Just e <- elemById s void$ onEvent e MouseDown f
setInner elemId s = do
Just e <- elemById elemId
setProp e "innerText" s
offsetX = fst . mouseCoords
offsetY = snd . mouseCoords
fillrect canvas x y w h c = let f = fromIntegral in
renderOnTop canvas $color c$ fill $rect (f x, f y) (f$ x + w, f $y + h) mustCanvas = maybe undefined id <$> getCanvasById "canvas"
#endif

The rest of the code is the same for Haste and Asterius except for a preventDefault call. The Haste edition prevents the default effect of key down events if they are valid moves, so the browser behaves mostly as usual during play and entirely as usual after winning.

With Asterius, calling preventDefault() from our code is difficult because JavaScript and WebAssembly call one another, and events are handled in between; by the time we call preventDefault() it is too late. We work around this with a JavaScript helper eats key events in a certain focusable div element before ceding control to wasm.

The user might have to click in a particular area to use the keys, and must click outside in order to use typcial shortcuts such as Ctrl-R. To mimic our Haste code, we could perhaps preventDefault for all key events on the document body then somehow generate events that we want to pass on.

We mostly redraw only the two tiles affected by the animation. However, if a move is interrupted by another move before its animation is complete, we redraw the whole board before starting the new animation.

#if defined(ASTERIUS) || defined (__HASTE__)
data Anim = Anim Int (Int, Int) (Int, Int)
data Game = Game (Maybe Anim) Board (Int, Int)

getColour n = rgb x x x where
x | n == hole = 0
| otherwise = 255 - (225 * (n - 1) div (side^2 - 1))

frameCount = 8
sz = 64

main = do
canvas <- mustCanvas
won <- newIORef undefined
game <- newIORef undefined
let
win = do
writeIORef won True
setInner "msg" "A WINNER IS YOU"
draw b = forM_ (M.assocs b) $\((r, c), n) -> fillrect canvas (c*sz) (r*sz) sz sz$ getColour n
loop = do
Game a b cur <- readIORef game
case a of
Nothing -> when (b == goal) win
Just (Anim frame tgt@(r1,c1) src@(r0,c0)) -> do
let sc b a = a*sz + (b - a)*sz*framedivframeCount
writeIORef game $Game (if frame == frameCount then Nothing else Just$ Anim (frame + 1) tgt src) b cur
fillrect canvas (c0*sz) (r0*sz) sz sz $getColour hole fillrect canvas (c1*sz) (r1*sz) sz sz$ getColour hole
fillrect canvas (sc c0 c1) (sc r0 r1) sz sz $getColour$ b!src
delayCall 16 loop

newGame = do
b <- gen
let Just cur = fst <$> find ((== hole) . snd) (M.assocs b) writeIORef game$ Game Nothing b cur
writeIORef won False
setInner "msg" ""
draw b
move tgt (Game anim b cur) = do
writeIORef game $Game (Just$ Anim 0 tgt cur)
(M.insert tgt hole $M.insert cur (b!tgt) b) tgt case anim of Nothing -> loop Just _ -> draw b unlessWon f = readIORef won >>= flip unless f newGame onMouseDown "reshuffle"$ \_ -> newGame

onMouseDown "canvas" $\e -> unlessWon$ do
let tgt@(r, c) = (div (offsetY e) sz, div (offsetX e) sz)
g@(Game _ b (r0, c0)) <- readIORef game
when (M.member (r, c) b && (c - c0)^2 + (r - r0)^2 == 1) $move tgt g onKeyDown$ \k -> unlessWon $do g@(Game _ b (r, c)) <- readIORef game let go tgt = when (M.member tgt b)$ do
#ifdef __HASTE__
preventDefault
#endif
move tgt g
case keyCode k of
38  -> go (r + 1, c)
40  -> go (r - 1, c)
37  -> go (r, c + 1)
39  -> go (r, c - 1)
_ -> pure ()
#endif

In this page, we use the output of the Haste compiler, which is significantly smaller than the output of Asterius.

hastec --opt-all 15.lhs

162153 15.js

ahc-link --bundle --browser --ghc-option -O --input-hs 15.lhs

 96075 15.js
811685 15.wasm

Ben Lynn blynn@cs.stanford.edu 💡