Animating with Coroutines

Published on Thursday, June 2nd, 2022

Using an concept known as coroutines you can produce some really nice looking imperative animation code! I was inspired by two great YouTube videos recently to try a new approach to building animations in games.

// Look at this nice imperative animation code 😎
coroutine(function* animation() {
// scale the entity 2x over 1000 ms duration
yield* scaleTo(entity, 2, 1000);
// delay animation 1000 ms duration
yield* delay(1000);
// move the entity to (200, 100) 500 ms duration
yield* moveTo(entity, {x: 200, y: 100}, 500);
// rotate the entity to 4 PI over 1000 ms duration
yield* rotateTo(entity, 4 * Math.PI, 1000);
});

TLDR show me the code

What is a coroutine anyway?

The word "coroutine" definitely sounds fancy! Coroutines are a type of suspendable execution. They are a useful way of simulating concurrent tasks or sequences over time without blocking the main thread. They are made possible by a new(ish) feature in JavaScript called generators!

Why are they called "coroutines"? Well it's because they cooperatively share execution. In our example below the coroutine passes control into the generator, and it yield's control back up to the coroutine.

In our example, we are working with animation's over time, so each yield can be thought of as a single frame that takes an "elapsed" amount of time.

function coroutine(generatorFunction: () => Generator<any, void, number>) {
const generator = generatorFunction(); // instantiate the coroutine
generator.next(); // execute until the first yield to start the generator
return (elapsed: number) => {
return generator.next(elapsed);
}
}

coroutine(function* myAnimation() {
let elapsed: number;
// each yield represents a frame
elapsed = yield; // frame 1
elapsed = yield; // frame 2
elapsed = yield; // frame 3
// ...
});

Implementing Animations with requestAnimationFrame

We need something to drive updating each frame for our animations, we can use a classic game update approach with requestAnimationFrame.

let lastTime = performance.now();
let running = true;
const mainloop = (time: number) => {
try {
if (!running) return;
requestAnimationFrame(mainloop);
const now = time;
const elapsed = now - lastTime;

// update coroutines
for (let animation of animations) {
animation(elapsed);
}

// draw stuff
clear(ctx);
draw(ctx, elapsed)
lastTime = now;
} catch(e) {
running = false;
throw e;
}
}
mainloop(performance.now());
animations.push(coroutine(function* myAnimation(){ ... }))

Now we can use our fancy coroutines to make some fancy animations!

MoveTo Generator with Linear Interpolation

We can build some animation primitives. One of the first things you might want to do is move things around, something like moveTo which will animate an entity to a set of coordinates over a duration.

First things first we'll need to define a "Linearly Interpolate" function, or as they say in the biz "lerp". Traditionally easing functions like lerp specify a numeric start and end value, and a time between 0 and 1.

This means that when time is 0, we return the start, and when time is 1 we return the end, else we mix them. Looking at our example below that math checks out. You can think of this as a weighted average between the start and end over time 😎

const lerp = (start: number, end: number, time: number) => {
return start * (1-time) + end * time;
}

Another useful helper is clamp, which is useful for keep a value between an upper and lower bound. And it is important that our time passed to lerp stays in between 0 and 1.

const clamp = (val: number, min: number, max: number) => {
return Math.min(max, Math.max(val, min));
}

Now we have those utilities explained, let's start with the basic structure of our animation generator:

function* moveTo(entity: Entity, destination: {x: number, y: number}, durationMs: number){
let totalTime = 0;
while (true) {
let elapsed = yield;
totalTime += elapsed;
// TODO implement the rest
}
}

Next we can use JavaScript scope to capture the entities original x/y position. Notice that it is above the yield.

function* moveTo(entity: Entity, destination: {x: number, y: number}, durationMs: number) {
let totalTime = 0;
// Capture the original position
const { x: originalX, y: originalY } = entity;
while(true) {
let elapsed = yield;
totalTime += elapsed;
// TODO implement the rest
}
}

