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. 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.

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 seconds 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=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 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: 0.3 });
function fire() {
// spawn a projectile
}
// 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();

Both take one-off overrides that apply to that run only. The slot keeps the config it was created with for every later start:

const dash = pc.slot({ duration: 0.4 });
dash.start({ duration: 0.15 }); // this dash is short
// ...after it completes:
dash.start(); // back to 0.4

start() on a slot that is still running does nothing, overrides included — use restart(overrides) to end the current run and start a new one.

Slots can also run callbacks on completion:

import { Component } from "@yagejs/core";
class HealthComponent extends Component {
vulnerable = true;
}
const invincibility = pc.slot({
duration: 2,
onComplete: () => entity.get(HealthComponent).vulnerable = true,
});

A slot with loop: true never completes, so its onComplete never runs. To run something every few seconds, loop a sequence instead:

import { Sequence } from "@yagejs/core";
function spawnWave() {
// spawn the next wave of enemies
}
// Spawn a wave every 2 seconds, for as long as the entity lives
pc.run(new Sequence().wait(2).call(() => spawnWave()).loop().build());

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.

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

import { Process, ProcessComponent, Transform, type Vec2 } from "@yagejs/core";
const pc = entity.get(ProcessComponent);
function spawnExplosion(at: Vec2) {
// spawn the explosion effect at `at`
}
pc.run(Process.delay(0.5, () => {
spawnExplosion(entity.get(Transform).position);
entity.destroy();
}));

pc.run() hands back the Process it scheduled, and toPromise() on that process resolves when the run ends — completion, cancel(), or a reset for a re-run. That is how an async method waits for work it scheduled:

import { ProcessComponent, Scene, Tween } from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
class GameOverScene extends Scene {
readonly name = "game-over";
}
const pc = entity.get(ProcessComponent);
const overlay = entity.get(SpriteComponent);
const scenes = engine.scenes;
async function fadeOutAndLeave() {
await pc
.run(Tween.custom((v) => (overlay.alpha = v), 1, 0, 0.4))
.toPromise();
await scenes.replace(new GameOverScene());
}

The promise carries no result, and a process that has already completed resolves one immediately. Read whatever the process wrote once it resolves.

Every duration is in seconds and must be finite and greater than zero. Process, Process.delay, Sequence.wait, Tween.to/custom/vec2, and a slot’s duration all throw on 0, a negative number, NaN, or Infinity, naming the value they were given. Tween endpoints must be finite too, so a NaN in a position value fails at the call instead of being written into the property.

Tween.stagger is the one exception on zero: its stepSeconds must be finite and zero or greater, and passing 0 starts every item at once.

A duration completes on the tick that reaches it. Elapsed time is accumulated by adding each frame’s delta, so a duration built from whole steps — 0.25 s of 1/60 s steps — sums a fraction short of its target; the window still closes on step 15 rather than running one step long:

import { Process, ProcessComponent } from "@yagejs/core";
const pc = entity.get(ProcessComponent);
function endAttackWindow() {
// close the attack's hit window
}
// On the fixed clock at 60 Hz: fires on step 15.
pc.run(Process.delay(0.25, () => endAttackWindow()), { clock: "fixed" });

elapsed still reports the time that actually accumulated, overshoot included, and a looping process carries only the overshoot into its next pass.

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:

import { Process, ProcessComponent } from "@yagejs/core";
const pc = entity.get(ProcessComponent);
function endDash() {
// return the entity to normal movement
}
// 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.

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";
function endDash() {
// return the entity to normal movement
}
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";
function spawnWave(wave: number) {
// spawn the enemies of this wave
}
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 smoothly interpolate a value over time. They run as processes, so they respect pause state and entity lifetime.

import {
ProcessComponent,
Tween,
Transform,
Vec2,
easeOutQuad,
easeInOutQuad,
} from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
const pc = entity.get(ProcessComponent);
const sprite = entity.get(SpriteComponent);
const targetPos = new Vec2(400, 300);
// 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 value
const 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 frame
pc.run(
Tween.custom(
(v) => t.setScale(1 + v, 1 + v),
0, 0.5, 0.5, easeOutQuad,
),
);

An easing takes t in [0,1] and returns 0 at t = 0 and 1 at t = 1. easeLinear holds a constant speed; the ten families below each come in an ease-in form (slow start), an ease-out form (slow finish) and an ease-in-out form (slow at both ends).

FamilyEase inEase outEase in-out
sineeaseInSineeaseOutSineeaseInOutSine
quadeaseInQuadeaseOutQuadeaseInOutQuad
cubiceaseInCubiceaseOutCubiceaseInOutCubic
quarteaseInQuarteaseOutQuarteaseInOutQuart
quinteaseInQuinteaseOutQuinteaseInOutQuint
expoeaseInExpoeaseOutExpoeaseInOutExpo
circeaseInCirceaseOutCirceaseInOutCirc
backeaseInBackeaseOutBackeaseInOutBack
elasticeaseInElasticeaseOutElasticeaseInOutElastic
bounceeaseInBounceeaseOutBounceeaseInOutBounce

