mischief-ecs
Safe HaskellNone
LanguageGHC2024

Mischief.ECS.Tutorial.Dungeon

Description

This module walks the user through creating a small game in the terminal.

Previous Chapter: Startup Guide

Next Chapter: App and Plugins

Main Page

Synopsis

    Learn You an ECS for Great Mischief! - 2. Coding a Dungeon Game

    Introduction

    This module will walk you through creating a simple terminal-based dungeon crawler in Mischief. The goal is to have a player which we can freely move on a 2D grid, as well as various objects placed on tiles, such as enemies, weapons, obstacles, etc.

    Creating an App

    Let's start by creating our App and a Main Plugin which will serve as the starting point of all our logic.

    import Mischief.ECS.Prelude
    
    main :: IO ()
    main = do
      app <- newApp MainPlugin
      runApp app
    
    data MainPlugin = MainPlugin deriving (Eq)
    
    instance Plugin MainPlugin where
      init _ = info "Hello World!"
    

    If you run this program you should see "Hello World!" logged to the terminal.

    Spawning the Grid

    The game will play out on a small 2D grid. There are many ways of representing this Mischief, the way I've chosen to do it is by having each tile of the grid be an entity, and the full list of entities stored in a global resource.

    The Tile component will be used to mark which entities are tiles:

    data Tile = Tile deriving (Component)
    

    Each tile will also have a Pos component containing it's position on the grid:

    data Pos = Pos (Int, Int) deriving (Component)
    

    Grid will be the resource containing the (bidimensional) list of all tile entities:

    data Grid = Grid [[Entity]] deriving (Component)
    

    I've written two functions returning the width and height of the grid:

    gridH :: Int
    gridH = 10
    
    gridW :: Int
    gridW = 20
    

    Let's write a system that spawns the tiles and initializes the grid resource:

    spawnGrid :: System ()
    spawnGrid = do
      tiles <-
        for [0 .. gridH - 1] $ \i ->
          for [0 .. gridW - 1] $ \j ->
            spawn (Tile, Pos (i, j))
    
      insertRes $ Grid tiles
    

    insertRes inserts the component as a singleton resource into the ECS. We can grab its value at any time by using res @Grid.

    Now we just need to modify MainPlugin so that it schedules spawnGrid to happen when the app starts.

    import Mischief.ECS.Systems qualified as Systems
    
    instance Plugin MainPlugin where
      init _ = Systems.add Startup spawnGrid
    

    Traversing the Grid

    Next, we need to code a way for traversing between adjacent tiles. Having a tile entity, we should have easy access to the entities found above, below, to the left and right of it.

    First, I've written this function which gets an entity by position:

    getTile :: (Int, Int) -> System (Maybe Entity)
    getTile (x, y) = do
      grid <- res @Grid
      pure $ do
        Grid tiles <- grid
        line <- tiles !? x
        line !? y
    

    Now we can write a function that gets the position of a given tile and finds tiles offset by a certain amount:

    moveBy :: (Int, Int) -> Entity -> System (Maybe Entity)
    moveBy (x, y) entity = do
      Just pos <- get (C @Pos) entity
      let (Pos (x', y')) = value pos
      getTile (x' + x, y' + y)
    

    Queries will be explain in-depth later, but what happens essentially is that, our query returns a Result Pos for the given entity, and we get and unwrap the inner Pos value using the value function.

    We can completely bypass this by just using the Val query transformer which unwraps the Result for us:

    moveBy (x, y) entity = do
      Just (Pos (x', y')) <- get (Val (C @Pos)) entity
      getTile (x' + x, y' + y)
    

    We can compress the code even further by using quasi-queries. They are macros which allow us to write queries in faster, easier ways. They will be explained in a further chapter, but should be pretty easy to understand at an intuitive level. For instance, we can replace the above get with the g quasi-query:

    moveBy (x, y) entity = do
      Just (Pos (x', y')) <- [g|*Pos|] entity
      getTile (x' + x, y' + y)
    

    The * is the quasi equivalent of Val, although it can also just be written as Val or val if you prefer.

    Spawning the Player

    Our game needs a player, so we should have a component that uniquely identifies it:

    data Player = Player deriving (Component)
    

    We should also have a relationship to associate an entity to a tile, teling us that it's currently placed on that tile.

    data OnTile = OnTile deriving (Component)
    

    Additionally, an entity should only be able to be on a single tile at a time. Mischief has a convenient way of doing this hidden in the Component class:

    instance Component OnTile where
      isExclusiveRel = True
    

    This means that if we insert a new OnTile relationship to the player, the old one will be automatically removed.

    Anyway, it's finally time to spawn our player:

    spawnPlayer :: System ()
    spawnPlayer = do
      Just tile <- getTile (5, 5)
      _ <- spawn (Player, Rel OnTile tile)
      pure ()
    

    Rel OnTile tile inserts the (OnTile, tile) relationship on the player.

    We can avoid the pure by just using void to consume the value of spawn:

    spawnPlayer = do
      Just tile <- getTile (5, 5)
      void $ spawn (Player, Rel OnTile tile)
    

    Now we can add the player spawning logic to Startup:

    instance Plugin MainPlugin where
      init _ = do
        Systems.add Startup spawnPlayer
        Systems.add Startup spawnGrid
    

    Except there's something really wrong in the logic above! If we run the app, we will get an error pointing us to the Just tile <- in spawnPlayer. That system assumes getTile will produce a valid result, but that will only happen if the grid is already initialized. Which means we want to guarantee that spawnPlayer happens after spawnGrid.

    We can do this by providing an explicit order when scheduling:

    instance Plugin MainPlugin where
      init _ = do
        Systems.add Startup $ spawnPlayer `after` spawnGrid
        Systems.add Startup spawnGrid
    

    The app should now run without issues!

    Adding Walls

    Our game will also have Walls which can block the player's movement.

    data Wall = Wall deriving (Component)
    

    I've written a system which spawns walls and places them on the tiles around the edge of the grid:

    spawnWall :: (Int, Int) -> System Entity
    spawnWall pos = do
      Just tile <- getTile pos
      spawn (Wall, Rel OnTile tile)
    
    spawnWalls :: System ()
    spawnWalls = do
      for_ [0 .. gridW - 1] $ i -> spawnWall (0, i)
      for_ [0 .. gridW - 1] $ i -> spawnWall (gridH - 1, i)
      for_ [1 .. gridH - 2] $ i -> spawnWall (i, 0)
      for_ [1 .. gridH - 2] $ i -> spawnWall (i, gridW - 1)
    

    The system also needs to be scheduled to run, so MainPlugin now looks like this:

    instance Plugin MainPlugin where
      init _ = do
        Systems.add Startup spawnPlayer `after` spawnGrid
        Systems.add Startup (spawnGrid, spawnWalls)
    

    Additionally, I wrote a system which checks if a given tile has a wall on it:

    hasWall :: Entity -> System Bool
    hasWall tile = do
      walls <- query' E (With (C @Wall, R @OnTile tile))
      pure $ not $ null walls
    

    E just grabs the Entity of all queried entities. With is a query filter that makes it so the query only iterates over entities which have those components (in this case, they must have Wall and must have a OnTile relationship to this precise tile). Note that query' is the filtered version of query.

    Here's the same system but in quasi-notation:

    hasWall tile = do
      walls <- [q|Entity / With (Wall, OnTile -> tile)|]
      pure $ not $ null walls
    

    We can compress it even further using <$>:

    hasWall tile = not . null <$> [q|Entity / With (Wall, OnTile -> tile)|]
    

    Moving the Player

    Next, we should write a system which moves the player from one tile to another.

    First, let's write the actual logic for moving in a certain direction:

    movePlayerBy :: (Int, Int) -> System ()
    movePlayerBy dir = do
      Just player <- single' E (With (C @Player))
    
      Just rel <- get (R @OnTile Any) player
      let tile = rel.target
    
      newTile <- moveBy dir tile
    
      for_ newTile $ t -> do
        wall <- hasWall t
        unless wall $ insert (Rel OnTile t) player
    

    Let's break it down line-by-line.

    First, we get the entity of the player:

    Just player <- single' E (With (C @Player))
    

    single is a variant of query which returns a Maybe based on whether there is exactly one entity matching the query or not. We know there is exactly one player, and we know by this point it should be spawned, so doing the Just player <- unwrapping is fine.

    The query could also be writtten as:

    [s|Entity / With Player|]
    

    Next, we get the entity of the current tile the player is on:

    Just rel <- get (R @OnTile Any) player
    let tile = rel.target
    

    Querying for R OnTile Any will give us a list of all OnTile relationships of the player. However, because earlier we set OnTile to be Exclusive, Mischief knows to only return a single relationship.

    We then use rel.target to get the target entity of the relationship, which, in this case, is the tile we are looking for.

    The get could also be written as:

    [g|OnTile -> *|] player
    

    After, we use the earlier moveDir system to get the new tile the player will be on:

    newTile <- moveBy dir tile
    

    And finally, we unwrap it (moveBy returns a Maybe Entity), check if there is a Wall on it, and if there isn't, move the player to it.

    for_ newTile $ t -> do
      wall <- hasWall t
      unless wall $ insert (Rel OnTile t) player
    

    This could also be written as:

    for_ newTile $ t ->
     hasWall t >>= flip unless (insert (Rel OnTile t) player)
    

    We also need to somehow get input from the user. Mischief exposes some useful functions for this in the following module:

    import Mischief.ECS.Stdin qualified as Stdin
    

    These functions are useful for the purpose of this tutorial but should probably never be used in a released game. Instead, you should import a dedicated haskell input library, or use mischief-input which is based on SDL.

    In order to read input we'll need to add Stdin.init to a plugin, which you'll see a bit later.

    For now, we can just use the Stdin.readLast to empty the input buffer and get the last character typed by the user, if any.

    movePlayer :: System ()
    movePlayer = do
      c <- Stdin.readLast
      for_ c $ case
        'w' -> movePlayerBy (-1, 0)
        's' -> movePlayerBy (1, 0)
        'a' -> movePlayerBy (0, -1)
        'd' -> movePlayerBy (0, 1)
        _ -> pure ()
    

    The for_ just 'iterates' over the Maybe, applying the function if it has a value, and doing nothing otherwise.

    At this point the codebase is starting to grow, so I have decided to create a new PlayerPlugin which handles all the player logic (including the new movement system), and make it a dependency of MainPlugin:

    data MainPlugin = MainPlugin deriving (Eq)
    
    instance Plugin MainPlugin where
      init _ = do
        Stdin.init
        Systems.add Startup (spawnGrid, spawnWalls)
    
      plugins _ = plug PlayerPlugin
    
    data PlayerPlugin = PlayerPlugin deriving (Eq)
    
    instance Plugin PlayerPlugin where
      init _ = do
        Systems.add Startup $ spawnPlayer `after` spawnGrid
        Systems.add Update movePlayer
    

    Displaying the Grid

    It's finally time to actually display our game's grid in the terminal!

    You may remember earlier we wrote a system which checks if there is a wall on a given tile.

    hasWall :: Entity -> System Bool
    hasWall tile = not . null <$> [q|Entity / With (Wall, OnTile -> tile)|]
    

    It'd be useful to have something similar but for any arbitrary type of entity. We can write a generic variant of it like this:

    tileHas :: forall c. (QueryType c) => Entity -> System Bool
    tileHas tile = not . null $ [q|Entity / With (c, OnTile -> tile)|]
    

    QueryType is a sort of catch-all constraint that makes sure c is a component and can be queried (so nothing weird like it being a tuple).

    Make sure to add {-# LANGUAGE AllowAmbiguousTypes #-} at the top of your .hs file, otherwise the type system will not like that c can not be inferred from the function's signature (alternatively you can just pass a Proxy c as a workaround but I personally prefer the other way).

    We can now write hasWall as just:

    hasWall = tileHas @Wall
    

    Now it should be easy to write a system that receives a tile and returns a character to represent it:

    showTile :: Entity -> System Char
    showTile tile = do
      player <- tileHas @Player tile
      wall <- tileHas @Wall tile
    
      pure $
        if
          | player -> '@'
          | wall -> '#'
          | otherwise -> '.'
    

    This requires the MultiWayIf language extension, but there are many ways to write it without it, it's just a personal preference.

    Next, I've written a system which produces a String for the whole grid by calling the previous function on each tile:

    showGrid :: System String
    showGrid = do
      Just (Grid tiles) <- res @Grid
      lines <- for tiles $ traverse showTile
      pure $ unlines lines
    

    All that's left is to write a system that prints the string, and schedule it to happen each frame. We'll do the actual printing via the printClear function of Mischief.ECS.Stdout, which automatically clears the terminal.

    printGrid :: System ()
    printGrid = printClear =<< showGrid
    
    instance Plugin MainPlugin where
      init _ = do
        Stdin.init
        Systems.add Startup (spawnGrid, spawnWalls)
        Systems.add Update printGrid
    

    If you run the app now, you should see the game's grid and we able to use wasd to move the player around!

    ####################
    #..................#
    #..................#
    #..................#
    #..................#
    #....@.............#
    #..................#
    #..................#
    #..................#
    ####################
    

    Generating Random Positions

    For some of the next sections, an ability to choose random tiles would be very useful. So let's work on that.

    I've chosen to use the random package, so just add it as a dependency to your project and import it:

    import System.Random
    import System.Random.Stateful
    

    We need some sort of mutable generator, so I'll create a resource to hold it:

    data Rand = Rand (IOGenM StdGen) deriving (Component)
    
    newGen :: System Rand
    newGen = Rand <$> (newIOGenM =<< initStdGen)
    

    Don't forget to insert the resource!

    instance Plugin MainPlugin where
      init _ = do
        Stdin.init
        Systems.add Startup (spawnGrid, spawnWalls)
        Systems.add Update printGrid
    
        insertRes =<< newGen
    

    Now it's possible to write a system that generates a random position on the grid:

    randomPos :: System (Int, Int)
    randomPos = do
      Just (Rand gen) <- res @Rand
      i <- applyIOGen (uniformR (0, gridH - 1)) gen
      j <- applyIOGen (uniformR (0, gridW - 1)) gen
      return (i, j)
    

    And a system that uses it to get the Entity of a random tile:

    randomTile :: System Entity
    randomTile = unwrap <$> getTile randomPos
    

    unwrap is a utility function provided by Mischief that just grabs the value out of a Maybe, or panics if there is no value. But in this case, we know there will be a value since the provided position is valid.

    Adding Enemies

    It would be a pretty boring game if there were no obstacles. For that reason, we're going to add some enemies.

    Here's the marker component that will be used to identify them:

    data Enemy = Enemy deriving (Component)
    

    I'll use this system to spawn an enemy, using the randomTile function defined earlier:

    spawnEnemy :: System Entity
    spawnEnemy = do
      tile <- randomTile
      spawn (Enemy, Rel OnTile tile)
    

    And this as a driver to handle all enemy spawning (it just spawns 5 enemies):

    spawnEnemies :: System ()
    spawnEnemies = for_ [0 .. 4] $ const spawnEnemy
    

    I've also modified the showTile system to take enemies into account:

    showTile :: Entity -> System Char
    showTile tile = do
      player <- tileHas @Player tile
      enemy <- tileHas @Enemy tile
      wall <- tileHas @Wall tile
    
      pure $
        if
          | player -> '@'
          | wall -> '#'
          | enemy -> '!'
          | otherwise -> '.'
    

    Finally, we need a to schedule the enemy spawning, so I've created a new EnemyPlugin:

    data EnemyPlugin = EnemyPlugin deriving (Eq)
    
    instance Plugin EnemyPlugin where
      init _ = Systems.add Startup spawnEnemies
    

    And added it to the list of plugins added by MainPlugin:

    instance Plugin MainPlugin where
      init _ = ...
    
      plugins = plug (PlayerPlugin, EnemyPlugin)
    

    You should now see something like this when running the app:

    ####################
    #..................#
    #..!...............#
    #....!.!...........#
    #..................#
    #....@.............#
    #..................#
    #..................#
    #........!.!.......#
    ####################
    

    Moving Enemies

    Right now the enemies just sit there. Let's make them move!

    But first, I'd like to present you a new concept: transitive queries.

    Up until now, if we wanted the position of the tile of the player we'd do somehing like:

    Just tile <- single' (R @OnTile Any) (With (C @Player))
    Just pos <- get (Val (C @Pos)) tile.target
    

    We'd do a query to get the player's relationship to the tile, then do another query on the actual tile to get its position.

    But this could also be written as:

    Just pos <- single' (R @OnTile (Q (Val (C @Pos)))) (With (C @Player))
    

    Instead of getting all relationship, we use the Q marker to run the given query on each target of the relationship. So we transitively get the position of the tile through its relationship to the player.

    If you think this is uglier than just the two queries earlier, don't worry, the quasi notation looks much better:

    Just pos ['s'|OnTile - (*Pos) / With Player|]
    

    Don't forget to put the () around *Pos!

    You should now be able to understand this system that handles the movement of the enemies:

    moveEnemies :: System ()
    moveEnemies = do
      Just pos ['s'|OnTile - (*Pos) / With Player|]
    
      enemies ['q'|Entity, OnTile - (Entity, *Pos) / With Enemy|]
      for_ enemies $ (enemy, (enemyTarget, enemyPos)) -> do
    
      diff <- decideEnemyDir enemyPos pos
    
      newTile <- moveBy diff enemyTile
      for_ newTile $ t -> do
        insert (Rel OnTile t) enemy
    

    With this helper function for deciding which direction to move on, based on the player's position.

    decideEnemyDir :: Pos -> Pos -> System (Int, Int)
    decideEnemyDir (Pos (ex, ey)) (Pos (px, py)) = do
      pure $
        if
          | ex > px -> (-1, 0)
          | ey > py -> (0, -1)
          | ex - (1, 0)
          | ey - (0, 1)
          | otherwise -> (0, 0)
    

    And don't forget to schedule it:

    instance Plugin EnemyPlugin where
      init _ = do
        Systems.add Startup spawnEnemies
        Systems.add Update moveEnemies
    

    Except there's a small problem. If you run the app now, you may notice you don't see any enemies!

    That's because they all already got to the player and are hiding behind it! We've set moveEnemies to happen every frame, and our frames are happening almost instantly. So we need to add some sort of timing to the enemy's movement.

    There are many cleaner high-level solutions to fix this, some of them even using async systems, but instead, I'll take the opportunity to introduce you to an important notion, Time!

    In order to use Time utilities, you need to add the TimePlugin to your app, so I'll add it to our MainPlugin:

    plugins = plug (PlayerPlugin, EnemyPlugin, TimePlugin)
    

    In any system you can use the deltaTime function to get the number of seconds passed since the last frame.

    Mischief also provides a hnady way of keeping track of time via the Timer.

    import Mischief.ECS.Timer (Timer)
    import Mischief.ECS.Timer qualified as Timer
    

    We can now create a Cooldown component which stores a Timer.

    data Cooldown = Cooldown {timer :: Timer} deriving (Component)
    

    We want this to always be on every Enemy, so we can make it a required component of the Enemy component.

    instance Component Enemy where
      required = require @Cooldown
    

    In order to have that compile, we also need to provide a Default instance for Cooldown:

    instance Default Cooldown where
      def = Timer.new 1 Timer.Repeat
    

    Timer.new takes a Float (the duration of the timer), and a Mode which is either Repeat or Once.

    Cooldown should now automatically be on every enemy.

    We can use the Timer.tick function to advance the state of a timer. It returns the new state, along with a Bool that tells us whether the timer has just finished or not.

    All that's left is to put all of this together:

    moveEnemies :: System ()
    moveEnemies = do
      Just pos ['s'|OnTile - (*Pos) / With Player|]
      delta <- deltaTime
    
      enemies ['q'|Entity, OnTile - (Entity, *Pos), Cooldown / With Enemy|]
      for_ enemies $ (enemy, (enemyTile, enemyPos), cooldown) -> do
        let (timer, finished) = Timer.tick delta cooldown.timer
        set cooldown $ Cooldown timer
    
        when finished $ do
          diff <- decideEnemyDir enemyPos pos
    
          newTile <- moveBy diff enemyTile
          'for_ newTile $ t -> do
            insert (Rel OnTile t) enemy
    

    Each frame, we tick the cooldown timer of each enemy, and only move them if that timer has just finished. This means every enemy will now move only once per 0.5 seconds.

    Don't forget to use the set to pass the new timer back into the ECS! In Mischief, all variables you use are immutable, so you need to explicitly order mutations.

    You can now run your app and see the enemies chasing you!

    Enemy Collision

    Right now the enemies just kinda overlap each other and go under the player. We can fix that by preventing them to move.

    Now, let's write a function that tells us whether a certain tile is free to move on or not:

    tileIsFree :: Entity -> System Bool
    tileIsFree tile = do
      wall <- tileHas @Wall tile
      enemy <- tileHas @Enemy tile
      player <- tileHas @Player tile
      pure $ not (wall || enemy || player)
    

    Plus, an extra one which takes the tile's position directly rather than the entity:

    tileAtPosIsFree :: (Int, Int) -> System Bool
    tileAtPosIsFree pos = do
      tile <- getTile pos
      maybe (pure False) tileIsFree tile
    

    And let's integrate it into the system which decides the enemy's movement direction:

    decideEnemyDir :: Pos -> Pos -> System (Int, Int)
    decideEnemyDir (Pos (ex, ey)) (Pos (px, py)) = do
      left <- tileAtPosIsFree (ex - 1, ey)
      up <- tileAtPosIsFree (ex, ey - 1)
      right <- tileAtPosIsFree (ex + 1, ey)
      down <- tileAtPosIsFree (ex, ey + 1)
    
      pure $
        if
          | ex > px && left -> (-1, 0)
          | ey > py && up -> (0, -1)
          | ex && right - (1, 0)
          | ey && down - (0, 1)
          | otherwise -> (0, 0)
    

    (There are definitely much better ways to write this but I can't really be bothered, feel free to make it cleaner at home)

    I've also replaced the hasWall in the movePlayerBy function with tileIsFree, so the player can collide with enemies as well (make sure to also replace the unless with when!).

    The game should now have fullly working collision and feel much more solid!

    Health

    Here's a simple one: let's add a Health component to the player and have it be displayed under the grid each frame.

    I'll also give it a Default instance so it can be required by the Player component.

    data Health = Health {hp :: Int} deriving (Component)
    
    instance Default Health where
      def = Health 100
    
    instance Component Player where
      required = require @Health
    

    I've written a system that returns a string for the health:

    showHealth :: System String
    showHealth = do
      Just health <- [s|Health / With Player|]
      pure $ "Health: " ++ show health.hp
    

    And I added it to the printing system:

    printGrid :: System ()
    printGrid = do
      grid <- showGrid
      health <- showHealth
      printClear $ health ++ "\n" ++ grid
    

    In case you're thinking about it, yes, Health could just be a resource, I just decided to make it a component.

    Your game should now print the health at the top:

    Health: 100
    ####################
    #............!.....#
    #.....!............#
    #..................#
    #..................#
    #....@!......!..!..#
    #..................#
    #..................#
    #..................#
    ####################
    

    Quitting

    It feels weird that the player can reach 0 health but the game just keeps running. So let's add some logic for quitting:

    onDamage :: Damage -> System ()
    onDamage dmg = do
      player <- [s|(Entity, Health) / With Player, Without Invincible|]
    
      for_ player $ (entity, health) -> do
        modify health $ (Health x) -> Health $ max (x - dmg.amount) 0
    
        insert Invincible entity
        delay 1000000 $ remove (C @Invincible) entity
    
        when (health.hp == 0) $ liftIO exitSuccess
    

    But you may notice, the player actually takes an extra hit before that condition is triggered. That's because the health variable is immutable. When we call modify, we update the live value of the component, but our local variable stays as it is.

    We can use update to get the live value:

    Just health <- update health
    when (health.hp == 0) $ liftIO exitSuccess
    

    The logic should now work as expected.

    Taking Damage

    Let's add behavior for enemies damaging the player. I'll also take this opportunity to introduce you to Events.

    We can create a damage event like this:

    data Damage = Damage deriving (Event)
    

    We also need an observer system for it:

    onDamage :: Damage -> System ()
    onDamage dmg = do
      Just health <- [s|Health / With Player|]
      modify health $ (Health x) -> Health $ max (x - dmg.amount) 0
    

    In order to activate the system when the event is triggered, we need to spawn an observer for it. I'll do it in the PlayerPlugin:

    import Mischief.ECS.Observers as Observers
    
    instance Plugin PlayerPlugin where
      init _ = do
        Systems.add Startup $ spawnPlayer `after` spawnGrid
        Systems.add Update movePlayer
        void $ Observers.spawn onDamage
    

    The last thing we need is a way for enemies to trigger the event. I made a system which checks if an enemy is adjacent to the player and triggers the event:

    tryDamage :: System ()
    tryDamage = do
      Just player <- [s|OnTile -> (*Pos) / With Player|]
      enemies <- [q|OnTile -> (*Pos) / With Enemy|]
    
      for_ enemies $ pos -> do
        when (isAdjacent pos player) $ do
          trigger (Damage 5)
    

    With this helper function:

    isAdjacent :: Pos -> Pos -> 'Bool
    isAdjacent (Pos (x1, y1)) (Pos (x2, y2)) =
      let dx = abs (x1 - x2)
          dy = abs (y1 - y2)
       in (dx == 1 && dy == 0) || (dx == 0 && dy == 1)
    

    I've scheduled tryDamage to happen every frame, after both the player and enemies have moved:

    instance Plugin EnemyPlugin where
      init _ = do
        Systems.add Startup spawnEnemies
        Systems.add Update moveEnemies
        Systems.add Update $ tryDamage `after` moveEnemies `after` movePlayer
    

    This now technically works, except that the enemies almost instantly kill the player on contact. That's because they deal damage every frame, the same issue we had when they were moving each frame.

    I'll show you a different way to solve this problem. We can add an Invincible component on the player after being hit once, which causes it to not receive damage, and which is removed after a delay.

    data Invincible = Invincible deriving (Component)
    

    I'll modify the onDamage observer like so:

    onDamage :: Damage -> System ()
    onDamage dmg = do
      player <- [s|(Entity, Health) / With Player, Without Invincible|]
    
      for_ player $ (entity, health) -> do
        modify health $ (Health x) -> Health $ max (x - dmg.amount) 0
    
        insert Invincible entity
        delay 1000000 $ remove (C @Invincible) entity
    

    Let's analyze it.

    This line queries the player's Entity and Health, but only if they don't have the Invincible component.

    player <- [s|(Entity, Health) / With Player, Without Invincible|]
    

    If the query returned something (if the player isn't invincible), it will take damage.

    modify health $ (Health x) -> Health $ max (x - dmg.amount) 0
    

    Then the Invincible component will be inserted on the player.

    insert Invincible entity
    

    And finally, we use the delay async function to tell Mischief to run remove the component after a delay (in miliseconds):

    delay 1000000 $ remove (C @Invincible) entity
    

    Now, the player will only be able to take damage once per second!

    Spawning Coins

    Now, for our last bit of logic, we should add a reason for the player to not die. Let's spawn a bunch of coins!

    First, we need a marker component for the coins:

    data Coin = Coin deriving (Component)
    

    Second, here's a system that spawns a coin on a random free tile:

    spawnCoin :: System ()
    spawnCoin = do
      tile <- randomTile
      free <- tileIsFree tile
      if free
        then
          void $ spawn (Coin, Rel OnTile tile)
        else
          spawnCoin
    

    (If the tile is not free, it will just keep looping and generating tiles until it finds one that is).

    Third, we can use intervals to make the system repeat every two seconds.

    import Mischief.ECS.Interval qualified as Interval
    
    instance Plugin MainPlugin where
      init _ = do
        Stdin.init
        Systems.add Startup (spawnGrid, spawnWalls)
        Systems.add Update printGrid
    
        interval <- Interval.start 2000000 spawnCoin
    
        insertRes =<< newGen
    

    You can also use Interval.stop on the returned value to stop the interval at any point, but I won't be doing that here.

    Finally, we should display the coins:

    showTile :: Entity -> System Char
    showTile tile = do
      player <- tileHas @Player tile
      enemy <- tileHas @Enemy tile
      wall <- tileHas @Wall tile
      coin <- tileHas @Coin tile
    
      pure $
        if
          | player -> '@'
          | wall -> '#'
          | enemy -> '!'
          | coin -> '$'
          | otherwise -> '.'
    

    Collecting Coins

    All that's left is letting the player collect coins and keeping track of how many they got.

    I'll do this via a resource this time.

    data Coins = Coins Int deriving (Component)
    

    I'll insert the resource in MainPlugin:

    instance Plugin MainPlugin where
      init _ = do
        Stdin.init
        Systems.add Startup (spawnGrid, spawnWalls)
        Systems.add Update printGrid
    
        interval <- Interval.start 2000000 spawnCoin
    
        insertRes =<< newGen
        insertRes $ Coins 0
    

    And I'll update the display to also show the number of coins:

    showCoins :: System String
    showCoins = do
      Just (Coins c) <- res @Coins
      pure $ "Coins: " ++ show c
    
    printGrid :: System ()
    printGrid = do
      grid <- showGrid
      health <- showHealth
      coins <- showCoins
      printClear $ health ++ "\n" ++ grid ++ "\n" ++ coins ++ "\n"
    

    And now finally, a system that checks if there are any coins on the same tile as the player, despawns them, and increments the resource:

    collectCoins :: System ()
    collectCoins = do
      Just playerTile ['s'|OnTile - (Entity) / With Player|]
      coins ['q'|Entity / With OnTile - playerTile, With Coin|]
    
      Just (Coins c) <- res @Coins
      'insertRes $ Coins $ c + length coins
    
      for_ coins despawn
    

    And I'll schedule it:

    instance Plugin PlayerPlugin where
      init _ = do
        Systems.add Startup $ spawnPlayer `after` spawnGrid
        Systems.add Update movePlayer
        Systems.add Update $ collectCoins `after` movePlayer
    
        void $ Observers.spawn onDamage
    

    And that's it! Out player should now be able to collect coins!

    Health: 5
    ####################
    #..................#
    #..................#
    #$....!!!!!@...$...#
    #$.................#
    #..................#
    #......$...........#
    #.$................#
    #..................#
    ####################
    Coins: 16
    

    Next Steps

    Don't worry if there are different details you haven't understood yet. The next chapters will go into details over the many aspects of the ECS. This chapter was just meant to give you a general idea of working with Mischief.

    Next Chapter: App and Plugins