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 { 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";
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.
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:
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.
const flight = feelParallel( feelMotionTrail({ position: () => player.get(Transform).worldPosition, duration: "held", lifetime: 0.18, }), feelLoop(feelFlightLines({ direction: () => velocity }), 0.04),);
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.
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 { feelEffect } from "@yagejs-addons/feel/renderer";
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:
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 { damageImpact, dashBurst, enemyDeath,} from "@yagejs-addons/feel/recipes";
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.
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:
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.
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:
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, with optional speed randomization. The
sound handle belongs to the cue until the sound ends naturally. A sound is a
zero-time sequence step, so the next sequence child starts immediately, but
the overall playback remains active while the sound plays. Graceful release or
cancellation stops a sound that is still playing. onEnd runs only after
natural audio completion. When once: true shares a sound between Feel
playbacks, each playback owns one AudioManager.requestOnce request. Releasing
one cue does not stop another request or a playOnce owner.
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:
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:
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:
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:
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.