Skip to content

Coroutines and animation

Layer: kernel (good)

Two ways to make something happen over time, for two different shapes of problem: coroutines for a one-shot sequence you write as a script, and timelines for a curve you sample.

Coroutines

A coroutine is a resumable piece of gameplay logic written as a sync* generator:

class RoundSystem extends GameSystem with FixedTickable {
  /// A coroutine takes **no parameters**.
  Iterable startRound() sync* {
    showBanner();
    yield 2.0;                          // wait two simulated seconds
    hideBanner();
    yield null;                         // wait one fixed step
    yield WaitUntil(() => everyoneReady);
    beginPlay();
  }
}

Coroutine is Iterable Function() — a plain no-argument generator. That signature lets a coroutine be started without allocating a closure, which matters because starting one is ordinary gameplay code.

Start it from anywhere that has a simulation — a GameState, a GameSystem, a SceneStruct, or a prefab. Pass the function itself, not a call to it:

final running = startCoroutine(startRound);

Do not wrap it in a closure

startCoroutine(() => entrance(entity));   // allocates a closure per start
That is what no heap allocation on the hot path is about, and it is why the parameterised form below exists.

The body first advances on the next fixed step, never inline, so starting one is safe from anywhere including outside the tick window.

What a yield may be

Yielded Resumes
null Next fixed step
a num After that many simulated seconds
a YieldInstruction When it says so, polled once per step
another Iterable After running that one to completion

Anything else throws instead of being silently treated as "next frame".

Seconds are simulated, accumulated from fixedTimeStep, so a coroutine replays identically — Future.delayed would not.

Nesting is a plain stack, so a coroutine can be composed of coroutines without either knowing about the other:

Iterable cutscene(Entity actor) sync* {
  yield walkToDoor(actor);       // an Iterable, run to completion first
  yield openDoor(actor);
  yield 1.0;                     // beat
  yield walkInside(actor);
}

Iterable walkToDoor(Entity self) sync* {
  while ((transform.transformOffsetX[self] - targetX[self]).abs() > 0.1) {
    transform.transformOffsetX[self] += speed[self] * fixedDelta;
    yield null;                  // one step at a time
  }
}

Nesting is a plain yield of another Iterable, so nothing is started here — only the outermost coroutine goes through startCoroutineWithParam.

Instructions

yield WaitUntil(() => player.isGrounded);
yield WaitWhile(() => menuIsOpen);
yield WaitForFuture(saveGame());

WaitWhile exists because WaitUntil(() => !busy) reads worse than WaitWhile(() => busy).

Stopping

final running = startCoroutine(startRound);
stopCoroutine(running);        // idempotent
stopAllCoroutines();           // everything *this owner* started

CoroutineFuture implements Future<void>, so a coroutine can be awaited:

await startCoroutine(startRound);
await startCoroutineWithParam(entrance, param: entity);

When the body needs an argument

A coroutine that has to know which entity it is running for takes exactly one parameter, and is started through the parameterised call — never through a closure:

/// One argument, and the type is CoroutineWithParam<Entity>.
Iterable entrance(Entity self) sync* {
  yield 0.5;                            // wait half a simulated second
  sprite.visible[self] = true;
  yield null;
  yield WaitUntil(() => landed[self]);  // an ordinary bool column
  sprite.color[self] = 0xFFFFFFFF;
}
startCoroutineWithParam(entrance, param: entity);

More than one argument: use a record

CoroutineWithParam<T> takes a single T, and for several values that T is a Dart record:

Iterable walkTo((Entity self, double x, double y) to) sync* {
  final (self, targetX, targetY) = to;         // destructure once, up front
  while ((transform.transformOffsetX[self] - targetX).abs() > 0.1) {
    transform.transformOffsetX[self] += speed[self] * fixedDelta;
    yield null;
  }
  transform.transformOffsetY[self] = targetY;
}
startCoroutineWithParam(walkTo, param: (entity, 120.0, -40.0));