Then Convert our current totalTime to something between 0 and 1 for our lerp.

const currentTime = clamp(totalTime / durationMs, 0, 1);

Then the meat of our moveTo is calculating the lerp and setting the properties on the entity we are animating!

const currentX = lerp(originalX, destination.x, currentTime);
const currentY = lerp(originalY, destination.y, currentTime);
entity.x = currentX;
entity.y = currentY;
if (totalTime >= durationMs) {
console.log('moveTo complete', currentX, currentY);
return;
}

Finally, putting the whole thing together it looks like this

function* moveTo(entity: Entity, destination: {x: number, y: number}, durationMs: number) {
let totalTime = 0;
const { x: originalX, y: originalY} = entity;
while(true) {
let elapsed = yield;
totalTime += elapsed;
const currentTime = clamp(totalTime / durationMs, 0, 1);
const currentX = lerp(originalX, destination.x, currentTime);
const currentY = lerp(originalY, destination.y, currentTime);
entity.x = currentX;
entity.y = currentY;
if (totalTime >= durationMs) {
console.log('moveTo complete', currentX, currentY);
return;
}

}
}

Here is an example of our moveTo animations in action, each animation runs in sequence on after the other!

The yeild* is a new piece of syntax but it basically means that if the thing to the right is a generator, dig into and run that generator! Cool right?

coroutine(function* boxAnimation() {
yield* moveTo(entity, {x: 50, y: 550}, 1000);
yield* moveTo(entity, {x: 750, y: 550}, 1000);
yield* moveTo(entity, {x: 750, y: 50}, 1000);
yield* moveTo(entity, {x: 50, y: 50}, 1000);
});

box animating counter clockwise around the screen

Concurrency with Coroutines

There are more cool tricks we can play, like simulating concurrent animations with coroutines!

Let's say in addition to moveTo I also create a rotateTo and a scaleTo animation, AND I want to do all three at once! Well now that we have generators helping us out we can drive multiple generators at the same time.

In this example we grab a list of our animations, and drive them until they are all done:

function* parallel(...animations: Generator[]) {
while (true) {
let elapsed = yield;
let results: IteratorResult<any, any>[] = [];
// Drive each animation 1 frame
for (let anim of animations) {
results.push(anim.next(elapsed));
}
// Wait to all the child animations are done
if (results.every(f => f.done)){
return;
}
}
}

See how it reads in usage:

coroutine(function* boxAnimation() {
// animations run in parallel
yield* parallel(
moveTo(entity, {x: 400, y: 400}, 1000),
rotateTo(entity, 2 * Math.PI, 1000),
scaleTo(entity, 2, 1000),
)
});

!box moving, rotating, and scaling at the same time

Wrapping Sequences with Coroutines

Sometimes it's useful to specify different groups of animations that you want to run.

This collects up the animations and runs each of them!

function sequence(...animations: Generator[]) {
return function* generatorSequence() {
for(let generator of animations) {
yield* generator;
}
}();
}

Now we can run 2 separate sequential animations AT THE SAME TIME! 🤯

coroutine(function* manyBoxesAnimation() {
yield* parallel(
sequence(
moveTo(yellowEntity, {x: 50, y: 550}, 1000),
moveTo(yellowEntity, {x: 750, y: 550}, 1000),
moveTo(yellowEntity, {x: 750, y: 50}, 1000),
moveTo(yellowEntity, {x: 50, y: 50}, 1000),
),
sequence(
moveTo(greenEntity, {x: 750, y: 50}, 1000),
moveTo(greenEntity, {x: 750, y: 550}, 1000),
moveTo(greenEntity, {x: 50, y: 550}, 1000),
moveTo(greenEntity, {x: 50, y: 50}, 1000),
)
)
})

Two boxes animating at the same time

I hope this helps you aspiring game devs out there who are trying to build some animations!

Check out the code and play around

-Erik

Help support me on Github Sponsors or Patreon

Become a Patron!