rand n = fromInteger . readInteger <$> jsEval ("Math.floor(Math.random() * " ++ show n ++ ");") cls = jsEval_ "tty.value = '';" output s = jsEval_ $ "out(`" ++ s ++ "\n`);" outputs = mapM_ output input cont = do jsEval "inp();" setGlobal cont inputCont s = global >>= ($ toUpper <$> s) delay t cont = do setGlobal cont jsEval_ $ "setTimeout(() => run('delayCont'), " ++ show t ++ ");" delayCont = global >>= id toUpper c | 'a' <= c && c <= 'z' = chr $ n - 32 | otherwise = c where n = ord c
Let’s Play!
Inspired by a talk by John Carmack [follow-up post], I wrote some browser games in Haskell.
Back to BASICs
In my offline childhood, source code was hard to come by, so I was always elated if I saw computer programming books published by Usborne in the 1980s at my local library.
Let’s attempt to remake these games. We crudely simulate a crude terminal with
CSS and JavaScript. We define versions of common BASIC commands. Since print
is a Haskell keyword, we use output
instead of PRINT
, and also define
outputs
, which prints a list of strings.
There is an awkward mismatch with the INPUT
command due to the asynchronous
nature of the DOM. We work around this by defining input
to take a
continuation as an argument. Our delay
function is similar. In both cases,
we abuse the global
and setGlobal
hacks to store continuations. This means
there is a race condition, though it’s difficult to trigger.
Now for the fun part: we convert the BASIC of yesteryear to Haskell. Let’s take Spiderwoman from Creepy Computer Games, where Spiderwoman is thinking of a letter that the player must guess by typing in words.
lose = output "YOU ARE NOW A FLY" play g choice = do outputs ["", "TRY A WORD", "", ""] input check where next = play (g + 1) choice check w | length w < 4 || length w > 8 = next | choice `elem` w = do outputs ["YES - IT'S ONE OF THOSE", "", "DO YOU WANT TO GUESS ? (Y OR N)"] input \r -> if r == "N" then cls *> next else do outputs ["", "WHAT IS YOUR GUESS THEN ? "] input \g -> if g /= [choice] then lose else outputs ["OK - YOU CAN GO", "(THIS TIME)"] | otherwise = do outputs ["", "IT'S NOT IN THAT WORD"] delay 500 $ cls *> if g > 15 then output "YOU ARE TOO LATE" *> lose else next spiderwoman = do cls outputs ["SPIDERWOMAN", "HAS CHOSEN"] play 1 =<< (['A'..'Z']!!) <$> rand 26
Lastly, we write some glue code to connect HTML elements to our code, and start the game:
jsEval_ "initSpiderwoman(repl);" spiderwoman jsEval_ "tty.focus();"
Plug and play
To solve the JavaScript problem, I initially used the Haste compiler. The GHCJS compiler seemed heavyweight and tough to install. It worked great. I could mostly pretend it was Haskell as usual.
Sadly, Haste seems abandoned. The good news is that GHC is gaining JavaScript and WebAssembly backends. I would migrate, but my goals have shifted. Simple games ought to be simple to write. For toy programs, there should be no need for setting up complex development environments and lengthy compile times and lengthy boilerplate.
When a game is sufficiently short and sweet, I use a version of my own Haskell compiler because of some features:
-
Zero-install. The webpage loads a wasm binary version of my compiler and runs it on the code within.
-
Helpers for global variables. Our
global
andsetGlobal
functions are slightly less unprincipled thanunsafePerformIO
withnewIORef
. This suits the event-driven nature of the DOM, as we can spread code among many top-level functions rather than stuff everything into a single main function. -
Interactive REPL. Anyone can edit and run the code on the page (though of course changes cannot be saved).
-
Module fetching. Even halfway through a program we can fetch object files elsewhere on my website and import their definitions. I no longer need to start every program with a series of imports.
-
In the same vein, my compiler uses my preferred language options so I no * longer have to declare them at the start.
Our code hits the ground running. For example, the following computes the 100th Fibonacci number. You can edit it and run it again: click the button or press Ctrl-Enter. Pressing Alt-Enter will run it and give you a new box to add more code, with access to all previous definitions.
fibs=0:1:zipWith (+) fibs (tail fibs) fibs !! 100