@phalanx-engine/abilities
v0.1.4
Published
Deterministic gameplay ability system for Phalanx Engine
Downloads
795
Maintainers
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-definitionmin/max, FIFO modifier aggregation (Add/Multiply/Override), clamping - Gameplay effects:
Instant,Duration,Periodicwith tick-baseddurationTicks/periodTicks - Gameplay tags: hierarchical strings (
State.Buff.Speed),tagsRequired/tagsBlocked/tagsGrantedon effects and abilities - Abilities: declarative definitions, activation queue, cost/cooldown via effects,
CanActivatechecks - Targeting:
Self,Entity,Pointwith 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
Cueinstances (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 buildPeer 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), disposeRegistries 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 AttributeDefEffects
| 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 viaremoveEffectsByTag - Ad-hoc:
abilities.addTag/removeTagfor spawn setup (teams, factions) - Ability gates:
tagsRequired,activationBlockedTagsonAbilityDef; effecttagsRequired/tagsBlockedon 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):
- Engine subscribes once per
cueIdtogameplayCueKey(cueId)on init. - On each dispatch:
factory()→onSpawn(event, ctx)→ active list if notisFinished(). - Each
afterFrame:update(dt); whenisFinished(),dispose()and remove. - 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.*fromphalanx-mathfor 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()fromphalanx-ecsat 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 | CueConfig — cueId → () => 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): AbilitySystemDefinitionsEvents
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
- phalanx-ecs:
GameWorld,Entity,resetEntityIdCounter, registerabilities.tickSystemsin deterministic order alongside movement/physics/combat systems. - phalanx-math:
FP.FromInt,FP.FromFloat,FP.Add,FP.Mul, etc. for all magnitudes. - Client-only cues: pass a non-empty
cuesmap withCuesubclasses; presentation runs inafterFrame. Never mutate simulation from cue code. - User-owned systems: projectiles, rockets, AoE searches, and Aura ticking stay in game code. Call
applyEffectoractivateAbilityfrom these systems on deterministic events (collision, timer tick).
Testing
pnpm --filter @phalanx-engine/abilities testTests 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.
