npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@phalanx-engine/abilities

v0.1.4

Published

Deterministic gameplay ability system for Phalanx Engine

Downloads

795

Readme

Phalanx Abilities

A deterministic gameplay ability system (GAS-inspired) for the Phalanx Engine. Built for lockstep multiplayer: attributes, effects, tags, abilities, targeting, and gameplay cues all run on fixed-point math and tick-aligned ECS systems.

Sibling packages: phalanx-ecs (ECS core), phalanx-math (fixed-point math).

Features

  • Attributes: base + current, per-definition min/max, FIFO modifier aggregation (Add / Multiply / Override), clamping
  • Gameplay effects: Instant, Duration, Periodic with tick-based durationTicks / periodTicks
  • Gameplay tags: hierarchical strings (State.Buff.Speed), tagsRequired / tagsBlocked / tagsGranted on effects and abilities
  • Abilities: declarative definitions, activation queue, cost/cooldown via effects, CanActivate checks
  • Targeting: Self, Entity, Point with deterministic resolve
  • Activation hooks: deterministic callbacks for projectiles and rockets (user-owned entities)
  • Gameplay cues: per-tick simulation buffer → optional client presentation via self-managing Cue instances (VFX/SFX/UI)

MVP scope

Included in v0.1: flat modifiers, channeling via Duration + removeEffectsByTag, hooks.

Planned for v2: execution calculations, granted abilities, stacking rules, line-of-sight raycast, SoA attribute storage.

Installation

Not on npm yet — clone the monorepo and build via pnpm.

git clone https://github.com/phaeton-forge/phalanx-engine.git
cd phalanx-engine
pnpm install
pnpm --filter @phalanx-engine/abilities build

Peer dependencies: @phalanx-engine/ecs ^0.1.0, @phalanx-engine/math ^0.1.0

Quick start

This example wires @phalanx-engine/ecs (GameWorld) and @phalanx-engine/math (FP) into one abilities pipeline.

import { Entity, GameWorld, resetEntityIdCounter } from '@phalanx-engine/ecs';
import { FP } from '@phalanx-engine/math';
import {
  createAbilitySystem,
  Cue,
  defineAbility,
  defineAbilitySystem,
  defineAttribute,
  defineEffect,
  type CueContext,
  type GameplayCueDispatchedEvent,
} from '@phalanx-engine/abilities';

/** Presentation cue — spawned per dispatch, animated in afterFrame. */
class FireballHitCue extends Cue {
  private done = false;

  public onSpawn(event: GameplayCueDispatchedEvent, _ctx: CueContext): void {
    // build VFX from event + ctx.entityManager
  }

  public override update(_dt: number): void {
    // animate each render frame
    if (/* animation complete */) this.done = true;
  }

  public override isFinished(): boolean {
    return this.done;
  }

  public override dispose(): void {
    // remove VFX / free resources
  }
}

// 1. Declare attributes, effects, and abilities once (typically a dedicated module).
const combatDefinitions = defineAbilitySystem({
  attributes: [
    defineAttribute({
      id: 'Health',
      default: FP.FromInt(100),
      min: FP.FromInt(0),
      max: FP.FromInt(100),
      clamp: 'both',
    }),
    defineAttribute({
      id: 'Mana',
      default: FP.FromInt(50),
      min: FP.FromInt(0),
      max: FP.FromInt(50),
      clamp: 'both',
    }),
  ],
  effects: [
    defineEffect({
      id: 'Effect.Fireball',
      type: 'Instant',
      modifiers: [{ attributeId: 'Health', op: 'Add', magnitude: FP.FromInt(-25) }],
      cues: ['Cue.Fireball.Hit'],
    }),
    defineEffect({
      id: 'Effect.Fireball.Cooldown',
      type: 'Duration',
      durationTicks: 30,
      tagsGranted: ['Cooldown.Ability.Fireball'],
    }),
  ],
  abilities: [
    defineAbility({
      id: 'Ability.Fireball',
      costEffectId: undefined,
      cooldownEffectId: 'Effect.Fireball.Cooldown',
      activationBlockedTags: ['Cooldown.Ability.Fireball', 'State.Stun'],
      target: { kind: 'Entity', origin: { kind: 'Caller' } },
      targetEffectIds: ['Effect.Fireball'],
    }),
  ],
});

