mischief-ecs
Safe HaskellNone
LanguageGHC2024

Mischief.ECS.Tutorial.Startup

Description

This module walks the user through setting up Mischief and creating a simple app.

Next Chapter: Coding a Dungeon Game

Main Page

Synopsis

    Learn You an ECS for Great Mischief! - 1. Startup Guide

    This chapter will guide you through setting up a working Mischief app and performing some simple operations on data.

    What do I need to know?

    This book doesn't assume any knowledge of other game engines or programming paradigms, but it does expect some Haskell knowledge.

    While it is possible to read this book and get a pretty good idea of what Mischief is and how it works, you'll have a much better time if you have at least a very basic understanding of Haskell syntax.

    Setup

    In order to use Mischief, you'll first need to install GHC and cabal. You can follow this quick-start guide in order to do that.

    After you have a new project set up, just add mischief-ecs under build-depends in you .cabal file.

    We recommend using GHC2024 as the language standard (set in your .cabal file).

    Language Extensions

    We generally recommend using the following language extensions in a Mischief project:

    DeriveAnyClass
    DuplicateRecordFields
    NoFieldSelectors
    DerivingStrategies
    OverloadedRecordDot
    MultiWayIf
    OverloadedStrings
    QuasiQuotes
    RequiredTypeArguments
    TypeFamilyDependencies
    

    QuasiQuotes and OverloadedStrings are especially important because some Mischief features are not available without them (namely quasi-queries and logging). The rest of the extensions are highly optional.

    You can paste these extensions in the default-extensions field of your .cabal file.

    Text

    Mischief uses Text instead of String where possible, including in its logging system. The i macro from string-interpolate is re-exported by Mischief and will be often used for logging in this tutorial.

    Sometimes functions from Data.Text are used as well (such as T.show), so it's recommended to add text as a dependency to your project and to qualify it when you want to use it:

    import Data.Text qualified as T
    

    The ECS

    Mischief's ECS logic is designed to be very approachable and simple to write.

    Components are just types deriving the Component typeclass.

    data Position = Position {x :: Float, y :: Float} deriving (Component)
    

    Systems are functions in the System monad.

    updatePositions :: System ()
    updatePosition =
      [q|Position, Velocity|]
        & qinsert ((Position p, Velocity v) -> (Position p + v))
        & query_
    

    Entities are opaque ids used to represent and manipulate data.

    data Entity = Entity Int
    

    The App

    A Mischief program usually starts with creating an App and adding a plugin to it. So let's do that!

    import Mischief.ECS.Prelude
    
    main :: IO ()
    main = do
      app <- newApp
      addPlugin @MyPlugin app
      runApp app
    
    data MyPlugin
    
    instance Plugin MyPlugin
    

    Your First System

    Copy the following function into your file:

    helloWorld :: System ()
    helloWorld = info "Hello World!"
    

    This will be our first system. It just logs a message saying "Hello World!". The only remaining step is to schedule it to run!

    instance Plugin MyPlugin where
      init :: System ()
      init = do
         systems helloWorld
           & schedule @Update
    

    The systems function will grab the system for us, and schedule @Update will add it to the Update schedule, making it run once per frame. If you run your app again, you will see "Hello World!" printed to your terminal many, many times.

    Your First Component

    Let's do a little more than greeting the whole world, let's greet some individual people!

    In ECS, you would generally model people as entities with a set of components that define them. Let's start with a simple Person component:

    data Person = Person deriving (Component)
    

    So how can we give people names? In a more traditional design you could just add a name :: String field to Person. But the ECS makes you think of it differently! A Name is just a piece of data that can be attached to anything. A dog could also have a name. So why not just make a Name component?

    data Name = Name String deriving (Component)
    

    No need to write this one though, since this exact Name is already defined internally by Mischief and exported by the Prelude.

    Now that we can represent people with names, let's make a system that spawns some:

    addPeople :: System ()
    addPeople = do
      kim <- spawn (Person, Name "Kimberly")
      nick <- spawn (Person, Name "Nicholas")
      flo <- spawn (Person, Name "Florian")
      pure ()
    

    You can register it to run on the app's Startup schedule, making it run only once, at the start:

    instance Plugin MyPlugin where
      init = do
        systems helloWorld
          & schedule @Update
    
        systems addPeople
          & schedule @Startup
    

    Your First Query

    If you run your app, the people will be spawned but we aren't doing anything with them yet! Let's make a system that greets them:

    greetPeople :: System ()
    greetPeople = do
      people <- query [q|Name, Person|]
      for_ people $ \(name, _) -> do
        info [i|Hello #{name}!|]
    

    The above query function will grab the Name and Person of every entity. We then iterate over them in order to greet them.

    The Person component however, is only queried to ensure we are querying the right entities. We don't care about its value at all! So we can instead write it as a filter to limit the types of entities selected by the query and save us the trouble of carrying an extra variable around.

    greetPeople :: System ()
    greetPeople = do
      people <- query [q|Name / With Person|]
      for_ people $ \name ->
        info [i|Hello #{name}!|]
    

    With Person is a filter, telling our query builder to only select entities with the Person component. We use / to separate the data that we're querying from the filters.

    Additionally, Mischief lets you write and process queries by piping dedicated functions into each other. For instance, our earlier function is equivalent to:

    greetPeople :: System ()
    greetPeople = do
      [q|Name / With Person|]
        & qinfo (\name -> [i|Hello #{name}!|])
        & query_
    

    The quasi-query ([q|..|]) produces our query, we then use qinfo to display a message to the terminal for each element in the query, and finally we use query_ to run all the commands and discard the results (the normal query returns the results).

    Now we can schedule this system to also run:

    instance Plugin MyPlugin where
      init = do
        systems (helloWorld, greePeople)
          & schedule @Update
    
        systems addPeople
          & schedule @Startup
    

    Running our app will result in the following output:

    [INFO] Hello World!
    [INFO] Hello Kimberly!
    [INFO] Hello Nicholas!
    [INFO] Hello Florian!
    

    Note that "Hello World" might show above or beneath the others. That's because systems in the same schedule can run in any order unless they are explicitly ordered.

    Your First Mutation

    If we want to change the name of some people, we can apply a mutation to a value obtained from the query:

    updateFlo :: System ()
    updateFlo = do
      people <- query [q|Entity, Name / With Person|]
      for_ people $ \(entity, name) -> do
        when (name == Name "Florian") $
          insert (Name "Florianne") entity
    

    We are querying the name of each entity (Name), along with its actual id (Entity). We then iterate over all the names, and once we see "Florian", we re-insert the component, changing its value to "Florianne". Re-insertion is the main way to mutate data in Mischief.

    Although.. that feels awfully imperative doesn't it? Let's rewrite the same system, this time using piping:

    updateFlo :: System ()
    updateFlo = do
      [q|Name / With Person|]
        & qfilter (== Name "Florian")
        & qinsert (\_ -> Name "Florianne")
        & query_
    

    This time we apply a filter over our Query, leaving only those entities with their name set to "Florian". We then use qinsert to map the old name to the new one and insert it on the entity.

    Let's add the new system to a schedule:

    init = do
       systems (helloWorld, greetPeople)
         & schedule @Update
    
       systems addPeople
         & schedule @Startup
    
       systems updateFlo
         & before greetPeople
         & schedule @Update
    

    Note that we have explicitly ordered updateFlo to happen before greetPeople. We want to only greet Flo after their name has changed! Running the app should now show "Hello Florianne!" instead of "Florian".

    Your First Resource

    Resources are a great way to store global information that can be easily written to and read in any system.

    Let's say we want to have a custom greeting that we can change at runtime. We could store that in a resource:

    data Greeting = Greeting String deriving (Component)
    

    Yes, resources are just normal components!

    We'll also give it a Show instance to make printing it easier:

    instance Show Greeting where
      show (Greeting a) = a
    

    Let's insert a greeting from our init system:

    init = do
       systems (helloWorld, greetPeople)
         & schedule @Update
    
       systems addPeople
         & schedule @Startup
    
       systems updateFlo
         & before greetPeople
         & schedule @Update
    
      insertRes (Greeting "Hey")
    

    insertRes inserts the corresponding resource into the World.

    And let's modify greetPeople so that it uses the current greeting from the resource:

    greetPeople :: System ()
    greetPeople = do
      Just greeting <- res @Greeting
    
      [q|Name / With Person|]
        & qinfo (\name -> [i|#{greeting} #{name}!|])
        & query_
    

    You should now see this when running the app:

    [INFO] Hello World!
    [INFO] Hey Kimberly!
    [INFO] Hey Nicholas!
    [INFO] Hey Florianne!
    

    Your First Relationship

    Relationships in Mischief are pairs made up of a Component and an Entity. Let's implement a simple relationship between our entities that says which like which.

    We'll start by defining a component:

    data Likes = Likes deriving (Component)
    

    Let's now modify our spawning system to also insert relationships between our three entities. We can insert a relationship using the Rel keyword.

    addPeople :: System ()
    addPeople = do
      kim <- spawn (Person, Name "Kimberly")
      nick <- spawn (Person, Name "Nicholas")
      flo <- spawn (Person, Name "Florian")
    
      insert (Rel Likes kim) flo
      insert (Rel Likes nick, Rel Likes flo) kim
    

    We've now made flo like kim, and we've made kim like both nick and flo!.

    Your First Transitive Query

    We now have relationships but we aren't doing much with them. What about having a system that displays the name of each entity, along with the name of all entities they like?

    We can make use of a mechanism called a transitive query, which look like this:

    showLikes :: System ()
    showLikes = do
      [q|Name, Likes -> (Name)|]
        & qinfo (\(name, likes) -> [i|#{name} likes #{likes}|])
        & query_
    

    Pretty cool, huh?

    Now let's schedule our new system to run:

    init = do
       systems (helloWorld, greetPeople, showLikes)
         & schedule @Update
    
       systems addPeople
         & schedule @Startup
    
       systems updateFlo
         & before (greetPeople, showLikes)
         & schedule @Update
    
      insertRes (Greeting "Hey")
    

    We should now see these additional lines printed to the terminal:

    [INFO] Florianne likes [From (42v1, Kimberly)]
    [INFO] Kimberly likes [From (44v1, Nicholas),From (45v1, Florianne)]
    

    Explanation: From

    You may have noticed earlier, when we query for -> (Name) and then print the names, we don't get the actual names, but instead something that looks like From (42v1, Kimberly).

    That's because, when doing transitive queries, the components come wrapped in this:

    data From c = From {comp :: c, entity :: Entity}
    

    They have a different origin entity than the other components in the query, and this is our main way of keeping track of that.

    To get rid of the wrapper we can just change the query to this:

    showLikes :: System ()
    showLikes = do
      [q|Name, Likes -> (Name)|]
        & qinfo (\(name, likes) -> [i|#{name} likes #{map (.comp) likes}|])
        & query_
    

    You can find more information on this in the Components Chapter.

    What's Next?

    What you learn next is up to you.

    The next chapter will have you working on a little dungeon game in the terminal and introduce you to more notions. If you prefer to learn by example I would recommended checking that out.

    Then there's a chapter which goes over different situations and problems you may encounter and describes various solutions to them.

    After that, there are chapters giving you a technical overview for various parts of the ECS (Components, Queries, Systems, and so on).

    Next Chapter: Coding a Dungeon Game