A framework for creating simulations of systems consisting of autonomous actors interacting with each other (e.g. an ecosystem). See live demos.
NOTE: This is not yet published to packages.elm-lang.org so below instructions won't work. I'm still waiting for community feedback before I publish. If you have any, please share it here (open an issue) or via Elm Discourse.
Below is a concept for the future installation procedure. If you want to play with this framework then simply clone the repository and follow the tutorial below. You don't need to install anything.
It's an Elm package. Install it the usual way:
elm install tad-lispy/ecosystem-simulationFor physical calculations (length, velocity, mass, etc.) we are using excellent Units and Geometry packages by @ianmackenzie and to control colors the Color package by @avh4. You will need them too. Here is how to install these packages:
elm install ianmackenzie/elm-units
elm install ianmackenzie/elm-geometry
elm install avh4/elm-colorI think it's good to explain this framework by walking you through an example simulation. This tutorial is written with an Elm beginner (maybe even a non-programmer) in mind. If you would rather jump straight into API docs then head to the package page (TODO: not ready yet, sorry).
Let's build a simple simulations. Start by installing Elm and setting up a project:
elm initThen install this package and other dependencies as described [above in the Installation section](#installation and create src/Main.elm with the following code inside:
TODO: Once the package is published we can use Ellie for this tutorial.
module Main exposing (main)
import Color exposing (Color)
import Dict
import Ecosystem exposing (ActorUpdate, Change(..), Id, Spawn)
import Environment exposing (Environment)
import Interaction exposing (Interaction)
import Length exposing (Length, meters)
import Quantity exposing (zero)
import Speed exposing (metersPerSecond)
import Vector2d exposing (Vector2d)
main =
Ecosystem.simulation
{ size = meters 500
, updateActor = updateActor
, paintActor = paintActor
, paintBackground = always Color.black
, init = init
, gatherStats = always (Dict.singleton "Void" 0)
, statsRetention = zero
}
init =
[]
updateActor id this environment =
{ velocity = Vector2d.zero
, change = Unchanged
, spawn = []
, interactions = []
}
paintActor actor =
Ecosystem.Dot
{ size = meters 0.2
, fill = Color.lightBlue
, stroke = Color.blue
}This should be enough to compile our program. Later I will discuss the code above, but first let's just try to run it. Enter the following command in the terminal:
elm reactorIn your browser open http://localhost:8000/src/Main.elm - you should see a perfectly empty screen. That's ok since we don't have any actors in our system yet. So, what is the meaning of all of this?
First let's look at the main value. It's a standard Elm program. You can read more about it in the Official Elm Guide. We construct it by calling the Ecosystem.simulation function and passing it a record of type Ecosystem.Setup actor action. We will get to the actor and the action type parameters later.
The types are very important concept in Elm and sometimes I explain things in terms of types. Usually they are uppercase words (Like
Setup,Length, etc). If I give type annotations (like below) it goes like this: first the name of the value (e.g.size) then a colon (:) and then type (e.g.Length). Don't wory if you don't understand the type system very well yet. You should be able to follow the tutorial anyway. Just glance over the fragments about types and come back later when you are more familiar with the concept.
The setup record consists of seven fields:
-
size : LengthThe simulation takes place on a square wrapped plane. This field defines how long is the edge of this square. Basically how big is the simulated world. To express the size in meters we need the
Lengthmodule imported on top of our file and itsmetersfunction. -
init : List (Spawn actor action)The initial setup of the simulation. We will discuss the
Spawn actor actiontype in a moment. The number of elements in this list will translate to number of actors present at the beginning of the simulation. Later these actors can spawn new actors or remove themselves, but we need to start the whole show somehow. Since the list is empty, there are no actors - hence the empty screen. -
updateActor : Id -> actor -> Environment actor action -> Update actor actionThis function will be called for each actor on every frame of the simulation. It will be passed three arguments:
-
The id of this actor
-
The state of this actor
-
The data about the environment
We can use this data to get information about the environment such as:
-
other actors and their positions relative to this one,
-
interactions concerning this actor,
-
how much time has passed since last update
so we can calculate rates of changes, like velocity etc.
It will be discussed in details later.
-
The
updateActorfunction must return a record of typeActorUpdate actor actionwith the following fields:change : Change actorvelocity : Vector2d MetersPerSecond Coordinatesinteractions : List (Interaction action)spawn : List (Spawn actor action)
We will discuss it in a moment (I promise!)
-
-
paintActor : actor -> ImageHow should your actor be represented on the screen. Image is a type with two variants:DotandText. For now we can use aDotvariant with a following record:{ size : Length -- the radius of the dot , fill : Color -- color of the inside , stroke : Color -- color of the outline (outer 20%) }
-
paintBackground : Duration -> ColorThis function controls the appearence of the background. It has access to a duration of the simulation (how much time has passed since the start) and has to return a color.
-
gatherStats : List actor -> StatsTODO: Describe how gatherStats work
-
statsRetention : DurationTODO: Describe how statsRetention works
Ok, so what is this whole Spawn actor action type? It's a record:
{ actor : actor
, displacement : Vector2d Meters Coordinates
, interactions : List (Interaction action)
}The actor field controls what is the initial state of the new actor. We will need to come up with some type of value that will describe our actors.
The displacement controls how far and in what direction the new actor will spawn relative to its parent. The initial actors have no parents, so they will spawn in an arbitrary point (let's call it the origin). You can use displacement to move them away from this point, so that if you have more than one actor, they don't end up all in one place.
Newly spawned actors can immediately interact with other actors. You could use the interactions field for that, but for now let's not do this. It's a list, so we can simply set it to [] - an empty list.
We have established some new concepts and should be ready to add actors to our simulation. First we will need to provide concrete types to actor and action type parameters that we saw everywhere. Let's define them. The simplest type in Elm is called unit. It looks like this: (). There is only one possible value of this type that is also called unit and looks like this: (). Seriously. So let's define our custom types. Put the following in your code right after the all the imports:
type alias Actor =
()
type alias Action =
()Now we can plug these types in to our program:
init =
[ { actor = ()
, displacement = Vector2d.zero
, interactions = []
}
]That should be enough to produce a single actor - although without much agency yet. Reload the browser and observe a single motionless blue dot in the middle of the screen.
Let's place another actor five meters to the west of the first one.
init =
[ ...
, { actor = ()
, displacement = Vector2d.meters -5 0
, interactions = []
}
]The
...means that you should just leave the same code as there was before - don't type it literally.
And now we have two perfectly still actors. Let's give them some agency starting with movement. We can do it using the velocity field of the ActorUpdate Actor Action record. Velocity is a single value expressing speed and direction. We will need to decide on both of them.
Let's choose a speed then. We can pick any value we want here. I like when things are moving fast - let's say 5 m/s. Then a direction. Again, anything will work, but we got to choose something. So let them move left. In terms of our coordinate systen it will be Direction2d.positiveX. Here is how we need to change the updateActor function to express this:
updateActor id this environment =
let
speed =
metersPerSecond 5
velocity =
Vector2d.withLength speed Direction2d.positiveX
in
{ velocity = velocity
, change = Unchanged
, spawn = []
, interactions = []
}To make this work we need to import the Speed and Direction2d modules. Add these lines where the rest of the imports are (around line 3):
import Speed exposing (metersPerSecond)
import Direction2dReload the browser and your actors should march across the screen at a steady pace of 5m per second. By this I mean 5m in their world - on your screen it will be much smaller distance. Everything is scaled. Notice that once they reach the "edge" they will re-appear on the other side. That's what I'm talking about when saying that the simulation happens on a wrapped plane. It works the same for left-right and up-down movement. In fact for the actors there is no such thing as the edge of the world - just like there is no such thing as the edge of the earth for us (take that, flat-earthers π).
You can follow their movement by pressing one of the w a s d keys. If you click on one of the actors, the viewport will automatically follow it. Since there is no points of reference, it will look like they are not moving. With - and + buttons you can zoom in and out. With p you can play and pause the simulation. With shift + p you can step one frame (16ms) forward.
Time to discuss the ActorUpdate actor action type. It is what we are returning from updateActor function on every frame of the simulation. It's a record that describes what happens to each actor. Here is its type:
{ velocity : Vector2d MetersPerSecond Coordinates
, change : Change actor
, interactions : List (Interaction actor)
, spawns : List (Update actor action)
}The velocity field controls in which direction and how fast is the actor moving in this frame.
Don't worry about the
Coordinatestype parameter - if you ever need to provide it just useEcosystem.Coordinates.
The change controls how the internal state of the actor changes. It's a union type with the following variants:
-
Unchangedthe actor remains as it was
-
Changed actorthe actor changes. Its new state is the tagged value of type
actorso in our case it can only be(). No way to change really. But later we will see how actors can change their state or even morph into completely different kinds of actors (e.g. fromFly { annoyence: Float }intoSpotOnTheWall { howDifficultWillItBeToWipeIt: Float }. -
Removedthe actor is to be removed from the simulation.
The interactions field describes all actions taken by this actor towards other actors. We will cover interactions later. For now we will just assign an empty list here i.e there will be no interactions.
The spawns field allows an actor to create new actors. It's a List of Spawn actor action values that I described above when discussing the init value.
I hope you didn't find it too difficult so far, but also let's agree that it's not a very interesting simulation yet. Our actors are pretty dumb and stubborn. How about giving them the following behaviours:
-
They will move away from each other, as if disgusted by one another.
-
If there is no other actor in 50m radius, they will spawn a single, equally disgusting child.
Sounds like fun? Let's analyse the problem. First the movement.
Actor needs to know where is the nearest other actor. Fortunately this information is provided to the updateActor function as the third argument called the environment. We can get positions of other actors by calling the Environment.actors funtion with the environment value.
It has a following signature:
Environment.actors : Environment actor action -> List GroupWe can read it like this:
Environment.actorsis a function that takes one argument of typeEnvironment actor action(a type with two parameters, in our case it'sEnvironment Actor Actionwhich is the same asEnvironment () ()). When given this argument it will return a value of typeList Group- list of groups.
It will give us a list of all other actors grouped into clusters. Each group comes with a position relative to this actor (the one who calls the function) and a list of its members (List Ecosystem.Id). Here we are only interested in the position of the nearest group. We can get it by sorting the list by distance and taking the first element (the head of the list):
nearest =
environment
|> Environment.actors
|> List.sortBy
(.position
>> Vector2d.length
>> Length.inMeters
)
|> List.headThe
|>is the function application operator sometimes called the pipe or the pizza operator. The>>is the function composition operator. You can read more about them in the official Elm documentation.
Once we have the result as nearest then we can define direction as:
direction =
nearest
|> Maybe.map .position
|> Maybe.andThen Vector2d.direction
|> Maybe.map Direction2d.reverseWe already defined a speed before, so we should have everything to re-define the velocity - speed together with direction:
velocity =
direction
|> Maybe.map (Vector2d.withLength speed)
|> Maybe.withDefault Vector2d.zeroWhat with all the
Maybes?First it may be that there are no other actors in this simulation. Then the list of groups would be empty and its head would be
Nothing. Sonearestis aMaybe Group.Second maybe concerns the direction. If the other actor is exactly in the same spot as this one then there would be no sense to talk about a direction (what's the direction from here to here?) and the
Vector2d.directionfunction would returnNothing. Truth be told it will never happen - if another actor is at the same point as this one it will be skipped by theEnvironment.groupsfunction. But the type system doesn't understand this. SoVector2d.directionreturns aMaybe Direction2dand we need to deal with it. We combine the two maybes into one using cleverMaybe.andThenfunction. If either of them isNothingthen the actor won't move (thevelocityis set toVector2d.zero). If both the nearest group and the direction exists then we use them to calculate velocity.
That should solve the movement issue. Let's plug it into the let block and see. The whole updateActor function should look like this:
updateActor id this environment =
let
speed =
metersPerSecond 5
nearest =
environment
|> Environment.actors
|> List.sortBy
(.position
>> Vector2d.length
>> Length.inMeters
)
|> List.head
direction =
nearest
|> Maybe.map .position
|> Maybe.andThen Vector2d.direction
|> Maybe.map Direction2d.reverse
velocity =
direction
|> Maybe.map (Vector2d.withLength speed)
|> Maybe.withDefault Vector2d.zero
in
{ change = Unchanged
, velocity = velocity
, interactions = []
, spawn = []
}In the browser we should observe that the two actors move away from each other until they are half the world apart. At this stage they start to shake. So strong is their mutual repulsion that they go to the opposite sides of their world and still try to go further away. Unfortunately every step brings them closer together, so immediately they take a step back. Hell is other actors.
Sometimes they start chasing each other around the world in a kind of lockstep.
Ok, now what about spawning? Let's consider where should an actor place its beloved child. Of course away from those nasty other actors. Simplest thing is to spawn away from the nearest group.
But wait! What if there is no other group (nearest is Nothing)? If there is no "others" then there is no "away". We need to make some design decisions. We could just decide that the actor should remain the only one in the simulation. Why spoil the perfect state of loneliness? But that would be rather boring for us to watch. So why not spawn four nasty little actors and let them run away in all directions? Let's do that!
In the let block define spawn as follows.
case direction of
Nothing ->
[ { actor = this
, displacement = Vector2d.meters 3 0
, interactions = []
}
, { actor = this
, displacement = Vector2d.meters 0 3
, interactions = []
}
, { actor = this
, displacement = Vector2d.meters -3 0
, interactions = []
}
, { actor = this
, displacement = Vector2d.meters 0 -3
, interactions = []
}
]Now we need to handle the case where there are other actors. We only want to spawn if they are further away than 50m. So we need to know the distance to the nearest group. We can do it like this:
case direction of
Nothing ->
...
Just away ->
if
nearest
|> Maybe.map .position
|> Maybe.withDefault Vector2d.zero
|> Vector2d.length
|> Quantity.greaterThan (Length.meters 50)
then
[ { actor = this
, displacement =
Vector2d.withLength
(Length.meters 2)
away
, interactions = []
}
]
else
[]Again we had to deal with a maybe. Remember that
nearestis aMaybe Group.
We have to add new import:
import Quantity exposing (zero)Here is the complete code for the demo program:
module Main exposing (main)
import Color
import Dict
import Direction2d
import Duration exposing (minutes)
import Ecosystem exposing (Change(..))
import Environment
import Length exposing (meters)
import Quantity exposing (zero)
import Speed exposing (metersPerSecond)
import Vector2d
main : Ecosystem.Program Actor Action
main =
Ecosystem.simulation
{ size = meters 500
, updateActor = updateActor
, paintActor = paintActor
, paintBackground = always Color.black
, init = init
, gatherStats = always (Dict.singleton "Void" 0)
, statsRetention = zero
}
type alias Actor =
()
type alias Action =
()
init =
[ { actor = ()
, displacement = Vector2d.zero
, interactions = []
}
]
updateActor id this environment =
let
speed =
metersPerSecond 5
nearest =
environment
|> Environment.actors
|> List.sortBy
(.position
>> Vector2d.length
>> Length.inMeters
)
|> List.head
direction =
nearest
|> Maybe.map .position
|> Maybe.andThen Vector2d.direction
|> Maybe.map Direction2d.reverse
velocity =
direction
|> Maybe.map (Vector2d.withLength speed)
|> Maybe.withDefault Vector2d.zero
spawn =
case direction of
Nothing ->
[ { actor = this
, displacement = Vector2d.meters 3 0
, interactions = []
}
, { actor = this
, displacement = Vector2d.meters 0 3
, interactions = []
}
, { actor = this
, displacement = Vector2d.meters -3 0
, interactions = []
}
, { actor = this
, displacement = Vector2d.meters 0 -3
, interactions = []
}
]
Just away ->
if
nearest
|> Maybe.map .position
|> Maybe.withDefault Vector2d.zero
|> Vector2d.length
|> Quantity.greaterThan (meters 50)
then
[ { actor = this
, displacement =
Vector2d.withLength
(meters 2)
away
, interactions = []
}
]
else
[]
in
{ change = Unchanged
, velocity = velocity
, interactions = []
, spawn = spawn
}
paintActor actor =
Ecosystem.Dot
{ size = meters 1
, fill = Color.white
, stroke = Color.green
}Let's reload the browser and see! Hopefully they behave the way we wanted them to. And if we wait long enough the actors will fill the space more or less evenly and stop reproducing. That's interesting because we didn't directly program them to do so. We have just instilled a deeply rooted hatred to one another and strong urge to reproduce. And here they are conquerring the world and exploiting every last bit of it. Maybe there is a lesson here?
This is what I would call an emerging property. We program micro behaviours of actors and observe macro trends in the system.
If you want to share your simulation, run the following command in the terminal:
elm make Main.elmIt will create a file called index.html that you can open in your browser, send to a friend or publish on-line.
I hope you didn't find it too difficult to follow so far. If something in this tutorial is not clear or just wrong please reach out to me by opening an issue or via Elm slack. I'm Tad Lispy there too.
TODO: Next we will introduce a second kind of actor with a different attitude and play with interactions. We will also talk about gathering and retaining statistics about our actors and changing the appearence of actors and their environment.
There are several goals and constraints that drive the development of this system. I will discuss them below.
This system was created mostly for fun and I hope it will remain fun to use it for simulations development.
I hope it can be used by hobbysts (for fun) or in education (programming, ecology, systems thinking). This means that it should be easy to set up. Preferabely development should not require anything else than Elm compiler (and tools coming with it) or a service like Ellie and a modern web browser. Results should be easy to share (publish on-line, etc.)
Applications should focus on programming actors. The macro behaviour of a system should be an amerging property of micro behaviours of actors. We program behaviours and watch the trends emerging.
Smooth animations, nice colors, crispy shapes. It should be attractive and fun to play with. Let's try to keep kids interested in it long enough for them to learn something.
-
Richer environment
Global state of the ecosystem (e.g. climate) and local conditions of the environment (e.g. weather).
-
Statistics
In the app there should be some simple graphs (population, etc. - partially done). There should also be a way to export data for more advanced analytics with tools like Jupyter Notebook.
-
Embedding simulations
A way to run and control the simulation as part of your own Elm application.