Skip to main content

Rounds

Games often consist of a sequence of levels, rounds, waves or stages, where each one is harder than the last. Let's see how we can implement this in Easel.

We will divide Astroblast into waves of asteroids. With each wave, the asteroids will get faster, becoming more and more difficult to dodge. The challenge is for the player to see how many waves they can survive!

Game loop

In Easel, when we have a game loop that consists of a repeating sequence of steps, we want to organize it using a loop block, with each round being a function that we await on before continuing to the next step. This way, it is easy to see our step-by-step sequence by reading the code from top to bottom.

Let's start with writing the main game loop, and then we will fill in the details.

  1. Select main.easel.
  2. In your Main function, find the line AsteroidFieldSystem
  3. Replace it with the following highlighted snippet:
pub game fn World.Main(maxHumanPlayers=5) {
// ...

SpawnEachPlayer owner {
// ...
}

await Spawn dialog {
WaitingForPlayersDialog
}

// DELETE this line: AsteroidFieldSystem
let wave = 1
loop {
PrintWaveMsg(wave=)
await PlayAsteroidRound
wave += 1
}
}

Next we will create the two new functions PrintWaveMsg and PlayAsteroidRound that we are now referring to in our new game loop.

Displaying the wave number

Let's create the PrintWaveMsg function, which will display a message at the start of each wave to let the player know which wave they are on.

Select main.easel. Insert the highlighted code snippet just after your Main function:

pub game fn World.Main(maxHumanPlayers=5) {
// ...

let wave = 1
loop {
PrintWaveMsg(wave=)
await PlayAsteroidRound
wave += 1
}
}

fn PrintWaveMsg(wave) {
PrintBottom(duration=5s) {
Panel(backgroundColor=#f004, animate=Animate:FlyFromTop) {
"Wave " + wave
}
Blank(height=1)
}
}

Asteroid round

Now let's create the PlayAsteroidRound function, which will manage a single round of asteroids. PlayAsteroidRound will spawn an entity to represent the round. The round entity will use the AsteroidFieldSystem that we previously created to spawn asteroids for the duration of the round.

Select asteroidField.easel, and insert the highlighted code snippet at the top of the file:

pub await fn PlayAsteroidRound(duration=60s) {
await Spawn {
AsteroidFieldSystem
await Tick(duration)
Expire
}
}

pub fn this.AsteroidFieldSystem() {
// ...
}

Click Preview to test your game. You should see a message saying "Wave 1" at the start of the game, and then after 60 seconds, it should change to "Wave 2", and so on.

Now we have a game loop!

tip

If you want to speed up testing, temporarily change duration=60s to duration=10s in the PlayAsteroidRound function signature. Don't forget to change it back when you are done testing!

info

In Easel, entities are not just physical objects in the game world, they can also represent abstract concepts like a "round of asteroids". An entity is really just a group of components that have the same lifespan.

Draining the asteroids

We want to completely clear one wave before the next wave starts. Not only does this give the player a break between waves, but it means each round could be completely different. One round could be asteroids, followed by a round defending against enemy invaders, then another round could be a boss fight, and so on.

To make this feel natural, after each 60-second wave, we will stop spawning new asteroids. Then we will go into draining mode where we silently remove any asteroid that drifts off-screen. Eventually all asteroids will be removed and the wave will end with a clear screen.

Draining mode flag

We will now create a flag called DrainingModeEnabled, which will be true whenever we are in draining mode, and false otherwise. Later we will use this flag to make asteroids remove themselves when they drift off-screen.

Create a new file called draining.easel. Copy-and-paste the highlighted code snippet into it:

pub collect World.DrainingModeEnabled = false
info

We have made DrainingModeEnabled a collector, a special kind of property that has its value given to it by other entities.

Entering draining mode

Now after the 60-second wave, we will enter draining mode by setting DrainingModeEnabled to true.

Select asteroidField.easel. Insert the highlighted code snippet in the PlayAsteroidRound function:

pub await fn PlayAsteroidRound(duration=60s) {
await Spawn {
AsteroidFieldSystem
await Tick(duration)
Expire
}
await Spawn {
give DrainingModeEnabled(true)
while QueryAny(filter=Category:Asteroid) { await Tick }
Expire
}
}

We are not done yet, but click preview to make sure you have not made any syntax errors before moving on.

info

Instead of setting DrainingModeEnabled to true and then later setting it back to false, we are using a collector. In PlayAsteroidRound, we give true to the DrainingModeEnabled collector (see Line 8 above), and then when the entity expires, it will automatically stop giving true, so DrainingModeEnabled will revert back to its default value of false.

Draining system

Now let's create a DrainingSystem which any entity can use to check if we are in draining mode, and if so, remove itself when it drifts off-screen.

Select draining.easel. Insert the highlighted code snippet at the bottom of the file:

pub collect World.DrainingModeEnabled = false

pub fn this.DrainingSystem(radius, [body]) {
on AfterPhysics {
if DrainingModeEnabled {
if Pos.X.Abs > BoundaryRadius + radius || Pos.Y.Abs > BoundaryRadius + radius {
Expire
}
} else {
await DrainingModeEnabled
}
}
}

