Processes & Tweens
YAGE’s process system (from @yagejs/core) gives you timers, tweens, and
sequenced workflows that run inside the game loop. A process tied to an entity
is cancelled automatically when that entity is destroyed. Scene-scoped and
engine-global processes follow their scene or the engine instead.
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 seconds 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=0.4s. 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: 0.3 });
// 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: 2, onComplete: () => entity.get(HealthComponent).vulnerable = true,});Cancelling a slot keeps it registered so it can start again. When its owner will never reuse it, remove the slot from the component:
pc.removeSlot(invincibility);removeSlot() cancels an active slot, unregisters it, and returns true.
It returns false when the slot belongs to another ProcessComponent or was
already removed.
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(0.5, () => { spawnExplosion(entity.transform.position); entity.destroy();}));Choosing a Clock
Section titled “Choosing a Clock”Every process and slot on a ProcessComponent advances on one of two clocks:
"frame"(the default) — rendered-frame time. Right for visuals: tweens, fades, shakes."fixed"— the engine’s fixed timestep, the same clock physics steps on. Right for gameplay timing that must match a fixed-step simulation: attack windows, cooldowns, timers that gate physics-driven movement.
Pick the clock when you schedule the work:
// A dash window that stays in step with physics:pc.run(Process.delay(0.25, () => endDash()), { clock: "fixed" });
// A cooldown on the fixed clock:const cooldown = pc.slot({ duration: 0.3, clock: "fixed" });Why it matters: frame time and fixed time drift apart whenever a frame is
dropped, a single frame runs two fixed steps, or the display refreshes faster
than the fixed rate. A gameplay window on the frame clock then opens and
closes at slightly different simulation times each run. Keeping gameplay
timers on "fixed" and visuals on "frame" — even on the same entity —
removes that drift without making animations choppy.
Both clocks share the same pause and time-scale behavior for entity processes:
scene pause stops them, and global, scene, and per-entity time scales apply to
both. Fixed-clock processes advance after the physics step and before
component fixedUpdate(dt) calls. The slot’s clock is set at creation;
start()/restart() overrides cannot change it.
Gameplay subsystems pick the fixed clock for you: the abilities addon
schedules its timelines and cooldowns on "fixed" by default. The "frame"
default here applies to what you schedule directly — tweens, fades, and other
presentation work.
A KeyframeAnimator animation carries the choice on its own definition, since
the animator schedules the track for you: clock: "fixed" on one animation def
puts that track on the fixed clock and leaves the animator’s other animations
where they are.
Scoped Queues
Section titled “Scoped Queues”An entity-scoped process queue carries the clock for everything it enqueues.
It routes through the entity’s ProcessComponent and cancels only what it put
there:
import { makeEntityScopedQueue, Process } from "@yagejs/core";
const gameplay = makeEntityScopedQueue(entity, { clock: "fixed" });gameplay.run(Process.delay(0.25, () => endDash()));
// Tear down everything this queue started:gameplay.cancelAll();The clock is read when the queue is created, so run(p) takes none. One
queue carries one clock. Frame-clock visuals on the same entity belong in a
second queue, and that queue has its own cancelAll().
The scene and global queues take the same option. A round timer, a wave
spawner, or a boss enrage at 60 seconds belongs to the scene rather than to
any one entity. A timer scheduled on an entity’s ProcessComponent ends when
that entity is destroyed.
A scene resolves the ProcessSystem through its engine context, which is
bound by the time onEnter() runs:
import { Process, ProcessSystemKey, Scene, makeSceneScopedQueue,} from "@yagejs/core";
class Arena extends Scene { readonly name = "arena";
onEnter(): void { const processSystem = this.context.resolve(ProcessSystemKey); const waves = makeSceneScopedQueue(processSystem, this, { clock: "fixed" }); waves.run(Process.delay(30, () => spawnWave(2), ["waves"])); }}A scene queue pauses with its scene and follows the scene’s time scale on
both clocks. makeGlobalScopedQueue(processSystem, { clock: "fixed" }) runs
under the global time scale alone, with no scene pause gating.
waves.cancelAll() cancels what that queue enqueued.
processSystem.cancelForScene(scene, "waves") cancels every process carrying
the "waves" tag in that scene, on both clocks. The tag lives on the process,
so the snippet passes it as Process.delay’s third argument.
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 component property with a setter (durations in seconds)pc.run(Tween.custom((v) => (sprite.alpha = v), 1, 0, 0.3, 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, 0.6, 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, 0.5, 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(0.2) .then(Tween.custom((v) => (sprite.alpha = v), 1, 0, 0.4, 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.custom((v) => (sprite.alpha = v), 1, 0, 0.3, easeOutQuad), Tween.vec2((v) => t.setPosition(v.x, v.y), t.position, offscreen, 0.3, 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.custom((v) => (sprite.alpha = v), 1, 0.5, 0.5, easeInOutQuad)) .then(Tween.custom((v) => (sprite.alpha = v), 0.5, 1, 0.5, easeInOutQuad)) .loop() .start(),);
// Repeat a fixed number of timespc.run( new Sequence() .call(() => flash()) .wait(0.1) .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(3, () => { scene.switchTo(NextLevel);}));TimerEntity exposes .run(), .slot(), .removeSlot(), 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:
const t = entity.get(Transform);pc.run(Tween.custom((v) => (sprite.alpha = v), 1, 0, 0.3, easeOutQuad), { tags: ["vfx"] });pc.run(Tween.custom((v) => t.setScale(v, v), 1, 2, 0.3, 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
tick of the track’s clock, pushing the result to a setter function you supply.
import { KeyframeAnimator, ProcessComponent, Transform, Vec2, 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: 0.5, data: 10 }, { time: 1, data: 0 }, ], setter: (v) => { const t = entity.get(Transform); t.setPosition(t.position.x, 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 seconds
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: 0.5, data: 10 }, { time: 1, data: 0 }, ], setter: (v) => { const t = entity.get(Transform); t.setPosition(t.position.x, baseY + (v as number)); }, loop: true, }, pulse: { keyframes: [ { time: 0, data: 1.0 }, { time: 0.4, data: 1.2 }, { time: 0.8, 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 with the interpolated value on every tick of the track’s clock. Omit for pure-timeline tracks that only fire event callbacks |
clock | Clock that advances playback — "frame" (the default) or "fixed" |
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: 0.25, data: 0, event: () => audio.play("step") }, { time: 0.5, data: 0, event: () => audio.play("door") }, ], // no setter — the values aren't read, only the events matter },});When a timeline’s events drive gameplay rather than presentation — spawning a hitbox, opening a parry window, applying damage — put that animation on the fixed clock:
new KeyframeAnimator({ combo: { clock: "fixed", keyframes: [ { time: 0, data: 0, event: () => spawnHitbox("jab") }, { time: 0.18, data: 0, event: () => spawnHitbox("cross") }, { time: 0.4, data: 0, event: () => endCombo() }, ], }, flash: { // No clock — the default "frame" keeps the visual smooth. keyframes: [ { time: 0, data: 1 }, { time: 0.1, data: 1.3 }, { time: 0.2, data: 1 }, ], setter: (v) => entity.get(Transform).setScale(v as number, v as number), },});Why it matters: on the frame clock, the hitbox at time: 0.18 spawns at a
different point in the fixed-step sequence on every run, because rendered-frame
time and fixed time drift apart. When a slow frame hits the
maxFixedStepsPerFrame cap, the fixed loop leaves part of that frame’s elapsed
time unsimulated, while a frame-clock timeline still advances by the whole
frame delta. On "fixed" the beats stay in step with the world they drive.
Setter-driven visuals stay on "frame". A setter on the fixed clock is written
only on fixed steps, so a rendered frame that runs no fixed step shows the
previous value. The clock is chosen per animation, so both can live on one
animator as above.
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: 0.6, data: 100 }, ], setter: (v) => { const t = entity.get(Transform); t.setPosition(v as number, t.position.y); }, }),);
// 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(0.12) .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: 0.08, data: 0.7 }, { time: 0.24, data: 1.1 }, { time: 0.36, 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 = 0.25) { const t = camera.get(Transform); const base = t.position; camera.get(ProcessComponent).run( Tween.custom( (k) => { const m = magnitude * (1 - k); t.setPosition( base.x + (Math.random() * 2 - 1) * m, base.y + (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 the camera to its base position 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. The scene’s SceneTime
service freezes the entire scene — components, processes, tweens, particles,
and physics — and restores it on a real-time timer:
import { SceneTimeKey } from "@yagejs/core";
// From a Component:this.use(SceneTimeKey).freezeFor(0.08); // 80ms freeze frameThe duration counts real seconds, so the freeze cannot stall its own timer. Overlapping freezes (or a freeze during slow motion) compose safely — see Hitstop, Slow Motion, and Freeze Frames.
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.custom((v) => (sprite.alpha = v), 1, 0.5, 0.2, easeInOutQuad)) .wait(0.3) .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 = 0.2) { const sprite = entity.get(SpriteComponent); sprite.alpha = 0; entity.get(ProcessComponent).cancel("fade"); entity.get(ProcessComponent).run( Tween.custom((v) => (sprite.alpha = v), 0, 1, duration, easeOutQuad), { tags: ["fade"] }, );}
function fadeOut(entity: Entity, duration = 0.2, onDone?: () => void) { const sprite = entity.get(SpriteComponent); entity.get(ProcessComponent).cancel("fade"); entity.get(ProcessComponent).run( new Sequence() .then(Tween.custom((v) => (sprite.alpha = v), 1, 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: 0.8, data: 4 }, { time: 1.6, data: 0 }, ], setter: (v) => { const t = entity.get(Transform); t.setPosition(t.position.x, 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.