Feel
@yagejs-addons/feel groups the small responses around an action and plays
them from one call. A hit can squash the target, flash its sprite, shake the
camera, freeze the scene for 50 ms, play a sound, and burst particles without
putting six unrelated timers in combat code.
The Feel addon example groups the
effects across four scenes. It covers impacts, trails, afterimages,
highlights, springs, visibility, cue composition, scene and target time
effects, animation, callbacks, shockwaves, camera modifiers, glitch, blur,
implosion, dissolve, practical recipes, and a custom effect. Press N or P
to move between scenes with a slide transition.
import type { Entity } from "@yagejs/core";import type { ParticleEmitterComponent } from "@yagejs/particles";import { SpriteComponent, type CameraEntity } from "@yagejs/renderer";import { Feel, feelHitStop, feelParallel,} from "@yagejs-addons/feel";import { feelCameraShake, feelHitFlash, feelScalePunch, feelSquash,} from "@yagejs-addons/feel/renderer";import { feelSound } from "@yagejs-addons/feel/audio";import { feelParticleBurst } from "@yagejs-addons/feel/particles";
// Your game's objects:declare const enemy: Entity;declare const camera: CameraEntity;declare const sparks: ParticleEmitterComponent;const enemySprite = enemy.get(SpriteComponent);
enemy.add( new Feel({ hit: feelParallel( feelSquash({ target: enemySprite, amount: 0.2 }), feelHitStop({ duration: 0.05 }), feelCameraShake({ camera, intensity: 5 }), feelHitFlash(enemySprite.fx, { color: 0xffffff }), feelSound({ alias: "impact", speed: [0.95, 1.05] }), feelParticleBurst({ emitter: sparks, count: [8, 12] }), ), }),);
enemy.get(Feel).play("hit");Install
Section titled “Install”npm install @yagejs-addons/feel @yagejs/core
# Add only the optional peers your cues use:npm install @yagejs/renderer @yagejs/effectsnpm install @yagejs/audionpm install @yagejs/particlesThe root import contains the Feel component, composition helpers, time
effects, keyframe animation, and callbacks. Visual motion, sprite animation,
camera, filter, audio, and particle adapters use separate entry points, so the
root import does not load PixiJS or optional plugins.
Build one response from small effects
Section titled “Build one response from small effects”feelParallel starts every child together. feelSequence waits for each
child’s timeline to finish. feelDelay offsets one child, and feelRepeat
repeats a finite child a fixed number of times. feelLoop repeats a finite
child until the playback is released.
import type { ParticleEmitterComponent } from "@yagejs/particles";import type { SpriteComponent } from "@yagejs/renderer";import { feelDelay, feelParallel, feelSequence } from "@yagejs-addons/feel";import { feelScalePunch } from "@yagejs-addons/feel/renderer";import { feelSound } from "@yagejs-addons/feel/audio";import { feelParticleBurst } from "@yagejs-addons/feel/particles";
declare const pickupSprite: SpriteComponent;declare const glitter: ParticleEmitterComponent;
const pickup = feelSequence( feelParallel( feelScalePunch({ target: pickupSprite, scale: 1.3 }), feelSound({ alias: "pickup" }), ), feelDelay(0.04, feelParticleBurst({ emitter: glitter, count: 10 })),);Every named cue can control retriggers:
import { Feel } from "@yagejs-addons/feel";
const feel = entity.add( new Feel({ hit: { effect: pickup, overlap: "restart", // "restart" (default), "ignore", or "allow" chance: 0.9, cooldown: 0.05, intensity: [0.9, 1.1], }, }),);
feel.play("hit", { intensity: 1.5, duration: 0.2,});feel.stop("hit");play() returns null while the component is dormant or when chance,
cooldown, or overlap: "ignore" rejects the trigger. Otherwise it returns a
handle with active, finished, release(), and stop(). intensity is a
finite non-negative multiplier; values above 1 are valid. An adapter may
clamp the property it writes, such as alpha.
A per-play duration replaces the total duration of a finite cue. Feel scales
finite child start times, durations, gaps, and local update clocks together.
The relative timing stays the same. A positive duration cannot stretch a
zero-duration cue. A cue whose FeelNode.duration is null does not accept a
duration override. Feel validates both play options before a restart can cancel
the active playback.
The root entry exports FeelPulseTiming for finite renderer pulses. It contains
duration, peakAt, attackEasing, and releaseEasing. Most pulses, including
opacity, recoil, and bounce, peak at 0.25 and use easeOutQuad for both
phases. Hit flash uses a linear triangle with its peak at 0.5 and lasts 0.12
seconds by default. Builders validate and capture their timing when called.
release() is graceful. Held states begin their release tails, active finite
children finish, and pending sequence children still run. The handle stays
active until the cue completes and emits FeelCompletedEvent. stop() is
immediate cancellation and emits FeelStoppedEvent. Restart overlap,
disabling, and destroying Feel also cancel active cues. finished resolves
after completion or cancellation.
import { Transform, type Entity, type Vec2Like } from "@yagejs/core";import { Feel, feelLoop, feelParallel } from "@yagejs-addons/feel";import { feelFlightLines, feelMotionTrail } from "@yagejs-addons/feel/renderer";
declare const player: Entity;declare const velocity: Vec2Like; // the player's current velocity
const flight = feelParallel( feelMotionTrail({ position: () => player.get(Transform).worldPosition, duration: "held", lifetime: 0.18, }), feelLoop(feelFlightLines({ direction: () => velocity }), 0.04),);
const feel = player.add(new Feel({ flight }));const flightPlayback = feel.play("flight");flightPlayback?.release();A loop finishes its current iteration after release and starts no new one. Release during a loop gap completes the loop immediately. A zero-duration loop child needs a positive gap.
Visual motion and physics
Section titled “Visual motion and physics”The /renderer entry includes position, rotation, and scale springs alongside
punches, recoil, bounce, squash/stretch, and shakes. Each effect targets a
VisualComponent, such as SpriteComponent. Position and rotation
contributions add; scale contributions multiply. Several cues can affect one
visual while its entity keeps moving normally.
import type { Vec2Like } from "@yagejs/core";import type { SpriteComponent } from "@yagejs/renderer";import { feelParallel } from "@yagejs-addons/feel";import { feelPositionSpring, feelRecoil, feelRotationSpring, feelScaleSpring, feelSquash,} from "@yagejs-addons/feel/renderer";
declare const playerSprite: SpriteComponent;declare const weaponSprite: SpriteComponent;declare const aimDirection: Vec2Like;
feelSquash({ target: playerSprite, axis: "y", amount: 0.25 });feelRecoil({ target: weaponSprite, direction: aimDirection, distance: 8,});
const springHit = feelParallel( feelPositionSpring({ target: playerSprite, offset: { x: -12, y: 0 } }), feelRotationSpring({ target: playerSprite, radians: 0.15 }), feelScaleSpring({ target: playerSprite, scale: 1.25 }),);A spring starts at the supplied visual displacement and oscillates around the
live base value until it settles. duration controls the settling time,
oscillations controls the number of rebounds, and decay controls how
quickly each rebound weakens. The defaults are 0.5 seconds, 2.5 oscillations,
and a decay of 2.
The renderer recomputes the displayed transform from the current base
Transform plus all active visual modifiers every frame. Each playback owns
one removable modifier. Cancellation removes that modifier without reversing
arithmetic or restoring a stale snapshot, so overlapping effects cannot leave
drift.
Camera and renderer feedback
Section titled “Camera and renderer feedback”The /renderer entry adds camera shake, rotation, and zoom pulses, opacity
pulses, blinking, hit flash, and shockwave. feelEffect accepts any YAGE
effect factory, so the addon also supports current and future
@yagejs/effects presets without one wrapper per filter.
import { easeOutQuad } from "@yagejs/core";import { bloom, chromaticAberration } from "@yagejs/effects";import type { RenderLayer } from "@yagejs/renderer";import { feelParallel } from "@yagejs-addons/feel";import { feelEffect } from "@yagejs-addons/feel/renderer";
declare const worldLayer: RenderLayer; // the scene's "world" layer
const critical = feelParallel( feelEffect(worldLayer.fx, bloom({ bloomScale: 1.5 }), { duration: 0.2, peakAt: 0.4, attackEasing: easeOutQuad, }), feelEffect(worldLayer.fx, chromaticAberration({ separation: 6 }), { duration: 0.12, }),);Each generic pulse attaches the effect, moves its primary intensity from zero to the cue-scaled peak and back, then removes it. Choose the renderer scope as you would without the addon: component for one sprite, layer for the world without the HUD, scene for the whole scene, or screen across scenes.
feelHitFlash, feelOpacity, feelRecoil, and feelBounce accept the same
four timing fields. Feel attributes a custom easing failure to its builder and
rejects a non-finite result before writing renderer state.
Use a dedicated Feel wrapper when cue playback needs more than that intensity
pulse. feelGlitch refreshes the filter’s bands from the scene’s seeded random
source during playback. Its default presence reaches full strength at
peakAt: 0.08, stays there until releaseAt: 0.72, then releases:
import type { SpriteComponent } from "@yagejs/renderer";import { feelGlitch } from "@yagejs-addons/feel/renderer";
declare const enemySprite: SpriteComponent;
const damaged = feelGlitch({ host: enemySprite.fx, slices: 8, offset: 24, refreshRate: 20, duration: 0.3,});Static zoom blur, axis blur, and implosion pulses use feelEffect directly.
The renderer effects remain available for persistent use without Feel.
feelDissolve is also a dedicated wrapper because it advances from intact to
transparent instead of pulsing back to zero. Cancellation removes its filter
and reveals the source visual again.
Ready-made recipes
Section titled “Ready-made recipes”The /recipes entry contains ready-made compositions. Recipe names omit the
feel prefix so imports remain visibly different from basic effect nodes:
import type { SpriteComponent } from "@yagejs/renderer";import { Feel } from "@yagejs-addons/feel";import { damageImpact, dashBurst, enemyDeath,} from "@yagejs-addons/feel/recipes";
declare const enemySprite: SpriteComponent;declare const playerSprite: SpriteComponent;let lastDamage = 0; // set by the game's combat code
const feel = entity.add( new Feel({ hurt: damageImpact({ target: enemySprite, value: () => lastDamage }), dash: dashBurst({ target: playerSprite, direction: { x: 1, y: 0 }, peakAt: 0.45, }), die: enemyDeath({ target: enemySprite, onComplete: ({ entity }) => entity.destroy(), }), }),);A recipe returns a normal FeelNode and uses the existing Feel component.
dashBurst applies its top-level pulse curve to squash and axis blur. Its
duration also controls the flight lines. The defaults are 0.3 seconds, a peak
at 0.3, and easeOutQuad for both phases.
| Recipe | What it combines |
|---|---|
impact | Hit flash, scale punch, visual shake, and an impact ring |
damageImpact | impact plus a floating damage number |
dashBurst | Axis stretch, axis blur, and directional flight lines |
spawnPop | Scale and position springs plus a short glow |
enemyDeath | Impact flash, ring, scale punch, shake, glow, and edged dissolve |
voidCollapse | Inward blur, center-expanding implosion, peak hold, and color shift |
Recipes do not add sound, camera movement, or time changes. enemyDeath
requires an onComplete callback because the addon cannot decide whether a
game should destroy, pool, hide, or replace an enemy. The callback runs after
the temporary handles are removed. It does not run if playback is cancelled.
Nested option objects expose the recipe’s basic parts.
Animated highlights
Section titled “Animated highlights”feelOutline, feelGlow, and feelColorize provide named pulses for three
common target highlights. Each effect resolves its target when the cue starts,
owns one filter handle, and removes only that handle when it finishes. Feel
marks the handle as temporary, so a snapshot taken during the pulse does not
restore it without its owning playback.
import type { SpriteComponent } from "@yagejs/renderer";import { feelParallel } from "@yagejs-addons/feel";import { feelGlow, feelOutline } from "@yagejs-addons/feel/renderer";
declare const enemySprite: SpriteComponent;
const selected = feelParallel( feelOutline({ target: enemySprite, color: 0xffd54a, thickness: 3, duration: 0.3, }), feelGlow({ target: enemySprite, color: 0xff8800, outerStrength: 5, duration: 0.3, }),);Damage numbers, floating text, and impact rings
Section titled “Damage numbers, floating text, and impact rings”The renderer entry can also create short-lived world-space callouts:
import { feelParallel } from "@yagejs-addons/feel";import { feelDamageNumber, feelFloatingText, feelImpactRing,} from "@yagejs-addons/feel/renderer";
// Set by the game's combat code:let lastDamage = 0;let lastHitWasCritical = false;
const criticalHit = feelParallel( feelDamageNumber({ value: () => lastDamage, critical: () => lastHitWasCritical, prefix: "-", layer: "effects", }), feelImpactRing({ color: 0xffd54a, spikes: 8, layer: "effects", }),);
const pickup = feelFloatingText({ text: "Health restored", style: { fill: 0x66ff99 }, travel: { x: 0, y: -40 },});feelFloatingText and feelDamageNumber default to the cue entity’s current
world Transform. Pass position or a position function to spawn elsewhere.
Text, critical-hit state, and damage values can also be functions, evaluated
for each playback.
Each playback creates its own temporary entity. Completion and cancellation destroy that entity, so several callouts can overlap without sharing a base position, alpha, or scale. Active callouts are omitted from save snapshots. This path suits ordinary combat and pickup feedback. A game that displays very large numbers of callouts every frame should use a custom pool instead.
Flight lines, motion trails, and afterimages
Section titled “Flight lines, motion trails, and afterimages”feelFlightLines draws a short field of directional streaks. The field moves
opposite the supplied direction while it fades. feelMotionTrail samples a
live world position and draws a line through the recent samples.
feelAfterimage leaves tinted copies of a sprite’s current frame behind its
rendered pose.
import { Transform, type Entity, type Vec2Like } from "@yagejs/core";import type { SpriteComponent } from "@yagejs/renderer";import { feelParallel } from "@yagejs-addons/feel";import { feelAfterimage, feelFlightLines, feelMotionTrail,} from "@yagejs-addons/feel/renderer";
declare const player: Entity;declare const playerSprite: SpriteComponent;declare const velocity: Vec2Like; // the player's current velocity
const dash = feelParallel( feelFlightLines({ direction: () => velocity, count: 10, length: [20, 48], duration: 0.25, }), feelMotionTrail({ position: () => player.get(Transform).worldPosition, duration: 0.3, lifetime: 0.18, color: 0x66ddff, }), feelAfterimage({ target: playerSprite, count: 5, interval: 0.05, lifetime: 0.25, tint: 0x1e3a8a, }),);A fixed flight-line direction must be finite with a magnitude greater than
1e-6. feelFlightLines checks fixed directions when the node is built. A
direction function is evaluated once when each burst starts. If the function
returns a finite zero or near-zero vector, Feel creates no streak entity for
that burst. The empty burst still lasts for its configured duration, so
sequences, repeats, and loops keep their timing.
The motion trail collects samples for duration, then remains alive for one
lifetime so the final segments fade. Afterimages accept SpriteComponent
and AnimatedSpriteComponent; each copy captures the current animation frame,
anchor, and effective rendered transform. All three effects own temporary
entities. Completion and cancellation destroy those entities without changing
the source Transform.
Set a motion trail’s duration to "held" when gameplay owns the end signal.
The trail samples until release, stops collecting points, and stays alive for
one lifetime while the last points fade.
Time, sound, particles, and animation
Section titled “Time, sound, particles, and animation”feelHitStop freezes the scene. Its owner freezes by default, including any
squash or shake that the same Feel component is advancing. Set
includeOwner: false to let that entity’s components, processes, and particle
emitters continue during hitstop. The scene’s shared physics world remains
frozen.
Without a target, feelSlowMotion scales the scene and excludes the Feel
owner by default. Set includeOwner: true when the owner should slow too. Pass
target to slow only one entity, or use feelTargetFreeze as the descriptive
zero-scale form:
import type { Entity } from "@yagejs/core";import { feelParallel, feelSlowMotion, feelTargetFreeze } from "@yagejs-addons/feel";
declare const enemy: Entity;
const stagger = feelParallel( feelSlowMotion({ target: enemy, scale: 0.25, duration: 0.2 }), feelTargetFreeze({ target: enemy, duration: 0.05 }),);Target time effects stop component updates, processes, sprite animation, and particle emitters. They do not stop a rigid body because physics uses one shared scene timestep.
Time requests expire on SceneTime’s raw clock. Stopping a cue after a time
request starts does not undo that request. A finite play-time duration override
also scales the issued request duration. A positive-duration time node advances
its sequence only after its retained TimeEffectHandle becomes inactive, even
when its owner receives zero or scaled update time.
feelSound plays a preloaded alias or picks one recording from a non-empty
alias list. The selection uses the scene’s seeded random source on each sound
start. A sound is a zero-time sequence step: the next child starts immediately,
while the overall cue stays active until the recording ends.
Choose whether the recording should finish after its owner disappears:
import { feelLoop } from "@yagejs-addons/feel";import { feelSound } from "@yagejs-addons/feel/audio";
const confirm = feelSound({ alias: "confirm", lifetime: "sound" });const hurt = feelSound({ alias: ["hurt-1", "hurt-2", "hurt-3"] });const reel = feelLoop(feelSound({ alias: "reel", fadeOut: 0.02 }), 0.15);The default lifetime: "cue" stops the sound on release, cancellation, disable,
or destruction. Use lifetime: "sound" for menu confirmations, gunshots, and
other sounds that should finish through those events or a scene change. Audio
channel stops and asset unloading can still interrupt them. onEnd runs only
on natural audio completion. Cue lifetime suppresses the callback once release
or cancellation begins, including when the recording ends during its fade.
With sound lifetime, the callback may run after its Feel owner is gone.
For cue-owned sounds, fadeOut sets a fade to silence in seconds. It must be
finite and non-negative; the default 0 stops immediately. A positive fade
cannot be combined with sound lifetime. Graceful release waits for the audio
fade to finish. Cancellation ends the cue immediately while the audio tail
continues. Fades use the engine-global frame clock and follow
ProcessSystem.timeScale, so they continue across scene changes and individual
scene pauses. They are not sample-accurate audio ramps.
With once: true, cues share a recording by the selected alias and channel.
Releasing one cue cannot stop or fade a recording another cue still owns.
Only the last request’s fade duration applies. A new request made during that
fade starts a fresh recording.
feelLoop(feelSound(...), gap) repeats sounds on cue update frames with a
positive gap between their starts. Copies can overlap if the gap is shorter
than the recording. Use SoundComponent when you need continuous audio
looping.
feelParticleBurst bursts an existing emitter. feelParticleEmit owns a
temporary emission request. Set duration: "held" to emit until graceful
release. Particles that are already alive keep their own lifetimes. Manual
emission and overlapping requests remain active when one cue finishes.
feelKeyframeAnimation starts a core KeyframeAnimator timeline. Import
feelSpriteAnimation from /renderer for named AnimationController
playback:
import type { AnimationController } from "@yagejs/renderer";import { feelSpriteAnimation } from "@yagejs-addons/feel/renderer";
declare const enemyAnimations: AnimationController;
const stagger = feelSpriteAnimation("stagger", { target: enemyAnimations, mode: "oneShot", onCancel: () => console.log("Stagger animation interrupted"),});feelKeyframeAnimation and the "play" or "force" sprite modes start
playback and complete their Feel node immediately. A "oneShot" sprite node
with an explicit duration stays active for that duration and accepts finite
play-time retiming. Stopping the cue later does not stop the animation.
duration, onComplete, and onCancel require mode: "oneShot".
onComplete reports natural animation completion. onCancel reports an
interruption, such as another one-shot, forcePlay, unlock, or destruction.
Neither callback changes the Feel node’s timing. Without an explicit duration,
the node completes immediately after starting the animation.
Save and load
Section titled “Save and load”Feel feedback does not become saved game state. Cue definitions, cooldown
clocks, active playbacks, renderer and camera modifiers, temporary filters,
time requests, particle-emission requests, live particles, and transient visual
entities are runtime-only. Normal entity setup constructs Feel whenever the
game builds the scene:
import { Entity } from "@yagejs/core";import { SpriteComponent } from "@yagejs/renderer";import { Feel } from "@yagejs-addons/feel";import { feelSquash } from "@yagejs-addons/feel/renderer";
class Enemy extends Entity { setup() { const sprite = this.get(SpriteComponent); this.add(new Feel({ hit: feelSquash({ target: sprite }) })); }}The component starts with no cue in progress. Built-in Feel effects leave base transform, camera, and visual values unchanged.
Custom effects and events
Section titled “Custom effects and events”Use feelCall for an instant game callback. defineFeelEffect creates a
game-specific timed effect. The context reports the leaf’s effective
duration, and update(progress, dt) receives dt on the cue’s local clock:
import type { SpriteComponent, VisualTransformModifierHandle } from "@yagejs/renderer";import { defineFeelEffect } from "@yagejs-addons/feel";
declare const sprite: SpriteComponent;
const customSquash = defineFeelEffect(0.2, (context) => { let modifier: VisualTransformModifierHandle | undefined; return { start: () => (modifier = sprite.modifiers.addTransform()), update: (progress) => modifier?.setScale(1 + Math.sin(progress * Math.PI) * 0.2 * context.intensity), finish: () => modifier?.remove(), };});The returned FeelNode composes with every built-in node. A custom effect can
also resolve scene services through context.resolve. Developer callbacks can
use context.invoke when they need their own error-attribution label. Custom
effects should acquire removable handles in start and release them in
finish, which runs on completion and cancellation. An effect that owns an
external source can also provide release() and isComplete(). The source
can keep the overall playback active after a zero-time sequence step.
Use defineFeelState for an effect that attacks, holds until release, then
returns to zero:
import { easeOutQuad } from "@yagejs/core";import type { SpriteComponent } from "@yagejs/renderer";import { defineFeelState } from "@yagejs-addons/feel";
declare const sprite: SpriteComponent;
const focusGlow = defineFeelState( { attack: 0.08, release: 0.16, attackEasing: easeOutQuad }, () => { const opacity = sprite.modifiers.addOpacity(); return { update: (amount) => opacity.setFactor(1 + amount * 0.5), finish: () => opacity.remove(), }; },);The state instance receives update(amount, dt). amount moves from 0 to
1, stays at 1, and falls from its current value to 0 after release.
Releasing during attack does not jump to full intensity. Attack and release
durations are not changed by a play-time duration override. State easing
callbacks and instance hooks use Feel’s callback error boundary.
The host entity emits FeelStartedEvent, FeelCompletedEvent, and
FeelStoppedEvent.
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/addons/feel.md.