We are still not done yet, but click preview to make sure you have not made any syntax errors before moving on.

Using the draining system

Now we just need to add DrainingSystem to our asteroids so they will remove themselves when we are in draining mode.

Select asteroid.easel. Insert the highlighted code snippet into your Asteroid function:

pub fn unit.Asteroid(pos, parent?, division=1) {
// ...

Body(
pos=,
velocity = speed * RandomVector,
heading=1rev*Random,
turnRate=0.1rev*SignedRandom,
)
BoundaryWrappingSystem(radius=)
PolygonCollider(shape=Circle(radius=), category=Category:Asteroid)
HealthSystem(maxHealth = 100 / division)
DrainingSystem(radius=)

with Health {
let proportion = Health / MaxHealth
ImageSprite(image=, color=proportion.Mix(#f88, #fff), radius=, shadow=0.5)
}

on Hurt {
Strobe(growth=0.1, shine=0.5, dissipate=0.25s)
}

// ...
}

Click Preview to test your game. You should see that after 60 seconds, the asteroids will stop spawning. Then, once you clear the screen of asteroids, the next wave will start.

tip

Don't forget you can speed up the testing process by temporarily shortening the round duration in the PlayAsteroidRound function signature.

Speeding up the asteroids

Now that we have the waves working, let's make each wave more difficult by making the asteroids faster each time.

Adding a speed factor

First, let's add a field called AsteroidSpeedMultiplier, which we can use to make the asteroids faster each wave.

Select asteroid.easel, and insert the following highlighted snippet at the top of the file:

pub field World.AsteroidSpeedMultiplier = 1

pub fn unit.Asteroid(pos, parent?, division=1) {
// ...
}
info

We are using a field here instead of a property here because we are not going to need AsteroidSpeedMultiplier to send signals when it changes.

Using the speed factor

Now that we have created our AsteroidSpeedMultiplier property, the asteroids need to use it to determine how fast they should go.

  1. Select asteroid.easel
  2. Find the line let speed = 2 * division
  3. Replace it with the following highlighted snippet:
pub prop World.AsteroidSpeedMultiplier = 1

pub fn unit.Asteroid(pos, parent?, division=1) {
use body=unit

let radius = 3 / division
let speed = 2 * AsteroidSpeedMultiplier * division

let image = match division {
1 => PickRandom(@asteroid-big-*.png),
2 => PickRandom(@asteroid-med-*.png),
3 => PickRandom(@asteroid-small-*.png),
4 => PickRandom(@asteroid-tiny-*.png),
}

// ...
}

Increasing the speed factor

Now we just need to increase the AsteroidSpeedMultiplier after each wave.

Select main.easel and insert the highlighted code snippet into your Main function:

pub game fn World.Main(maxHumanPlayers=5) {
// ...

let wave = 1
loop {
PrintWaveMsg(wave=)
await PlayAsteroidRound

AsteroidSpeedMultiplier *= 1.1
wave += 1
}
}
// ...

Click Preview to test your game. You should feel that each wave gets 10% faster each time, making the game harder and harder as you progress.

tip

To make this even easier to test, change the multiplier to something huge like 10.0 instead of 1.1. It will be very obvious that the asteroids are getting faster each wave! Don't forget to change it back when you are done testing.

Recap

Congratulations, you have now made a rounds system for your game!

At the heart of the rounds system is the game loop. For now, our game loop always repeatedly runs an asteroid round, but you could modify this structure a lot to create quite different games:

  • Add more types of rounds: asteroid rounds, enemy invasion rounds, boss fight rounds, and so on.
  • Rounds could represent different levels: each level could have a different layout, different enemies, and different challenges.
  • Let the user choose the next room: In a haunted house game with many rooms, each room would be implemented as a round. A room could have multiple doors leading to different rooms, determining which room comes next.
  • Remove the loop, and you could simply have a linear sequence of 10 levels, each one different from the last, finally showing a round of end credits after all levels are complete.

We hope you can see the possibilities of what you can create with the structure you have learned in this chapter!

Lesson: Thinking in Sequences

Let's take a look once again at our PlayAsteroidRound function:

pub await fn PlayAsteroidRound(duration=60s) {
await Spawn {
AsteroidFieldSystem
await Tick(duration)
Expire
}
await Spawn {
give DrainingModeEnabled(true)
while QueryAny(filter=Category:Asteroid) { await Tick }
Expire
}
}

Notice how it has two await Spawn { ... } blocks one after the other. When you use await Spawn instead of just Spawn, it will wait until that entity expires before moving on to the next line of code. Notice that the last line in each block is Expire. This makes sure that when one await Spawn { ... } block finishes, the next one starts. The logic flows top-to-bottom in a clear sequence of steps.

This is a common pattern used in Easel which you may find yourself using in your own games. See Asynchronous Programming to learn more.

tip

The await in the function signature fn await PlayAsteroidRound signifies to the caller that PlayAsteroidRound is going to make them wait. In other words, it is a blocking function.

If you have a function that waits on something and does not return immediately, always change the function signature to await fn to make this clear. This will make sure your function behaves in a predictable way and can be easily composed with other code.