resetEntityIdCounter();

const world = new GameWorld({ tickRate: 20 });

// 2. Create the ability system and register tick systems on the world.
const abilities = createAbilitySystem(world, {
  definitions: combatDefinitions,
  cues: {
    'Cue.Fireball.Hit': () => new FireballHitCue(),
  },
});

world.registerSystems([...abilities.tickSystems], [], 'default');

// 3. Spawn a combatant with the bundled AbilitySystemComponent.
const hero = new Entity();
const abilityComponent = abilities.initComponent({
  attributes: { Health: FP.FromInt(100), Mana: FP.FromInt(50) },
  abilities: ['Ability.Fireball'],
  tags: ['Team.Hero'],
});
hero.addComponent(abilityComponent);
world.entityManager.addEntity(hero);

// 4. Drive simulation by tick (lockstep-safe).
abilities.activateAbility(hero.id, 'Ability.Fireball', { entityId: enemyId });
world.processAllTicks(currentTick);
// CuePresentationSystem runs in afterFrame — call world.start() or invoke
// afterFrame on frame systems when driving presentation manually in tests.

Tick discipline: activateAbility and applyEffect enqueue work. Observable changes (attributes, tags, active effects) apply when ability tick systems run inside world.processAllTicks() (or your lockstep beforeTick/afterTick pipeline). Never expect synchronous attribute updates in the same call stack as the facade.

Architecture

createAbilitySystem(world, config)
├── AbilitySystemRegistries     (per-world: attributes, effects, abilities, hooks)
├── AbilitySystemRuntime        (activation queue, instance ids, GameplayCueBuffer)
├── AbilitySystemFacade         (applyEffect, activateAbility, …)
└── tickSystems[]               (registered on GameWorld)

Per simulation tick (client GameWorld):
  AbilityActivationSystem       → CanActivate, cost/cooldown/self effects
  EffectApplicationSystem       → pendingAdd, Instant/Duration/Periodic, tags, OnApplied cues
  AbilityHookExecutorSystem     → hookId callbacks (projectiles, rockets)
  EffectTickSystem              → duration countdown, Periodic ticks, OnExpired cues
  AttributeAggregationSystem    → FIFO modifiers + clamp → current
  CueDispatchSystem?            → CuePresentationSystem (when cues map is non-empty)
  CueBufferCleanupSystem        → clear buffer end of tick (effects/full pipelines)

Per render frame (client GameWorld):
  CuePresentationSystem?        → afterFrame: spawn Cue per dispatch, update(dt), dispose

Registries and runtime state are per GameWorld, not global singletons. Two worlds do not share attribute indices or cue buffers.

Gameplay cues exist only where deterministic simulation runs (typically each client GameWorld). A headless relay server does not need the cue pipeline.

Core concepts

Attributes

Registered with defineAttribute. Each entity with an AbilitySystemComponent holds base and current in BigInt64Array slots indexed by registration order.

Modifier aggregation (FIFO by instanceId):

acc = base
for each active effect instance (sorted by instanceId ASC):
  Add      → acc = acc + magnitude
  Multiply → acc = acc * magnitude
  Override → acc = magnitude
current = clamp(acc) per AttributeDef

Effects

| Type | Behavior | |------|----------| | Instant | Modifies base immediately when applied | | Duration | Stays in ActiveEffectsComponent.queue for durationTicks; grants tags while active | | Periodic | Duration + fires modifiers every periodTicks; optional executePeriodicOnApplication |

Durations and periods are whole simulation ticks (number), compared to runtime.currentTick — not FixedPoint values.

Abilities

defineAbility describes activation rules. On success, the activation system applies costEffectId, cooldownEffectId, and selfEffectIds to the caster, resolves target, applies targetEffectIds, then runs hookId if set.

activateAbility returns true when the request is queued, not when it ultimately succeeds. Listen for ABILITY_ACTIVATED_EVENT on the world EventBus for the final verdict and resolved targets.

Gameplay tags

  • Effect-granted: from tagsGranted; removed when the effect expires or is stripped via removeEffectsByTag
  • Ad-hoc: abilities.addTag / removeTag for spawn setup (teams, factions)
  • Ability gates: tagsRequired, activationBlockedTags on AbilityDef; effect tagsRequired / tagsBlocked on targets

