Skip to content

css-particle-migration

<!-- Loaded by combat-effects-upgrade when task involves DOM particle replacement, element pooling, or CSS @keyframes for effects.ts -->

From plugin
vexjoy-agent
413198 skills198 agents10 commands86 hooks
Install
$ npx -y skills add notque/vexjoy-agent --agent claude-code

How it fires

How this agent gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.

Context preview

The summary Claude sees to decide when to auto-load this agent.

<!-- Loaded by combat-effects-upgrade when task involves DOM particle replacement, element pooling, or CSS @keyframes for effects.ts -->

Agent definition

css-particle-migration.md

CSS Particle Migration Reference

<!-- Loaded by combat-effects-upgrade when task involves DOM particle replacement, element pooling, or CSS @keyframes for effects.ts -->

Current failure mode in `effects.ts`:

// ANTI-PATTERN — runs on every effect call
const el = document.createElement('div');
Object.assign(el.style, { position: 'fixed', left: x + 'px', top: y + 'px' });
document.body.appendChild(el);
setTimeout(() => el.remove(), duration);

Problems: GC pressure from allocation/deallocation + `appendChild`/`remove` forces style recalculation. Fix: pre-allocated pool with CSS class toggling.

---

ParticlePool: TypeScript Implementation

// src/lib/ParticlePool.ts

type PoolOptions = {
  count: number;
  className: string;
  container?: HTMLElement;
};

export class ParticlePool {
  private readonly pool: HTMLDivElement[] = [];
  private readonly className: string;
  private readonly container: HTMLElement;

  constructor({ count, className, container }: PoolOptions) {
    this.className = className;
    this.container = container ?? document.body;

    // Pre-allocate all elements at construction time
    for (let i = 0; i < count; i++) {
      const el = document.createElement('div');
      el.style.position = 'fixed';
      el.style.pointerEvents = 'none';
      el.style.willChange = 'transform, opacity'; // hint GPU layer — set here, not inline per-effect
      el.setAttribute('aria-hidden', 'true');
      this.container.appendChild(el);
      this.pool.push(el);
    }
  }

  /**
   * Acquire a particle from the pool.
   * Returns null if all particles are in use — caller should degrade gracefully.
   */
  acquire(x: number, y: number): HTMLDivElement | null {
    const el = this.pool.find(p => !p.classList.contains('particle-active'));
    if (!el) return null;

    // Reset stale values from previous animation
    el.style.transform = '';
    el.style.opacity = '';
    el.style.left = x + 'px';
    el.style.top = y + 'px';

    el.classList.add('particle-active', this.className);

    // Auto-return on animation end — synchronous, no timer drift
    const onEnd = () => {
      el.classList.remove('particle-active', this.className);
      el.removeEventListener('animationend', onEnd);
    };
    el.addEventListener('animationend', onEnd, { once: true });

    return el;
  }

  /** Force-return all elements (e.g. on combat scene unmount) */
  releaseAll(): void {
    for (const el of this.pool) {
      el.classList.remove('particle-active', this.className);
    }
  }

  /** Clean up — call in useEffect cleanup when component unmounts */
  destroy(): void {
    this.releaseAll();
    for (const el of this.pool) {
      el.remove();
    }
    this.pool.length = 0;
  }
}

---

Pool Registry: Shared Pools Per Effect Type

// src/lib/effectPools.ts
import { ParticlePool } from './ParticlePool';

// Pools are module-level singletons — created once, shared across all effect calls
// Pool sizes: effect max count + 25% buffer

let _pools: Record<string, ParticlePool> | null = null;

export function getEffectPools(): Record<string, ParticlePool> {
  if (_pools) return _pools;
  _pools = {
    impact:    new ParticlePool({ count: 8,  className: 'particle-impact' }),
    confetti:  new ParticlePool({ count: 24, className: 'particle-confetti' }),
    gold:      new ParticlePool({ count: 16, className: 'particle-gold' }),
    sparkle:   new ParticlePool({ count: 16, className: 'particle-sparkle' }),
    heal:      new ParticlePool({ count: 8,  className: 'particle-heal' }),
    finisher:  new ParticlePool({ count: 16, className: 'particle-finisher' }),
    damage:    new ParticlePool({ count: 4,  className: 'particle-damage' }),
    block:     new ParticlePool({ count: 4,  className: 'particle-damage' }), // reuses damage class
    floating:  new ParticlePool({ count: 4,  className: 'particle-floating' }),
    buff:      new ParticlePool({ count: 4,  className: 'particle-buff' }),
    debuff:    new ParticlePool({ count: 4,  className: 'particle-debuff' }),
  };
  return _pools;
}

export function destroyEffectPools(): void {
  if (!_pools) return;
  for (const pool of Object.values(_pools)) {
    pool.destroy();
  }
  _pools = null;
}

---

Migrated Effect Functions

Before / After: `createImpactBurst`

// BEFORE — 5 createElement calls per hit
export function createImpactBurst(x: number, y: number, type: 'strike' | 'grapple' | 'aerial'): void {
  const colors = { strike: '#ff4444', grapple: '#ff8800', aerial: '#4488ff' };
  for (let i = 0; i < 5; i++) {
    setTimeout(() => {
      const el = document.createElement('div');
      const angle = (i / 5) * Math.PI * 2;
      Object.assign(el.style, {
        position: 'fixed', left: x + 'px', top: y + 'px',
        width: '8px', height: '8px', borderRadius: '50%',
        background: colors[type],
        transform: `translate(${Math.cos(angle) * 40}px, ${Math.sin(angle) * 40}px)`,
      });
      document.body.appendChild(el);
      setTimeout(() => el.remove(), 600);
    }, i * 30);
  }
}

// AFTER — pool acquire, CSS class drives animation
export function createImpactBurst(x: number, y: number, type: 'strike' | 'grapple' | 'aerial'): void {
  const pools = getEffectPools();
  const angleStep = (Math.PI * 2) / 5;

  for (let i = 0; i < 5; i++) {
    const angle = i * angleStep;
    const el = pools.impact.acquire(x, y);
    if (!el) continue; // pool exhausted — degrade gracefully

    // CSS custom properties drive per-particle direction
    el.style.setProperty('--angle', angle.toString());
    el.style.setProperty('--delay', `${i * 30}ms`);
    el.dataset['type'] = type; // for CSS attr() or data selectors
  }
}

---

CSS @keyframes: Complete Definitions

Add to your global stylesheet or a `particles.css` module. All animations use only GPU-composited properties: `transform` and `opacity`.

/* Timing presets — match original effect durations */
:r
Read more
Ships withvexjoy-agent

Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.

Get the whole plugin, auto-invoked