Skip to content

Latest commit

 

History

History
1341 lines (1088 loc) · 41 KB

README.md

File metadata and controls

1341 lines (1088 loc) · 41 KB

Quick Start

I'd recommend doing the tutorial yourself, but if you just want to hit the ground running you can try out this example by cloning the repo, cd into tunnel-crawler and running stack build && stack exec tunnel-crawler-exe.

Building your first App with Eve

Glad you could join us! This will be a quick walkthrough of the steps involved in building a simple app. After completing this tutorial you should feel comfortable building something of your own! Eve is moving fast, so this guide is likely to go out of date from time to time, if you notice that something seems a bit off (or doesn't compile); feel free to open an issue here, or hop into our gitter chat!

If you haven't read through Eve's README yet (slacker! 😉) Eve is a framework for designing reactive event-driven applications, with a specialty towards extensibility! I've always found examples and tutorials to be the most helpful when learning to use a new library or technology, so I figured I'd combine the two; so here's my tutorial on building an example app in Eve!

Being that Eve is an Event-Driven application framework I want us to build something which is responsive to user input, and maybe even has some form of time-reliant aspect. Games tend to have these properties, and people are always complaining that Haskell doesn't have any game frameworks, so lets make a dungeon-crawler! I don't have all day though, so let's simplify it a bit and make it a 1-dimensional 'Tunnel-Crawler' instead!

Here's what we're shooting for, we want a little game where you can move your little tunnel crawler back and forth in his tunnel and collect coins which spawn over time. It's sure to be a AAA success!

First things first, let's set up our app! We'll use stack for this since it provides reproducible builds and is relatively easy to work with.

Creating a new Eve Project

If you're comfortable setting up a new project using stack you can probably go ahead and skip down to the 'Providing Events' section. If you're unfamiliar with Stack you can check out a decent guide HERE. Go ahead and install Stack now, I'll wait! (Psst! I'd recommend brew update && brew install haskell-stack). Make sure you run stack setup before we get started.

Once you've got that all figured, open the command line and go to a folder where you'd like to keep your project and run stack new tunnel-crawler. This creates a new project and sets up an application template for us! Go ahead and cd your way in there and let's get started. First we'll need to specify that we want to use the eve package; do this by adding eve to the build-depends list under the library section of tunnel-crawler.cabal. We'll go ahead and add the other dependencies we'll need for the tutorial while we're at it, so your build-depends should now look like this:

  build-depends:       base >= 4.7 && < 5
                     , eve
                     , mtl
                     , data-default
                     , lens
                     , random

Eve is a newish package so we'll probably also need to add it to our stack.yaml file. Go ahead and change the line that says extra-deps to extra-deps: ["eve-0.1.5"]

With that stack should be able to find Eve! Open up app/Main.hs in your favourite text-editor and change main into main = gameLoop; we're going to do most of our work in Lib.hs so this is all we'll need to change in here. Save and close Main.hs and open up Lib.hs. Let's do a trivial gameLoop just to make sure we have our project set up properly before moving on to more difficult things. You can delete the contents of Lib.hs and replace it with this instead:

module Lib where

gameLoop :: IO ()
gameLoop = print "Hi!"

This doesn't have anything to do with Eve yet, but should help us make sure our project is set up right. At this point open a command terminal, cd into your tunnel-crawler directory and run stack build && stack exec tunnel-crawler-exe (you may wish to alias this to something, we'll be doing it a lot).

Fingers crossed that it worked out for you, you may need to do some trouble-shooting to make sure stack is working for you, but once you get that all sorted out we'll get started on actually using Eve!

Providing Events

So eve_ is the main function we'll be using to run our app; it looks like this:

eve_ :: App () -> IO ()

You can see that eve_ takes an App () as its only argument. App is a monad (if you're not familiar with monads that's okay; a bit of familiarity goes a long way, but you should still be able to follow along); There are two types of 'commands' or 'actions' that we care about with Eve, Apps and Actions. They're almost identical except that Actions can be made to run over specific states whereas Apps can only be used over the application's global state. This will all make sense in a second I swear! Let's come back to that in a bit.

Okay! So, we saw that eve_ needs an App in order to run, the basic idea is that you pass eve_ an App which describes your application's setup. To start off let's just use the simplest setup we can think of:

module Lib where

import Eve

setup :: App ()
setup = return ()

gameLoop :: IO ()
gameLoop = eve_ setup

Brilliant! Our players will have so much fun with this! Okay let's run it (with stack build && stack exec tunnel-crawler-exe)

tunnel-crawler-exe: thread blocked indefinitely in an MVar operation