Targeting

type TargetSpec =
  | { kind: 'Self' }
  | { kind: 'Entity'; origin: TargetOrigin }
  | { kind: 'Point'; origin: TargetOrigin };

type TargetOrigin =
  | { kind: 'Caster' }
  | { kind: 'TargetEntity'; entityId: number }
  | { kind: 'Point'; x: FixedPoint; z: FixedPoint }
  | { kind: 'Caller' }; // reads activateAbility(..., providedTarget)

Activation hooks

Register in createAbilitySystem({ hooks: { ... } }) or facade.registerHook. Hooks run after cost/cooldown/self effects on the activation tick. Use for spawning projectiles/rockets (entities live in your game code, not in this package).

import type { AbilityHook } from '@phalanx-engine/abilities';

const spawnProjectile: AbilityHook = (ctx) => {
  // ctx.abilityId, ctx.casterEntityId, ctx.resolvedTargets, ctx.providedTarget, ctx.tick
  // Spawn projectile entity; on hit call abilities.applyEffect(targetId, 'Effect.Damage', casterId)
};

Gameplay ability recipes

These five patterns match the arena-shooter target game. Projectiles and damage multipliers outside pure GAS math are called out explicitly.

1. Auto-attack (projectile on hit)

Ability applies cooldown; hook spawns the projectile; damage is an Instant effect on impact.

defineEffect({
  id: 'Effect.AutoAttack.Cooldown',
  type: 'Duration',
  durationTicks: 30,
  tagsGranted: ['Cooldown.Ability.AutoAttack'],
});
defineEffect({
  id: 'Effect.AutoAttack.Damage',
  type: 'Instant',
  modifiers: [{ attributeId: 'Health', op: 'Add', magnitude: FP.FromInt(-10) }],
  cues: ['Cue.AutoAttack.Hit'],
});
defineAbility({
  id: 'Ability.AutoAttack',
  cooldownEffectId: 'Effect.AutoAttack.Cooldown',
  activationBlockedTags: ['Cooldown.Ability.AutoAttack', 'State.Stun'],
  target: { kind: 'Entity', origin: { kind: 'Caller' } },
  hookId: 'Hook.SpawnProjectile.AutoAttack',
});

// In hooks['Hook.SpawnProjectile.AutoAttack']: spawn projectile entity.
// On hit: abilities.applyEffect(targetId, 'Effect.AutoAttack.Damage', casterId);

2. Health regeneration (Periodic Effect)

defineEffect({
  id: 'Effect.HealthRegen',
  type: 'Periodic',
  periodTicks: 60,
  durationTicks: 600,
  modifiers: [{ attributeId: 'Health', op: 'Add', magnitude: FP.FromInt(5) }],
});
defineAbility({
  id: 'Ability.HealthRegen',
  target: { kind: 'Self' },
  targetEffectIds: ['Effect.HealthRegen'],
});

3. Armor-shred beam (channeling)

defineEffect({
  id: 'Effect.ArmorShred',
  type: 'Duration',
  durationTicks: 300,
  modifiers: [{ attributeId: 'Armor', op: 'Add', magnitude: FP.FromInt(-20) }],
  tagsGranted: ['State.Debuff.ArmorShred'],
});
defineAbility({
  id: 'Ability.ArmorShredBeam',
  target: { kind: 'Entity', origin: { kind: 'Caller' } },
  targetEffectIds: ['Effect.ArmorShred'],
});

// On beam release (input system, same tick discipline):
abilities.removeEffectsByTag(targetId, 'State.Debuff.ArmorShred');

4. Mark beam (damage multiplier — user-side)

The library stores IncomingDamageMultiplier on the target; your damage pipeline must read it when applying damage.

defineAttribute({
  id: 'IncomingDamageMultiplier',
  default: FP.FromInt(1),
  min: FP.FromInt(0),
  max: FP.FromInt(10),
  clamp: 'both',
});
defineEffect({
  id: 'Effect.Marked',
  type: 'Duration',
  durationTicks: 240,
  modifiers: [
    { attributeId: 'IncomingDamageMultiplier', op: 'Multiply', magnitude: FP.FromFloat(1.25) },
  ],
  tagsGranted: ['State.Marked'],
});

