Skip to content

Processes & Tweens

YAGE’s process system (from @yagejs/core) gives you timers, tweens, and sequenced workflows that run inside the game loop. Everything is tied to entities, so processes are automatically cleaned up when an entity is destroyed.

The process system has four overlapping primitives. They’re not redundant — each is the cleanest fit for a different shape of problem.

UseReach for
Wait N ms then run a callbackProcess.delay()
Cooldown / restartable timerpc.slot()
Animate a single property A → BTween.to() / .vec2()
Interpolate a number from→to with a custom setterTween.custom()
Arbitrary per-frame logic (no interpolation)new Process({ update })
Multi-step “do this, then this, then this”Sequence
Run several animations togetherSequence.parallel()
Multi-point or non-monotonic animation curvesKeyframeAnimator
Fire discrete events at specific timesKeyframeAnimator + event

A few rules of thumb:

  • Tween is for two-point animations — start value, end value, easing. Reach for it first; it’s the simplest of the bunch.
  • Sequence is for orchestration, not animation. If your problem fits on a timeline (“flash, wait, fade, destroy”), it’s a sequence — even when the individual steps happen to be tweens.
  • KeyframeAnimator is for curves a tween can’t draw. A bobbing motion that goes up, back down, and past the start; an idle loop with three hand-tuned poses; a track that needs to fire a sound at exactly t=400ms. If a single ease curve isn’t enough, you want keyframes.
  • pc.slot() is for state, not animation. Cooldowns, invincibility windows, “time until next spawn” — anything you’ll check repeatedly with slot.completed.

When in doubt, start with a tween or a sequence. Promote to KeyframeAnimator only when you find yourself layering multiple tweens to fake one curve.

Add a ProcessComponent to any entity to gain access to entity-level timing:

import { ProcessComponent } from "@yagejs/core";
entity.add(new ProcessComponent());
const pc = entity.get(ProcessComponent);

A slot is a reusable, restartable process handle. It is ideal for cooldowns, timers, and anything that needs to be checked or restarted repeatedly.

const cooldown = pc.slot({ duration: 300 });
// In update():
if (cooldown.completed) {
cooldown.start(); // begin the cooldown
fire();
}

restart() combines cancel + start in one call, which is useful when you want to reset a timer that may still be running:

cooldown.restart();

Slots can also run callbacks on completion:

const invincibility = pc.slot({
duration: 2000,
onComplete: () => entity.get(HealthComponent).vulnerable = true,
});

For fire-and-forget delays or single-use timers, use pc.run():

import { Process } from "@yagejs/core";
pc.run(Process.delay(500, () => {
spawnExplosion(entity.transform.position);
entity.destroy();
}));

Tweens smoothly interpolate a value over time. They run as processes, so they respect pause state and entity lifetime.

import { Tween, Transform, easeOutQuad, easeInOutQuad } from "@yagejs/core";
// Tween a single property
pc.run(Tween.to(sprite, "alpha", 0, 300, easeOutQuad));
// Tween a Vec2 (e.g., position) — pass a setter that applies the value
const t = entity.get(Transform);
pc.run(Tween.vec2(
(v) => t.setPosition(v.x, v.y),
t.position,
targetPos,
600,
easeInOutQuad,
));
// Custom tween — interpolates from→to over duration; setter receives
// the eased value each frame
pc.run(
Tween.custom(
(v) => t.setScale(1 + v, 1 + v),
0, 0.5, 500, easeOutQuad,
),
);
FunctionCurve
easeLinearConstant speed
easeInQuadAccelerate
easeOutQuadDecelerate
easeInOutQuadAccelerate then decelerate
easeOutBounceBounce at the end

Import any easing from @yagejs/core.

Chain multiple steps into a linear or branching workflow using the sequence builder:

import { Sequence } from "@yagejs/core";
pc.run(
new Sequence()
.call(() => sprite.alpha = 1)
.wait(200)
.then(Tween.to(sprite, "alpha", 0, 400, easeOutQuad))
.call(() => entity.destroy())
.start(),
);

Run multiple processes at the same time within a sequence:

const t = entity.get(Transform);
pc.run(
new Sequence()
.parallel(
Tween.to(sprite, "alpha", 0, 300, easeOutQuad),
Tween.vec2((v) => t.setPosition(v.x, v.y), t.position, offscreen, 300, easeInQuad),
)
.call(() => entity.destroy())
.start(),
);

The sequence advances past a .parallel() step once all of its children complete.

Sequence.start() builds and returns the wrapping Process but does not schedule it — pass it to pc.run(...) for the sequence to actually tick.

// Loop forever
pc.run(
new Sequence()
.then(Tween.to(sprite, "alpha", 0.5, 500, easeInOutQuad))
.then(Tween.to(sprite, "alpha", 1.0, 500, easeInOutQuad))
.loop()
.start(),
);
// Repeat a fixed number of times
pc.run(
new Sequence()
.call(() => flash())
.wait(100)
.repeat(5)
.start(),
);

For scene-level timing that doesn’t belong to any game object, use TimerEntity. It comes with a ProcessComponent pre-attached so you don’t need to create a custom entity:

