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

@zakkster/lite-soa-particle-engine

v1.0.2

Published

Zero-GC canvas particle system using Structure of Arrays and flat TypedArrays. CPU cache-friendly.

Readme

@zakkster/lite-soa-particle-engine

npm version npm bundle size npm downloads npm total downloads Zero Dependencies TypeScript License: MIT

Zero-GC canvas particle system using Structure of Arrays (SoA) and flat TypedArrays.

The fastest way to move thousands of dots on a Canvas. No objects. No GC. No mercy.

🎬 Live Demo (SoaParticleEngine)

https://codepen.io/Zahari-Shinikchiev/debug/gbwmWvJ

Performance

| Library / Engine | Allocations per Frame | Avg Frame Time (ms) | GC Events (10s) | Deterministic | Notes | |---|---|---|---|---|---| | Lite SoA Engine | 0 | 1.2 | 0 | Yes | Flat arrays, no objects | | pixi-particles | ~3,000 | 4.8 | 2–3 | No | Object churn | | tsparticles | ~5,000 | 7.2 | 4–6 | No | Heavy object creation | | Vanilla OOP Particles | ~100,000 | 12–20 | 10+ | No | Each particle = object | | three.js GPU Particles | 0 | 0.8 | 0 | No | Fast but non-deterministic |

Why SoA is faster

Traditional particle systems store each particle as an object: { x, y, vx, vy, life }. When you loop over 10,000 particles, the CPU fetches each object from a random memory location — cache miss after cache miss.

SoA stores each property in a contiguous Float32Array. When you loop over x[0], x[1], x[2]..., the data is sequential in memory — the CPU prefetcher loads it all into L1 cache in one shot. 10x fewer cache misses for tight physics loops.

Installation

npm install @zakkster/lite-soa-particle-engine

Quick Start

import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const engine = new SoaParticleEngine(5000);

engine.onTick((dt, x, y, vx, vy, life, invLife, data, max) => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    for (let i = 0; i < max; i++) {
        if (life[i] <= 0) continue;

        // Physics
        life[i] -= dt;
        vy[i] += 400 * dt;  // gravity
        x[i] += vx[i] * dt;
        y[i] += vy[i] * dt;

        // Render
        ctx.globalAlpha = Math.max(0, life[i] * invLife[i]);
        ctx.fillRect(x[i], y[i], 4, 4);
    }
});

engine.start();

// Emit anywhere
canvas.addEventListener('click', (e) => {
    for (let i = 0; i < 50; i++) {
        const angle = Math.random() * Math.PI * 2;
        const speed = 100 + Math.random() * 200;
        engine.emit(e.offsetX, e.offsetY, Math.cos(angle) * speed, Math.sin(angle) * speed, 1.5);
    }
});

API

new SoaParticleEngine(maxParticles?)

Allocates all memory once. Default: 1000 particles.

Methods

| Method | Description | |--------|-------------| | .emit(x, y, vx, vy, life, dataFlag?) | Emit a particle. Ring buffer overwrites oldest when full. | | .onTick(callback) | Register the frame callback. Receives raw TypedArrays. | | .start() | Start the RAF loop. | | .stop() / .pause() | Stop the RAF loop. | | .clear() | Kill all particles. | | .destroy() | Stop and release all TypedArray memory. |

The onTick Callback

engine.onTick((dt, x, y, vx, vy, life, invLife, data, max) => {
    // dt: seconds since last frame (capped at 0.1)
    // x, y: Float32Array positions
    // vx, vy: Float32Array velocities
    // life: Float32Array remaining life
    // invLife: Float32Array (1/initialLife) — multiply for normalized progress
    // data: Int32Array — recipe IDs or custom flags
    // max: array length
    //
    // MUTATE THESE DIRECTLY — that's the whole point
});

The data Channel

Each particle has an Int32Array slot for custom integer data. Use it for recipe IDs, team colors, particle types, or any per-particle flag:

engine.emit(x, y, vx, vy, life, 1);  // type 1 = fire
engine.emit(x, y, vx, vy, life, 2);  // type 2 = smoke

engine.onTick((dt, x, y, vx, vy, life, invLife, data, max) => {
    for (let i = 0; i < max; i++) {
        if (life[i] <= 0) continue;
        if (data[i] === 1) ctx.fillStyle = 'orange';
        if (data[i] === 2) ctx.fillStyle = 'gray';
        // ...
    }
});

Recipes

Mouse Trail

canvas.addEventListener('mousemove', (e) => {
    engine.emit(
        e.offsetX, e.offsetY,
        (Math.random() - 0.5) * 50,  // slight spread
        -50 - Math.random() * 50,     // float upward
        0.8
    );
});

Rain

function spawnRain() {
    for (let i = 0; i < 3; i++) {
        engine.emit(
            Math.random() * canvas.width,  // random X
            -10,                            // above screen
            0,                              // no horizontal velocity
            300 + Math.random() * 200,      // fast downward
            2.0                             // 2 second life
        );
    }
    requestAnimationFrame(spawnRain);
}

Explosion with Multiple Types

function explode(x, y) {
    // Core flash (type 0)
    for (let i = 0; i < 20; i++) {
        const a = Math.random() * Math.PI * 2;
        engine.emit(x, y, Math.cos(a) * 300, Math.sin(a) * 300, 0.3, 0);
    }
    // Debris (type 1)
    for (let i = 0; i < 40; i++) {
        const a = Math.random() * Math.PI * 2;
        const s = 50 + Math.random() * 150;
        engine.emit(x, y, Math.cos(a) * s, Math.sin(a) * s, 1.5, 1);
    }
}

Ring Buffer Behavior

When the pool is full, emit() overwrites the oldest particle. This is intentional — visual degradation without crashes, allocations, or GC pauses. Under extreme load, older particles disappear slightly early rather than the frame rate dropping.

TypeScript

import { SoaParticleEngine, type TickCallback } from '@zakkster/lite-soa-particle-engine';

const tick: TickCallback = (dt, x, y, vx, vy, life, invLife, data, max) => {
    // fully typed Float32Array/Int32Array access
};

License

MIT