A record keeps the arguments named and type-checked at the call site without a wrapper class or a builder. It costs one small allocation per start — which is fine for the occasional event a coroutine is for, and is not something to do per entity per tick.

This is the engine's general answer to "one type parameter, several values": commands and network messages do exactly the same thing.

Coroutines are sync*, never async*

An async* generator resumes on a microtask. The engine requires every component write to land between beginTick and commitTickbeginTick copies the last published snapshot over the write slot, so anything written outside that window is discarded.

A coroutine exists to write component data after waiting. Under async* every one of those writes lands after commitTick and is therefore thrown away — silently in release, on an assert in debug. Not intermittently: every time, for every write after the first yield.

sync* resumes synchronously, so the scheduler drives it from inside the tick window and the writes land where they must. It is also exactly what Unity's IEnumerator coroutines are, for what is probably the same reason.

Never await inside gameplay that writes components

await puts you on a microtask, outside the tick window. If you need to wait on a real Future, wrap it: yield WaitForFuture(f).

Timelines

A coroutine is a script. A timeline is a curve — declared once, sampled by an integer, with no per-entity animation object anywhere.

class EnemyTimeline extends TimelineStruct {
  late final Track<double> x;
  late final Track<double> y;
  late final Track<int> frame;

  late final TimelineAnimation entrance;
  late final TimelineAnimation blink;

  @override
  void describeTrack(TimelineDescriptor descriptor) {
    x = descriptor.has<double>(0);        // default outside any clip
    y = descriptor.has<double>(-1);
    frame = descriptor.has<int>(0);
  }

  @override
  void describeAnimation(TimelineAnimationDescriptor descriptor) {
    // 0 -> 100 over one second, hold two, back to 0 over one. Four seconds.
    entrance = descriptor.has()
      ..track(x).key(0.0).key(100.0, 1.0).hold(2.0).key(0.0, 1.0);

    blink = descriptor.has()
      ..track(y).key(0.0).key(10.0, 1.0)
      ..track(frame).key(0).key(3, 1.0);
  }
}

Declare it on a prefab:

class Enemy extends EntityStruct with Transform2D, Renderable2D {
  late final EnemyTimeline timeline;
  final startedAt = Field.float64();

  @override
  void describeAnimation(AnimationTypeDescriptor descriptor) {
    super.describeAnimation(descriptor);
    timeline = descriptor.has(EnemyTimeline());
  }
}

Several clips can drive the same tracks. A track with no keys in the clip being sampled reports its declared default, so a clip only has to mention the tracks it actually moves.

Sampling — the cheap path

What an entity stores is one double: when it started. Everything else is derived:

@override
void onFixedUpdate() {
  for (final group in enemies.groups()) {
    final enemy = group.get<Enemy>();
    final transform = group.get<Transform2D>();
    for (final entity in group) {
      final sample = enemy.timeline.entrance.animate(
        offset: -enemy.startedAt[entity],
        wrapMode: WrapMode.loop,
      );
      transform
        ..transformOffsetX[entity] = enemy.timeline.x[sample]
        ..transformOffsetY[entity] = enemy.timeline.y[sample];
    }
  }
}

TimelineSample is an extension type over an int — 16 bits of clip id, 48 bits of microseconds — so producing one allocates nothing. That is the whole reason sampling is shaped this way instead of as an animation object that owns state and gets ticked: a system samples this per entity per frame.

WrapMode Past the clip's end
clamp Hold the last keyframe. A one-shot entrance or death
loop Start again from zero. An idle bob, a spinning coin
pingPong Forwards, backwards, forwards. A breathing scale, a hovering platform

pingPong is the one you would otherwise author twice and have to keep symmetrical by hand.

Playing — the push path

For a one-shot that must run to completion and then be awaited — an entrance, a door opening, a cutscene beat — where you want await, not a flag to check every tick:

await startAnimation(
  timeline.entrance,
  <TrackBinding>[
    timeline.x.bind(transform.transformOffsetX.bind(entity)),   // (1)!
    timeline.y.bind(transform.transformOffsetY.bind(entity)),
  ],
  wrapMode: WrapMode.clamp,
);
  1. DataPointer.bind(entity) pins a column to one row, giving the DataBinding<T> the track writes through. No closure, and nothing to keep in step.

It runs on the coroutine scheduler, so its writes land inside the tick window.

Which one to reach for

startAnimation costs a coroutine and a binding per track, so it is for the occasional event. In a per-entity update loop, use animate and index the track — it costs nothing.

Stopping an animation

An animation started with startAnimation can be stopped in two ways:

// Stop a single animation by its handle:
final playing = startAnimation(timeline.entrance, bindings);
stopAnimation(playing);

// Stop every coroutine playing this timeline across all entities:
stopAnimations(timeline.entrance);

Both forms complete the handle normally rather than throwing an error.

What a stopped animation leaves behind

Bound tracks retain whatever value the last tick wrote. Stopping an animation mid-fade leaves the value where it was when stopped.

Keys and curves

A clip is written as a chain of keyframes per track. Two calls do everything:

entrance = descriptor.has()
  ..track(x)
      .key(0.0)                          // (1)!
      .key(100.0, 1.0)                   // (2)!
      .hold(2.0)                         // (3)!
      .key(0.0, 1.0, Curves.easeInOut);  // (4)!
  1. key(value) with no duration places the first keyframe at t = 0. The track reads 0.0 at the start of the clip.
  2. key(value, duration) — reach 100.0 one second after the previous keyframe. Between them the value is interpolated.
  3. hold(seconds) — stay at the previous value for two seconds. Sugar for repeating the last keyframe: written by hand it means naming the same value twice, and the two copies then have to be kept in step by whoever edits the clip.
  4. key(value, duration, curve) — return to 0.0 over one second, eased.

That clip is four seconds long: 0 + 1 + 2 + 1. Its length is derived from its keys; you never state it.






hold(2.0) 100 0 0 1 2 3 4 seconds key key key
The clip is four seconds long, and its length is derived from its keys: 0 + 1 + 2 + 1.

Durations are relative, not absolute

Each call advances a write head, so .key(0).key(100, 1.0).key(0, 1.0) is a two-second clip — not a one-second one with keys at t = 1 and t = 1.

Relative instead of absolute because absolute times would make inserting a keyframe mean renumbering every one after it.

Call Meaning
key(v) Be v at the current position on the timeline. Used for the first key
key(v, d) Reach v d seconds after the previous key, interpolating
key(v, d, curve) The same, shaped by curve
hold(d) Keep the previous value for d seconds

A negative duration throws — a keyframe cannot arrive before the one it follows. hold() before any key() throws too: there is nothing to hold.

The curve belongs to the key being moved towards

.key(100.0, 1.0, Curves.easeIn)

reads as "ease into 100", not "ease out of whatever came before". Any Flutter Curve works, and Curves.linear is the default.

The curve shapes t between two keys before the blend, so an interpolator is always a straight linear blend and never has to know what easing was asked for.

Tracks without arithmetic

A Track<int> for a sprite index, or a track of any type with no meaningful midpoint, is discrete: it holds the previous value until the next key is reached instead of inventing a value in between. That is correct for a frame number and is the honest fallback for a type that cannot be interpolated.

..track(frame).key(0).key(3, 1.0)   // frames 0,1,2,3 — stepped, not blended

Multiple tracks in one clip

The .. cascade is what lets one clip drive several tracks:

blink = descriptor.has()
  ..track(y).key(0.0).key(10.0, 1.0)
  ..track(frame).key(0).key(3, 1.0);

Each track(...) starts its own write head at zero, so the two chains are independent timelines within the same clip.


Next

Performance rules →