import { TimerEntity, Process } from "@yagejs/core";
const timer = scene.spawn(TimerEntity);
timer.run(Process.delay(3000, () => {
scene.switchTo(NextLevel);
}));

TimerEntity exposes .run(), .slot(), and .cancel() directly — they forward to its internal ProcessComponent, so you don’t need to fetch the component manually.

Tag processes so you can cancel groups of them later:

pc.run(Tween.to(sprite, "alpha", 0, 300, easeOutQuad), { tags: ["vfx"] });
pc.run(Tween.to(sprite, "scale", 2, 300, easeOutQuad), { tags: ["vfx"] });
// Cancel all processes tagged "vfx"
pc.cancel("vfx");

A process can carry multiple tags — { tags: ["vfx", "ui"] } lets the same process be cancelled by either group.

This is useful for cleaning up visual effects when an entity changes state (for example, cancelling hit-flash tweens when the entity dies).

Tween is great for point-to-point animations — “fade from 1 to 0”, “slide from A to B”. When you need multi-point animation — a bobbing motion that goes up, back down, and past the start, or a four-frame easing curve that isn’t expressible as a single easeInOut — reach for KeyframeAnimator.

KeyframeAnimator is a component that hosts multiple named keyframe tracks and runs any number of them concurrently. Each track is a list of (time, value) control points, and the animator interpolates between them on every frame, pushing the result to a setter function you supply.

import {
KeyframeAnimator,
ProcessComponent,
Transform,
easeInOutQuad,
} from "@yagejs/core";
const entity = scene.spawn("lantern");
entity.add(new Transform({ position: new Vec2(100, 200) }));
entity.add(new ProcessComponent());
const anim = entity.add(
new KeyframeAnimator({
bob: {
keyframes: [
{ time: 0, data: 0 },
{ time: 500, data: 10 },
{ time: 1000, data: 0 },
],
setter: (v) => (entity.get(Transform).y = 200 + (v as number)),
loop: true,
easing: easeInOutQuad,
},
}),
);
anim.play("bob");

This lantern rises 10 pixels, falls back, and loops forever — something neither a single Tween nor an easing function could express on its own. KeyframeAnimator requires ProcessComponent on the same entity because it runs as a process under the hood. Each keyframe’s time is in milliseconds along the track.

You can declare several named animations and play them independently:

const anim = entity.add(
new KeyframeAnimator<"bob" | "pulse">({
bob: {
keyframes: [
{ time: 0, data: 0 },
{ time: 500, data: 10 },
{ time: 1000, data: 0 },
],
setter: (v) => (entity.get(Transform).y = baseY + (v as number)),
loop: true,
},
pulse: {
keyframes: [
{ time: 0, data: 1.0 },
{ time: 400, data: 1.2 },
{ time: 800, data: 1.0 },
],
setter: (v) => entity.get(Transform).setScale(v as number, v as number),
loop: true,
speed: 1.5,
},
}),
);
anim.play("bob");
anim.play("pulse");
// ... later:
anim.stop("pulse");

The generic parameter KeyframeAnimator<"bob" | "pulse"> gives you autocomplete and compile-time checking on the track names — typos like anim.play("bbo") become type errors.

Each KeyframeAnimationDef accepts the same options you’d expect:

OptionPurpose
keyframesThe array of { time, data, easing?, event? } control points
setterOptional — function called every frame with the interpolated value. Omit for pure-timeline tracks that only fire event callbacks
loopRestart at time 0 when the track finishes (default false)
speedMultiplier on track time (default 1)
durationOverride the auto-computed track length
easingDefault easing between keyframes (each keyframe can override)
onEnter / onExitLifecycle callbacks when a track starts or stops

You can also fire discrete events from within a track using the event property on a keyframe — useful for syncing sound or VFX to animation beats.

When all you need is a sequence of time-aligned side-effects — cutscene beats, gameplay-rhythm cues, audio one-shots — leave setter off:

new KeyframeAnimator({
intro: {
keyframes: [
{ time: 0, data: 0, event: () => audio.play("step") },
{ time: 250, data: 0, event: () => audio.play("step") },
{ time: 500, data: 0, event: () => audio.play("door") },
],
// no setter — the values aren't read, only the events matter
},
});

Under the hood, KeyframeAnimator is built on two primitives you can use directly when you don’t need the named-track machinery:

import { createKeyframeTrack, interpolate } from "@yagejs/core";
// A one-off keyframe track as a Process
pc.run(
createKeyframeTrack({
keyframes: [
{ time: 0, data: 0 },
{ time: 600, data: 100 },
],
setter: (v) => (entity.get(Transform).x = v as number),
}),
);
// Raw interpolation for bespoke driver code
const blended = interpolate(0, 100, 0.5, easeOutQuad); // ≈ 75

Interpolatable — the type parameter for keyframe data and the interpolate primitive — resolves to number | Vec2Like. Both are supported out of the box; if you need to animate other types (colour, quaternion), compose multiple number tracks or write a custom setter.

