Abilities
@yagejs-addons/abilities supplies the timed action and hit-receipt code that
action games usually repeat for every attack. Abilities are phase graphs driven
by intent strings. The same package supplies hitboxes, projectiles, touch
damage, guards, health, knockback, and hit-stun.
The root entry is headless. It depends on @yagejs/core and
@yagejs/physics, but has no renderer or Pixi dependency. Player input is an
optional adapter under @yagejs-addons/abilities/input; AI and scripted actors
call the runner directly.
Install
Section titled “Install”npm install @yagejs-addons/abilities @yagejs/core @yagejs/physics# Add this only when using AbilityDriver:npm install @yagejs/inputRegister PhysicsPlugin before using hitboxes, projectiles, touch damage, or
the default knockback reaction. The addon does not add a plugin of its own.
Quick start
Section titled “Quick start”-
Define an ability. A plain
timelineis a single phase.import type { AbilityDef } from "@yagejs-addons/abilities";import { hitbox } from "@yagejs-addons/abilities";const SLASH: AbilityDef = {id: "slash",cooldown: 0.45,duration: 0.35,timeline: [hitbox({from: 0.08,to: 0.2,shape: { type: "capsule", halfHeight: 18, radius: 10, axis: "x" },offset: { x: 30, y: 0 },hit: { damage: 18, knockback: 260, stun: 0.3 },}),],}; -
Add the runner and receiver components. A hittable entity declares the
Hittabletrait and delegatesreceiveHittoHitReceiver.Facingsupplies the default aim for delivery steps; update it from movement or pass an explicitaimresolver.import { Entity, ProcessComponent, Transform, trait } from "@yagejs/core";import { ColliderComponent, RigidBodyComponent } from "@yagejs/physics";import {Abilities, Facing, Health, Hittable, HitReceiver, Stagger,} from "@yagejs-addons/abilities";import type { Hit, HitResult } from "@yagejs-addons/abilities";@trait(Hittable)class Fighter extends Entity {receiveHit(hit: Hit): HitResult {return this.get(HitReceiver).receive(hit);}setup(): void {this.add(new Transform());this.add(new ProcessComponent());this.add(new RigidBodyComponent({ type: "dynamic" }));this.add(new ColliderComponent({ shape: { type: "circle", radius: 12 } }));this.add(new Facing()); // defaults to +xthis.add(new Health({ max: 100 }));this.add(new Stagger());this.add(new HitReceiver({ team: "player", iframes: 0.15 }));this.add(new Abilities([SLASH]));}} -
Send an intent. Players and AI use the same runner call.
const abilities = fighter.get(Abilities);const result = abilities.send("slash");if (!result.ok) console.log(result.reason);
Author timelines and phases
Section titled “Author timelines and phases”Every timeline step is either a point (at) or a window (from/to). The
built-in steps cover common combat work: hitbox, spawn, guard, parry,
block, invulnerable, slowmo, anim, and staggerMotion. Use
defineStep for game-specific movement, animation, sound, or effects.
A combo is one definition with named phases. Each phase can handle an intent
through on. A guarded transition accepts the intent only inside its
transition window. Use from: "end" with for for a post-phase combo input:
const COMBO: AbilityDef = { id: "attack", tags: ["melee"], phases: { jab: { duration: 0.4, timeline: [/* jab steps */], on: { attack: { to: "cross", from: "end", for: 0.25 } }, }, cross: { duration: 0.46, timeline: [/* cross steps */], on: { attack: { to: "hook", from: "end", for: 0.25 } }, }, hook: { duration: 0.8, timeline: [/* finisher steps */] }, },};An absolute until value past the phase duration also creates linger. A
transition chooses either absolute until or relative for. from: "end"
is valid only on a fixed-duration phase. Sending the intent during linger
starts a new activation at the target phase without checking or re-arming the
original cooldown.
A hold phase lasts until release(intent), hold.max, or an after
transition. next is the normal release destination:
const CHARGE: AbilityDef = { id: "charge", phases: { windup: { hold: { max: 3 }, next: "strike", timeline: [anim({ at: 0, name: "charge" })], }, strike: { priority: 110, timeline: [hitbox({ from: 0.1, to: 0.25, shape: { type: "capsule", halfHeight: 18, radius: 10, axis: "x" }, hit: { damage: 24, knockback: 320, stun: 0.35 }, })], }, },};
abilities.send("charge");abilities.release("charge");When two lanes hold the same intent, pass release("charge", { lane: "weapon" })
to release only the named lane. Omitting lane releases the first matching
hold. AbilityDriver automatically releases the lane of the activation it owns.
priority decides whether an incoming definition may interrupt a busy lane.
cancels opens an explicit admission window. String matchers name resolved
definition ids; { tag: "movement" } matches any definition carrying that
tag:
const attack: AbilityDef = { id: "attack", duration: 0.7, cancels: [{ from: 0.35, into: [{ tag: "movement" }] }], timeline: [/* ... */],};
const dash: AbilityDef = { id: "dash", tags: ["movement"], timeline: [/* ... */],};Only one activation occupies a lane. Definitions use the "main" lane by
default; actions such as potions can use lane: "item" and run at the same
time.
Drive the runner
Section titled “Drive the runner”The runtime surface is small:
abilities.send(intent, { data, lane }); // PlayResultabilities.canSend(intent, { lane, interrupts: true }); // boolean dry-runabilities.release(intent); // complete a matching holdabilities.cancel(lane); // cancel one laneabilities.cancelAll(); // cancel every laneabilities.force(reactionDef); // reactions onlysend returns { ok: true, activation } or { ok: false, reason }. Rejection
reasons are "cooldown", "busy", and "noMatch". An unknown intent throws
because it is an authoring error.
canSend is polite by default: it does not count a higher-priority interrupt
as available, which lets buffered presses wait instead of cutting off the
current action. Pass { interrupts: true } for a full dry-run of a direct
send.
active(lane) returns AbilityActivation | null. The handle exposes the
definition, phase, phase clock, total elapsed time, payload, lane, entity,
forced flag, terminal state, isHolding, and isStepActive(kind). Use
activeId, isActive, elapsed,
cooldownRemaining, and cooldownRatio for simpler reads.
Listen to AbilityStarted, AbilityPhaseChanged, and AbilityEnded on the entity.
One run emits one start/end pair; phase changes do not end the activation.
Disabling Abilities, or deactivating its entity, pauses active phases, linger,
and cooldowns. New send, canSend, force, and release calls are refused.
Open windows call their disable hooks to release effects, including detached
hitboxes and scene-level slow motion. The sibling components keep their own
enabled values. Enabling Abilities restores the same activation, clocks,
and open-window effects.
The clock
Section titled “The clock”Ability timing is game logic, so it advances on the engine’s fixed timestep
by default: phase timelines and their windows, after schedules, linger
reach, and cooldowns all count fixed-step seconds, in step with physics
rather than the display’s frame rate. The other addon timers do the same —
HitReceiver i-frames, Stagger, TouchDamage intervals, Projectile
lifetime, and hitbox follow tracking run in fixedUpdate.
A send() made from a component’s fixedUpdate first advances on the next
fixed step. Fixed-clock processes run before component fixed updates; see the
frame order when coordinating an attack with physics.
Presentation stays smooth regardless: an anim step triggers a
KeyframeAnimator whose playback runs on rendered-frame time by default, and
any tween a step starts keeps the frame clock unless scheduled otherwise. An
animation def can pick the fixed clock with clock: "fixed" for timing that
must stay in step with the fixed step, most often a timeline whose keyframe
events drive gameplay. Input is sampled once per rendered
frame, so an intent sent this frame starts a timeline that then advances on
the fixed step.
For a purely presentation-driven timeline with no simulation coupling, opt into rendered-frame timing:
entity.add(new Abilities(defs, { clock: "frame" }));The component’s clock is readable as abilities.clock — a custom step that
starts its own gameplay timer can match it with
pc.run(p, { clock: ctx.abilities.clock }).
Map input actions to intents
Section titled “Map input actions to intents”AbilityDriverComponent handles input edges, tap/hold classification, retry
buffers, hold release, payload capture, and resuming an interrupted hold. Add
it after Abilities; it resolves InputManagerKey, updates automatically,
and cleans up with the entity. Disabling the component or its entity releases
the input listeners and any owned hold. Enabling it binds a fresh driver:
import { AbilityDriverComponent } from "@yagejs-addons/abilities/input";
this.add(new AbilityDriverComponent({ defaults: { tapWithin: 0.22, holdAt: 0.5 }, bindings: { attack: { tap: { send: "attack", buffer: 0.18 }, hold: { send: "charge", fromNeutral: true, resume: true, release: { send: "charge-release", buffer: 0.4 }, }, }, dash: { press: { send: "dash", buffer: 0.12 } }, },}));Use the plain AbilityDriver(input, abilities, options) when another object
owns lifecycle. Call its update() from normal update, not fixedUpdate, and
call dispose() on removal. Gesture thresholds and buffers use raw input
seconds; ability phases use scaled scene time.
Every press, tap, hold, and nested hold.release send accepts buffer
and data. A data resolver runs at the input edge and receives the action,
gesture, intent, lane, raw heldFor, and the activation owned by the press.
The captured value becomes activation.payload even when a buffered send
fires later.
Use gate(context) for game-side admission such as stamina. Use
beforeFire(context) to sample or spend state immediately before an admitted
send. Games that do not want the adapter call send and release directly.
Replace a complete loadout
Section titled “Replace a complete loadout”Use addDefinitions(defs) to install optional definitions without cancelling
runs or clearing cooldown and linger state. The method validates the complete
prospective set before changing the live indexes.
replaceDefinitions validates and compiles the complete prospective set
before changing live state. A validation failure leaves the installed set
untouched. A successful replacement cancels every active run, removes linger
and cooldown work, installs the new intent vocabulary, and emits cancellation
events after listeners can observe the new set.
abilities.replaceDefinitions(next.defs);driverComponent.replace(next.input);The game owns the matching input map. Replacing definitions does not reload an
input mapping. Driver replacement discards buffered sends, recorded edges,
and held-input ownership; a held action must be released and pressed again.
Use namespaced ids such as "sword/attack" and "staff/attack" when
loadouts use different intent vocabularies.
Receive and deliver hits
Section titled “Receive and deliver hits”HitReceiver resolves a hit in this order: team filter, i-frames, open guards,
then ordered consequence stages. The default stages subtract
StandardHitData.damage through Health and apply
knockback/stun through Stagger.
Hit carries the source entity, unit direction, optional team, string tags,
typed data, and, when the delivery could measure it, a contact: the
world-pixel point on the target collider’s surface and the unit normal out of
that surface toward the attacking shape. The default data fields are:
interface StandardHitData { damage?: number; knockback?: number; // px/s stun?: number; // seconds hitstop?: number; // carried to HitDealt; the game applies it}The built-in delivery paths share one HitDelivery contract:
hitboxcreates a sensor for a timeline window. It accepts circle, box, capsule, or polygon shapes, a facing-local offset, optional follow, collider layers/mask, aim, team, tags, and a static or fire-timeHitSpec. Withevery, current overlaps receive repeat deliveries; without it, each target receives at most one hit per window.spawncreates a game-defined@trait(AbilitySpawned)entity. The setup context carries the original caster, aim, team, spawn position, typed params, activation, and optional reporting delivery.positionaccepts an absolute world point or fire-time resolver; facing-localoffsetapplies afterward.Projectileis the supplied moving entity for this path.TouchDamagedelivers on contact at a fixed interval.createHitDeliveryis the lower-level escape route for a custom overlap source.
HitReceived fires on the victim with { hit, guardOutcomes } after a hit
lands. HitGuarded fires for an engaged guard. createReportingDelivery also
emits HitDealt on the original attacker with the result, typed default
fire-time data, target, optional ability provenance, and the hit’s contact.
Reporting deliveries inherit the source HitReceiver.team; an explicit
delivery team wins.
Where a hit landed
Section titled “Where a hit landed”hit.contact places impact effects on the struck surface instead of at an
authored offset. hitbox and a sensor Projectile measure the sensor and
target collider pair that fired the trigger, a solid Projectile or
TouchDamage body uses the collision’s own contact, and repeat hits measure
again. The geometry is captured before damage or death changes the target, so
a killing hit reports it on HitDealt too. It is absent when nothing could be
measured, and direction keeps its meaning: from the delivery origin toward
the target’s position.
attacker.on(HitDealt, ({ target, contact }) => { const at = contact?.point ?? target.get(Transform).worldPosition; sparks.burstAt(at, contact?.normal);});A hitbox whose window opens already overlapping the target reports a point on the target’s surface from the current overlap. It cannot reconstruct where an animated swing first touched.
A custom overlap source passes what it knows as the third argument of
deliver. resolveHitContact(collider, event) turns a trigger or collision
event into that value; queryHitContact re-measures a remembered pair for a
repeat delivery.
collider.onTrigger((ev) => { if (ev.entered) delivery.deliver(ev.other, origin, resolveHitContact(collider, ev));});Destroyed targets return "ignored" and receive no hit callbacks, even before
their deferred teardown runs. An attack can still hit after its caster dies.
Import the event tokens to subscribe. Their ids are abilities:health:damaged,
abilities:health:healed, abilities:health:died, abilities:hit:received,
abilities:hit:dealt, and abilities:hit:guarded.
Solid projectiles
Section titled “Solid projectiles”Projectile defaults to a sensor with no gravity. Set sensor: false to use
solid collision response and gravityScale: 1 to apply world gravity:
spawn({ at: 0, entity: Projectile, params: { speed: 200, lifetime: 3, shape: { type: "circle", radius: 5 }, sensor: false, gravityScale: 1, consume: (result) => result !== "ignored", }, hit: { damage: 12 },});This rule does not consume the projectile when delivery returns "ignored",
so it can land on a one-way platform. Configure oneWay on the platform’s collider.
Without a custom rule, shouldConsumeProjectile(result, otherIsSensor) consumes
on any accepted hit or non-sensor contact. Speed must be finite; lifetime must
be finite and non-negative.
Pooled attacks
Section titled “Pooled attacks”Pass acquire to reuse a game-owned entity pool instead of creating an entity
for every attack:
spawn({ at: 0.2, entity: PooledAttack, params: { speed: 300, lifetime: 2 }, hit: { damage: 12 }, acquire: (context) => attackPool.acquire(context),});The callback receives the resolved spawn context and, as its second argument,
the live StepContext. Returning undefined skips the spawn, including when
the pool is full. No extra entity is created.
Use a purpose-built PoolableEntity with
setup(context?: AbilitySpawnContext<AttackParams>) to construct components
once. The optional context supports pool prewarming and preserves typed
params. In onAcquire(context), store the new abilitySpawnContext and reset
position, velocity, lifetime, and hit state. Release through the pool when the
attack finishes. The supplied Projectile is not poolable.
Guards, invulnerability, reactions, and time
Section titled “Guards, invulnerability, reactions, and time”guard opens a window whose policy returns "pass", "modified", or
"negate". Use block for scaled damage/knockback/stun and parry for a
negating result with an optional punish hit. invulnerable opens a receipt
window that coexists with the receiver’s post-hit i-frames.
staggerReaction creates the default forced reaction definition. The default
reactionStep runs only when a landed hit has positive stun. With
Abilities, it prefers a forced staggerReaction; Stagger is the direct
fallback when no runner is present. Knockback without positive stun does not
start a reaction. Reaction priority is REACTION_PRIORITY (100). Give a
phase a higher priority only when that phase has earned super armor.
anim drives core’s renderer-free KeyframeAnimator. Sprite-sheet and other
renderer animation controllers remain game code; define a custom timeline step
when those controllers should follow an ability.
slowmo({ from, to, ... }) is a cancellation-bound window.
slowmo({ at, for, ... }) creates a raw-time request that can outlive phase
completion or cancellation. Hitstop stays game-owned: declare hitstop beside
the hit numbers, then respond to
HitDealt with SceneTime.freezeFor(data.hitstop). This lets one game choose
which landed hits freeze time and which entities are excluded.
Define game-specific steps
Section titled “Define game-specific steps”defineStep(name, hooks) returns a typed point/window factory. Components own
the effect; the step only opens, updates, and closes it:
const lunge = defineStep<{ speed: number }>("velocity", { enter({ speed }, ctx) { const direction = ctx.entity.get(Facing).unit; ctx.entity.get(RigidBodyComponent).setVelocity(direction.scale(speed)); }, exit(_params, ctx) { ctx.entity.get(RigidBodyComponent).setVelocity(Vec2.ZERO); },});If a custom window owns a live resource, add onDisable and onEnable hooks.
onDisable temporarily releases the resource when Abilities becomes
dormant. onEnable restores it without reopening the window or resetting its
clock. These hooks do not change another component’s enabled value unless
the custom implementation explicitly does so.
Give every velocity-owning window the same kind, then damp or overwrite
velocity only when !abilities.active()?.isStepActive("velocity") and
Stagger.active is false.
For a step that takes no params, write defineStep<Record<never, never>>(...) or
omit the type argument. Avoid Record<string, never>: its [string]: never index
signature also covers the timing fields defineStep adds to the returned factory
(at for a point step; from / to / every? for a window step), forcing them
to never so the factory can’t be called.
Integrate game stats and costs
Section titled “Integrate game stats and costs”The addon has no attribute or resource model. Four existing boundaries cover the common numeric cases:
- Attack values: pass a fire-time
HitSpecbuilder tohitboxorspawn. - Defense: prepend a game-authored
HitStagethat adjustshit.databeforedefaultHitSteps. - Maximum HP: push the computed value into the public
Health.maxfield, then clamp or healhpaccording to game rules. - Cooldown speed: use a
Scalarfunction. The runner resolves it once when the ability starts, so an already armed cooldown does not change under a later haste update.
Cooldowns must be finite. Static values are checked when definitions compile; function results are checked at activation before cooldown state changes. Finite values at or below zero mean no cooldown.
const ATTACK: AbilityDef = { id: "attack", cooldown: (ctx) => 0.8 / statsOf(ctx.entity).attackSpeed, timeline: [ hitbox({ from: 0.1, to: 0.2, shape: { type: "capsule", halfHeight: 18, radius: 10, axis: "x" }, hit: (ctx) => ({ damage: statsOf(ctx.entity).attack, stun: 0.2 }), }), ],};Resource costs use input or game policy: check in a binding gate, then spend
in beforeFire after admission. AI can perform the same check before calling
send.
Use custom hit data
Section titled “Use custom hit data”Raw primitives accept generics directly:
interface ElementHit extends StandardHitData { element: "fire" | "ice";}
const receiver = new HitReceiver<ElementHit>({ steps });const burn = hitbox<ElementHit>({ ...args, hit: { element: "fire", damage: 8 } });For a game or combat system that uses the same data type throughout, call
createHitTools once. The returned factories pin the type and supply guards
for singleton trait and event boundaries:
const elementHits = createHitTools<ElementHit>({ isData(data): data is ElementHit { return typeof data === "object" && data !== null && "element" in data; },});
const receiver = elementHits.receiver({ steps: [elementHits.stage(resist)] });const burn = elementHits.hitbox({ ...args, hit: { element: "fire", damage: 8 } });
attacker.on(HitDealt, ({ data }) => { if (elementHits.isData(data)) applyElementFeedback(data.element);});Use isHit before passing an unknown Hit across the global Hittable
boundary to a typed receiver. The helper does not create components, events,
ability catalogs, or input drivers; every returned operation is also available
as a raw export.
Save and restore
Section titled “Save and restore”YAGE does not persist combat components automatically. If health is durable,
store { hp: health.hp, max: health.max } in the game’s explicit save root and
rebuild it with new Health({ max, initial: hp }). Construction does not emit
damage, heal, or death events.
Treat cooldowns, active phases or lanes, activation payloads, linger, forced reactions, driver buffers, held-input ownership, receiver i-frames, open guards, invulnerability windows, stagger, facing, and time requests as runtime state unless the game deliberately models them as durable domain state. Load the explicit state first, then rebuild definitions, input, and a safe gameplay scene from it.
Death and corpses
Section titled “Death and corpses”HealthDied reports death; the game decides what death does. Removing a
controller from inside its own event listener is safe. Switching the body to
"static" makes an immovable corpse: nothing pushes it and it pushes nothing.
entity.on(HealthDied, () => { body.setType("static"); entity.remove(EnemyController);});Corpses remain hittable unless the receiver filter rejects them. Preserve the default team rule when adding the dead-state check:
const health = entity.get(Health);
const sameTeamAllowed = (hit: Hit, receiver: HitReceiver) => receiver.team === undefined || hit.team === undefined || hit.team !== receiver.team;
entity.add( new HitReceiver({ team: "enemy", filter: (hit, receiver) => !health.isDead && sameTeamAllowed(hit, receiver), }),);Try the example
Section titled “Try the example”The repository’s examples/abilities-addon.html is a playable arena brawl. It
shows phased combos, charge/release, cancel windows, guards, reactions,
hitstop, a game-side stats slice, an input driver, and complete combo/power
loadout replacement. Press E to replace the definitions and matching input
driver while the same Abilities component stays mounted.