function applyDamageWithMark(
  abilities: AbilitySystem,
  targetId: number,
  baseDamage: FixedPoint
): void {
  const mult =
    abilities.tryGetAttribute(targetId, 'IncomingDamageMultiplier')?.current ?? FP.FromInt(1);
  const scaled = FP.Mul(baseDamage, mult);
  // apply Instant damage effect or direct Health modifier with `scaled`
}

This is an intentional MVP limitation — v2 may add execution calculations.

5. Custom execution formulas

phalanx-abilities doesn't support complex math inside effect definitions yet. For complex formulas (e.g. Damage = (Base + Strength * 2) * (1 - Armor / 100)), read the attributes from the facade and calculate manually before calling applyEffect.

defineEffect({
  id: 'Effect.Damage.Marked',
  type: 'Instant',
  modifiers: [{ attributeId: 'Health', op: 'Add', magnitude: FP.FromInt(-10) }],
  cues: ['Cue.Hit'],
});

const targetMarked = abilities.hasTag(enemyId, 'State.Marked');
if (targetMarked) {
  abilities.applyEffect(enemyId, 'Effect.Damage.Marked');
}

Gameplay cues

Cues are deterministic simulation-side notifications for local presentation (VFX, SFX, UI). They are not networked. Simulation systems write cue events into an internal per-tick buffer; when you register client presentation, the engine dispatches each event and spawns a short-lived Cue instance to animate it.

Simulation pipeline

simulation systems → GameplayCueBuffer → CueDispatchSystem → CuePresentationSystem
                                      → CueBufferCleanupSystem (end of tick)

GameplayCueBuffer is internal runtime state on AbilitySystemRuntime — not a public config option and not an entity component. For tests, use pipeline: 'effects-retain-cues' to keep buffered events across ticks without dispatch.

Effects declare cues as a shortcut array (OnApplied only) or structured phases:

defineEffect({
  id: 'Effect.Poison',
  type: 'Periodic',
  durationTicks: 6,
  periodTicks: 2,
  cues: {
    onApplied: ['Cue.Poison.Apply'],
    onPeriodic: ['Cue.Poison.Tick'],
    onExpired: ['Cue.Poison.Expire'],
  },
});

Per-dispatch self-managing Cue model

One dispatched cue event = one short-lived Cue instance. Register factories in createAbilitySystem:

import { Cue, type CueConfig, type CueContext, type GameplayCueDispatchedEvent } from '@phalanx-engine/abilities';

const cues: CueConfig = {
  'Cue.Damage.Sphere': () => new DamageSphereCue(scene),
  'Cue.Death': () => new DeathCue(scene),
};

createAbilitySystem(world, { definitions, cues });

| Type | Role | |------|------| | Cue | Abstract base: onSpawn, update(dt), isFinished(), dispose() | | CueContext | Read-only { entityManager, eventBus } — no GameWorld | | CueFactory | () => Cue — invoked per dispatch, not once at init | | CueConfig | Readonly<Record<string, CueFactory>> — map key is the cue id |

Two-phase init: the factory closure captures presentation deps (scene, audio); onSpawn(event, ctx) binds the instance to the dispatch event and world services.

export class DamageSphereCue extends Cue {
  private done = false;

  public constructor(private readonly scene: THREE.Scene) {
    super();
  }

  public onSpawn(event: GameplayCueDispatchedEvent, ctx: CueContext): void {
    const impact = resolveImpact(ctx.entityManager, event);
    if (!impact) {
      this.done = true; // nothing to show — engine skips the active list
      return;
    }
    this.scene.add(createBurstVfx(impact));
  }

  public override update(dt: number): void {
    // animate each render frame
    if (/* animation complete */) this.done = true;
  }

  public override isFinished(): boolean {
    return this.done;
  }

  public override dispose(): void {
    // remove VFX / free resources
  }
}

Lifecycle (driven by CuePresentationSystem):

  1. Engine subscribes once per cueId to gameplayCueKey(cueId) on init.
  2. On each dispatch: factory()onSpawn(event, ctx) → active list if not isFinished().
  3. Each afterFrame: update(dt); when isFinished(), dispose() and remove.
  4. On world dispose: dispose all live cues.

