Skip to content

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.

Terminal window
npm install @yagejs-addons/abilities @yagejs/core @yagejs/physics
# Add this only when using AbilityDriver:
npm install @yagejs/input

Register PhysicsPlugin before using hitboxes, projectiles, touch damage, or the default knockback reaction. The addon does not add a plugin of its own.

  1. Define an ability. A plain timeline is 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 },
    }),
    ],
    };
  2. Add the runner and receiver components. A hittable entity declares the Hittable trait and delegates receiveHit to HitReceiver. Facing supplies the default aim for delivery steps; update it from movement or pass an explicit aim resolver.

    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 +x
    this.add(new Health({ max: 100 }));
    this.add(new Stagger());
    this.add(new HitReceiver({ team: "player", iframes: 0.15 }));
    this.add(new Abilities([SLASH]));
    }
    }
  3. 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);

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.

The runtime surface is small:

abilities.send(intent, { data, lane }); // PlayResult
abilities.canSend(intent, { lane, interrupts: true }); // boolean dry-run
abilities.release(intent); // complete a matching hold
abilities.cancel(lane); // cancel one lane
abilities.cancelAll(); // cancel every lane
abilities.force(reactionDef); // reactions only

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

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

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.

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.

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:

  • hitbox creates 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-time HitSpec. With every, current overlaps receive repeat deliveries; without it, each target receives at most one hit per window.
  • spawn creates a game-defined @trait(AbilitySpawned) entity. The setup context carries the original caster, aim, team, spawn position, typed params, activation, and optional reporting delivery. position accepts an absolute world point or fire-time resolver; facing-local offset applies afterward. Projectile is the supplied moving entity for this path.
  • TouchDamage delivers on contact at a fixed interval.
  • createHitDelivery is 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.

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.

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.

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.

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.

The addon has no attribute or resource model. Four existing boundaries cover the common numeric cases:

  • Attack values: pass a fire-time HitSpec builder to hitbox or spawn.
  • Defense: prepend a game-authored HitStage that adjusts hit.data before defaultHitSteps.
  • Maximum HP: push the computed value into the public Health.max field, then clamp or heal hp according to game rules.
  • Cooldown speed: use a Scalar function. 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.

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.

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.

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),
}),
);

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.