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: Common Patterns.

Main Page

Synopsis

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

    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, coins, obstacles, etc.

    If you'd rather go straight to learning about particular solutions or technical details rather than learning by example, feel free to skip to the next chapters. You can always come back to this one once you have a better understanding of things.

    Each section in this chapter adds its own isolated mechanics to the game, so there's no harm in reading up to a point and taking a break or starting another chapter.

    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
      addPlugin @MainPlugin app
      runApp app
    
    data MainPlugin
    
    instance Plugin MainPlugin where
      init = pure ()
    

    You can already run your program and it should work! Although it doesn't do or print anything.

    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.

    We'll use this Tile component 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)
    

    Let's make a resource that stores the list of all tile entities, and call it Grid:

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

    We're also writing these two helper 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
    

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

    instance Plugin MainPlugin where
      init = do
        systems spawnGrid
          & schedule @Startup
    

    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, given the Grid:

    getTile :: (Int, Int) -> Grid -> Maybe Entity
    getTile (x, y) (Grid tiles) = do
      line <- tiles !? x
      line !? y
    

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

    moveBy :: (Int, Int) -> Pos -> Grid -> Maybe Entity
    moveBy (x, y) (Pos (x', y')) = getTile (x' + x, y' + y)
    

    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
    
    instance Component OnTile where
      type IsExclusiveRel OnTile = True
    

    Setting IsExclusiveRel to True on the component instance will make it so there can only be one such relationship on an entity at a time. In our case, this means the player can only be on one tile at a time, and moving them will remove the previous relationship.

    Now it's finally time to spawn our player:

    spawnPlayer :: System ()
    spawnPlayer = do
      Just grid <- res @Grid
      let Just tile = getTile (5, 5) grid
      void $ spawn (Player, Rel OnTile tile)
    

    Rel OnTile tile inserts the (OnTile, tile) relationship on the player. Tile (5, 5) is just an arbitrary tile I chose to place the player on.

    Now we can schedule the player spawning logic to Startup:

    instance Plugin MainPlugin where
      init = do
        systems spawnGrid
          & schedule @Startup
    
        systems spawnPlayer
          & schedule @Startup
    

    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 grid <- in spawnPlayer. We are assuming the grid already exists when that system runs, 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 spawnGrid
          & schedule @Startup
    
        systems spawnPlayer
          & after spawnGrid
          & schedule @Startup
    

    The app should now run without issues!

    Adding Walls

    Our game will also have Walls which can block the player's movement. We'll make yet another marker component to represent them:

    data Wall = Wall deriving (Component)
    

    Let's write a system which spawns a wall on a specific position:

    spawnWall :: (Int, Int) -> System (Maybe Entity)
    spawnWall pos = do
      Just grid <- res @Grid
      for (getTile pos grid) $ \tile ->
        spawn (Wall, Rel OnTile tile)
    

    And another system that spawns walls on all the tiles at the edge of the grid:

    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 spawnGrid
          & schedule @Startup
    
        systems (spawnPlayer, spawnWalls)
          & after spawnGrid
          & schedule @Startup
    

    Additionally, it would be helpful to write a system which checks if a given tile has a wall on it:

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

    We are querying for all entities which have a Wall component and a OnTile relationship to the given entity, and checking whether the list is not null or not.

    But it would be even more useful to have something similar but for any arbitrary component. We can write a generic variant of it like this:

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

    Make sure to add {-# LANGUAGE AllowAmbiguousTypes #-} at the top of your .hs file, otherwise the type checker will not like that c can not be inferred from the function's input.

    We can now write hasWall as just:

    hasWall :: Entity -> System Bool
    hasWall = tileHas @Wall
    

    Displaying the Grid

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

    Let's start by writing a system that receives a tile and returns a character to represent it based on what's placed on 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 alternate ways to write it; it's just a personal preference.

    Next, let's write a system which produces a String to represent 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, after each print.

    printGrid :: System ()
    printGrid = printClear =<< showGrid
    

    And we need to schedule if, of course.

    instance Plugin MainPlugin where
      init = do
        Stdin.init
    
        systems spawnGrid
          & schedule @Startup
    
        systems (spawnPlayer, spawnWalls)
          & after spawnGrid
          & schedule @Startup
    
        systems printGrid
          & schedule @Update
    

    If you run the app now, you should see the game's grid in your terminal!

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

    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 grid <- res @Grid
      [q|OnTile -> (Pos) / With Player|]
        & qmapMaybe (\pos -> moveBy dir pos.comp grid)
        & qfilterM (\_ newTile -> not <$> hasWall newTile)
        & qinsert (\newTile -> Rel OnTile newTile)
        & query_
    

    qmapMaybe tries to get the new tile, after which we run a filter to check if we the tile has a wall on it. Finally we qmap the tile to the new relationship. As you've seen before, qmap automatically runs an insertion of the returned components.

    Receiving Input

    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
    
    instance Plugin MainPlugin where
      init = do
        Stdin.init
    
        systems spawnGrid
          & schedule @Startup
    
        systems spawnWalls
          & after spawnGrid
          & schedule @Startup
    
        systems printGrid
          & schedule @Update
    
      deps = [dep @PlayerPlugin]
    
    data PlayerPlugin
    
    instance Plugin PlayerPlugin where
      init = do
        systems spawnPlayer
          & after spawnGrid
          & schedule @Startup
    
        systems movePlayer
          & schedule @Update
    

    You should now be able to move the player around when running the game!

    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 spawnGrid
          & schedule @Startup
    
        systems spawnWalls
          & after spawnGrid
          & schedule @Startup
    
        systems printGrid
          & schedule @Update
    
        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 = do
      Just grid <- res @Grid
      randomPos <- randomPos
    
      pure . unwrap $ getTile randomPos grid
    

    unwrap is a utility function provided by Mischief that just grabs the value out of a Maybe, or panics if there is no value. 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
    
    instance Plugin EnemyPlugin where
      init = do
        systems spawnEnemies
          & after spawnGrid
          & schedule @Startup
    

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

    instance Plugin MainPlugin where
      init = ...
    
      deps = [dep @PlayerPlugin, dep @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!

    Fist, I've written this helper System that receives an enemy's position, the player's position, and decides the direction the enemy will move in. (yes, it technically could be a pure function but you'll see why it's a System a bit later).

    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 < px -> (1, 0)
          | ey < py -> (0, 1)
          | otherwise -> (0, 0)
    

    Then, I wrote this system that grabs the player's position and then iterates over all enemies to move them:

    moveEnemies :: System ()
    moveEnemies = do
      Just (From _ playerPos) <- single [q|OnTile -> (Pos) / With Player|]
      Just grid <- res @Grid
    
      [q|OnTile -> (Pos) / With Enemy|]
        & qtraverse (\_ pos -> (,pos) <$> decideEnemyDir pos.comp playerPos)
        & qmapMaybe (\(diff, pos) -> moveBy diff pos.comp grid)
        & qinsert (Rel OnTile)
        & query_
    

    If the lambdas get overwhelming you can always create intermediary functions that work on queries:

    qdecideEnemyTile :: Grid -> Pos -> Query System (From Pos) -> Query System Entity
    qdecideEnemyTile grid playerPos x =
      x
        & qtraverse (\_ pos -> (,pos) <$> decideEnemyDir pos.comp playerPos)
        & qmapMaybe (\(diff, pos) -> moveBy diff pos.comp grid)
    

    So our system is now just:

    moveEnemies :: System ()
    moveEnemies = do
      Just (From _ playerPos) <- single [q|OnTile -> (Pos) / With Player|]
      Just grid <- res @Grid
    
      [q|OnTile -> (Pos) / With Enemy|]
        & qdecideEnemyTile grid playerPos
        & qinsert (Rel OnTile)
        & query_
    

    And now to schedule our enemy movement:

    data EnemyPlugin
    
    instance Plugin EnemyPlugin where
      init = do
        systems spawnEnemies
          & after spawnGrid
          & schedule @Startup
    
        systems moveEnemies
          & schedule @Update
    

    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:

    deps = [dep @PlayerPlugin, dep @EnemyPlugin, dep @TimePlugin]
    

    We'll now import the Time module which contains various utlities for keeping track of time, including Time.delta which returns the time, in seconds, that has passed between frames.

    import Mischief.ECS.Time qualified as Time
    

    Mischief also provides a handy way of keeping track of time via the Timer. This module contains functions to work with it.

    import Mischief.ECS.Timer qualified as Timer
    

    A Timer is an object that can be ticked down each frame, to trigger certain conditions only once a cerain amount of time has passed.

    Let's 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 = Cooldown $ 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 added on every enemy with the default value.

    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. I've written this intermediary function to use with queries that will filter the query so that each enemy can only attack when their cooldown has just finished:

    qfilterCooldown :: Query System a -> Query System a
    qfilterCooldown x = do
      x
        & qextend [qd|Cooldown|] (,)
        & qfilterM
          (\entity (_, Cooldown timer) -> do
              delta <- Time.delta
              let (timer', justFinished) = Timer.tick delta timer
              insert (Cooldown timer') entity
              pure justFinished
          )
        & qmap fst
    

    First we use qextend to also query for the Cooldown of the current entity. Then we run a small impure filter that gets the delta, updates the Timer, modifies the value of Cooldown by re-inserting it on the entity, and filters based on whether the timer had just finished or not.

    Notice that this time we're using qd instead of q. That's because qextend expects a query data.

    We also use a final qmap to get the query back to its original data. This makes it extremely generic!

    Here's the new moveEnemies function, with the timer-based filter:

    moveEnemies :: System ()
    moveEnemies = do
      Just (From _ playerPos) <- single [q|OnTile -> (Pos) / With Player|]
      Just grid <- res @Grid
    
      [q|OnTile -> (Pos) / With Enemy|]
        & qfilterCooldown
        & qdecideEnemyTile grid playerPos
        & qinsert (Rel OnTile)
        & query_
    

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

    Enemy Collision

    Right now the enemies just kind of 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
      Just grid <- res @Grid
      maybe (pure False) tileIsFree (getTile pos grid)
    

    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 < px && right -> (1, 0)
          | ey < py && down -> (0, 1)
          | otherwise -> (0, 0)
    

    (There are definitely much better ways to write this, feel free to experiment and 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.

    movePlayerBy :: (Int, Int) -> System ()
    movePlayerBy dir = do
      Just grid <- res @Grid
      [q|OnTile -> (Pos) / With Player|]
        & qmapMaybe (\pos -> moveBy dir pos.comp grid)
        & qfilterM (const tileIsFree)
        & qinsert (Rel OnTile)
        & query_
    

    The game should now have fully 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 <- single [q|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
    

    Your game should now print the health at the top:

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

    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 {amount :: Int} deriving (Event)
    

    We also need an observer system for it:

    onDamage :: Damage -> System ()
    onDamage dmg = do
      [q|Health / With Player|]
        & qinsert (\(Health x) -> Health $ max (x - dmg.amount) 0)
        & query_
    

    We also need to spawn an observer that listens to the world and triggers the event. I'll do so from the player plugin:

    instance Plugin PlayerPlugin where
      init = do
        systems spawnPlayer
          & after spawnGrid
          & schedule @Startup
    
        systems movePlayer
          & schedule @Update
    
        void $ spawn (Observer onDamage)
    

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

    tryDamage :: System ()
    tryDamage = do
      adjacentEnemies <-
        [q|OnTile -> (Pos) / With Player|]
          & qjoin (\pos -> [q|OnTile -> (Pos) / With Enemy|] & qfilter (\a -> isAdjacent a.comp pos.comp)) (,)
          & query
    
      unless (null adjacentEnemies) $ 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)
    

    The qjoin function maps each of our positions into an entirely new query, filtering the entities of that query based on the position. This is actually a less powerful bind operation, as we'll see in the next chapter.

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

    instance Plugin EnemyPlugin where
      init = do
        systems spawnEnemies
          & after spawnGrid
          & schedule @Startup
    
        systems moveEnemies
          & schedule @Update
    
        systems tryDamage
          & after moveEnemies
          & after movePlayer
          & schedule @Update
    

    This now technically works, except that the enemies almost instantly defeat 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 new way to solve this problem. We'll 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
      [q|Health / With Player, Without Invincible|]
        & qinsert (\(Health x) -> (Health $ max (x - dmg.amount) 0, Invincible))
        & qtap (\e _ -> delay 1000000 $ remove (C @Invincible) e)
        & query_
    

    We are now only querying the player only if they are not invincible. We are then inserting the Invincible components on them, and removing it after a fixed delay of 1 second. qtap is the function used to apply an arbitrary side effect over the query's elements.

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

    Quitting

    It feels weird that the player can reach 0 health but the game just keeps running. So I've added an extra instruction into our qtap exits the program when the hp reaches 0.

    onDamage :: Damage -> System ()
    onDamage dmg = do
      [q|Health / With Player, Without Invincible|]
        & qinsert (\(Health x) -> (Health $ max (x - dmg.amount) 0, Invincible))
        & qtap
          ( \e (Health hp) -> do
              delay 1000000 $ remove (C @Invincible) e
              when (hp == 0) $ liftIO exitSuccess
          )
        & query_
    

    But you may notice, the player actually takes an extra hit before that condition is triggered. That's because the result of the function from qinsert is not actually propagated further in the query. If you look at the signature of qinsert you'll notice it actually ends in: Query m a -> Query m a. We can instead use the qmodify function which maps the values, inserts them, and propagates them:

    onDamage :: Damage -> System ()
    onDamage dmg = do
      [q|Health / With Player, Without Invincible|]
        & qmodify (\(Health x) -> (Health $ max (x - dmg.amount) 0, Invincible))
        & qtap
          ( \e (Health hp, _) -> do
              delay 1000000 $ remove (C @Invincible) e
              when (hp == 0) $ liftIO exitSuccess
          )
        & query_
    

    The logic should now work as expected.

    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. Each containing a value:

    data Coin = Coin Int 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 5, 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. They're a convenient utility provided by Mischief.

    import Mischief.ECS.Interval qualified as Interval
    
    instance Plugin MainPlugin where
      init = do
        Stdin.init
    
        systems spawnGrid
          & schedule @Startup
    
        systems spawnWalls
          & after spawnGrid
          & schedule @Startup
    
        systems printGrid
          & schedule @Update
    
        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 have collected.

    Let's make another component which will be held on the player, containing the total amount of coins they've collected:

    data Coins = Coins Int deriving (Component, Generic, Default)
    

    And make it another required component of Player:

    instance Component Player where
      required = require @(Health, Coins)
    

    And then update the display to also show the number of coins:

    showCoins :: System String
    showCoins = do
      Just (Coins c) <- single $ [q|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
      players <- query [q|Entity, OnTile -> (Entity), Coins / With Player|]
      for_ players $ \(player, From _ tile, Coins coins) -> do
        collected <-
          [q|Coin / With OnTile -> tile|]
            & qtap (\e _ -> despawn e)
            & query
    
        insert (Coins $ foldr (\(Coin x) -> (+ x)) coins collected) player
    

    We iterate over all players, then query all coins that share the same tile as the current player, while despawning them. We then do a foldr to sum the value of all coins over the Coins of the player and re-insert it. There are much cleaner, less imperative, ways of writing this, some of which will be covered in the next chapter.

    Now to schedule it:

    instance Plugin PlayerPlugin where
      init = do
        systems spawnPlayer
          & after spawnGrid
          & schedule @Startup
    
        systems movePlayer
          & schedule @Update
    
        systems collectCoins
          & after movePlayer
          & schedule @Update
    
        void $ spawn (Observer 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 various details that you haven't fully understood yet. The next chapters will go into detail over the many aspects of the ECS. This chapter was just meant to give you a general idea of working with Mischief. You can find the whole code for this example here.

    Next Chapter: Common Patterns