Skip to content

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");
Terminal window
npm install @yagejs-addons/feel @yagejs/core
# Add only the optional peers your cues use:
npm install @yagejs/renderer @yagejs/effects
npm install @yagejs/audio
npm install @yagejs/particles

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

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.

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.

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.

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.

RecipeWhat it combines
impactHit flash, scale punch, visual shake, and an impact ring
damageImpactimpact plus a floating damage number
dashBurstAxis stretch, axis blur, and directional flight lines
spawnPopScale and position springs plus a short glow
enemyDeathImpact flash, ring, scale punch, shake, glow, and edged dissolve
voidCollapseInward 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.

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.

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.

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.

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.