Cues are presentation-only — never call applyEffect / activateAbility from cue code. phalanx-abilities does not import rendering libraries; inject scene/audio via the factory closure.

A non-empty cues map automatically registers CueDispatchSystem and CuePresentationSystem. Omit cues (or pass {}) for headless/simulation-only worlds; CueBufferCleanupSystem still runs where the pipeline requires it.

Pipeline: effects-retain-cues

pipeline: 'effects-retain-cues' runs effect systems but does not clear the cue buffer each tick — useful for asserting buffered events in tests. It does not register dispatch or presentation unless you also pass a non-empty cues map. Using it with an empty cues map intentionally retains the buffer without dispatch (dev warning in non-production builds).

Determinism rules

  • Use FP.* from phalanx-math for all modifier magnitudes
  • Store durations as integer ticks, not floats or Date.now
  • Target resolution is snapshotted at activation; movement after activation does not change which entity or point was targeted
  • Call resetEntityIdCounter() from phalanx-ecs at match start so projectile spawns get identical ids on every peer
  • Hooks must be pure deterministic simulation — no Math.random() or wall-clock time

API reference

Factory

createAbilitySystem(world: GameWorld, config: CreateAbilitySystemConfig): AbilitySystem

| Config field | Purpose | |--------------|---------| | definitions | defineAbilitySystem({ attributes, effects?, abilities? }) | | hooks | Record<hookId, AbilityHook> | | pipeline | 'full' (default), 'activation', 'effects', 'effects-retain-cues', 'attributes' | | cues | CueConfigcueId → () => Cue. Non-empty map registers dispatch + presentation |

AbilitySystem (returned by factory)

| Method | Description | |--------|-------------| | initComponent(init?) | Create AbilitySystemComponent with optional seed data | | activateAbility(casterId, abilityId, providedTarget?) | Queue activation | | applyEffect(targetId, effectId, sourceId?) | Queue effect (sourceId defaults to -1) | | getAttribute / tryGetAttribute | Read base/current | | hasTag / addTag / removeTag | Tag queries and ad-hoc tags | | removeEffectsByTag / removeEffectsByDefId | Flag instances for removal next tick | | tickSystems | Register on GameWorld |

Lower-level access: AbilitySystemFacade is exported for advanced wiring; most games use createAbilitySystem only.

DSL helpers

defineAttribute(def: AttributeDef): AttributeDef
defineEffect(def: EffectDefInput): EffectDef
defineAbility(def: AbilityDef): AbilityDef
defineAbilitySystem(bundle): AbilitySystemDefinitions

Events

import {
  ABILITY_ACTIVATED_EVENT,
  type AbilityActivatedEvent,
  Cue,
  CuePresentationSystem,
  GAMEPLAY_CUE_EVENT,
  gameplayCueKey,
  type CueConfig,
  type CueContext,
  type CueFactory,
  type GameplayCueDispatchedEvent,
} from '@phalanx-engine/abilities';

Components and types

Exported: AbilitySystemComponent, AbilitiesComponentType, effect/attribute/tag types, individual systems for custom pipelines.

See src/index.ts for the full public surface.

Integration checklist

  1. phalanx-ecs: GameWorld, Entity, resetEntityIdCounter, register abilities.tickSystems in deterministic order alongside movement/physics/combat systems.
  2. phalanx-math: FP.FromInt, FP.FromFloat, FP.Add, FP.Mul, etc. for all magnitudes.
  3. Client-only cues: pass a non-empty cues map with Cue subclasses; presentation runs in afterFrame. Never mutate simulation from cue code.
  4. User-owned systems: projectiles, rockets, AoE searches, and Aura ticking stay in game code. Call applyEffect or activateAbility from these systems on deterministic events (collision, timer tick).

Testing

pnpm --filter @phalanx-engine/abilities test

Tests use GameWorld.processAllTicks() with pipeline subsets (activation, effects, …). See tests/helpers.ts for patterns.

Agent skill

For AI-assisted development, use the repository skill:

skills/phalanx-abilities/SKILL.md

It covers decision trees, the five recipes above, determinism rules, and anti-patterns when extending combat systems.

License

Same as the Phalanx Engine monorepo.