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.
Picking the Right Tool
Section titled “Picking the Right Tool”The process system has four overlapping primitives. They’re not redundant — each is the cleanest fit for a different shape of problem.
| Use | Reach for |
|---|---|
| Wait N ms then run a callback | Process.delay() |
| Cooldown / restartable timer | pc.slot() |
| Animate a single property A → B | Tween.to() / .vec2() |
| Interpolate a number from→to with a custom setter | Tween.custom() |
| Arbitrary per-frame logic (no interpolation) | new Process({ update }) |
| Multi-step “do this, then this, then this” | Sequence |
| Run several animations together | Sequence.parallel() |
| Multi-point or non-monotonic animation curves | KeyframeAnimator |
| Fire discrete events at specific times | KeyframeAnimator + event |
A few rules of thumb:
Tweenis for two-point animations — start value, end value, easing. Reach for it first; it’s the simplest of the bunch.Sequenceis 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.KeyframeAnimatoris 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 withslot.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.
ProcessComponent
Section titled “ProcessComponent”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,});One-Off Processes
Section titled “One-Off Processes”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
Section titled “Tweens”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 propertypc.run(Tween.to(sprite, "alpha", 0, 300, easeOutQuad));
// Tween a Vec2 (e.g., position) — pass a setter that applies the valueconst 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 framepc.run( Tween.custom( (v) => t.setScale(1 + v, 1 + v), 0, 0.5, 500, easeOutQuad, ),);Built-In Easings
Section titled “Built-In Easings”| Function | Curve |
|---|---|
easeLinear | Constant speed |
easeInQuad | Accelerate |
easeOutQuad | Decelerate |
easeInOutQuad | Accelerate then decelerate |
easeOutBounce | Bounce at the end |
Import any easing from @yagejs/core.
Sequences
Section titled “Sequences”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(),);Parallel Steps
Section titled “Parallel Steps”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.
Looping
Section titled “Looping”// Loop foreverpc.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 timespc.run( new Sequence() .call(() => flash()) .wait(100) .repeat(5) .start(),);TimerEntity
Section titled “TimerEntity”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.
Cancelling by Tag
Section titled “Cancelling by Tag”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).
Keyframe Animation
Section titled “Keyframe Animation”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.
Multiple Tracks
Section titled “Multiple Tracks”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:
| Option | Purpose |
|---|---|
keyframes | The array of { time, data, easing?, event? } control points |
setter | Optional — function called every frame with the interpolated value. Omit for pure-timeline tracks that only fire event callbacks |
loop | Restart at time 0 when the track finishes (default false) |
speed | Multiplier on track time (default 1) |
duration | Override the auto-computed track length |
easing | Default easing between keyframes (each keyframe can override) |
onEnter / onExit | Lifecycle 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.
Pure-timeline tracks (no setter)
Section titled “Pure-timeline tracks (no setter)”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 },});Lower-Level Primitives
Section titled “Lower-Level Primitives”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 Processpc.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 codeconst blended = interpolate(0, 100, 0.5, easeOutQuad); // ≈ 75Interpolatable — 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.
Common Recipes
Section titled “Common Recipes”Patterns you’ll write over and over. Copy, adapt, and tag them so you can cancel groups when state changes.
Damage Flash
Section titled “Damage Flash”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).
Squash & Stretch on Land
Section titled “Squash & Stretch on Land”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");Camera Shake
Section titled “Camera Shake”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.
Hit-Pause (Time Freeze)
Section titled “Hit-Pause (Time Freeze)”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).
Enemy Telegraph
Section titled “Enemy Telegraph”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(), );}UI Fade In/Out
Section titled “UI Fade In/Out”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"] }, );}Looping Idle (Two-Frame Bob)
Section titled “Looping Idle (Two-Frame Bob)”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");Cancellation Hygiene
Section titled “Cancellation Hygiene”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.