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");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 temporarily release the effects they created, 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.
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,
and typed data. 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, and optional ability provenance. Reporting deliveries
inherit the source HitReceiver.team; an explicit delivery team wins.
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.
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”Health is serializable under a stable namespaced type and round-trips
{ hp, max } without emitting damage, heal, or death events during restore.
All other addon runtime values are transient. Snapshots do not resume cooldowns, active phases or lanes, activation payloads, linger, forced reactions, driver buffers or held-input ownership, receiver i-frames, open guards, invulnerability windows, stagger, facing, or time requests. Loading a snapshot is a game-owned reconstruction point: restore entity state, rebuild the definitions and input driver, then re-enter safe gameplay state.
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. A dynamic physics body
can act as an immovable corpse by zeroing velocity and disabling translation:
entity.on(HealthDied, () => { body.setVelocity(Vec2.ZERO); body.setEnabledTranslations(false, false); 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.