The back and elastic families leave [0,1] between the endpoints, which is the point of them — back reaches about 0.1 past an endpoint, easeInElastic and easeOutElastic about 0.37, easeInOutElastic about 0.12. That overshoot carries the interpolated value with it, so pair them with a target that tolerates the excursion. A scale pop is the classic fit:

import { ProcessComponent, Transform, Tween, easeOutBack } from "@yagejs/core";
const pc = entity.get(ProcessComponent);
const t = entity.get(Transform);
pc.run(Tween.custom((v) => t.setScale(v, v), 0, 1, 0.35, easeOutBack));

A camera zoom is not: camera.zoomTo(0.1, 1, easeOutElastic) drives zoom briefly negative and flips the view for those frames. The bounce family stays inside [0,1] but rises, falls back and rises again.

Import any easing from @yagejs/core.

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

import { ProcessComponent, Sequence, Tween, easeOutQuad } from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
const pc = entity.get(ProcessComponent);
const sprite = entity.get(SpriteComponent);
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())
.build(),
);

Run multiple processes at the same time within a sequence:

import {
ProcessComponent,
Sequence,
Transform,
Tween,
Vec2,
easeInQuad,
easeOutQuad,
} from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
const pc = entity.get(ProcessComponent);
const sprite = entity.get(SpriteComponent);
const offscreen = new Vec2(-100, 300);
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())
.build(),
);

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

Sequence.build() compiles the steps into the wrapping Process and returns it. Nothing ticks until a runner drives that process — pass it to pc.run(...), or to a scene or global queue’s run(...).

Cancelling a sequence cancels whatever step it is running. A tween instance you built, passed to .then() or .parallel(), and still hold is cancelled with the sequence, so it reads completed === true rather than staying unfinished.

A built sequence is reusable: pass one as a step of another sequence, or repeat it, and it starts from its first step each time.

import { ProcessComponent, Sequence, Tween, easeInOutQuad } from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
const pc = entity.get(ProcessComponent);
const sprite = entity.get(SpriteComponent);
function flash() {
// toggle the sprite's tint
}
// Loop forever
pc.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()
.build(),
);
// Repeat a fixed number of times
pc.run(
new Sequence()
.call(() => flash())
.wait(0.1)
.repeat(5)
.build(),
);

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, Scene } from "@yagejs/core";
class NextLevel extends Scene {
readonly name = "next-level";
}
const timer = scene.spawn(TimerEntity);
timer.run(Process.delay(3, () => {
engine.scenes.replace(new 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.

Tag processes so you can cancel groups of them later:

import { ProcessComponent, Transform, Tween, easeOutQuad } from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
const pc = entity.get(ProcessComponent);
const sprite = entity.get(SpriteComponent);
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).

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.

A track needs at least two keyframes whose times are finite and sorted ascending, and a duration and speed that are finite and greater than zero — anything else throws when the animation starts. The first keyframe does not have to sit at time 0; a track that starts at 0.2 s holds that keyframe’s value through the lead-in.

You can declare several named animations and play them independently:

import { KeyframeAnimator, Transform } from "@yagejs/core";
const baseY = entity.get(Transform).position.y;
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:

OptionPurpose
keyframesThe array of { time, data, easing?, event? } control points
setterOptional — function called with the interpolated value on every tick of the track’s clock. Omit for pure-timeline tracks that only fire event callbacks
clockClock that advances playback — "frame" (the default) or "fixed"
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:

import { KeyframeAnimator } from "@yagejs/core";
import { AudioManagerKey } from "@yagejs/audio";
const audio = context.resolve(AudioManagerKey);
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:

import { KeyframeAnimator, Transform } from "@yagejs/core";
function spawnHitbox(attack: "jab" | "cross") {
// spawn the hitbox for `attack`
}
function endCombo() {
// return to idle
}
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.

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

import {
ProcessComponent,
Transform,
createKeyframeTrack,
easeOutQuad,
interpolate,
} from "@yagejs/core";
const pc = entity.get(ProcessComponent);
// A one-off keyframe track as a Process
pc.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 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.

import { type Entity, ProcessComponent, Sequence } from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
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))
.build(),
{ 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.

import { KeyframeAnimator, Transform, easeOutQuad } from "@yagejs/core";
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");

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

import {
type Entity,
ProcessComponent,
Transform,
Tween,
easeOutQuad,
} from "@yagejs/core";
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.

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 frame

The 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.

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

import {
type Entity,
ProcessComponent,
Sequence,
Tween,
easeInOutQuad,
} from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
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)
.build(),
);
}

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

import {
type Entity,
ProcessComponent,
Sequence,
Tween,
easeInQuad,
easeOutQuad,
} from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
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?.())
.build(),
{ tags: ["fade"] },
);
}

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

import { KeyframeAnimator, Transform, easeInOutQuad } from "@yagejs/core";
const baseY = entity.get(Transform).position.y;
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");

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, down to the tweens a sequence started. 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.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/core-concepts.md.