Behaviors
A behavior is a concurrently executing block of code that is attached to an entity. A behavior might be responsible for a player's movement, a bullet's trajectory, or a countdown timer, for example. A single entity often has many behaviors. A behavior automatically gets attached to the current entity (this) when it is created. When an entity despawns, all of its behaviors are automatically stopped.
In some programming languages, the term fiber is used to describe a lightweight thread of execution that runs concurrently with the rest of the program. Easel has fibers too, but we call them behaviors because they are part of entities in the game world. This allows them to be managed in an integrated way with everything else in the game.
There are four types of behavior blocks. Simple behavior blocks concurrently execute a block of code straight away. In addition to this, on, once and with blocks define blocks of code that are concurrently executed when certain events occur.
behavior
A behavior
block executes concurrently with the rest of the program.
This allows for multiple streams of logic to run at the same time.
pub fn World.Example() {
behavior { // Starts a concurrent block of code
loop {
Transmission { "Jump!" }
await Tick(5s)
}
}
Transmission { "Simon says:" }
}
// Outputs "Simon says:" once, and "Jump" every 5 seconds
once
A once
block creates a concurrent behavior that first awaits an expression, then executes its block of code once.
If the this
entity despawns before the awaited expression returns, the block of code will never be executed.
pub fn World.Example() {
once Tick(1s) {
Transmission { "world!" }
}
Transmission { "Hello" }
}
// Outputs "Hello", then "world!" after 1 second
on
An on
block runs each time an event occurs.
Specifically, on
creates a concurrent behavior that awaits its expression, then executes its block of code,
then loops back to the start of the block where it repeats the process.
This repeats forever until its this
entity despawns or a break
statement is encountered.
pub fn World.Example() {
on Tick(1s) {
Transmission { "and again" }
}
Transmission { "Hello again" }
}
// Outputs "Hello again", then "and again" every second
with
A with
block runs once unconditionally, then each time an event occurs.
Specifically, with
creates a concurrent behavior that executes its block of code once unconditionally (without evaluating its expression),
then loops back to the start where it awaits its expression before executing its block of code again.
This repeats forever until its this
entity despawns or a break
statement is encountered.
with
blocks are used to setup an initial state and then update the state each time it changes.
pub prop this.Health
pub fn World.Example() {
Health = 100
with Health {
Transmission { "Health: " + Health }
}
Health = 50
}
// Outputs "Health: 100" then "Health: 50"
What can be awaited?
on
, once
and with
blocks call await
on their expression, and so can only be used with functions declared with an await fn
specifier.
If called on a function that does not have an await
specifier, the compiler will error.
See await to learn more about what can be awaited.
pub await fn WaitForOneSecond() { // must be an `await fn`
await Tick(1s)
}
pub fn World.Example() {
on WaitForOneSecond {
// ...
}
}
Using return values from await expressions
once
and on
blocks can receive return values from the expression they await
by declaring a space-separated list of parameters before the body.
// must be an `await fn` to be used with `on`
pub await fn WaitForMessage() -> string {
await Tick(1s)
return "Hello, world!"
}
pub fn World.Example() {
// `message` takes its value from the return value of `WaitForMessage`
on WaitForMessage message {
Transmission { %(message) }
}
}
// Outputs "Hello, world!" every second
The values are destructured the same way as subblock parameters and so may be declared in a different order to the return values of the expression. That is, first they are destructured by name if possible, and then by position.
// must be an `await fn` to be used with `on`
pub await fn WaitForPerson() -> name, age {
await Tick(1s)
return "Augustus", 25
}
pub fn World.Example() {
// `age` and `name` are destructured by name so can be out-of-order
on WaitForPerson age name {
Transmission { "Hello " + name + ", you are " + age + " years old!" }
}
}
// Outputs "Hello Augustus, you are 25 years old!" every second
with
s (not once
or on
) specifically do not receive return values from the expression they await.
This is because they skip execution of their await expression the first time they run
and so do not have return values the first time.
Awaiting multiple expressions
A once
, on
or with
block can await multiple expressions, separated by commas.
The block will execute when the first expression returns.
The other expressions will be terminated.
The block will receive the return value from the first expression only if the expression returns a single value,
otherwise it will receive undefined.
pub await fn WaitForACarrot() -> vegetable {
await Tick(1s)
return "Carrot"
}
pub await fn WaitForAPotato() -> vegetable {
await Tick(2s)
return "Potato"
}
pub fn World.Example() {
on WaitForACarrot, WaitForAPotato vegetable {
Transmission { %(vegetable) }
}
}
// Outputs "Carrot" every 1 seconds
Lifespan
Every behavior is owned by the current entity (this).
When this
despawns, all of its behaviors are terminated.
pub field vegetable.NumMessages
pub fn Example() {
Spawn vegetable { // `this` is the vegetable entity
NumMessages = 0
on Tick(1s) {
Transmission { "Potato" }
NumMessages += 1
if NumMessages >= 3 {
Despawn // despawn the vegetable
}
}
}
}
// Outputs "Potato", "Potato", "Potato" then stops
Behavior replacement
Only one behavior with a given ID may exist on an entity at a time. When a behavior is created on a given entity, it will replace a previous behavior that was previously created on that entity with the same ID. If an ID is not specified for a behavior, a unique ID will be automatically generated. These rules work exactly the same as with other components with IDs, but they will be restated here specifically for behaviors for clarity.
A behavior may be given an explicit ID by specifying it in angle brackets after the behavior
keyword.
If you are using an once
, on
or with
block,
insert the behavior
keyword before the once
, on
or with
keyword.
pub fn Example() {
behavior<hello> on Tick(1s) {
Transmission { "Hello" }
}
behavior<hello> on Tick(1s) { // replaces previous behavior<hello>
Transmission { "HELLO" }
}
behavior<world> on Tick(1s) { // different ID, so creates a new behavior
Transmission { "world!" }
}
}
// Outputs "HELLO" and "World" every one second
If a behavior has no explicit ID, then an ID will be automatically generated for it. Normally, entities need many behaviors, and this makes it easy to add as many behaviors as you need to an entity without needing to manually set their ID.
When using automatic IDs, a unique ID is assigned to each line of code. This can cause one situation which may be surprising. That is, calling the behavior block in a loop will replace the previous instance.
pub fn Example() {
// This loop does not create 5 separate behaviors.
// Instead it keeps replacing the same behavior with a new one.
// Only the last created behavior will be active.
for i in RangeInclusive(0, 5) {
// This is like: behavior<auto123> on Tick(1s)
// The behavior gets replaced each time the loop repeats
// because it always has the same ID
on Tick(1s) {
Transmission { %("The number is " + i) }
}
}
}
// Outputs "The number is 5" every one second
Deleting behaviors
Behaviors can be deleted using the delete
keyword, followed by an ID to identify the behavior.
pub fn Example() {
Spawn potato {
behavior<sayWhoIAm> on Tick(1s) {
Transmission { "I am a Potato" }
}
await Tick(5s)
delete behavior<sayWhoIAm>
}
}
Execution order
Easel is a concurrent but not parallel language. Only one behavior is executing at once.
Easel executes everything depth-first.
When you start a behavior, control immediately switches to the new behavior.
When the behavior goes to sleep (for example, due to hitting an await Tick
),
control returns back to the original behavior.
The following code will display the numbers 1
to 4
in order:
pub fn Example() {
Transmission { "1" }
behavior {
Transmission { "2" }
await Tick(1s)
Transmission { "4" }
}
Transmission { "3" }
}
The "depth-first" rule is useful because it means you can rely upon any setup code inside a Spawn
block to have executed before
returning back to the caller. For example:
let x = Spawn {
this.name = "Bob"
}
Transmission { "My name is " + x.name } // Displays "My name is Bob"
This works because the "depth-first" order-of-execution rule means
everything inside the Spawn
block executes before returning to the Transmission
.
Massive Concurrency
A game often consists of many entities, each with many behaviors, and Easel is designed to handle this efficiently.
When a behavior encounters an await
, it goes to sleep and does not take up any processing power.
Even if there are thousands of concurrent behaviors running at once,
often only a small percentage of them wake up each tick, making Easel very efficient.
Easel's concurrent programming model allows Easel games to be written in a more natural way, with each independent stream of logic able to be laid out linearly step-by-step.