Patterns you’ll write over and over. Copy, adapt, and tag them so you can cancel groups when state changes.

A short red-then-white pulse on hit. Tag it so a follow-up hit cancels the in-flight flash before starting a new one.

const TINT_HIT = 0xff5050;
const TINT_NORMAL = 0xffffff;
function damageFlash(entity: Entity) {
const sprite = entity.get(SpriteComponent);
const pc = entity.get(ProcessComponent);
pc.cancel("flash");
pc.run(
new Sequence()
.call(() => (sprite.tint = TINT_HIT))
.wait(120)
.call(() => (sprite.tint = TINT_NORMAL))
.start(),
{ tags: ["flash"] },
);
}

For a smooth fade rather than a hard cut, tween a separate flashAmount value 0 → 1 → 0 and resolve the tint per-frame inside the setter (channel- wise lerp on 0xRRGGBB).

Single keyframe track — scale Y squashes down, then bounces back. The “past the resting state” overshoot makes it feel responsive.

const anim = entity.add(new KeyframeAnimator<"land">({
land: {
keyframes: [
{ time: 0, data: 1.0 },
{ time: 80, data: 0.7 },
{ time: 240, data: 1.1 },
{ time: 360, data: 1.0 },
],
setter: (v) => entity.get(Transform).setScale(1, v as number),
easing: easeOutQuad,
},
}));
// On landing:
anim.play("land");

Random offset for a fixed window, decaying to zero. Shake intensity drives both magnitude and the per-frame jitter.

function shake(camera: Entity, magnitude = 8, duration = 250) {
const t = camera.get(Transform);
const baseX = t.x, baseY = t.y;
camera.get(ProcessComponent).run(
Tween.custom(
(k) => {
const m = magnitude * (1 - k);
t.x = baseX + (Math.random() * 2 - 1) * m;
t.y = baseY + (Math.random() * 2 - 1) * m;
},
0, 1, duration, easeOutQuad,
),
{ tags: ["shake"] },
);
}

Cancel with pc.cancel("shake") if a stronger shake supersedes a weaker one. Reset t.x/t.y to baseline after cancellation if your camera controller doesn’t already.

A few frames of frozen time on a heavy impact. Drives the scene’s timeScale rather than running a tween — the entire scene pauses, then resumes.

function hitPause(scene: Scene, duration = 80) {
scene.timeScale = 0;
setTimeout(() => (scene.timeScale = 1), duration);
}

A Process.delay won’t work for this recipe — timeScale = 0 freezes the scene’s processes, so the delay never resolves. setTimeout runs against wall-clock time and is unaffected. If you’d rather avoid setTimeout, spawn the unfreezing timer in a parent scene whose timeScale is still 1 (e.g. a paused-stack root scene).

The “wind-up” before a heavy attack. Sequence orchestrates the windup, hold, and execution.

function telegraph(enemy: Entity, attack: () => void) {
const sprite = enemy.get(SpriteComponent);
const pc = enemy.get(ProcessComponent);
pc.run(
new Sequence()
.call(() => (sprite.tint = 0xff5050))
.then(Tween.to(sprite, "alpha", 0.5, 200, easeInOutQuad))
.wait(300)
.call(() => {
sprite.alpha = 1;
sprite.tint = 0xffffff;
})
.call(attack)
.start(),
);
}

Two tweens — one for entry, one for exit. Tag so a fade-out cancels an in-flight fade-in cleanly.

function fadeIn(entity: Entity, duration = 200) {
const sprite = entity.get(SpriteComponent);
sprite.alpha = 0;
entity.get(ProcessComponent).cancel("fade");
entity.get(ProcessComponent).run(
Tween.to(sprite, "alpha", 1, duration, easeOutQuad),
{ tags: ["fade"] },
);
}
function fadeOut(entity: Entity, duration = 200, onDone?: () => void) {
const sprite = entity.get(SpriteComponent);
entity.get(ProcessComponent).cancel("fade");
entity.get(ProcessComponent).run(
new Sequence()
.then(Tween.to(sprite, "alpha", 0, duration, easeInQuad))
.call(() => onDone?.())
.start(),
{ tags: ["fade"] },
);
}

Lightweight idle motion for an NPC or pickup. KeyframeAnimator with loop: true is a one-liner.

const anim = entity.add(new KeyframeAnimator<"idle">({
idle: {
keyframes: [
{ time: 0, data: 0 },
{ time: 800, data: 4 },
{ time: 1600, data: 0 },
],
setter: (v) => (entity.get(Transform).y = baseY + (v as number)),
loop: true,
easing: easeInOutQuad,
},
}));
anim.play("idle");

Two patterns to remember:

  • Tag every effect tween so you can cancel groups by name when state changes. pc.cancel("vfx") on death; pc.cancel("flash") before starting a new flash.
  • Processes auto-cancel on entity destroy. You don’t need cleanup logic for the common case — entity.destroy() cancels everything bound to it. Same for slots.

If you find yourself writing a manual _running boolean alongside a process, you probably want a pc.slot() instead — it tracks completion and exposes restart() / cancel() / completed for free.