Uh-Oh! Something went wrong here; I'll save you the trouble and tell you that it's because eve is an event driven application, but we haven't given it any events to work with! Basically, the event-loop froze waiting for something to happen. This is why every eve application needs to provide either an asyncEventProvider or an asyncActionProvider in its initialization block; these serve to provide your application with events in response to some external stimuli, whether it be a key-press, network event, or file-system change!

Let's add a little routine which provides keypress events!

{-# LANGUAGE RankNTypes #-}
module Lib where

import Eve
import Control.Monad

data KeyPress = KeyPress Char

-- This provides KeyPress events to our app
keypressProvider :: EventDispatcher -> IO ()
keypressProvider dispatcher = forever $ do
  c <- getChar 
  dispatcher $ KeyPress c

setup :: App ()
setup = do
  -- We register the keypressProvider as an event provider here.
  asyncEventProvider keypressProvider

gameLoop :: IO ()
gameLoop = eve_ setup

Okay, so we've added a keypressProvider, it has the type EventDispatcher -> IO () which seems a bit strange, how does it work? EventDispatcher is a type alias provided by Eve which resolves to:

type EventDispatcher = forall event. event -> IO ()

It means that our 'provider' needs to be a function which accepts a EventDispatcher as its first argument, and then we can just call that dispatcher function any time we have a new event and it'll make sure it gets where it needs to go. In this case we use forever to continually getChars from stdin and dispatch a KeyPress event for each one! Note that we added the RankNTypes language pragma at the top of the file, we'll need that any time we use the EventDispatcher type.

If you were to run the app now you'd notice that it no longer crashes, but no matter what you type nothing happens! That's because we're dispatching events, but there's no-one listening! Let's add an event-listener and make something happen.

Here's a checkpoint with what you should have so far:

{-# LANGUAGE RankNTypes #-}
module Lib where

import Eve
import Control.Monad
import Control.Monad.Trans

data KeyPress = KeyPress Char

keypressProvider :: EventDispatcher -> IO ()
keypressProvider dispatcher = forever $ do
  c <- getChar
  dispatcher (KeyPress c)

echo :: KeyPress -> App ()
echo (KeyPress c) = liftIO (print $ "You pressed: " ++ [c])

setup :: App ()
setup = do
  asyncEventProvider keypressProvider
  addListener_ echo

gameLoop :: IO ()
gameLoop = eve_ setup

Listening for Events

So we added the echo listener. A listener is any function which takes some Event and returns an App or Action. The echo listener is simple, it responds to keypresses by printing out which key you pressed! Since we're just using getChar it'll only work properly on printable characters, but we'll worry about that later.

Notice how we use liftIO from Control.Monad.Trans to run print inside an App. This is because App is a Monad Transformer; see mtl for more details if you care, but all you really need to know is that liftIO lets you run IO inside an App or Action. Now that we've set up echo we need to register it so it listens for keypress events. Since we have KeyPress in the function signature for echo Eve can actually infer which event it's listening for and we can just use addListener on it. In this case we use addListener_ which discards the ListenerId; that's okay for now since we don't plan on using removeListener on it later.

It's been a while since we ran anything, so let's give it another go! Hopefully it compiled for you nicely, but as you type characters it doesn't seem to be printing our message. What gives? Well it turns out that stdin is buffered by newlines by default, so you'll have to hit your return key to see all the responses. Normally we'd probably use a library which handles all of this terminal wonkiness for us, but for the sake of simplicity we'll do it ourselves. Go ahead and alter setup to include this magic formula which tells our terminal we don't want it to buffer. Also add the new System.IO import at the top of your file.

import System.IO
setup :: App ()
setup = do
  liftIO $ do
    hSetBuffering stdin NoBuffering
    hSetBuffering stdout NoBuffering
    hSetEcho stdin False
  asyncEventProvider keypressProvider
  addListener_ echo

Okay, so the app should be a bit more responsive now! Congrats, you've built your first event-driven app! Let's keep moving!

Storing State

If this is going to be a game then we'll need to keep track of what's going on! If our keypresses are going to move our tunnel-crawler character then we should store their position somewhere. Eve's way of storing state can be a little unintuitive at first, but it starts to make sense as your application gets larger, especially if you allow people to write plugins for it.

We'll take a simple approach in this case where all of our game state is stored in a single 'GameState' object.

import Data.Default
data GameState = GameState Int

instance Default GameState where
  def = GameState 0

As you can see our GameState is pretty simple, it consists of just a single int which we'll use to track our player's position in the tunnel. We've also added the Data.Default import and have defined an instance of Default for our GameState which works pretty much exactly how you'd expect. States which we store in Eve require a Default instance because of how they're accessed; if you're dealing with a state where it's tough to come up with a good default you can wrap your type in a Maybe and use Nothing as your default, then initialize your state in the setup block when you have more information.

Now let's use our keypress events to change our new player position state! You can go ahead and delete echo and we'll write a new keypress handler called (unoriginally) handleKeypress

-- This action runs over a GameState,
-- We'll see how to write this MUCH cleaner using
-- 'lenses' soon.
updatePos :: Char -> Action GameState ()
updatePos 'a' = modify dec
  where
    dec (GameState pos) = GameState $ pos - 1
updatePos 'd' = modify inc
  where
    inc (GameState pos) = GameState $ pos + 1
updatePos _ = return ()

handleKeypress :: KeyPress -> App ()
handleKeypress (KeyPress c) = do
  runAction $ updatePos c
  GameState pos <- runAction get
  liftIO $ print pos

Then we'll go ahead and remove the old addListener_ call and replace it with: addListener_ handleKeypress.

Now you might be noticing that this is looking a bit clunky, it's great that we can define whole Actions over a given state, but what if we want to access multiple different states all interleaved together? It gets a bit tedious to continually use runAction over these things. This is where the lens library comes in. Eve cooperates nicely with different lenses and lens combinators. Let's rewrite our handleKeypress and updatePos using lenses to clean it up a bit.

It's been a while since we've showed the whole file, here's a checkpoint for you, I've added comments explaining how we've cleaned things up using lenses!

{-# LANGUAGE RankNTypes #-}
-- Need TemplateHaskell so makeLenses can generate lenses for us.
{-# LANGUAGE TemplateHaskell #-}
module Lib where

import Eve
import System.IO

-- New import! We're using lenses now!
import Control.Lens
import Control.Monad
import Control.Monad.State
import Control.Monad.Trans
import Data.Default

data KeyPress = KeyPress Char

-- We've turned GameState into a totally legit Haskell Record now,
-- note that _pos' has an underscore prefix so that 'makeLenses' will
-- generate a pos' lens for us.
data GameState = GameState
  { _pos' :: Int
  }
-- This uses Template Haskell, it may be a bit confusing if you haven't seen it before,
-- it comes from the Lens library. All you need to know is that this generates
-- code for us which provides a lens: pos' :: Lens' GameState Int
makeLenses ''GameState

instance Default GameState where
  def = GameState 0

-- Now that we have the pos' lens from GameState to our int, we can
-- use Eve's 'makeStateLens' utility to generate a lens for us which will
-- work inside 'Action's and 'App's!
-- 'App' keeps track of an 'AppState' for us, so that shows up in this lens type
pos :: Lens' AppState Int
pos = makeStateLens pos'

handleKeypress :: KeyPress -> App ()
handleKeypress (KeyPress c) = do
  -- We've refactored this code to use lenses, look how much cleaner it is,
  -- Almost like we're programming imperatively ;)
  case c of
    'a' -> pos -= 1
    'd' -> pos += 1
    _ -> return ()
  position <- use pos
  liftIO $ print position

keypressProvider :: EventDispatcher -> IO ()
keypressProvider dispatcher = forever $ do
  c <- getChar
  dispatcher (KeyPress c)

setup :: App ()
setup = do
  liftIO $ do
    hSetBuffering stdin NoBuffering
    hSetBuffering stdout NoBuffering
    hSetEcho stdin False
  asyncEventProvider keypressProvider
  addListener_ handleKeypress

gameLoop :: IO ()
gameLoop = eve_ setup

Rendering to Screen

Okay! Wow, we've been through a lot! Feel free to take a breather and experiment a bit before moving on. I hope you're convinced that the benefits of using lenses is worth the trouble it can take to learn them. We can rely on utilities that Eve provides to avoid their complexities for the most part.

Okay! So we're changing our player's position, but we can't actually see anything! Let's sort that out now! Let's add a hook to our app so that it renders the game's state to screen each time something happens. Let's write some rendering code!

-- A simple tunnel drawing
tunnel :: String
tunnel = replicate 20 '.'

-- Replace the char at a position with a new char
-- We'll use this to draw our player's position 
-- in the tunnel.
replaceAt :: Char -> Int -> String -> String
replaceAt _ _ [] = []
replaceAt c 0 (_:xs) = c:xs
replaceAt c n (x:xs) = x:replaceAt c (n-1) xs

-- This clears the current line then draws the tunnel.
render :: App ()
render = do
  liftIO $ putChar '\r'
  n <- use pos
  liftIO . putStr . replaceAt '$' n $ tunnel

Okay! So the rendering code is set up, render will clear the current line printing \r is an old terminal trick which returns the print-head on the terminal to the beginning of the current line. We've got a render function now, how should we trigger it?? Well our player is moving in response to events, so what if we just re-render the screen after each event is processed? We can add something to our setup block for this! We'll trigger our render action after each event using the handy afterEvent_ listener hook provided by Eve.

setup :: App ()
setup = do
  ...
  afterEvent_ render

Easy! We'll also remove the old code that prints our position from handleKeypress as it's just getting in the way now. Here's what it looks like:

handleKeypress :: KeyPress -> App ()
handleKeypress (KeyPress c) = do
  case c of
    'a' -> pos -= 1
    'd' -> pos += 1
    _ -> return ()

Compile and run that, and we've got a working game! We can see the player move back and forth by typing 'a' and 'd'. we can leave the tunnel off either end if we want, let's just call that a feature for now 😉

If you're content to start making apps, then go for it! Otherwise, the guide continues on to talk about time-related events and how extensions can tie into Eve apps.

Checkpoint! Here's roughly what your code should look like:

{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TemplateHaskell #-}
module Lib where

import Eve
import System.IO
import Control.Lens
import Control.Monad
import Control.Monad.State
import Control.Monad.Trans
import Data.Default

data KeyPress = KeyPress Char

data GameState = GameState
  { _pos' :: Int
  }

makeLenses ''GameState

instance Default GameState where
  def = GameState 0

pos :: Lens' AppState Int
pos = makeStateLens pos'

handleKeypress :: KeyPress -> App ()
handleKeypress (KeyPress c) = do
  case c of
    'a' -> pos -= 1
    'd' -> pos += 1
    _ -> return ()

keypressProvider :: EventDispatcher -> IO ()
keypressProvider dispatcher = forever $ do
  c <- getChar
  dispatcher (KeyPress c)

-- A simple tunnel drawing
tunnel :: String
tunnel = replicate 20 '.'

-- Replace the char at a position with a new char
replaceAt :: Char -> Int -> String -> String
replaceAt _ _ [] = []
replaceAt c 0 (_:xs) = c:xs
replaceAt c n (x:xs) = x:replaceAt c (n-1) xs

render :: App ()
render = do
  liftIO $ putChar '\r'
  n <- use pos
  liftIO . putStr . replaceAt '$' n $ tunnel

setup :: App ()
setup = do
  liftIO $ do
    hSetBuffering stdin NoBuffering
    hSetBuffering stdout NoBuffering
    hSetEcho stdin False
  asyncEventProvider keypressProvider
  addListener_ handleKeypress
  afterEvent_ render

gameLoop :: IO ()
gameLoop = eve_ setup

Time-based Events

All the good old unix games (like rogue) only reacted when the player moved, but using our fancy modern laptops we can probably handle a game where things happen in our game over time. How are we going to do that? You guessed it: Events!

We'll add a simple timer which ticks every few seconds and causes some treasure to appear in the tunnel. We've learned all the tools we'll need for this already actually. The timer will have to run asyncronously and it will trigger a Timer event each time it fires. Let's give it a go!

We'll add our new Timer event:

-- new import!
import Control.Concurrent

-- new timer event!
data Timer = Timer

-- This dispatches a Timer event every 3 seconds
-- for some reason threadDelay takes microseconds :/
timer :: EventDispatcher -> IO ()
timer dispatch = forever $ do
  threadDelay 3000000
  dispatch Timer

setup :: App ()
setup = do
  ...
  asyncEventProvider timer

Okay, so timer is the brains of this operation. It looks pretty similar to our keypressProvider, basically we run an IO which dispatches events whenever it wants. Well that was quick! We've got a timer event firing every 3 seconds, let's do something with it! How about we spawn some treasure for the player to collect!

-- new import!
import System.Random

-- Let's update our game state so we can track where the treasures are
-- We'll just use a list of positions which will represent which spots
-- have treasure
data GameState = GameState
  { _pos' :: Int
  , _treasures' :: [Int]
  }

-- We need to add this to our Default instance too.
instance Default GameState where
  def = GameState 0 []

treasures :: Lens' AppState [Int]
treasures = makeStateLens treasures'

-- This generates a random position within our tunnel then adds
-- it to the list of treasures we've defined.
spawnTreasure :: App ()
spawnTreasure = do
  -- Generate a random number
  newTreasure <- liftIO $ randomRIO (1, 20)
  -- '%=' from the lens library runs a function over
  -- the focus of the lens, in this case we 
  -- prepend the new treasure position.
  treasures %= (newTreasure:)

-- We need to update our render method too:
render :: App ()
render = do
  liftIO $ putChar '\r'
  n <- use pos
  t <- use treasures
  liftIO . putStr . replaceAt '$' n . addTreasures t $ tunnel
    where
      addTreasures t tunnel = foldr (replaceAt '%') tunnel t

setup :: App ()
setup = do
  ...
  -- spawn a treasure whenever the timer goes off.
  addListener_ (const spawnTreasure :: Timer -> App ())

I'm showing off a new way to register a listener here, you'll notice that spawnTreasure doesn't actually accept a Timer event, which is how we usually tell the type system what sort of event it's expecting. In this case we can use const to create a function which takes (and ignores) one argument, then returns the App we defined. We annotate the type of const spawnTreasure in-line so that GHC knows which type we expect it to take (Timer) and so that it gets registered for the proper events!

You can use this, or the other method, whichever you prefer. But remember the type annotation if you do this new one! You'll get a strange type error without it!

Okay, let's try that, give it a try! Hopefully you'll see your tunnel slowly fill with treasure! But wait! Darn! We can't collect any of it. Let's take care of that.

-- new import
import Data.List

collectTreasure :: App ()
collectTreasure = do
  p <- use pos
  t <- use treasures
  when (p `elem` t) (treasures %= delete p)

For now we'll wire that in after each time we move.

handleKeypress :: KeyPress -> App ()
handleKeypress (KeyPress c) = do
  case c of
    'a' -> pos -= 1
    'd' -> pos += 1
    _ -> return ()
  collectTreasure

Done deal! We can collect that treasure! We'll probably want to keep track of how much treasure we've collected, we'll do that in the next section as we learn how extensions tend to work in Eve.

Checkpoint!

{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TemplateHaskell #-}
module Lib where

import Eve
import System.IO
import System.Random
import Control.Concurrent
import Control.Lens
import Control.Monad
import Control.Monad.State
import Control.Monad.Trans
import Data.Default
import Data.List

data Timer = Timer
data KeyPress = KeyPress Char

data GameState = GameState
  { _pos' :: Int
  , _treasures' :: [Int]
  }
makeLenses ''GameState

instance Default GameState where
  def = GameState 0 []

pos :: Lens' AppState Int
pos = makeStateLens pos'

treasures :: Lens' AppState [Int]
treasures = makeStateLens treasures'

handleKeypress :: KeyPress -> App ()
handleKeypress (KeyPress c) = do
  case c of
    'a' -> pos -= 1
    'd' -> pos += 1
    _ -> return ()
  collectTreasure

keypressProvider :: EventDispatcher -> IO ()
keypressProvider dispatcher = forever $ do
  c <- getChar
  dispatcher (KeyPress c)

-- A simple tunnel drawing
tunnel :: String
tunnel = replicate 20 '.'

-- Replace the char at a position with a new char
replaceAt :: Char -> Int -> String -> String
replaceAt _ _ [] = []
replaceAt c 0 (_:xs) = c:xs
replaceAt c n (x:xs) = x:replaceAt c (n-1) xs

render :: App ()
render = do
  liftIO $ putChar '\r'
  n <- use pos
  t <- use treasures
  liftIO . putStr . replaceAt '$' n . addTreasures t $ tunnel
    where
      addTreasures t tunnel = foldr (replaceAt '%') tunnel t

timer :: EventDispatcher -> IO ()
timer dispatch = forever $ do
  threadDelay 3000000
  dispatch Timer

collectTreasure :: App ()
collectTreasure = do
  p <- use pos
  t <- use treasures
  when (p `elem` t) (treasures %= delete p)

spawnTreasure :: App ()
spawnTreasure = do
  r <- liftIO $ randomRIO (1, 20)
  treasures %= (r:)

setup :: App ()
setup = do
  liftIO $ do
    hSetBuffering stdin NoBuffering
    hSetBuffering stdout NoBuffering
    hSetEcho stdin False
  asyncEventProvider keypressProvider
  addListener_ handleKeypress
  addListener_ (const spawnTreasure :: Timer -> App ())
  afterEvent_ render
  asyncEventProvider timer

gameLoop :: IO ()
gameLoop = eve_ setup

Keeping score with extensions

It would be pretty easy to use what we've learned so far to keep a 'score' counter in the state and just display it alongside the tunnel, but we're going to show off how Eve allows you to tie in extensions to your application!

Let's generalize over the idea of displaying a score, instead of being specific about displaying a score specifically, let's let extensions add information to the game that they'd like to display, then we'll just collect it all and display it for them. To do this, we'll use the 'return' value from our event listeners.

But let's not get ahead of ourselves, if we're going to allow extensions to keep score, they'll need to know when a piece of treasure was collected. Let's add an event for that.

data TreasureCollected = TreasureCollected

And we fire it when we collect the treasure:

collectTreasure :: App ()
collectTreasure = do
  p <- use pos
  t <- use treasures
  when (p `elem` t) $ do
    treasures %= delete p
    dispatchEvent_ TreasureCollected

Okay! So let's say just for fun that we're going to offload keeping score to an extension, this means that the extension won't have access to any of the GameState (unless we decided to expose it, but in this case we won't)

Usually extensions would be completely different Haskell Libraries, but we'll fake it for now by just putting it in a different file. Let's make Extension.hs in the same directory as Lib.hs, then we'll need to add Extension to the list of exposed modules in our cabal file, here's the spot we need to change:

# tunnel-cralwer.cabal
library
  hs-source-dirs:      src
  exposed-modules:     Lib, Extension # <- Add this 

Okay, cabal (and stack) should be happy now, let's build the score-keeping functionality inside Extension.hs

-- Extension.hs
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TemplateHaskell #-}

module Extension where

import Eve
import Control.Lens
import Lib
import Data.Default

-- We don't have access to GameState so we'll have to keep track of our own!
data Score = Score
  { _score' :: Int
  }
makeLenses ''Score

instance Default Score where
  def = Score 0

score :: Lens' AppState Int
score = makeStateLens score'

addPoint :: App ()
addPoint = score += 1

keepScore :: App ()
keepScore = addListener_ (const addPoint :: TreasureCollected -> App ())

There's a bit of boilerplate for each new state object we define, but usually we'll only need to do this when defining a new extension.

You've seen most of this before, we're using the same lens trick as before to make it easy to work with our score, we add an action to increment the score by one, then we add a helper called keepScore which just registers addPoint as a listener to the TreasureCollected event; it's good practice to package up all of your listeners for an extension into a single App or Action which you export all at once. To simulate adding this extension to our app's configuration we're going to have to change gameLoop a little. Let's rename it to runCrawler and add an argument which is a block of extensions. Delete gameLoop and add this instead:

-- Lib.hs
runCrawler :: App () -> IO ()
runCrawler extensions = eve_ (setup >> extensions)

Conceptually extensions aren't any different from the setup we're doing ourselves, it's just a bunch of listeners and maybe some event providers, so we can just tack it on after our setup and run eve_ with that! Now we need to change it in our app/Main.hs. We'll import our extension and use the new runCrawler function with the keepScore extension we set up! This is how users or app developers would typically customize which extensions are applied.

-- app/Main.hs

module Main where

import Lib
import Extension

-- We add a new do block were we could add all sorts of extensions
main :: IO ()
main = runCrawler $ do
         keepScore
         -- Other extensions would go here!

With that we should have a running extension which keeps track of how much treasure we've collected! This is great and all, but we probably want to actually SHOW it to the user right?? Like I said earlier, we're going to do that in an extensible way too! We're going to program generally and assume that all sorts of extensions might want to display some info about what's going in the game. We're going to do that using an aggregated return value from an event dispatch!

But first, here's a checkpoint (we've got a few files now!):

-- app/Main.hs
module Main where

import Lib
import Extension

main :: IO ()
main = runCrawler $ do
         keepScore
-- src/Extension.hs
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TemplateHaskell #-}

module Extension where

import Eve
import Control.Lens
import Lib
import Data.Default

data Score = Score
  { _score' :: Int
  }
makeLenses ''Score

instance Default Score where
  def = Score 0

score :: Lens' AppState Int
score = makeStateLens score'

addPoint :: App ()
addPoint = score += 1

keepScore :: App ()
keepScore = addListener_ (const addPoint :: TreasureCollected -> App ())
-- src/Lib.hs
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TemplateHaskell #-}
module Lib where

import Eve
import System.IO
import System.Random
import Control.Concurrent
import Control.Lens
import Control.Monad
import Control.Monad.State
import Control.Monad.Trans
import Data.Default
import Data.List

data Timer = Timer
data KeyPress = KeyPress Char
data TreasureCollected = TreasureCollected

data GameState = GameState
  { _pos' :: Int
  , _treasures' :: [Int]
  }
makeLenses ''GameState

instance Default GameState where
  def = GameState 0 []

pos :: Lens' AppState Int
pos = makeStateLens pos'

treasures :: Lens' AppState [Int]
treasures = makeStateLens treasures'

handleKeypress :: KeyPress -> App ()
handleKeypress (KeyPress c) = do
  case c of
    'a' -> pos -= 1
    'd' -> pos += 1
    _ -> return ()
  collectTreasure

keypressProvider :: EventDispatcher -> IO ()
keypressProvider dispatcher = forever $ do
  c <- getChar
  dispatcher (KeyPress c)

-- A simple tunnel drawing
tunnel :: String
tunnel = replicate 20 '.'

-- Replace the char at a position with a new char
replaceAt :: Char -> Int -> String -> String
replaceAt _ _ [] = []
replaceAt c 0 (_:xs) = c:xs
replaceAt c n (x:xs) = x:replaceAt c (n-1) xs

render :: App ()
render = do
  liftIO $ putChar '\r'
  n <- use pos
  t <- use treasures
  liftIO . putStr . replaceAt '$' n . addTreasures t $ tunnel
    where
      addTreasures t tunnel = foldr (replaceAt '%') tunnel t

timer :: EventDispatcher -> IO ()
timer dispatch = forever $ do
  threadDelay 3000000
  dispatch Timer

collectTreasure :: App ()
collectTreasure = do
  p <- use pos
  t <- use treasures
  when (p `elem` t) $ do
    treasures %= delete p
    dispatchEvent_ TreasureCollected

spawnTreasure :: App ()
spawnTreasure = do
  r <- liftIO $ randomRIO (1, 20)
  treasures %= (r:)

setup :: App ()
setup = do
  liftIO $ do
    hSetBuffering stdin NoBuffering
    hSetBuffering stdout NoBuffering
    hSetEcho stdin False
  asyncEventProvider keypressProvider
  addListener_ handleKeypress
  addListener_ (const spawnTreasure :: Timer -> App ())
  afterEvent_ render
  asyncEventProvider timer

runCrawler :: App () -> IO ()
runCrawler extensions = eve_ (setup >> extensions)

Returning Data from Event Listeners

So far all of our event listeners have just been returning (), this is fine and dandy since we haven't really had anything else we'd want to return so far; but now we're faced with a tricky problem: We want to let extensions contribute status info for our game (kind of like a HUD display), but our app doesn't know which extensions will be used and so we can't call a function in the extension to get that info! We also don't really want to let extensions go mucking about in our internal state to add their own info since they might mess up something that we didn't expect. We've got a great tool for this though! Let's see how we can use our event system to 'ask' for things from our extensions.

So far we've just been dispatching events using the asyncEventProvider method, this works pretty great, but sometimes we'll want to dispatch an event from within an App or Action. Eve gives us the functions dispatchEvent and dispatchEvent_ for this. Let's check out their type signatures, (I've simplified the constraints and used more concrete types from what you'd see on Hackage so it's easier to explain).

dispatchEvent :: forall eventType result. (Monoid result) => eventType -> App result
dispatchEvent_ :: forall eventType. eventType -> App ()

Similar to how addListener works we've got two versions, both of them take an event, which they dispatch immediately and all the effects from listeners are triggered. dispatchEvent_ is the one you'll usually be using and it just returns (), but dispatchEvent can have a return value! When you call dispatchEvent Eve will look up all the listeners which match the type signature that's inferred from the context where dispatchEvent is used and runs them all; mappending the results together as it goes. If you're not familiar with what a Monoid is that's okay, just think of it as a type of value which can 'collect' more info by combining with other values of that type.

That's a lot to take in and it's a bit confusing to understand, so let's just get down to it and try it out, maybe that'll clear things up! We want to ask our extensions for whatever status info they want displayed, so let's make an event for that (inside Lib.hs).

-- Lib.hs
data GetStatusInfo = GetStatusInfo

getStatusInfo :: App [String]
getStatusInfo = dispatchEvent GetStatusInfo

We've got our new event, and we also made a little helper function that calls dispatchEvent for us. dispatchEvent has a very general type and sometimes GHC can get confused, so it's usually nice to wrap it up in a new function which specifies a very clear type. Instead of expecting extensions to properly register and listen for this event we'll make a helper for that too. This ensures for example that extensions don't accidentally register an App String when they meant App [String], it's unfortunate, but GHC would be totally fine with them doing that, but the action wouldn't be run by getStatusInfo since it only runs Actions of type App [String] and not App String! Let's make sure extensions do it right:

-- Lib.hs
provideStatusInfo :: App [String] -> App ()
provideStatusInfo app = addListener_ (const app :: GetStatusInfo -> App [String])

Awesome! Now extensions can just use provideStatusInfo and they don't even need to know that GetStatusInfo exists! We can use [String] since it's a Monoid, (all lists are Monoids; when you combine a bunch of lists they just get concatenated together). While we're in the neighborhood, let's set up our render action to print out any status info off to the side of the tunnel. Here's the new render method:

render :: App ()
render = do
  liftIO $ putChar '\r'
  n <- use pos
  t <- use treasures
  statuses <- getStatusInfo
  let renderedTunnel = replaceAt '$' n . addTreasures t $ tunnel
      renderedStatuses = intercalate " | " statuses
  liftIO . putStr $ renderedTunnel ++ renderedStatuses
    where
      addTreasures t tunnel = foldr (replaceAt '%') tunnel t

We get the aggregated list of all statuses from anyone who has used provideStatusInfo by using getStatusInfo, then we seperate each status by " | " and print them after the tunnel. Of course we don't have anything using provideStatusInfo yet, let's just add a line to our setup to print out the position of our crawler in the status info.

setup :: App ()
setup = do
  ...
  provideStatusInfo $ do
    p <- use pos
    return ["Position: " ++ show p]

Cool! Now we should be able to see the position to the right of the tunnel. Let's set all this up so we can show our score back in Extension.hs

-- Extension.hs
displayScore :: App [String]
displayScore = do
  s <- use score
  return ["Score: " ++ show s]

-- We'll register `displayScore` here with `provideStatusInfo`
keepScore :: App ()
keepScore = do
  addListener_ (const addPoint :: TreasureCollected -> App ())
  provideStatusInfo displayScore

Nice work! We don't need to make any changes in Main.hs since it's already using keepScore. It might be a little confusing how everything ties together, but the nice thing is that extensions don't really need to think about it; all they need to do is write an Action which returns the proper data and register it using the right helper! Now new extensions could add their own statuses if they want and we wouldn't have to change anything in the main app, they would just need to be added in Main.hs.

Well, that about wraps it up! There's still a lot of more advanced functionality hiding in Eve when you need it; but this should give you a good start on building something awesome! Here's one last checkpoint for those who skipped ahead; and as I said earlier, if anything in the tutorial didn't compile properly or didn't make sense, go ahead and write up an issue here or let me (Chris) know via gitter chat!

-- app/Main.hs
module Main where

import Lib
import Extension

main :: IO ()
main = runCrawler $ do
         keepScore
-- src/Extension.hs
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TemplateHaskell #-}

module Extension where

import Eve
import Control.Lens
import Lib
import Data.Default

data Score = Score
  { _score' :: Int
  }
makeLenses ''Score

instance Default Score where
  def = Score 0

score :: Lens' AppState Int
score = makeStateLens score'

addPoint :: App ()
addPoint = score += 1

displayScore :: App [String]
displayScore = do
  s <- use score
  return ["Score: " ++ show s]

keepScore :: App ()
keepScore = do
  addListener_ (const addPoint :: TreasureCollected -> App ())
  provideStatusInfo displayScore
-- src/Lib.hs
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TemplateHaskell #-}
module Lib where

import Eve
import System.IO
import System.Random
import Control.Concurrent
import Control.Lens
import Control.Monad
import Control.Monad.State
import Control.Monad.Trans
import Data.Default
import Data.List

data Timer = Timer
data KeyPress = KeyPress Char
data TreasureCollected = TreasureCollected
data GetStatusInfo = GetStatusInfo

data GameState = GameState
  { _pos' :: Int
  , _treasures' :: [Int]
  }
makeLenses ''GameState

instance Default GameState where
  def = GameState 0 []

pos :: Lens' AppState Int
pos = makeStateLens pos'

treasures :: Lens' AppState [Int]
treasures = makeStateLens treasures'

handleKeypress :: KeyPress -> App ()
handleKeypress (KeyPress c) = do
  case c of
    'a' -> pos -= 1
    'd' -> pos += 1
    _ -> return ()
  collectTreasure

keypressProvider :: EventDispatcher -> IO ()
keypressProvider dispatcher = forever $ do
  c <- getChar
  dispatcher (KeyPress c)

-- A simple tunnel drawing
tunnel :: String
tunnel = replicate 20 '.'

-- Replace the char at a position with a new char
replaceAt :: Char -> Int -> String -> String
replaceAt _ _ [] = []
replaceAt c 0 (_:xs) = c:xs
replaceAt c n (x:xs) = x:replaceAt c (n-1) xs

render :: App ()
render = do
  liftIO $ putChar '\r'
  n <- use pos
  t <- use treasures
  statuses <- getStatusInfo
  let renderedTunnel = replaceAt '$' n . addTreasures t $ tunnel
      renderedStatuses = intercalate " | " statuses
  liftIO . putStr $ renderedTunnel ++ renderedStatuses
    where
      addTreasures t tunnel = foldr (replaceAt '%') tunnel t

timer :: EventDispatcher -> IO ()
timer dispatch = forever $ do
  threadDelay 3000000
  dispatch Timer

collectTreasure :: App ()
collectTreasure = do
  p <- use pos
  t <- use treasures
  when (p `elem` t) $ do
    treasures %= delete p
    dispatchEvent_ TreasureCollected

spawnTreasure :: App ()
spawnTreasure = do
  r <- liftIO $ randomRIO (1, 20)
  treasures %= (r:)

getStatusInfo :: App [String]
getStatusInfo = dispatchEvent GetStatusInfo

provideStatusInfo :: App [String] -> App ()
provideStatusInfo app = addListener_ (const app :: GetStatusInfo -> App [String])

setup :: App ()
setup = do
  liftIO $ do
    hSetBuffering stdin NoBuffering
    hSetBuffering stdout NoBuffering
    hSetEcho stdin False
  asyncEventProvider keypressProvider
  addListener_ handleKeypress
  addListener_ (const spawnTreasure :: Timer -> App ())
  afterEvent_ render
  asyncEventProvider timer
  provideStatusInfo $ do
    p <- use pos
    return ["Position: " ++ show p]

runCrawler :: App () -> IO ()
runCrawler extensions = eve_ (setup >> extensions)

Go and build some cool stuff!