
Party
- 8 installs
- 65 repo stars
- Updated July 23, 2026
- cazala/party
Provides programmatic guidance for the @cazala/party particle-physics library covering engine setup, modules, particles, and CPU/WebGPU performance.
About
Reusable guidance for using the @cazala/party particle-physics library in a custom app, covering engine setup, runtime selection, particles, modules, and oscillators. Developers use it to add particles, configure modules, and apply performance-safe patterns across CPU and WebGPU.
- Engine setup and CPU/WebGPU runtime selection
- Performance-safe WebGPU patterns avoiding full readbacks
Party by the numbers
- 8 all-time installs (skills.sh)
- Ranked #1,733 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cazala/party --skill partyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 65 |
| Last updated | July 23, 2026 |
| Repository | cazala/party ↗ |
What it does
Provides programmatic guidance for the @cazala/party particle-physics library covering engine setup, modules, particles, and CPU/WebGPU performance.
Files
Party
Reusable guidance for using the @cazala/party library programmatically: engine setup, runtime selection, particles, modules, and performance constraints across CPU + WebGPU.
When to use this skill
- You need to instantiate the Party engine in a custom app (not the playground).
- You want examples for adding particles, configuring modules, or using oscillators.
- You need performance-safe patterns for WebGPU (avoiding full readbacks).
Quick start (minimal)
import {
Engine,
Environment,
Boundary,
Collisions,
Particles,
Trails,
} from "@cazala/party";
const canvas = document.querySelector("canvas")!;
const forces = [
new Environment({ gravityStrength: 600, gravityDirection: "down" }),
new Boundary({ mode: "bounce", restitution: 0.9, friction: 0.1 }),
new Collisions({ restitution: 0.85 }),
];
const render = [
new Trails({ trailDecay: 10, trailDiffuse: 4 }),
new Particles({ colorType: 2, hue: 0.55 }),
];
const engine = new Engine({ canvas, forces, render, runtime: "auto" });
await engine.initialize();
engine.play();Core concepts
Particle shape
type IParticle = {
position: { x: number; y: number };
velocity: { x: number; y: number };
size: number;
mass: number;
color: { r: number; g: number; b: number; a: number }; // 0..1 floats
};- Pinned particles:
mass < 0 - Removed particles:
mass === 0
Runtime selection
"auto"tries WebGPU first, then falls back to CPU oninitialize().- WebGPU has higher throughput but GPU → CPU readbacks are expensive.
Engine lifecycle
await engine.initialize();
engine.play(); // start loop
engine.pause(); // pause loop
engine.stop(); // cancel loop
engine.destroy(); // clean upModule system
Modules are typed force or render units:
- Force: apply acceleration/velocity changes or constraints.
- Render: draw particles/lines or post-process scene texture.
Each module exposes inputs and helpers, and can be toggled:
const boundary = new Boundary();
boundary.setEnabled(false);
boundary.setRestitution(0.8);Common tasks
Add particles
for (let i = 0; i < 200; i++) {
engine.addParticle({
position: { x: Math.random() * 500, y: Math.random() * 500 },
velocity: { x: (Math.random() - 0.5) * 6, y: (Math.random() - 0.5) * 6 },
mass: 1,
size: 3,
color: { r: 1, g: 1, b: 1, a: 1 },
});
}Bulk set particles
engine.setParticles(particlesArray);Use the Spawner utility
import { Spawner } from "@cazala/party";
const spawner = new Spawner();
const particles = spawner.initParticles({
count: 5000,
shape: "text",
text: "Party",
center: { x: 0, y: 0 },
position: { x: 0, y: 0 },
align: { horizontal: "center", vertical: "center" },
textSize: 80,
size: 3,
mass: 1,
colors: ["#ffffff"],
});
engine.setParticles(particles);Local queries without full readback (WebGPU-safe)
const { particles, truncated } = engine.getParticlesInRadius(
{ x: 0, y: 0 },
120,
{ maxResults: 200 }
);Export + import module settings
const settings = engine.export();
engine.import(settings);Oscillate a module input
engine.addOscillator({
moduleName: "boundary",
inputName: "restitution",
min: 0.4,
max: 0.95,
speedHz: 0.2,
});Performance notes
- WebGPU
getParticles()performs a full GPU → CPU readback; avoid in hot paths. - Prefer
getParticlesInRadius(...),setParticle(...), orsetParticleMass(...). - Tune
cellSize,maxNeighbors, andconstrainIterationsfor performance. - For large scenes, limit processing with
setMaxParticles(value).
API quick map
- Engine:
initialize(),play(),pause(),stop(),destroy() - View:
setSize(w,h),setCamera(x,y),setZoom(z) - Particles:
addParticle,setParticles,setParticle,setParticleMass,getParticle - Queries:
getParticlesInRadius,getCount,getFPS - Modules:
getModule(name), module setters,setEnabled(bool) - Serialization:
export(),import(settings) - Oscillators:
addOscillator,removeOscillator,clearOscillators
Sources
- Core library user guide:
docs/user-guide.md
name: playground
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.9
- name: Get pnpm store directory
id: pnpm_store
shell: bash
run: |
STORE_PATH="$(pnpm store path --silent)"
echo "STORE_PATH=${STORE_PATH}" >> "$GITHUB_ENV"
echo "store_path=${STORE_PATH}" >> "$GITHUB_OUTPUT"
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm_store.outputs.store_path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm -r install --frozen-lockfile --prod=false
- name: Verify build tooling
run: pnpm --filter @cazala/party exec -- rollup --version
- name: Build
run: |
set -euo pipefail
pnpm --filter=@cazala/party build
# Typecheck + build once (base="/party/").
pnpm --filter=@cazala/playground type-check
VITE_PUBLIC_BASE=/party/ pnpm --filter=@cazala/playground exec -- vite build
- name: Install Wrangler
run: npm install -g wrangler@3
- name: Create Cloudflare Pages project if it doesn't exist
run: |
# Set up wrangler authentication
export CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
export CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
# Check if project exists, create if it doesn't.
# NOTE: avoid substring matches.
if ! wrangler pages project list 2>/dev/null | grep -qE '(^|[[:space:]])party-playground([[:space:]]|$)'; then
echo "Creating Cloudflare Pages project 'party-playground'..."
wrangler pages project create party-playground --production-branch=main || true
else
echo "Project 'party-playground' already exists"
fi
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Deploy to Cloudflare Pages (preview)
id: deploy_preview
if: github.event_name == 'pull_request'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
# Capture deploy output so we can extract the preview URL for the PR comment.
wrangler pages deploy packages/playground/dist \
--project-name party-playground \
--branch "pr-${{ github.event.pull_request.number }}" \
--commit-dirty=true \
2>&1 | tee wrangler-deploy.log
PREVIEW_URL="$(grep -Eo 'https://[^ ]+\.pages\.dev[^ ]*' wrangler-deploy.log | head -n 1 || true)"
if [ -z "${PREVIEW_URL}" ]; then
echo "Could not detect Cloudflare Pages preview URL from wrangler output."
echo "url=" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Detected preview URL: ${PREVIEW_URL}"
echo "url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT"
- name: Create GitHub deployment (preview)
if: github.event_name == 'pull_request' && steps.deploy_preview.outputs.url != ''
uses: actions/github-script@v7
env:
PREVIEW_URL: ${{ steps.deploy_preview.outputs.url }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const ref = context.payload.pull_request?.head?.sha || context.sha;
const environment = 'preview';
const environment_url = process.env.PREVIEW_URL;
const deployment = await github.rest.repos.createDeployment({
owner,
repo,
ref,
environment,
auto_merge: false,
required_contexts: [],
transient_environment: true,
production_environment: false,
description: `Cloudflare Pages preview: ${environment_url}`,
});
await github.rest.repos.createDeploymentStatus({
owner,
repo,
deployment_id: deployment.data.id,
state: 'success',
environment,
environment_url,
log_url: environment_url,
description: 'Deployed to Cloudflare Pages (preview)',
});
- name: Comment on PR with preview URL
if: github.event_name == 'pull_request' && steps.deploy_preview.outputs.url != ''
uses: actions/github-script@v7
env:
PREVIEW_URL: ${{ steps.deploy_preview.outputs.url }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const marker = '<!-- party-preview-url -->';
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.pull_request.number;
const url = process.env.PREVIEW_URL;
// For pull_request events, context.sha is the temporary merge commit SHA.
// Use the PR head SHA so reviewers can find the real commit in the repo.
const sha = context.payload.pull_request?.head?.sha || context.sha;
const body = [
marker,
`**Preview deployment ready**`,
'',
`- **URL**: ${url}`,
`- **Commit**: ${sha}`,
].join('\n');
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
// If multiple marker comments exist (e.g. from a historical bug), update the most recent one.
const existing = [...comments]
.reverse()
.find(c => typeof c.body === 'string' && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
- name: Deploy to Cloudflare Pages (production)
id: deploy_production
if: github.event_name == 'push'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
wrangler pages deploy packages/playground/dist \
--project-name party-playground \
--branch "${{ github.ref_name }}" \
--commit-dirty=true \
2>&1 | tee wrangler-deploy.log
PRODUCTION_URL="$(grep -Eo 'https://[^ ]+\.pages\.dev[^ ]*' wrangler-deploy.log | head -n 1 || true)"
echo "url=${PRODUCTION_URL}" >> "$GITHUB_OUTPUT"
- name: Create GitHub deployment (production)
if: github.event_name == 'push' && steps.deploy_production.outputs.url != ''
uses: actions/github-script@v7
env:
PRODUCTION_URL: ${{ steps.deploy_production.outputs.url }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const ref = context.sha;
const environment = 'production';
const environment_url = process.env.PRODUCTION_URL;
const deployment = await github.rest.repos.createDeployment({
owner,
repo,
ref,
environment,
auto_merge: false,
required_contexts: [],
transient_environment: false,
production_environment: true,
description: `Cloudflare Pages production: ${environment_url}`,
});
await github.rest.repos.createDeploymentStatus({
owner,
repo,
deployment_id: deployment.data.id,
state: 'success',
environment,
environment_url,
log_url: environment_url,
description: 'Deployed to Cloudflare Pages (production)',
});
name: npm
on:
push:
branches:
- main
release:
types:
- published
concurrency:
group: npm-publish-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
cache: pnpm
- name: Upgrade npm (OIDC trusted publishing requires newer npm than Node 20 default)
run: npm install -g npm@11
- name: Install
run: pnpm -r install --frozen-lockfile --prod=false
- name: Verify build tooling
run: pnpm --filter @cazala/party exec -- rollup --version
- name: Set version (@next)
if: github.event_name == 'push'
run: node ./scripts/set-next-version.mjs
- name: Set version (release tag)
if: github.event_name == 'release'
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: node ./scripts/set-release-version.mjs
- name: Build core
run: pnpm --filter @cazala/party build
- name: Publish @next
if: github.event_name == 'push'
working-directory: packages/core
env:
NODE_AUTH_TOKEN: ""
run: npm publish --tag next --access public --ignore-scripts
- name: Publish release
if: github.event_name == 'release'
working-directory: packages/core
env:
NODE_AUTH_TOKEN: ""
run: npm publish --access public --ignore-scripts
GitHub Actions Workflows
Deploy to Cloudflare Pages
This workflow automatically deploys the playground to Cloudflare Pages:
- Production: Deploys to production on every push to
mainormasterbranch - Preview: Creates preview deployments for every pull request to
mainormaster
Setup Instructions
1. Create Cloudflare API Token:
- Go to https://dash.cloudflare.com/profile/api-tokens
- Click "Create Token"
- Use "Edit Cloudflare Workers" template or create custom token with:
- Account > Cloudflare Pages > Edit permissions
- Copy the token
2. Get Cloudflare Account ID:
- Go to https://dash.cloudflare.com/
- Click on any domain in your account (or go to Workers & Pages)
- Look at the URL - it will contain
/accounts/followed by a long string of characters/numbers - that's your Account ID - Alternatively, go to Workers & Pages → Overview, and the Account ID is shown in the right sidebar
- Or go to https://dash.cloudflare.com/profile/api-tokens and look at the URL - it contains your Account ID
- The Account ID is a long alphanumeric string (usually 32 characters)
3. Add GitHub Secrets:
- Go to your GitHub repository > Settings > Secrets and variables > Actions
- Add the following secrets:
CLOUDFLARE_API_TOKEN: Your API token from step 1CLOUDFLARE_ACCOUNT_ID: Your account ID from step 2
4. Create Cloudflare Pages Project (optional):
- The workflow will automatically create the project named
partyif it doesn't exist - Alternatively, you can create it manually:
- Go to Cloudflare Dashboard > Pages
- Create a new project named
party - You can skip the build settings since we're using GitHub Actions
Workflow Details
- Build command: Builds both
@cazala/party(core) and@cazala/playground - Output directory:
packages/playground/dist - Node version: 20
- Package manager: pnpm 9.15.0
The workflow will automatically:
- Install dependencies using pnpm
- Build the core package and playground
- Deploy to Cloudflare Pages
- Create preview deployments for PRs
- Deploy to production for master branch commits
name: worker
on:
push:
branches:
- main
paths:
- "packages/worker/**"
- "pnpm-lock.yaml"
- ".github/workflows/worker.yml"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.9
- name: Get pnpm store directory
id: pnpm_store
shell: bash
run: |
STORE_PATH="$(pnpm store path --silent)"
echo "STORE_PATH=${STORE_PATH}" >> "$GITHUB_ENV"
echo "store_path=${STORE_PATH}" >> "$GITHUB_OUTPUT"
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm_store.outputs.store_path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm -r install --frozen-lockfile --prod=false
- name: Typecheck
run: pnpm --filter worker run typecheck
- name: Deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm --filter worker run deploy
packages/core/dist
packages/playground/dist
node_modules
packages/core/node_modules
packages/playground/node_modules
.pnpm-store
.pnpm-store/
.DS_Store
**/.DS_Store
packages/playground/.DS_Store
.claude/ralph-loop.local.md
tasks/
AGENTS.md (repo briefing)
This repo is a small pnpm-workspace monorepo for Party: a TypeScript particle simulation engine with dual runtimes (WebGPU + CPU) and a React playground app.
Repo layout (what matters)
packages/core(@cazala/party)- Engine + module system + CPU/WebGPU runtimes.
packages/playground(@cazala/playground)- The interactive app (React + Redux Toolkit) that drives
@cazala/party. packages/worker(worker)- Cloudflare Worker reverse-proxy for hosting under
caza.la/party. docs/- User/maintainer guides (they’re the canonical narrative docs).
Running locally (fast path)
From repo root:
npm run setup— installs root deps, then workspace deps viapnpm.npm run dev— starts the playground dev server on `http://localhost:3000`.npm run build— builds core + playground.npm run type-check— builds core and type-checks playground.
Core engine mental model (@cazala/party)
Public entry points
packages/core/src/index.tsre-exports the public API (Engine,Module, built-in modules, types).- The “real” type shape for particles is in
packages/core/src/interfaces.ts.
Particle shape (don’t assume x/vx style)
IParticle is:
position: { x, y }velocity: { x, y }size: numbermass: numbercolor: { r, g, b, a }(0..1 floats)
Semantics used throughout:
- Pinned:
mass < 0 - Removed:
mass === 0
Engine selection + expensive operations
new Engine({ runtime: "auto" | "webgpu" | "cpu", ... })is a facade inpackages/core/src/engine.ts."auto"attempts WebGPU first and falls back to CPU oninitialize().- On WebGPU,
getParticles()is a full GPU→CPU readback: avoid in hot paths. - Prefer
getParticlesInRadius(...),setParticle(...), andsetParticleMass(...). - Playground tools are written around this constraint.
Module system (how simulation/extensibility works)
Core type is Module in packages/core/src/module.ts:
- Modules declare:
name(string literal, unique)role:ModuleRole.ForceorModuleRole.Renderinputs: map of input keys toDataType.NUMBERorDataType.ARRAY- Modules implement both descriptors:
webgpu(): WebGPUDescriptorcpu(): CPUDescriptor- Enabled is tracked separately via
module.setEnabled(bool)and gets propagated as anenableduniform.
Force lifecycle phases (both runtimes support these hooks):
global(WebGPU-only helper WGSL)state(pre-pass, e.g. fluids density)apply(forces/velocity changes)constrain(position corrections, iterated)correct(post-integration corrections)
Render modules contribute passes:
- WebGPU:
RenderPassKind.FullscreenorRenderPassKind.Compute(optionallyinstanced) - CPU: Canvas2D composition via
CPURenderDescriptor
Where to look when debugging WebGPU behavior
- Engine orchestrator:
packages/core/src/runtimes/webgpu/engine.ts - Resource plumbing:
packages/core/src/runtimes/webgpu/gpu-resources.ts - WGSL program build:
packages/core/src/runtimes/webgpu/module-registry.ts - Simulation concat/dispatch:
packages/core/src/runtimes/webgpu/simulation-pipeline.ts - Local neighborhood query (avoids full readback):
packages/core/src/runtimes/webgpu/local-query.ts - Spatial grid:
packages/core/src/runtimes/webgpu/spacial-grid.ts(typo is in filename/class)
CPU counterparts live under packages/core/src/runtimes/cpu/.
Playground mental model (@cazala/playground)
Architecture pattern
The pattern is “Redux slice → hook wrapper → UI”:
- Slices:
packages/playground/src/slices/** - Hook layer:
packages/playground/src/hooks/** - Hooks encapsulate Redux and provide “actions” to components.
- Many hooks follow a dual-write pattern: dispatch to Redux + immediately call engine for responsiveness.
- Components:
packages/playground/src/components/**
Tools system + hotkeys
- Tool orchestrator:
packages/playground/src/hooks/tools/index.ts - Individual tools:
packages/playground/src/hooks/tools/individual-tools/* - Global hotkeys are implemented in
packages/playground/src/components/GlobalHotkeys.tsx. - Tool switching requires Cmd/Ctrl + letter (A/S/D/F/G/H/J/K/L).
- Session “quick load 1–9” is Cmd/Ctrl + 1–9.
If you’re changing a tool, sanity-check you’re not introducing WebGPU readbacks (getParticles()) during mouse move/drag.
Worker (packages/worker)
Cloudflare Worker reverse-proxies requests:
- Incoming:
/party/*oncaza.la - Upstream:
vars.UPSTREAM_ORIGIN(defaulthttps://party.caza.la) - Adds response header:
x-edge-proxy: cazala-party-worker
Config: packages/worker/wrangler.toml Implementation: packages/worker/src/index.ts
Docs map
If you need deeper narrative docs, start here:
docs/user-guide.md(engine API + built-in modules)docs/module-author-guide.md(writing modules)docs/maintainer-guide.md(core internals)docs/playground-user-guide.md(UI/tools/hotkeys)docs/playground-maintainer-guide.md(playground patterns)
Maintainer Guide
This document explains the internal architecture of the core library for contributors. It covers code organization, the two runtimes (CPU and WebGPU), the module system, and major subsystems like the spatial grid, pipelines, and oscillators.
Code organization
packages/core/src/- `engine.ts`: facade that selects runtime (
cpu/webgpu/auto) and delegates the fullIEngineAPI - `interfaces.ts`:
IEngine,IParticle,AbstractEnginecommon logic (view, modules, config, oscillators, export/import, FPS) - `module.ts`:
Modulebase class,ModuleRole,DataType, and uniform plumbing - `modules/forces/*`: built-in forces (environment, boundary, collisions, behavior, fluids, sensors, interaction, joints, grab)
- `modules/render/*`: built-in render modules (particles, trails, lines)
- `runtimes/cpu/*`: CPU engine and helpers (Canvas2D rendering, neighbor queries, descriptors)
- `runtimes/webgpu/*`: WebGPU engine and builders (GPU resources, program/pipeline builders, spatial grid, shaders)
Engine selection and lifecycle
- Top-level `Engine` constructs either `WebGPUEngine` or `CPUEngine` based on
runtime. - When
runtime === "auto", initialization attempts WebGPU first and falls back to CPU if device/adapter creation fails (cleanup is handled, and the CPU engine is re-initialized with the same options). - The selected concrete engine provides all
IEnginemethods; the facade also exposes helpers like pin/unpin andisSupported(module).
Note on particle readbacks
- On WebGPU,
getParticles()requires a GPU → CPU readback of the full particle buffer and can be expensive for large scenes. - Prefer local queries like
getParticlesInRadius(center, radius, { maxResults })for tool-like occupancy checks. getParticlesInRadius(...)is implemented in WebGPU via a small compute compaction pass (seeruntimes/webgpu/local-query.ts) that only reads back a bounded result buffer.
AbstractEngine responsibilities
Shared functionality across both runtimes:
- Animation control:
play()/pause()/stop()/toggle(), dt clamping, FPS smoothing - View:
Viewtracks camera, zoom, and canvas size; view changes trigger runtime hooks - Configuration:
cellSize,maxNeighbors,maxParticles,constrainIterations,clearColor - Modules: array of
Moduleinstances;export()/import()serialize module inputs, includingenabled - Oscillators:
OscillatorManagerwrites into module inputs each frame viamodule.write()and triggersonModuleSettingsChanged()
WebGPU runtime
Key components (see `runtimes/webgpu/`):
- `GPUResources`: device/context acquisition, swapchain, shared bind groups, uniform buffers, scene textures
- `ModuleRegistry`: collects modules; attaches uniform writers/readers; materializes pass/phase requirements
- `SimulationPipeline`: builds the compute program by concatenating module-provided WGSL snippets across phases (
global,state,apply,constrain,correct); dispatches with configuredworkgroupSize - `RenderPipeline`: executes module render passes (Fullscreen/Compute/Instanced) in sequence, ping-ponging the scene texture as needed
- `SpacialGrid`: grid uniforms/buffers and neighbor iterators used by simulation WGSL
- `ParticleStore`: GPU storage for particle arrays (positions, velocities, size, mass, color, etc.) with known stride
- `LocalQuery`: compact local particle queries (
getParticlesInRadius) without full-scene readback
Execution order (per frame):
1. Update oscillators and inputs; flush uniform buffers when settings changed 2. Simulation state pass (optional), then apply (forces), then constrain (iterated), then correct 3. Rendering: render passes run in declared order; compute passes may read/write the scene texture; fullscreen passes composite 4. Present
Performance considerations:
workgroupSize(default 64) andmaxParticlesare configurable- dt is clamped to improve stability (
<= 100ms) - Neighbor queries depend on
cellSizeandmaxNeighbors; tune for density
CPU runtime
Parallels the WebGPU phases with pure TypeScript:
- Simulation phases implemented via
CPUDescriptorcallbacks (state,apply,constrain,correct) - Neighbor queries via a spatial grid with
getNeighbors(position, radius)(see `spatial-grid.ts`) - Rendering via Canvas2D
- Composition: modules declare how to interact with the canvas clear/draw order
- Effects like Trails use immediate-mode approximations (decay fill, canvas blur)
Module system
- Each module declares
name,role, andinputs(NUMBER/ARRAY); the engine binds them as uniforms/buffers - The base
Moduleexposeswrite()to update inputs andread()to snapshot them;setEnabled()toggles an implicitenabledinput - For force modules, both runtimes support the lifecycle hooks; render modules contribute render passes
- Arrays are supported and surfaced in WGSL via
getLength()and indexedgetUniform()access
Built-in module notes
- Environment: gravity/inertia/friction/damping; inward/outward/custom directions use grid/view transforms per runtime
- Boundary: bounce/warp/kill/none modes; optional repel with inside/outside scaling; tangential friction
- Collisions: position correction + impulse; handles identical-position separation
- Behavior: separation/alignment/cohesion/wander; consistent FOV checks; pseudo-random jitter to reduce bias
- Fluids: SPH density (
state) and pressure/viscosity (apply); near-pressure for dense packs; force clamping - Sensors: trail/color sampling; consistent world↔UV mapping and CPU sampling
- Interaction: falloff-based point force; attract/repel
- Joints: CSR incident lists; momentum preservation; optional particle↔joint and joint↔joint CCD
- Grab: single-particle override applied in
correct - Render: Particles (instanced soft-discs; ring for pinned), Trails (decay+diffuse compute), Lines (instanced quads)
Export/Import and settings change propagation
export()iterates modules and collects current input values plusenabledimport()writes values back and togglesenabled, then triggersonModuleSettingsChanged()- Oscillators update via a centralized manager; input writes also flow through the same mechanism
Extending the system
- Add new modules under `modules/forces/*` or `modules/render/*`
- For WebGPU: extend builders if the WGSL DSL needs new helpers
- For CPU: ensure compositing and sampling utilities cover your pass
- Update the playground to expose controls for new inputs
References
- Authoring: `module-author-guide.md`
- User Guide: `user-guide.md`
Module Author Guide
This guide explains how to build your own modules for the engine. There are two public roles you can implement:
- Force: runs in the simulation pipeline; can add to
acceleration/velocityand perform constraints/corrections - Render: runs in the rendering pipeline; can draw fullscreen, draw per-instance quads, or compute over the scene texture
Modules should support both runtimes when possible:
- CPU: implement a
cpu()descriptor - WebGPU: implement a
webgpu()descriptor
If you only implement one runtime, the other will be unsupported on that module. The top-level Engine#isSupported(module) can be used to test support.
Module base class
Create a TypeScript class extending Module<Name, Inputs, StateKeys?> and declare:
name: string literal, globally uniquerole:ModuleRole.ForceorModuleRole.Renderinputs: a map of input names toDataType.NUMBERorDataType.ARRAY
The base class provides:
write(partialInputs)andread()/readValue(key)/readArray(key)setEnabled(boolean)andisEnabled()- Uniform plumbing (the engine binds inputs into GPU buffers automatically)
Example skeleton
import {
Module,
ModuleRole,
DataType,
type WebGPUDescriptor,
type CPUDescriptor,
} from "@cazala/party";
type WindInputs = { strength: number; dirX: number; dirY: number };
export class Wind extends Module<"wind", WindInputs> {
readonly name = "wind" as const;
readonly role = ModuleRole.Force;
readonly inputs = {
strength: DataType.NUMBER,
dirX: DataType.NUMBER,
dirY: DataType.NUMBER,
} as const;
constructor() {
super();
this.write({ strength: 100, dirX: 1, dirY: 0 });
}
webgpu(): WebGPUDescriptor<WindInputs> {
return {
apply: ({ particleVar, getUniform }) => `{
let d = vec2<f32>(${getUniform("dirX")}, ${getUniform("dirY")});
let l = length(d);
if (l > 0.0) { ${particleVar}.acceleration += normalize(d) * ${getUniform(
"strength"
)}; }
}`,
};
}
cpu(): CPUDescriptor<WindInputs> {
return {
apply: ({ particle, input }) => {
const len = Math.hypot(input.dirX, input.dirY) || 1;
particle.acceleration.x += (input.dirX / len) * input.strength;
particle.acceleration.y += (input.dirY / len) * input.strength;
},
};
}
}Force module lifecycles
Both runtimes support a subset of hooks. Implement only the ones you need:
global(): injects global WGSL helpers (WebGPU only)state(...): per-particle pre-pass to compute and store state (e.g., fluid density)apply(...): add forces viaaccelerationor adjustvelocityconstrain(...): position constraints (runs multiple iterations per frame)correct(...): correct velocities post-integration
WebGPU descriptor context includes helpers such as:
getUniform(name[, indexExpr])getState(name[, indexExpr])andsetState(name, expr)getLength(arrayName)for array-backed inputs- Neighbor iteration utilities (e.g.,
neighbor_iter_init(position, radius))
CPU descriptor context includes helpers such as:
getNeighbors(position, radius)for neighbor queriesgetImageData(x, y, w, h)for sampling the canvas (used by sensors)viewcamera/zoom access
See built-in forces for examples:
- `Environment`: global forces and damping
- `Boundary`: bounds, warp/kill, tangential friction, optional repulsion
- `Collisions`: pairwise collision response with position correction
- `Behavior`: boids-like steering
- `Fluids`: SPH-like density and pressure with state + apply
- `Sensors`: trail/color sampling steering
- `Interaction`: mouse-driven attract/repel
- `Joints`: distance constraints with collision options and momentum preservation
- `Grab`: single-particle grabbing during drag
Render modules
Render modules contribute passes to the render pipeline.
WebGPU render descriptor
- Fullscreen pass:
{ kind: RenderPassKind.Fullscreen, vertex?, fragment, bindings, readsScene, writesScene } - Compute pass over the scene texture:
{ kind: RenderPassKind.Compute, kernel, bindings, readsScene, writesScene } - Instanced fullscreen:
{ instanced: true, instanceFrom: "someArrayInput" }(see `Lines`)
CPU render descriptor
composition: how the module participates in the canvas draw order (RequiresClear,HandlesBackground,Additive, etc.)setup(...)and/orrender(...)callbacks that receive screen-space coordinates, utilities, and the 2D context
Examples in core:
- `Particles`: fullscreen draw of instanced particles; custom color and hue modes; ring style for pinned particles
- `Trails`: compute-like two-pass effect (decay + blur) or canvas equivalents on CPU
- `Lines`: instanced line quads on GPU, stroke lines on CPU
Arrays and large inputs
- Declare array inputs with
DataType.ARRAY(e.g., index lists for `Lines`/`Joints`). - WebGPU path uploads them to buffers; CPU path receives them as JS arrays.
- Use
getLength(name)andgetUniform(name, indexExpr)in WGSL to read items.
Spatial grid and neighbor queries
- The engine maintains a spatial grid sized by
cellSizefor neighbor queries. - WebGPU exposes lightweight neighbor iterators; CPU provides
getNeighbors(). - Tune
cellSizeandmaxNeighborsvia the engine for performance vs. accuracy.
Supporting both runtimes
When possible:
- Implement both
webgpu()andcpu()for feature parity. - Keep numeric scales similar across runtimes (e.g., damping factors) so scenes feel consistent.
- For images/trails sampling, prefer engine-provided helpers over direct DOM access on CPU.
Testing modules
- Instantiate your module in the playground or your app and include it in the
forcesorrenderarrays. - Use
runtime: "auto"and confirm behavior matches on both CPU and WebGPU. - Validate export/import: the engine will serialize and restore your module inputs automatically.
Cross-references
- See also: `user-guide.md` for how users wire modules into an engine.
- See also: `maintainer-guide.md` for internal architecture details.
Playground Maintainer Guide
This guide covers the internal architecture, coding patterns, and development workflows for the Party Playground React application. It's designed to help new developers understand the codebase and contribute effectively.
Tech Stack
Core Technologies
- React 18: Component-based UI with hooks and modern patterns
- TypeScript: Full type safety with strict configuration
- Redux Toolkit: State management with modern Redux patterns
- Vite: Build tool and dev server for fast development
- CSS Modules: Scoped styling with PostCSS processing
Development Tools
- ESLint: Code linting with React and TypeScript rules
- Prettier: Code formatting with consistent style
- Vitest: Unit testing framework
- React DevTools: Redux DevTools integration
Key Dependencies
- @reduxjs/toolkit: Modern Redux with createSlice and RTK Query
- react-redux: React bindings for Redux
- lucide-react: Consistent icon library
- @cazala/party: Core physics engine
Architecture Overview
The playground follows a modular, layered architecture with clear separation of concerns:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ UI Components │ │ Hooks │ │ Redux Slices │
│ │ │ │ │ │
│ • Module UIs │◄──►│ • Module Hooks │◄──►│ • Module State │
│ • Tool Overlays │ │ • Tool Hooks │ │ • Actions │
│ • Common UI │ │ • Engine Hook │ │ • Selectors │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
▼
┌─────────────────┐
│ Engine Context │
│ │
│ • Core Engine │
│ • Module Refs │
│ • Canvas Utils │
└─────────────────┘File Organization
Directory Structure
packages/playground/src/
├── components/ # React components
│ ├── modules/ # Module-specific UI components
│ ├── modals/ # Modal dialogs
│ ├── ui/ # Reusable UI components
│ └── tools/ # Tool-related components
├── contexts/ # React contexts
├── hooks/ # Custom React hooks
│ ├── modules/ # Module-specific hooks
│ ├── tools/ # Tool system hooks
│ │ └── individual-tools/ # Individual tool implementations
│ └── utils/ # Utility hooks
├── slices/ # Redux Toolkit slices
│ ├── modules/ # Module state slices
│ └── utils/ # Utility slices
├── types/ # TypeScript type definitions
├── utils/ # Pure utility functions
└── styles/ # Global styles and CSS modulesNaming Conventions
- Components: PascalCase (
EnvironmentModule.tsx) - Hooks: camelCase with
useprefix (useEnvironment.ts) - Types: PascalCase (
ModuleState.ts) - Utils: camelCase (
sessionManager.ts) - CSS Modules: kebab-case (
.component-name)
Core Architectural Patterns
1. Module System Architecture
Each physics module follows a three-layer pattern:
Layer 1: Redux Slice (State Management)
// slices/modules/environment.ts
export const environmentSlice = createSlice({
name: "environment",
initialState: {
gravityStrength: 0,
gravityDirection: "down" as const,
// ... other properties
},
reducers: {
setEnvironmentGravityStrength: (state, action: PayloadAction<number>) => {
state.gravityStrength = action.payload;
},
resetEnvironment: () => initialState,
importEnvironmentSettings: (state, action) => {
Object.assign(state, action.payload);
},
},
});Layer 2: Module Hook (Integration)
// hooks/modules/useEnvironment.ts
export function useEnvironment() {
const dispatch = useAppDispatch();
const { environment } = useEngine();
const state = useAppSelector(selectEnvironmentState);
// Sync Redux state to engine when state changes
useEffect(() => {
if (environment) {
environment.setGravityStrength(state.gravityStrength);
// ... sync other properties
}
}, [environment, state]);
// Action creators with dual-write pattern
const setGravityStrength = useCallback(
(value: number) => {
dispatch(setEnvironmentGravityStrength(value)); // Redux update
environment?.setGravityStrength(value); // Immediate engine update
},
[dispatch, environment]
);
return {
// State properties (individual extractions)
gravityStrength: state.gravityStrength,
gravityDirection: state.gravityDirection,
// Action creators
setGravityStrength,
setGravityDirection,
// Utility actions
resetEnvironment: useCallback(
() => dispatch(resetEnvironment()),
[dispatch]
),
};
}Layer 3: Module Component (UI)
// components/modules/EnvironmentModule.tsx
export function EnvironmentModule({ enabled = true }: { enabled?: boolean }) {
const {
gravityStrength,
setGravityStrength,
gravityDirection,
setGravityDirection,
} = useEnvironment();
return (
<>
<Slider
sliderId="environment.gravityStrength"
label="Gravity Strength"
value={gravityStrength}
onChange={setGravityStrength}
min={0}
max={2000}
disabled={!enabled}
/>
<Dropdown
label="Direction"
value={gravityDirection}
onChange={setGravityDirection}
options={[
{ value: "up", label: "Up" },
{ value: "down", label: "Down" },
// ... more options
]}
disabled={!enabled}
/>
</>
);
}2. Tool System Architecture
Tools follow a hook-based pattern with standardized interfaces:
Tool Hook Interface
// types/tools.ts
export interface ToolHandlers {
onMouseDown?: (event: MouseEvent) => void | Promise<void>;
onMouseMove?: (event: MouseEvent) => void | Promise<void>;
onMouseUp?: (event: MouseEvent) => void | Promise<void>;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyUp?: (event: KeyboardEvent) => void;
}
export type ToolRenderFunction = (
ctx: CanvasRenderingContext2D,
size: { width: number; height: number },
mouse: { x: number; y: number; isDown: boolean }
) => void;Tool Implementation Pattern
// hooks/tools/individual-tools/useSpawnTool.ts
export function useSpawnTool(isActive: boolean) {
const { addParticle } = useEngine();
const { appendToTransaction, beginTransaction } = useHistory();
const { spawnSettings } = useInit();
const handlers: ToolHandlers = {
onMouseDown: async (event) => {
if (!isActive) return;
beginTransaction("Spawn particles");
const particles = createParticlesAtPosition(event, spawnSettings);
for (const particle of particles) {
addParticle(particle);
appendToTransaction(new AddParticleCommand(particle));
}
},
};
const renderOverlay: ToolRenderFunction = useCallback(
(ctx, size, mouse) => {
if (!isActive) return;
// Draw spawn preview at cursor
drawSpawnPreview(ctx, mouse, spawnSettings);
},
[isActive, spawnSettings]
);
return { handlers, renderOverlay };
}3. Hook Patterns and Conventions
Critical Pattern: No Direct Redux Usage in Components
❌ Never do this in components:
// DON'T: Direct Redux usage in components
const dispatch = useDispatch();
const state = useSelector(selectSomeState);✅ Always do this instead:
// DO: Use module hooks that wrap Redux
const { value, setValue, reset } = useModuleName();Hook Design Principles
1. Encapsulation: Hooks hide Redux complexity from components 2. Dual-Write Pattern: Update both Redux state and engine immediately 3. Memoization: Use useCallback for all functions, useMemo for objects 4. Individual Exports: Export individual properties, not entire state objects 5. Type Safety: Full TypeScript support with proper typing
Standard Hook Structure
export function useModuleName() {
// 1. Get dependencies
const dispatch = useAppDispatch();
const { moduleRef } = useEngine();
const state = useAppSelector(selectModuleState);
// 2. Sync state to engine
useEffect(() => {
if (moduleRef) {
moduleRef.updateFromState(state);
}
}, [moduleRef, state]);
// 3. Create action creators with useCallback
const setValue = useCallback(
(value: SomeType) => {
dispatch(setModuleValue(value));
moduleRef?.setValue(value);
},
[dispatch, moduleRef]
);
// 4. Return individual properties and actions
return {
// State (individual properties)
value: state.value,
otherValue: state.otherValue,
// Actions
setValue,
setOtherValue,
reset: useCallback(() => dispatch(resetModule()), [dispatch]),
};
}4. Command Pattern for Undo/Redo
The playground implements a sophisticated undo/redo system using the Command pattern:
Command Interface
// types/history.ts
export interface Command {
id: string;
label: string;
timestamp: number;
do(ctx: HistoryContext): void | Promise<void>;
undo(ctx: HistoryContext): void | Promise<void>;
tryMergeWith?(next: Command): Command | null;
}
export interface HistoryContext {
engine: IEngine;
addParticle: (particle: IParticle) => Promise<void>;
removeParticle: (index: number) => Promise<void>;
// ... other utilities
}Command Implementation Example
// commands/AddParticleCommand.ts
export class AddParticleCommand implements Command {
id = generateId();
label = "Add particle";
timestamp = Date.now();
constructor(private particle: IParticle, private index?: number) {}
async do(ctx: HistoryContext): Promise<void> {
const addedIndex = await ctx.addParticle(this.particle);
this.index = addedIndex; // Store for undo
}
async undo(ctx: HistoryContext): Promise<void> {
if (this.index !== undefined) {
await ctx.removeParticle(this.index);
}
}
}Usage in Tools
// In tool hooks
const { beginTransaction, appendToTransaction, commitTransaction } =
useHistory();
const handleMouseDown = async (event) => {
beginTransaction("Draw stroke");
const particle = await addParticle(particleData);
appendToTransaction(new AddParticleCommand(particle));
// ... more operations
commitTransaction(); // Groups all commands into single undo operation
};State Management Patterns
Redux Slice Structure
Each slice follows a consistent pattern:
export const moduleSlice = createSlice({
name: "moduleName",
initialState: {
// Primitive values for each module property
property1: defaultValue1,
property2: defaultValue2,
},
reducers: {
// Property setters: set[Module][Property]
setModuleProperty1: (state, action: PayloadAction<Type1>) => {
state.property1 = action.payload;
},
// Reset: reset[Module]
resetModule: () => initialState,
// Import: import[Module]Settings
importModuleSettings: (
state,
action: PayloadAction<Partial<ModuleState>>
) => {
Object.assign(state, action.payload);
},
},
});
// Export actions
export const { setModuleProperty1, resetModule, importModuleSettings } =
moduleSlice.actions;
// Export selectors
export const selectModuleState = (state: RootState) => state.modules.moduleName;
export const selectModuleProperty1 = (state: RootState) =>
state.modules.moduleName.property1;
// Export reducer
export default moduleSlice.reducer;Central Module Integration
// slices/modules/index.ts
import { combineReducers } from "@reduxjs/toolkit";
import environmentReducer from "./environment";
import fluidsReducer from "./fluids";
// ... other module reducers
export const modulesReducer = combineReducers({
environment: environmentReducer,
fluids: fluidsReducer,
// ... other modules
});
export type ModulesState = ReturnType<typeof modulesReducer>;Component Development Patterns
UI Component Guidelines
1. Single Responsibility: Components should have one clear purpose 2. Prop Interfaces: Use TypeScript interfaces for all props 3. Default Props: Use default parameters instead of defaultProps 4. Conditional Rendering: Use logical operators for clean conditional rendering 5. Event Handlers: Extract complex handlers to separate functions
Standard Component Structure
interface ComponentProps {
enabled?: boolean;
className?: string;
onSomething?: (value: SomeType) => void;
}
export function Component({
enabled = true,
className,
onSomething,
}: ComponentProps) {
// 1. Hooks (state, effects, callbacks)
const { value, setValue } = useRelevantHook();
// 2. Event handlers
const handleClick = useCallback(
(event: MouseEvent) => {
// handler logic
onSomething?.(newValue);
},
[onSomething]
);
// 3. Render
return (
<div className={cn("component-class", className)}>
{/* Component content */}
</div>
);
}CSS Module Patterns
/* Component.module.css */
.container {
/* Container styles */
}
.enabled {
/* Enabled state */
}
.disabled {
/* Disabled state */
opacity: 0.6;
pointer-events: none;
}
.item {
/* Item styles */
}
.item:hover {
/* Hover effects */
}Engine Integration Patterns
Engine Context Usage
The EngineContext provides centralized access to the engine and utilities:
// contexts/EngineContext.tsx
export function useEngine() {
const context = useContext(EngineContext);
if (!context) {
throw new Error("useEngine must be used within an EngineProvider");
}
return context;
}
// Usage in hooks
export function useModuleName() {
const { moduleName } = useEngine(); // Get module reference
// Use module reference for direct engine calls
const setValue = useCallback(
(value) => {
dispatch(setModuleValue(value));
moduleName?.setValue(value); // Immediate engine update
},
[dispatch, moduleName]
);
}Coordinate System Integration
// Engine context provides coordinate utilities
const { screenToWorld, worldToScreen } = useEngine();
// Convert mouse coordinates for engine operations
const handleMouseClick = (event: MouseEvent) => {
const screenCoords = { x: event.clientX, y: event.clientY };
const worldCoords = screenToWorld(screenCoords);
// Use world coordinates for engine operations
addParticle({ x: worldCoords.x, y: worldCoords.y /* ... */ });
};Development Workflow
Setting Up Development Environment
1. Install dependencies:
npm run setup2. Start development server:
npm run dev3. Run tests:
npm test4. Type checking:
npm run type-checkNote: The project uses pnpm workspaces internally but all commands are available through npm scripts. The setup command installs pnpm locally and sets up all workspace dependencies.Adding New Modules
1. Create Redux slice:
// slices/modules/newModule.ts
export const newModuleSlice = createSlice({
// Implementation
});2. Create module hook:
// hooks/modules/useNewModule.ts
export function useNewModule() {
// Implementation following standard pattern
}3. Create UI component:
// components/modules/NewModuleComponent.tsx
export function NewModuleComponent({ enabled = true }) {
// Implementation
}4. Integrate into main UI:
// Add to ModulesSidebar or appropriate locationAdding New Tools
1. Create tool hook:
// hooks/tools/individual-tools/useNewTool.ts
export function useNewTool(isActive: boolean) {
// Implement handlers and renderOverlay
return { handlers, renderOverlay };
}2. Register in tool system:
// Update tool registry and hotkey mappings3. Add UI controls:
// Add tool button to toolbarTesting Patterns
Unit Testing
// __tests__/hooks/useModule.test.ts
import { renderHook, act } from "@testing-library/react";
import { useModule } from "../hooks/useModule";
describe("useModule", () => {
it("should handle value updates correctly", () => {
const { result } = renderHook(() => useModule());
act(() => {
result.current.setValue(newValue);
});
expect(result.current.value).toBe(newValue);
});
});Integration Testing
// Test Redux integration
import { configureStore } from "@reduxjs/toolkit";
import { Provider } from "react-redux";
const testStore = configureStore({
reducer: { modules: modulesReducer },
});
const wrapper = ({ children }) => (
<Provider store={testStore}>{children}</Provider>
);Performance Considerations
React Performance
1. Memoization: Use useCallback and useMemo appropriately 2. Component Splitting: Break large components into smaller ones 3. Conditional Rendering: Avoid expensive renders when not needed 4. Event Handler Optimization: Debounce expensive operations
Redux Performance
1. Selector Memoization: Use reselect for complex selectors 2. Normalized State: Keep state flat and normalized 3. Minimal Updates: Update only necessary state slices
Engine Integration Performance
1. Dual-Write Pattern: Immediate engine updates for responsive UI 2. Batch Operations: Group multiple engine operations when possible 3. Async Boundaries: Use async operations for expensive engine calls
Common Patterns and Anti-Patterns
✅ Good Patterns
// 1. Use module hooks instead of direct Redux
const { value, setValue } = useModule();
// 2. Memoize callbacks
const handleChange = useCallback(
(newValue) => {
setValue(newValue);
},
[setValue]
);
// 3. Individual state properties
return {
property1: state.property1,
property2: state.property2,
setProperty1,
setProperty2,
};
// 4. Proper TypeScript usage
interface Props {
value: number;
onChange: (value: number) => void;
}❌ Anti-Patterns
// 1. DON'T use Redux directly in components
const dispatch = useDispatch(); // ❌
const state = useSelector(selectState); // ❌
// 2. DON'T return entire state objects
return { state }; // ❌ Return individual properties instead
// 3. DON'T forget memoization
const handleClick = () => {
/* ... */
}; // ❌ Use useCallback
// 4. DON'T bypass the hook layer
engine.module.setValue(value); // ❌ Use module hooks insteadDebugging and Development Tools
Redux DevTools
- Use Redux DevTools browser extension
- Time-travel debugging for state changes
- Action inspection and replay
React DevTools
- Component hierarchy inspection
- Props and state debugging
- Performance profiling
Engine Debugging
- Use browser console for engine state inspection
- FPS monitoring in top bar
- WebGPU vs CPU runtime information
Contributing Guidelines
Code Style
1. Follow TypeScript strict mode 2. Use Prettier for formatting 3. Follow ESLint rules 4. Write descriptive commit messages
Pull Request Process
1. Create feature branch from main 2. Implement changes following patterns 3. Add tests for new functionality 4. Update documentation if needed 5. Ensure all checks pass
Architecture Decisions
1. Discuss major changes in issues first 2. Follow existing patterns unless there's a compelling reason not to 3. Consider performance implications 4. Maintain backward compatibility when possible
This maintainer guide provides the foundation for understanding and contributing to the playground codebase. The consistent patterns and clear separation of concerns make the codebase maintainable and extensible while providing excellent developer experience.
Playground User Guide
The Party Playground is an interactive web application for creating, experimenting with, and managing particle physics simulations. This guide covers all user-facing features, controls, and workflows.
Getting Started
Launching the Playground
npm run devVisit http://localhost:3000 to access the playground interface.
Note: the dev server port is configured in packages/playground/vite.config.js.Interface Overview
The playground consists of four main areas:
1. Top Bar: Play/pause, clear, restart, save/load, and help controls 2. Left Sidebar: Initialization (INIT) panel for spawning particles 3. Center Canvas: Interactive simulation viewport with tool overlay 4. Right Sidebar: Physics modules for controlling simulation behavior
Canvas and Simulation
Basic Controls
- Play/Pause: Spacebar or top bar button
- Camera: Mouse wheel (or trackpad) to zoom
- Clear: Top bar Clear button (clears particles/joints)
- Restart: Top bar Restart button (re-spawns from current INIT settings)
Tools
Access tools via the toolbar or hotkeys. Active tool is highlighted and shows cursor overlay.
Spawn Tool (Cmd/Ctrl + S)
- Purpose: Add particles to the simulation
- Usage:
- Click: Spawn particle (persisted size)
- Drag: Set initial velocity (arrow)
- Modifiers:
- Ctrl/Cmd + Drag: Adjust size (persists, does not spawn)
- Shift: Stream while dragging
Grab Tool (Cmd/Ctrl + G)
- Purpose: Move particles with mouse
- Usage: Click and drag particles around the canvas
- Features: Physics-based dragging with smooth interpolation
Joint Tool (Cmd/Ctrl + H)
- Purpose: Create distance constraints between particles
- Usage: Click to create joints between selected particles
- Visualization: Joints appear as lines connecting particles
Pin Tool (Cmd/Ctrl + F)
- Purpose: Pin/unpin particles (make them immovable)
- Usage: Click/drag to pin inside the dashed circle
- Visualization: Pinned particles appear as rings instead of filled circles
- Modifiers:
- Shift + Click/Drag: Unpin inside circle
- Ctrl/Cmd + Drag: Adjust pin radius
Remove Tool (Cmd/Ctrl + D)
- Purpose: Delete particles and joints
- Usage: Click/drag to remove inside the dashed circle
- Modifiers:
- Ctrl/Cmd + Drag: Adjust removal radius
Draw Tool (Cmd/Ctrl + J)
- Purpose: Draw particle trails by dragging
- Usage: Click & drag to draw particles and auto-connect joints
- Features: Automatic joint creation between drawn particles
- Modifiers:
- Shift: Pin while drawing
- Ctrl/Cmd + Drag: Adjust particle size
Brush Tool (Cmd/Ctrl + K)
- Purpose: Paint multiple particles at once in a circular area
- Usage: Click/drag to fill the dashed circle with non-overlapping particles (uses current INIT particle size)
- Modifiers:
- Hold Shift: Spawn pinned particles
- Hold Cmd/Ctrl while dragging: Resize brush radius
Interact Tool (Cmd/Ctrl + A)
- Purpose: Create attraction/repulsion fields
- Usage:
- Left-click and hold: Attract particles to cursor
- Right-click and hold: Repel particles from cursor
- Features: Visual force field indicator
- Modifiers:
- Ctrl/Cmd + Drag: Adjust interaction radius
- Shift + Drag: Adjust strength
Shape Tool (Cmd/Ctrl + L)
- Purpose: Spawn a full-mesh polygon (particles + joints)
- Usage:
- Click: Spawn full-mesh polygon
- Modifiers:
- Ctrl/Cmd + Drag: Adjust radius
- Shift + Drag: Adjust sides (3-6)
Hotkeys and Shortcuts
Playback Control
- Spacebar: Play/pause simulation
Tools
- Cmd/Ctrl + A: Interact tool
- Cmd/Ctrl + S: Spawn tool
- Cmd/Ctrl + D: Remove tool
- Cmd/Ctrl + F: Pin tool
- Cmd/Ctrl + G: Grab tool
- Cmd/Ctrl + H: Joint tool
- Cmd/Ctrl + J: Draw tool
- Cmd/Ctrl + K: Brush tool
- Cmd/Ctrl + L: Shape tool
View Control
- Mouse Wheel / Trackpad: Zoom in/out
Interface
- Cmd/Ctrl + B: Toggle sidebar visibility
Undo / Redo
- Cmd/Ctrl + Z: Undo
- Cmd/Ctrl + Shift + Z or Cmd/Ctrl + Y: Redo
Numerical Hotkeys (1–9)
- Cmd/Ctrl + 1–9: Quick load sessions 1–9 (when available)
Initialization (INIT) Panel
The INIT panel controls how particles are spawned when using the Restart button or spawn tool.
Shape Options
- Random: Particles spawned randomly across canvas
- Grid: Particles arranged in a regular grid pattern
- Circle: Particles arranged in a circular formation
- Donut: Particles arranged in a ring (inner + outer radius)
- Square: Particles arranged in a square
- Text: Particles spawn to form the typed text
- Image: Particles spawn to form an image from a URL or upload
Particle Properties
- Number of Particles: 100–100,000
- Particle Size: 1–50 (this is the physics radius)
- Particle Mass: 0.1–10 (can be auto-derived from size)
- Colors: Optional palette (defaults to white if empty)
- Velocity Speed: 0–500
- Velocity Direction:
- Random
- In (towards center)
- Out (from center)
- Clockwise / Counter-Clockwise
- Custom (with an angle slider)
Advanced Options
- Grid spacing: Only for Grid shape (minimum is \(2 \times\) particle size)
- Join Rows and Columns: Only for Grid shape (creates joints + lines after spawn)
- Circle/Donut radius: Radius / Outer Radius sliders (Circle/Donut)
- Donut inner radius: Inner Radius slider (Donut)
- Square size: Square Size slider (Square)
- Square corner radius: Corner Radius slider (Square)
- Text fields: Text, Text Size, and Font (Sans Serif / Serif / Monospace)
- Image fields: Image URL or upload (URL disabled when upload is used), plus Image Size (max dimension). Transparent pixels are ignored and particle colors come from the image.
Physics Modules
Each module controls a different aspect of particle physics. Modules can be enabled/disabled and have adjustable parameters.
Environment
Global physics affecting all particles:
- Gravity Strength: Scales the gravity acceleration applied each frame.
- Gravity Direction: Direction of gravity (Down / Up / Left / Right / Inwards / Outwards / Custom).
- Gravity Angle: Sets the gravity direction when using Custom (in degrees).
- Inertia: Adds acceleration in the direction of current velocity (a “keep moving” boost).
- Friction: Linear drag; accelerates against velocity to slow particles down.
- Damping: Directly scales velocity each frame (extra velocity decay).
Boundary
Controls how particles interact with canvas edges:
- Mode: Boundary handling (Bounce = reflect, Warp = wrap around, Kill = remove when outside, None = no boundary constraint).
- Restitution: Bounciness when using Bounce (higher = more elastic).
- Friction: Tangential velocity damping when using Bounce (higher = more sliding loss).
- Repel Distance: How far from an edge the repel force starts pushing particles inward.
- Repel Strength: Strength of the repel force near/outside the bounds (applies in all modes).
Collisions
Particle-to-particle collision detection:
- Restitution: Elasticity of particle–particle collisions (higher = bouncier).
Behavior (Flocking)
Emergent group behaviors based on local interactions:
- Wander: Adds small random steering (perpendicular jitter) to break symmetry.
- Cohesion: Steers particles toward the local neighborhood’s center of mass.
- Alignment: Steers particles toward the average velocity of nearby neighbors.
- Repulsion: Strength of the “move away” steering when neighbors are too close.
- Separation: Distance threshold under which repulsion kicks in.
- Chase: Makes heavier particles steer toward lighter ones (predator-like behavior).
- Avoid: Makes lighter particles steer away from heavier ones (prey-like behavior).
- View Radius: How far each particle searches for neighbors.
- View Angle: Field-of-view cone for neighbor influence (in degrees).
Fluids
Fluid-like behavior, with selectable solver method:
- Method: SPH (density/pressure fluid) or PIC/FLIP (velocity-grid-inspired blend).
- Influence Radius: Neighbor search radius used by the fluid solver kernels.
- Density: Target density for the fluid (higher = “more crowded” before pressure pushes back).
- Pressure: Scales how strongly density deviations push particles apart/together.
- PIC/FLIP Ratio: Only when Method = PIC/FLIP; 0 = pure PIC (smoother), 1 = pure FLIP (more energetic).
- SPH-only controls:
- Max Acceleration: Caps the maximum fluid impulse to reduce instability.
- Viscosity: Velocity smoothing between neighbors (higher = thicker fluid).
- Enable Near Pressure: Enables an additional “near-density” pressure term for sharper clumping/structure.
- Near Pressure: Strength multiplier for the near-pressure term.
- Near Threshold: Distance threshold where near-pressure dominates over regular pressure.
Sensors
Sensor-based steering using the trails buffer:
- Distance: How far ahead the left/right sensors sample.
- Angle: How far left/right the sensors are rotated from the movement direction.
- Radius: Sampling radius (in screen texture space via zoom) around each sensor point.
- Threshold: Minimum trail intensity required to trigger a follow/flee decision.
- Strength: Sets the resulting steering velocity magnitude when a sensor “wins”.
- Follow Behavior: What to follow (None / Any intensity / Same color / Different color).
- Flee Behavior: What to avoid (None / Any intensity / Same color / Different color).
- Color Similarity Threshold: Only when Follow/Flee uses Same/Different; how strict color matching is.
- Flee Angle: Only when Flee Behavior is not None; how sharply to turn away when fleeing.
Interaction
There is no Interaction module panel in the sidebar: interaction is controlled by the Interact Tool (see Tools section).
Joints
Distance constraints between particles:
- Momentum: Blends velocity toward the actual post-constraint motion (helps reduce jitter in joint chains).
- Particle Collisions: Enables particle-vs-joint segment collision handling.
- Joint Collisions: Enables joint-vs-joint crossing resolution (nudges intersecting segments apart).
- Steps: Substeps for collision checking (higher = more robust CCD, slower).
- Friction: Tangential damping when particles collide with joint segments.
- Restitution: Bounciness when particles collide with joint segments.
- Separation: Push-apart amount used when resolving joint-vs-joint intersections.
Oscillators
Oscillators animate module parameters over time, creating dynamic effects.
Using Oscillators
1. Enable: Cmd/Ctrl + click any slider to enable oscillation 2. Speed Control: Cmd/Ctrl + click repeatedly to cycle: Slow → Normal → Fast 3. Range: Oscillation starts with the slider’s min/max bounds; you can adjust the oscillator min/max handles 4. Disable: Click the slider (without Cmd/Ctrl) to stop the oscillator
Visual Indicators
- Speed Badge: Shows current oscillation frequency
- Animated Slider: Handle moves automatically when oscillating
- Color Coding: Speed badge color reflects slow/normal/fast
Session Management
Saving Sessions
1. Click the Save button in the top bar 2. Enter session name in modal 3. Session includes:
- All module settings
- Particle positions and properties (if ≤1000 particles)
- Joints and their properties
- Oscillator configurations
- Camera position and zoom
Loading Sessions
1. Click the Load button in the top bar 2. Browse available sessions with metadata:
- Creation and modification dates
- Particle count
- Whether particle data is included
3. Click a session row to load it (full restore when particle data exists; otherwise respawns from config)
Session Management Features
- Rename: Click pencil icon to rename sessions
- Duplicate: Click copy icon to create session copy
- Delete: Click trash icon with confirmation
- Reorder: Drag sessions to change display order
- Export: Export session as JSON file
- Import: Import session from JSON file
Quick Session Loading
- Cmd/Ctrl + 1–9: Quick load a session (settings only)
Rendering and Visual Effects
Particle Visualization
- Show Particles: Toggle particle rendering
- Particle Color Type: Default / Custom / Hue
- Pinned Particles: Render as rings to indicate immovable state
Trails
- Show Trails: Toggle trails rendering
- Trail Decay
- Trail Diffuse
Lines
- Show Lines: Toggle line rendering
- Line Color
- Line Width
Global Render Options
- Invert Colors
- Clear Color
Undo/Redo System
Supported Actions
- Spawn tool gestures
- Remove tool gestures
- Pin/unpin tool gestures
- Joint tool operations
- Draw tool strokes
- Shape tool spawns
- Brush tool strokes
Usage
- Undo: Cmd/Ctrl + Z
- Redo: Cmd/Ctrl + Shift + Z or Cmd/Ctrl + Y
- Grouping: Related actions (like draw strokes) are grouped together
Optimization Tips
- Disable unused modules for better performance
- Reduce trail diffusion if experiencing slowdown
- Use fewer joints for complex simulations
- Monitor FPS indicator in sidebar
Runtime Information
- WebGPU: Better performance with many particles
- CPU Fallback: Universal compatibility
- Auto-Selection: Engine chooses best available runtime
Performance Panel
The PERFORMANCE panel includes runtime and tuning options:
- Use WebGPU: Toggle runtime (when supported)
- Constrain Iterations
- Max Neighbors
- Grid Cell Size
- Show Grid
- Particles / FPS counters
Tips and Tricks
Creative Workflows
1. Start Simple: Begin with basic gravity + boundary 2. Layer Effects: Add modules one at a time to understand interactions 3. Save Presets: Create sessions for different effect combinations 4. Experiment: Try extreme parameter values for unexpected results
Performance Optimization
1. Profile First: Use browser dev tools to identify bottlenecks 2. Modular Approach: Enable only needed modules 3. Batch Operations: Use restart instead of manual spawning for many particles
Advanced Techniques
1. Oscillator Choreography: Coordinate multiple oscillating parameters 2. Dynamic Interactions: Use interact tool during playback for live effects 3. Joint Structures: Create complex mechanical systems with joints 4. Trail Art: Use draw tool with trails for artistic effects
Keyboard Efficiency
1. Learn Number Keys: Quick session switching saves time 2. Tool Switching: Memorize tool hotkeys for fluid workflow 3. Modifier Keys: Use Shift/Cmd/Ctrl with tools for variations
This playground provides a powerful, flexible environment for exploring particle physics and creating dynamic visual effects. Experiment with different combinations of modules, tools, and parameters to discover unique behaviors and stunning visual results.
Party User Guide
This guide shows how to use the core library as an end user: creating an engine, selecting a runtime (CPU/WebGPU), configuring modules, adding particles, and using oscillators. It also documents all built-in force and render modules with their main inputs and simple examples.
Installation
npm install @cazala/partyQuick start
import {
Engine,
// Force modules
Environment,
Boundary,
Collisions,
Behavior,
Fluids,
Sensors,
Interaction,
Joints,
Grab,
// Render modules
Trails,
Lines,
Particles,
} from "@cazala/party";
const canvas = document.querySelector("canvas")!;
const forces = [
// Environment: gravity + damping/friction/inertia
new Environment({
gravityStrength: 600,
gravityDirection: "down", // "up"|"down"|"left"|"right"|"inwards"|"outwards"|"custom"
inertia: 0.05,
friction: 0.01,
damping: 0.0,
}),
// Boundary: keep particles within view; small tangential friction
new Boundary({
mode: "bounce", // "bounce"|"warp"|"kill"|"none"
restitution: 0.9,
friction: 0.1,
repelDistance: 20,
repelStrength: 50,
}),
// Collisions: elastic-ish collisions
new Collisions({ restitution: 0.85 }),
// Behavior: boids-style steering
new Behavior({
cohesion: 1.5,
alignment: 1.2,
repulsion: 2.0,
separation: 12,
viewRadius: 100,
viewAngle: Math.PI, // 180° field of view
wander: 20,
}),
// Fluids: SPH approximation; conservative defaults
new Fluids({
influenceRadius: 80,
targetDensity: 1.0,
pressureMultiplier: 25,
viscosity: 0.8,
nearPressureMultiplier: 40,
nearThreshold: 18,
enableNearPressure: true,
maxAcceleration: 60,
}),
// Sensors: physarum polycephalum slime
new Sensors({
sensorDistance: 30,
sensorAngle: Math.PI / 6,
sensorRadius: 3,
sensorThreshold: 0.15,
sensorStrength: 800,
followBehavior: "any", // "any"|"same"|"different"|"none"
fleeBehavior: "none",
colorSimilarityThreshold: 0.5,
fleeAngle: Math.PI / 2,
}),
// Interaction: point attract/repel (inactive until setActive(true))
new Interaction({
mode: "attract",
strength: 12000,
radius: 300,
active: false,
}),
// Joints: constraints (no joints yet, but configure dynamics)
new Joints({
momentum: 0.7,
restitution: 0.9,
separation: 0.5,
steps: 2,
friction: 0.02,
enableParticleCollisions: false,
enableJointCollisions: false,
}),
// Grab: single-particle dragging (provide inputs at interaction time)
new Grab(),
];
const render = [
new Trails({ trailDecay: 10, trailDiffuse: 4 }),
new Lines({ lineWidth: 2 }),
new Particles({ colorType: 2, hue: 0.55 }), // 2 = Hue, try 0..1
];
const engine = new Engine({
canvas,
forces,
render,
runtime: "auto", // "auto" picks WebGPU when available, otherwise CPU
});
await engine.initialize();
engine.play();Engine API
- Construction (required):
new Engine({ canvas, forces, render, runtime, ... })
- canvas: HTMLCanvasElement used for rendering
- forces:
Module[]list of force modules - render:
Module[]list of render modules - runtime:
"cpu" | "webgpu" | "auto"(use "auto" for best experience) - Optional:
constrainIterations,clearColor,cellSize,maxNeighbors,maxParticles,workgroupSize
- Lifecycle:
initialize(),play(),pause(),stop(),toggle(),destroy() - State:
isPlaying(),getFPS() - View:
getSize(),setSize(w,h),setCamera(x,y),getCamera(),setZoom(z),getZoom() - Particles:
addParticle(p),setParticles(p[]),setParticle(i, p),setParticleMass(i, mass),getParticles(),getParticlesInRadius(center, radius, opts?),getParticle(i),clear(),getCount() - Config:
getClearColor()/setClearColor(),getCellSize()/setCellSize(),getMaxNeighbors()/setMaxNeighbors(),getMaxParticles()/setMaxParticles(),getConstrainIterations()/setConstrainIterations() - Modules:
getModule(name)returns the module instance by name - Serialization:
export()returns{ [moduleName]: settings };import(settings)applies them - Oscillators: see “Oscillators” below
Notes
- When
runtime: "auto", the engine tries WebGPU first, then falls back to CPU if unavailable. - Pinned particles are represented by a negative
mass. The top-levelEnginealso includes helperspinParticles([...]),unpinParticles([...]),unpinAll()(CPU + WebGPU friendly). addParticle(p)returns the index of the created particle (or-1if capacity is reached).getParticles()can be expensive on WebGPU because it requires a GPU → CPU readback of the particle buffer; prefergetParticlesInRadius(...)for tool-like / local queries.getParticlesInRadius(center, radius, { maxResults })returns{ particles, truncated }with only the fields needed for local occupancy (position,size,mass).
Spawner utility
The Spawner helper generates IParticle[] for common shapes, including text and images.
import { Spawner } from "@cazala/party";
const spawner = new Spawner();
const particles = spawner.initParticles({
count: 5000,
shape: "text",
center: { x: 0, y: 0 },
position: { x: 0, y: 0 },
align: { horizontal: "center", vertical: "center" },
text: "Party",
font: "sans-serif",
textSize: 80,
size: 3,
mass: 1,
colors: ["#ffffff"],
});
engine.setParticles(particles);Notes:
sizecontrols particle radius;textSizeis the font size used to rasterize text.position+aligndefine the anchor point for the text bounds.- Supported fonts in the playground UI:
sans-serif,serif,monospace.
Image example:
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const particles = spawner.initParticles({
count: 12000,
shape: "image",
center: { x: 0, y: 0 },
position: { x: 0, y: 0 },
align: { horizontal: "center", vertical: "center" },
imageData,
imageSize: 400, // scales to this max dimension
size: 3,
mass: 1,
});Notes:
imageDatais required and must be provided synchronously.- Fully transparent pixels are ignored; particle colors come from the image pixels.
Engine methods and lifecycles
initialize()- Creates runtime resources (GPU device/queues or CPU canvas context), binds module uniforms, and builds pipelines. Await this before
play(). - In
runtime: "auto", falls back to CPU if WebGPU initialization fails. play()/pause()/stop()/toggle()- Controls the animation loop. Per frame the engine updates oscillators, runs simulation (state → apply → constrain×N → correct), then renders.
stop()halts and cancels the loop;pause()only toggles playing state.destroy()- Disposes GPU/canvas resources and detaches listeners. Call when the canvas/engine is no longer needed.
getSize()/setSize(w, h)- Updates view and internal textures/buffers on resize. Call on window/canvas size changes.
setCamera(x, y)/getCamera()andsetZoom(z)/getZoom()- Adjusts the world-to-screen transform. Affects bounds, neighbor grid extents, and rendering.
addParticle(p)/setParticles(p[])/setParticle(i, p)/setParticleMass(i, mass)/getParticles()/getParticlesInRadius(center, radius, opts?)/getParticle(i)/clear()- Manage particle data. For bulk changes prefer
setParticles()to minimize sync overhead. - Use
setParticleMass(i, 0)to remove a particle (matches engine semantics wheremass === 0is “removed”). - Prefer
getParticlesInRadius(...)for local queries (e.g. brush/pin/remove tools) to avoid full-scene readback on WebGPU. getCount()/getFPS()- Inspect particle count and smoothed FPS estimate.
getCount()returns the effective count (actual count limited bymaxParticlesif set). export()/import(settings)- Serialize/restore module inputs (including
enabled). Great for presets and sharing scenes. getModule(name)- Fetch a module instance to tweak inputs at runtime.
getActualRuntime()- Returns
"cpu" | "webgpu"for the active runtime.
Performance-critical settings
setCellSize(size: number)- Spatial grid resolution for neighbor queries. Smaller cells improve locality but increase bookkeeping; larger cells reduce overhead but widen searches. Typical: 8–64.
setMaxNeighbors(value: number)- Cap neighbors considered per particle in neighbor-based modules (collisions, behavior, fluids). Higher = more accurate in dense scenes, but slower. Typical: 64–256.
setMaxParticles(value: number | null)- Limit the number of particles processed in simulation and rendering. When set to a number, only particles with index <
maxParticlesare processed. Set tonull(default) to process all particles. Useful for performance tuning: if you have 100k particles but setmaxParticlesto 20k, only the first 20k will be simulated/rendered.getCount()returns the effective count (min of actual count andmaxParticles). setConstrainIterations(iterations: number)- Number of constraint iterations per frame (affects boundary/collision correction and joints). More = more stable/rigid, but slower. Defaults: CPU ≈ 5, WebGPU ≈ 50.
Runtime selection
- Use
runtime: "auto"unless you explicitly need one runtime. - WebGPU unlocks large particle counts and GPU compute; CPU offers maximum compatibility.
Camera and coordinates
- World coordinates are independent of canvas pixels;
setCamera(x,y)centers the view andsetZoom(z)controls scale. - Bounds-aware modules (e.g.,
Boundary) use the camera and zoom to compute visible extents consistently across runtimes.
Oscillators
Oscillators modulate module inputs continuously over time.
Oscillator API
addOscillator(params)- Add an oscillator to animate a module inputremoveOscillator(moduleName, inputName)- Remove a specific oscillatorupdateOscillatorSpeed(moduleName, inputName, speedHz)- Change oscillation speedupdateOscillatorBounds(moduleName, inputName, min, max)- Change oscillation rangeclearOscillators()- Remove all oscillatorsclearModuleOscillators(moduleName)- Remove all oscillators for a specific module
// Animate boundary restitution between 0.4 and 0.95 at 0.2 Hz
const oscId = engine.addOscillator({
moduleName: "boundary",
inputName: "restitution",
min: 0.4,
max: 0.95,
speedHz: 0.2,
});
// Later
engine.updateOscillatorSpeed("boundary", "restitution", 0.4);
engine.removeOscillator("boundary", "restitution");
// Clear all oscillators for a specific module
engine.clearModuleOscillators("boundary");
// Clear all oscillators
engine.clearOscillators();Inputs are addressed by the module’s input keys (documented per module below). Oscillators write values exactly as if you had called the module’s setters.
---
Built-in Modules
Modules come in two public roles:
- Force: contribute to simulation (acceleration/velocity/constraints)
- Render: draw into the scene texture or canvas
Differences and when they run
- Force modules execute during the simulation step. They may:
- Add forces to
particle.acceleration(e.g., gravity, boids steering) - Directly modify
particle.velocity(e.g., viscosity, sensor steering) - Adjust
particle.positionin constraint phases (e.g., collisions, joints) - Render modules execute after simulation each frame. They may:
- Draw instanced particles or lines via fullscreen passes
- Post-process the scene texture via compute-like passes (e.g., trails decay/diffuse)
- Toggle modules on/off at runtime via
setEnabled(boolean)to isolate effects and optimize performance.
Each module exposes a name and typed inputs. You can toggle any module on/off with module.setEnabled(boolean) and read current inputs via module.read(); use getModule(name) to retrieve instances from the engine.
Force modules
Environment (environment)
- Purpose: global gravity, inertia, friction, velocity damping
- Inputs (defaults in parentheses):
gravityStrength(0): magnitude of gravity acceleration applied toward a direction/origin.dirX,dirY(derived): gravity direction whenmodeis directional/custom; normalized internally.inertia(0): acceleration term along current velocity (velocity * dt * inertia) to preserve momentum.friction(0): deceleration opposite to velocity (-velocity * friction).damping(0): multiplicative velocity damping each step.mode(0): 0 directional/custom, 1 inwards (to view center), 2 outwards (from view center).- Helpers:
setGravityStrength(v)setGravityDirection("up"|"down"|"left"|"right"|"inwards"|"outwards"|"custom")setGravityAngle(radians)(used when direction iscustom)setDirection(x,y),setInertia(v),setFriction(v),setDamping(v)
Example
const env = new Environment({
gravityStrength: 1200,
gravityDirection: "down",
});
env.setFriction(0.02);Boundary (boundary)
- Purpose: enforce world bounds with optional repel force
- Inputs (defaults):
restitution(0.9): bounce energy retention.friction(0.1): tangential damping on contact.mode("bounce"): 0 bounce, 1 warp (wrap once fully outside), 2 kill (remove bymass=0), 3 none.repelDistance(0): inner distance from edges to start push.repelStrength(0): magnitude of inward push (outside=full, inside=scaled).
Example
const boundary = new Boundary({
mode: "bounce",
restitution: 0.85,
friction: 0.1,
});Collisions (collisions)
- Purpose: particle–particle collision resolution and bounce impulse
- Inputs (defaults):
restitution(0.8): elasticity along contact normal.
Notes
- Uses spatial grid neighbor iteration up to
maxNeighbors; resolves deepest overlap and applies impulse; small jitter reduces bias.
const collisions = new Collisions({ restitution: 0.8 });Behavior (behavior)
- Purpose: boids-like steering (separation, alignment, cohesion, chase/avoid, wander)
- Inputs (defaults):
wander(20): pseudo-random lateral perturbation magnitude.cohesion(1.5): steer toward neighbor centroid.alignment(1.5): steer toward neighbor average velocity.repulsion(2): steer away when withinseparationdistance.chase(0): chase lighter neighbors (mass delta bias).avoid(0): flee heavier neighbors (within halfviewRadius).separation(10): personal space radius for repulsion.viewRadius(100): neighbor search radius.viewAngle(1.5π): field-of-view in radians.
Notes
- FOV uses velocity direction; falls back to a default forward if nearly zero velocity.
const behavior = new Behavior({
cohesion: 1.5,
alignment: 1.5,
separation: 10,
viewRadius: 100,
});Fluids (fluids)
- Purpose: SPH-inspired fluid approximation (density pre-pass + pressure/viscosity apply)
- Inputs (defaults):
influenceRadius(100): neighbor radius for kernels.targetDensity(1): rest density.pressureMultiplier(30): scales pressure from density difference.viscosity(1): smooths velocity differences.nearPressureMultiplier(50): strong short-range pressure.nearThreshold(20): near-pressure distance.enableNearPressure(true): toggle for near-pressure.maxAcceleration(75): clamp for stability.
Notes
- Two passes:
state(density/near-density),apply(pressure/viscosity → velocity).
const fluids = new Fluids({
influenceRadius: 80,
pressureMultiplier: 25,
viscosity: 0.8,
});Sensors (sensors)
- Purpose: trail/color sampling based steering (follow and/or flee)
- Inputs (defaults):
sensorDistance(30),sensorAngle(π/6),sensorRadius(3)sensorThreshold(0.1),sensorStrength(1000)colorSimilarityThreshold(0.4)followBehavior(any): 0 any, 1 same, 2 different, 3 nonefleeBehavior(none): 0 any, 1 same, 2 different, 3 nonefleeAngle(π/2)
Notes
- Samples scene texture consistently across runtimes; no trails required.
const sensors = new Sensors({
sensorDistance: 30,
sensorAngle: Math.PI / 6,
followBehavior: "any",
});Interaction (interaction)
- Purpose: point attract/repel under user control
- Inputs (defaults):
mode(attract: 0/repel: 1),strength(10000),radius(500)positionX/Y(0),active(false)
const interaction = new Interaction({
mode: "attract",
radius: 300,
strength: 12000,
});
interaction.setPosition(0, 0);
interaction.setActive(true);Joints (joints)
- Purpose: distance constraints between particles, optional collisions, momentum preservation
- Inputs (defaults):
- Arrays:
aIndexes[],bIndexes[],restLengths[], CSRincidentJointOffsets/incidentJointIndices, derivedgroupIds[] - Scalars:
enableParticleCollisions(0),enableJointCollisions(0),momentum(0.7),restitution(0.9),separation(0.5),steps(1),friction(0.01) - Helpers:
setJoints([...]),add({ aIndex, bIndex, restLength }),remove(a,b),removeAll(), setters for all scalar inputs
const joints = new Joints();
joints.setJoints([{ aIndex: 0, bIndex: 1, restLength: 50 }]);
joints.setMomentum(0.7);Grab (grab)
- Purpose: efficient mouse-drag grabbing of a single particle (updates one particle per frame)
- Inputs:
grabbedIndex,positionX,positionY - Helpers:
grabParticle(index, {x,y}),releaseParticle(),isGrabbing()
const grab = new Grab();
grab.grabParticle(42, { x: 100, y: 100 });Render modules
Particles (particles)
- Purpose: draw particles as soft discs; pinned particles render as rings
- Inputs (defaults):
colorType(Default: 0, Custom: 1, Hue: 2)customColorR/G/B(1/1/1) whencolorType=Customhue(0) whencolorType=Hue
const particles = new Particles();
particles.setColorType(2); // Hue
particles.setHue(0.5);Trails (trails)
- Purpose: decay + diffuse passes over the scene texture
- Inputs (defaults):
trailDecay(10): fade speed toward clear colortrailDiffuse(0): blur radius (0–12 typical)
const trails = new Trails({ trailDecay: 12, trailDiffuse: 4 });Lines (lines)
- Purpose: draw lines between particle pairs (indices)
- Inputs (defaults):
aIndexes[],bIndexes[]: segment endpoints by particle indexlineWidth(1.5)lineColorR/G/B(-1/-1/-1): negative = use particle color- Helpers:
setLines([...]),add({ aIndex, bIndex }),remove(a,b),setLineWidth(v),setLineColor(color|null)
const lines = new Lines({ lines: [{ aIndex: 0, bIndex: 1 }] });
lines.setLineWidth(2);---
Tips
- Start with a small number of modules enabled; add more as needed.
- Increase
cellSizefor sparser scenes; reduce it for dense ones. - WebGPU: prefer
runtime: "auto"and let the engine fall back if needed.
MIT License
Copyright (c) 2025 cazala
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "party",
"version": "0.1.0",
"description": "A comprehensive particle physics simulation system with interactive playground",
"private": true,
"type": "module",
"keywords": [
"particle-physics",
"simulation",
"interactive",
"physics-engine",
"particles",
"canvas",
"typescript",
"react"
],
"repository": {
"type": "git",
"url": "https://github.com/cazala/party.git"
},
"author": "cazala",
"license": "MIT",
"scripts": {
"setup": "npm install && npx pnpm@9.15.9 install",
"dev": "npx pnpm@9.15.9 --filter=@cazala/playground dev",
"build": "npx pnpm@9.15.9 --filter=@cazala/party build && npx pnpm@9.15.9 --filter=@cazala/playground build",
"build:core": "npx pnpm@9.15.9 --filter=@cazala/party build",
"build:playground": "npx pnpm@9.15.9 --filter=@cazala/playground build",
"type-check": "npx pnpm@9.15.9 --filter=@cazala/party build && npx pnpm@9.15.9 --filter=@cazala/playground type-check",
"test": "echo 'No tests specified' && exit 0"
},
"devDependencies": {
"@webgpu/types": "^0.1.64",
"pnpm": "^9.15.0",
"typescript": "^5.0.0"
},
"packageManager": "pnpm@9.15.9",
"workspaces": [
"packages/*"
]
}
MIT License
Copyright (c) 2025 cazala
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "@cazala/party",
"version": "0.1.0",
"description": "High-performance TypeScript particle physics engine with dual runtime support (WebGPU compute + CPU fallback). Features modular architecture, real-time parameter oscillation, advanced physics modules, and comprehensive rendering capabilities.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"keywords": [
"particle-physics",
"physics-engine",
"webgpu",
"gpu-compute",
"dual-runtime",
"simulation",
"particles",
"forces",
"collision-detection",
"fluid-dynamics",
"sph",
"flocking",
"boids",
"spatial-grid",
"oscillators",
"modular-architecture",
"canvas-rendering",
"typescript",
"interactive",
"real-time"
],
"repository": {
"type": "git",
"url": "https://github.com/cazala/party.git",
"directory": "packages/core"
},
"homepage": "https://github.com/cazala/party#readme",
"bugs": {
"url": "https://github.com/cazala/party/issues"
},
"author": "cazala",
"license": "MIT",
"scripts": {
"build": "rollup -c",
"dev": "rollup -c --watch",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@rollup/plugin-typescript": "^11.1.0",
"@webgpu/types": "^0.1.64",
"rollup": "^4.0.0",
"tslib": "^2.6.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=16.0.0"
}
}@cazala/party
A high-performance TypeScript particle physics engine with dual runtime support (WebGPU compute + CPU fallback), modular architecture, and real-time parameter oscillation.
Features
- Dual Runtime Architecture: Automatic WebGPU/CPU runtime selection with seamless fallback
- GPU Compute Performance: WebGPU shaders for parallel particle processing at scale
- Modular Force System: Pluggable physics modules with four-phase lifecycle
- Spatial Grid Optimization: Efficient O(1) neighbor queries for collision detection
- Real-time Oscillators: Animate any module parameter with configurable frequency and bounds
- Advanced Rendering: Trails, particle instancing, line rendering with multiple color modes
- Export/Import Presets: Export/import module settings (inputs + enabled state)
- Cross-platform: Works in all modern browsers with automatic feature detection
- Spawner Utility: Generate particle shapes, including text and images
Installation
npm install @cazala/partyDevelopment Note: This package is part of a pnpm workspace. For development, clone the full repository and use npm run setup from the root.Quick Start
import {
Engine,
// Force modules
Environment,
Boundary,
Collisions,
Behavior,
Fluids,
// Render modules
Particles,
Trails,
} from "@cazala/party";
const canvas = document.querySelector("canvas")!;
const forces = [
new Environment({
gravityStrength: 600,
gravityDirection: "down",
inertia: 0.05,
friction: 0.01,
}),
new Boundary({
mode: "bounce",
restitution: 0.9,
friction: 0.1,
}),
new Collisions({ restitution: 0.85 }),
new Behavior({
cohesion: 1.5,
alignment: 1.2,
separation: 12,
viewRadius: 100,
}),
new Fluids({
influenceRadius: 80,
pressureMultiplier: 25,
viscosity: 0.8,
}),
];
const render = [
new Trails({ trailDecay: 10, trailDiffuse: 4 }),
new Particles({ colorType: 2, hue: 0.55 }),
];
const engine = new Engine({
canvas,
forces,
render,
runtime: "auto", // Auto-selects WebGPU when available
});
await engine.initialize();
// Add particles
for (let i = 0; i < 100; i++) {
engine.addParticle({
position: { x: Math.random() * canvas.width, y: Math.random() * canvas.height },
velocity: { x: (Math.random() - 0.5) * 4, y: (Math.random() - 0.5) * 4 },
mass: 1 + Math.random() * 2,
size: 3 + Math.random() * 7,
color: { r: 1, g: 1, b: 1, a: 1 },
});
}
engine.play();Core Concepts
Engine
The Engine class provides a unified API that automatically selects the best runtime:
const engine = new Engine({
canvas: HTMLCanvasElement,
forces: Module[], // Force modules
render: Module[], // Render modules
runtime: "auto", // "auto" | "webgpu" | "cpu"
// Optional configuration
constrainIterations: 50, // Constraint solver iterations
cellSize: 32, // Spatial grid cell size
maxNeighbors: 128, // Max neighbors per particle
maxParticles: 10000, // WebGPU buffer allocation + effective sim/render cap
clearColor: { r: 0, g: 0, b: 0, a: 1 }, // Background color
});
// Lifecycle
await engine.initialize();
engine.play();
engine.pause();
engine.stop();
await engine.destroy();
// State
const isPlaying = engine.isPlaying();
const fps = engine.getFPS();
const count = engine.getCount();
// Particles
engine.addParticle({
position: { x, y },
velocity: { x: vx, y: vy },
mass,
size,
color: { r: 1, g: 1, b: 1, a: 1 },
});
engine.setParticles([...particles]);
const particles = await engine.getParticles();
engine.clear();
// View
engine.setSize(width, height);
engine.setCamera(x, y);
engine.setZoom(scale);
// Configuration
const config = engine.export();
engine.import(config);Runtime Selection
- "auto": Tries WebGPU first, falls back to CPU if unavailable
- "webgpu": GPU compute with WGSL shaders (Chrome 113+, Edge 113+)
- "cpu": JavaScript simulation with Canvas2D rendering (universal compatibility)
// Check which runtime is active
const runtime = engine.getActualRuntime(); // "webgpu" | "cpu"
// Test module support
const isSupported = engine.isSupported(module);Particles
Particles are simple data structures with physics properties:
const particle = {
position: { x: 100, y: 100 }, // Position
velocity: { x: 1, y: -2 }, // Velocity
mass: 2.5, // Mass (negative = pinned)
size: 8, // Visual size
color: { r: 1, g: 0.42, b: 0.21, a: 1 }, // Color (0..1 floats)
};
// Bulk operations (preferred for performance)
engine.setParticles(particles);
const allParticles = await engine.getParticles();
// Individual operations
engine.addParticle(particle);
const singleParticle = await engine.getParticle(index);
// Pin/unpin helpers
engine.pinParticles([0, 1, 2]);
engine.unpinParticles([0, 1, 2]);
engine.unpinAll();Spawner
Generate particle arrays from common shapes (including text and images) using Spawner:
import { Spawner } from "@cazala/party";
const spawner = new Spawner();
const particles = spawner.initParticles({
count: 5000,
shape: "text",
center: { x: 0, y: 0 },
position: { x: 0, y: 0 },
align: { horizontal: "center", vertical: "center" },
text: "Party",
font: "sans-serif",
textSize: 80,
size: 3,
mass: 1,
colors: ["#ffffff"],
});
engine.setParticles(particles);Notes:
sizecontrols particle radius;textSizeis the font size used to rasterize text.- Playground font options:
sans-serif,serif,monospace.
Image example:
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const imageParticles = spawner.initParticles({
count: 12000,
shape: "image",
center: { x: 0, y: 0 },
position: { x: 0, y: 0 },
align: { horizontal: "center", vertical: "center" },
imageData,
imageSize: 400, // scales to this max dimension
size: 3,
mass: 1,
});Notes:
imageDatamust be provided synchronously (no URL fetching inside the spawner).- Fully transparent pixels are skipped; particle colors come from image pixels.
Modules
Modules are pluggable components that contribute to simulation or rendering:
// Force modules affect particle physics
const forces = [
new Environment({ gravityStrength: 1000 }),
new Boundary({ mode: "bounce" }),
new Collisions({ restitution: 0.8 }),
];
// Render modules draw visual effects
const render = [
new Particles({ colorType: 2, hue: 0.5 }),
new Trails({ trailDecay: 10 }),
];
// Module control
const module = engine.getModule("environment");
module.setEnabled(false);
const isEnabled = module.isEnabled();
// Read/write module inputs
const inputs = module.read();
module.write({ gravityStrength: 500 });Available Modules
Force Modules
Environment
Global physics: gravity, inertia, friction, damping
new Environment({
gravityStrength: 600, // Gravity magnitude
gravityDirection: "down", // "up"|"down"|"left"|"right"|"inwards"|"outwards"|"custom"
gravityAngle: Math.PI / 4, // Custom angle (when direction = "custom")
inertia: 0.05, // Momentum preservation (0-1)
friction: 0.01, // Velocity damping (0-1)
damping: 0.02, // Direct velocity reduction (0-1)
})Boundary
Boundary interactions and containment
new Boundary({
mode: "bounce", // "bounce"|"warp"|"kill"|"none"
restitution: 0.9, // Bounce energy retention (0-1)
friction: 0.1, // Tangential friction (0-1)
repelDistance: 50, // Distance to start repel force
repelStrength: 0.5, // Repel force magnitude
})Collisions
Particle-particle collision detection and response
new Collisions({
restitution: 0.8, // Collision elasticity (0-1)
})Behavior
Flocking behaviors (boids-style steering)
new Behavior({
cohesion: 1.5, // Attraction to group center
alignment: 1.2, // Velocity matching
repulsion: 2.0, // Separation force
separation: 12, // Personal space radius
viewRadius: 100, // Neighbor detection radius
viewAngle: Math.PI, // Field of view (radians)
wander: 20, // Random exploration
chase: 0.5, // Pursue lighter particles
avoid: 0.3, // Flee heavier particles
})Fluids
Smoothed Particle Hydrodynamics (SPH) fluid simulation
new Fluids({
influenceRadius: 80, // Particle interaction radius
targetDensity: 1.0, // Rest density
pressureMultiplier: 25, // Pressure force strength
viscosity: 0.8, // Internal friction
nearPressureMultiplier: 40, // Near-field pressure
nearThreshold: 18, // Near-field distance
enableNearPressure: true, // Enable near-field forces
maxAcceleration: 60, // Force clamping for stability
})Sensors
Trail-following and color-based steering
new Sensors({
sensorDistance: 30, // Sensor projection distance
sensorAngle: Math.PI / 6, // Sensor angle offset (30°)
sensorRadius: 3, // Sensor detection radius
sensorThreshold: 0.15, // Minimum detection threshold
sensorStrength: 800, // Steering force magnitude
followBehavior: "any", // "any"|"same"|"different"|"none"
fleeBehavior: "none", // "any"|"same"|"different"|"none"
colorSimilarityThreshold: 0.5, // Color matching threshold
fleeAngle: Math.PI / 2, // Flee direction offset (90°)
})Interaction
User-controlled attraction and repulsion
const interaction = new Interaction({
mode: "attract", // "attract"|"repel"
strength: 12000, // Force magnitude
radius: 300, // Interaction radius
active: false, // Initially inactive
});
// Control interaction
interaction.setPosition(mouseX, mouseY);
interaction.setActive(true);
interaction.setMode("repel");Joints
Distance constraints between particles
const joints = new Joints({
momentum: 0.7, // Momentum preservation (0-1)
restitution: 0.9, // Joint elasticity
separation: 0.5, // Separation force strength
steps: 2, // Constraint iterations
friction: 0.02, // Joint friction
enableParticleCollisions: false, // Particle-joint collisions
enableJointCollisions: false, // Joint-joint collisions
});
// Manage joints
joints.setJoints([
{ aIndex: 0, bIndex: 1, restLength: 50 },
{ aIndex: 1, bIndex: 2, restLength: 75 },
]);
joints.add({ aIndex: 2, bIndex: 3, restLength: 100 });
joints.remove(0, 1);
joints.removeAll();Grab
Single-particle mouse/touch dragging
const grab = new Grab();
// Grab particle
grab.grabParticle(particleIndex, { x: mouseX, y: mouseY });
// Update position
grab.updatePosition(newX, newY);
// Release
grab.releaseParticle();
// Check state
const isGrabbing = grab.isGrabbing();Render Modules
Particles
Instanced particle rendering with multiple color modes
new Particles({
colorType: 2, // 0=Default, 1=Custom, 2=Hue
customColorR: 1.0, // Custom color red (0-1)
customColorG: 0.4, // Custom color green (0-1)
customColorB: 0.2, // Custom color blue (0-1)
hue: 0.55, // Hue value (0-1) when colorType=2
})
// Pinned particles render as rings
// Particle size and color come from particle dataTrails
Decay and diffusion effects
new Trails({
trailDecay: 10, // Fade speed (higher = faster fade)
trailDiffuse: 4, // Blur amount (0-12 typical)
})Lines
Line rendering between particle pairs
const lines = new Lines({
lineWidth: 2.0, // Line thickness
lineColorR: -1, // Line color (-1 = use particle color)
lineColorG: -1,
lineColorB: -1,
});
// Manage lines
lines.setLines([
{ aIndex: 0, bIndex: 1 },
{ aIndex: 1, bIndex: 2 },
]);
lines.add({ aIndex: 2, bIndex: 3 });
lines.remove(0, 1);
lines.setLineColor("#ff0000"); // Or null for particle colorsOscillators
Oscillators animate module parameters over time with smooth interpolation:
// Add oscillator to animate boundary restitution
engine.addOscillator({
moduleName: "boundary",
inputName: "restitution",
min: 0.4, // Minimum value
max: 0.95, // Maximum value
speedHz: 0.2, // Frequency (cycles per second)
});
// Update oscillator parameters
engine.updateOscillatorSpeed("boundary", "restitution", 0.5);
engine.updateOscillatorBounds("boundary", "restitution", 0.2, 0.8);
// Remove oscillators
engine.removeOscillator("boundary", "restitution");
engine.clearModuleOscillators("boundary");
engine.clearOscillators();Configuration Management
Export and import complete simulation states:
// Export current configuration
const config = engine.export();
// Configuration format
const config = {
environment: {
enabled: true,
gravityStrength: 600,
gravityDirection: "down",
// ... all module inputs
},
boundary: {
enabled: true,
mode: "bounce",
restitution: 0.9,
// ... all module inputs
},
// ... all modules
};
// Import configuration
engine.import(config);
// Partial import (only specified modules)
engine.import({
environment: { gravityStrength: 1000 },
collisions: { restitution: 0.5 },
});Performance Optimization
Spatial Grid
The engine uses spatial partitioning for efficient neighbor queries:
engine.setCellSize(32); // Smaller = more precise, larger = faster
engine.setMaxNeighbors(128); // Higher = more accurate, slowerCell Size Guidelines:
- Dense simulations: 16-32
- Sparse simulations: 64-128
- Rule of thumb: 2-4x average particle size
Constraint Iterations
Control physics solver accuracy vs performance:
engine.setConstrainIterations(50); // Higher = more stable, slowerTypical Values:
- CPU: 5-10 iterations
- WebGPU: 20-100 iterations (GPU can handle more)
WebGPU Configuration
const engine = new Engine({
runtime: "webgpu",
workgroupSize: 64, // 32, 64, 128, or 256
maxParticles: 10000, // Pre-allocate GPU buffers
});Advanced Usage
Custom Modules
Create custom force modules by extending the Module class:
import { Module, ModuleRole, DataType } from "@cazala/party";
type WindInputs = { strength: number; dirX: number; dirY: number };
export class Wind extends Module<"wind", WindInputs> {
readonly name = "wind" as const;
readonly role = ModuleRole.Force;
readonly inputs = {
strength: DataType.NUMBER,
dirX: DataType.NUMBER,
dirY: DataType.NUMBER,
} as const;
constructor() {
super();
this.write({ strength: 100, dirX: 1, dirY: 0 });
}
// WebGPU implementation
webgpu() {
return {
apply: ({ particleVar, getUniform }) => `{
let d = vec2<f32>(${getUniform("dirX")}, ${getUniform("dirY")});
if (length(d) > 0.0) {
${particleVar}.acceleration += normalize(d) * ${getUniform("strength")};
}
}`,
};
}
// CPU implementation
cpu() {
return {
apply: ({ particle, input }) => {
const len = Math.hypot(input.dirX, input.dirY) || 1;
particle.acceleration.x += (input.dirX / len) * input.strength;
particle.acceleration.y += (input.dirY / len) * input.strength;
},
};
}
}Error Handling
try {
await engine.initialize();
} catch (error) {
if (error.message.includes("WebGPU")) {
console.log("WebGPU not supported, falling back to CPU");
// Engine automatically falls back when runtime: "auto"
}
}
// Check module support
if (!engine.isSupported(customModule)) {
console.warn("Custom module not supported in current runtime");
}Browser Support
- WebGPU: Chrome 113+, Edge 113+, Firefox Nightly (experimental)
- CPU Fallback: All modern browsers with Canvas2D support
- Feature Detection: Automatic runtime selection with graceful fallback
TypeScript Support
Full TypeScript support with comprehensive type definitions:
import type { IEngine, IParticle, Module } from "@cazala/party";
const engine: IEngine = new Engine({ /* ... */ });
const particle: IParticle = {
position: { x: 0, y: 0 },
velocity: { x: 1, y: 1 },
mass: 1,
size: 5,
color: { r: 1, g: 1, b: 1, a: 1 },
};License
MIT License - see LICENSE file for details.
import typescript from '@rollup/plugin-typescript';
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.js',
format: 'es',
sourcemap: true
}
],
plugins: [
typescript({
tsconfig: './tsconfig.json'
})
],
external: ['react']
};import {
IEngine,
IParticle,
GetParticlesInRadiusOptions,
GetParticlesInRadiusResult,
} from "./interfaces";
import { Module } from "./module";
import { WebGPUEngine } from "./runtimes/webgpu/engine";
import { CPUEngine } from "./runtimes/cpu/engine";
export type EngineOptions = {
canvas: HTMLCanvasElement;
forces: Module<string, any>[];
render: Module<string, any>[];
runtime: "cpu" | "webgpu" | "auto";
constrainIterations?: number;
clearColor?: { r: number; g: number; b: number; a: number };
cellSize?: number;
maxParticles?: number;
workgroupSize?: number;
maxNeighbors?: number;
};
export class Engine implements IEngine {
private engine: IEngine;
private actualRuntime: "cpu" | "webgpu"; // The actual runtime being used
private preferredRuntime: "cpu" | "webgpu" | "auto"; // The requested runtime (can be 'auto')
private originalOptions: EngineOptions; // Store original options for fallback
constructor(options: EngineOptions) {
this.preferredRuntime = options.runtime;
this.originalOptions = { ...options }; // Store original options for fallback
// Determine actual runtime to use
let targetRuntime: "cpu" | "webgpu" | "auto";
if (options.runtime === "auto") {
// Synchronous check - we'll handle WebGPU availability in initialize()
targetRuntime = "webgpu"; // Default to WebGPU for auto, fallback to CPU if it fails
} else {
targetRuntime = options.runtime;
}
this.actualRuntime = targetRuntime;
if (targetRuntime === "webgpu") {
this.engine = new WebGPUEngine(options);
} else {
this.engine = new CPUEngine(options);
}
}
// Delegate all methods to the concrete engine implementation
async initialize(): Promise<void> {
try {
await this.engine.initialize();
} catch (error) {
// Handle fallback for auto mode or WebGPU failures
if (this.preferredRuntime === "auto" && this.actualRuntime === "webgpu") {
console.warn(
"WebGPU initialization failed, falling back to CPU runtime:",
error
);
// Destroy the failed WebGPU engine
try {
await this.engine.destroy();
} catch (destroyError) {
console.warn("Error destroying failed WebGPU engine:", destroyError);
}
// Create CPU engine with same options
this.actualRuntime = "cpu";
const fallbackOptions = {
...this.originalOptions,
runtime: "cpu",
};
this.engine = new CPUEngine(fallbackOptions);
// Initialize the CPU engine
await this.engine.initialize();
} else {
throw error; // Re-throw if not auto mode or already CPU
}
}
// Log runtime selection for auto mode
if (this.preferredRuntime === "auto") {
if (this.actualRuntime === "cpu") {
console.warn(
"Auto runtime selection: Using CPU (WebGPU not available or failed)"
);
}
}
}
// Get the actual runtime being used (cpu or webgpu)
getActualRuntime(): "cpu" | "webgpu" {
return this.actualRuntime;
}
play(): void {
this.engine.play();
}
pause(): void {
this.engine.pause();
}
stop(): void {
this.engine.stop();
}
destroy(): Promise<void> {
return this.engine.destroy();
}
isPlaying(): boolean {
return this.engine.isPlaying();
}
toggle(): void {
this.engine.toggle();
}
getSize(): { width: number; height: number } {
return this.engine.getSize();
}
setSize(width: number, height: number): void {
this.engine.setSize(width, height);
}
setCamera(x: number, y: number): void {
this.engine.setCamera(x, y);
}
getCamera(): { x: number; y: number } {
return this.engine.getCamera();
}
setZoom(z: number): void {
this.engine.setZoom(z);
}
getZoom(): number {
return this.engine.getZoom();
}
// Oscillator API passthroughs
addOscillator(params: {
moduleName: string;
inputName: string;
min: number;
max: number;
speedHz: number;
options?: any;
}): string {
return this.engine.addOscillator(params);
}
removeOscillator(moduleName: string, inputName: string): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.engine as any).removeOscillator(moduleName, inputName);
}
updateOscillatorSpeed(
moduleName: string,
inputName: string,
speedHz: number
): void {
this.engine.updateOscillatorSpeed(moduleName, inputName, speedHz);
}
updateOscillatorBounds(
moduleName: string,
inputName: string,
min: number,
max: number
): void {
this.engine.updateOscillatorBounds(moduleName, inputName, min, max);
}
hasOscillator(moduleName: string, inputName: string): boolean {
return this.engine.hasOscillator(moduleName, inputName);
}
getOscillator(moduleName: string, inputName: string) {
return this.engine.getOscillator(moduleName, inputName);
}
clearOscillators(): void {
this.engine.clearOscillators();
}
clearModuleOscillators(moduleName: string): void {
this.engine.clearModuleOscillators(moduleName);
}
addOscillatorListener(
moduleName: string,
inputName: string,
handler: (value: number) => void
): void {
this.engine.addOscillatorListener(moduleName, inputName, handler);
}
removeOscillatorListener(
moduleName: string,
inputName: string,
handler: (value: number) => void
): void {
this.engine.removeOscillatorListener(moduleName, inputName, handler);
}
setOscillatorState(
moduleName: string,
inputName: string,
lastValue: number,
lastDirection: -1 | 0 | 1
): boolean {
return this.engine.setOscillatorState(
moduleName,
inputName,
lastValue,
lastDirection
);
}
getOscillatorsElapsedSeconds(): number {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this.engine as any).getOscillatorsElapsedSeconds();
}
setOscillatorsElapsedSeconds(seconds: number): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.engine as any).setOscillatorsElapsedSeconds(seconds);
}
setParticles(p: IParticle[]): void {
this.engine.setParticles(p);
}
addParticle(p: IParticle): number {
return this.engine.addParticle(p);
}
setParticle(index: number, p: IParticle): void {
this.engine.setParticle(index, p);
}
setParticleMass(index: number, mass: number): void {
this.engine.setParticleMass(index, mass);
}
getParticles(): Promise<IParticle[]> {
return this.engine.getParticles();
}
getParticle(index: number): Promise<IParticle> {
return this.engine.getParticle(index);
}
getParticlesInRadius(
center: { x: number; y: number },
radius: number,
opts?: GetParticlesInRadiusOptions
): Promise<GetParticlesInRadiusResult> {
return this.engine.getParticlesInRadius(center, radius, opts);
}
// Helpers for pinning/unpinning
async pinParticles(indexes: number[]): Promise<void> {
const particles = await this.getParticles();
for (const idx of indexes) {
if (particles[idx]) particles[idx].mass = -1;
}
this.setParticles(particles);
}
async unpinParticles(indexes: number[]): Promise<void> {
const particles = await this.getParticles();
for (const idx of indexes) {
if (particles[idx]) {
const size = particles[idx].size;
// Derive mass from size deterministically (simple proportional mapping)
particles[idx].mass = Math.max(0.1, size);
}
}
this.setParticles(particles);
}
async unpinAll(): Promise<void> {
const particles = await this.getParticles();
for (let i = 0; i < particles.length; i++) {
if (particles[i].mass < 0) {
const size = particles[i].size;
particles[i].mass = Math.max(0.1, size);
}
}
this.setParticles(particles);
}
clear(): void {
this.engine.clear();
}
getCount(): number {
return this.engine.getCount();
}
getFPS(): number {
return this.engine.getFPS();
}
export(): Record<string, Record<string, number>> {
return this.engine.export();
}
import(settings: Record<string, Record<string, number>>): void {
this.engine.import(settings);
}
// Configuration getters and setters
getClearColor(): { r: number; g: number; b: number; a: number } {
return this.engine.getClearColor();
}
setClearColor(color: { r: number; g: number; b: number; a: number }): void {
this.engine.setClearColor(color);
}
getCellSize(): number {
return this.engine.getCellSize();
}
setCellSize(size: number): void {
this.engine.setCellSize(size);
}
getConstrainIterations(): number {
return this.engine.getConstrainIterations();
}
setConstrainIterations(iterations: number): void {
this.engine.setConstrainIterations(iterations);
}
getMaxNeighbors(): number {
return this.engine.getMaxNeighbors();
}
setMaxNeighbors(size: number): void {
this.engine.setMaxNeighbors(size);
}
getMaxParticles(): number | null {
return this.engine.getMaxParticles();
}
setMaxParticles(value: number | null): void {
this.engine.setMaxParticles(value);
}
getModule(name: string): Module | undefined {
return this.engine.getModule(name);
}
// Check if a module is supported by the current runtime
isSupported(module: Module): boolean {
try {
if (this.actualRuntime === "webgpu") {
// For WebGPU, check if the module has a webgpu() method that doesn't throw
module.webgpu();
return true;
} else {
// For CPU, check if the module has a cpu() method that doesn't throw
module.cpu();
return true;
}
} catch (error) {
// If the method throws "Not implemented" or any other error, the module is not supported
return false;
}
}
}
export * from "./engine";
export * from "./module";
export * from "./spawner";
export * from "./interfaces";
export * from "./modules";
export * from "./vector";
export * from "./oscillators";
export * from "./forces/environment";
export * from "./forces/boundary";
export * from "./forces/collisions";
export * from "./forces/fluids";
export * from "./forces/behavior";
export * from "./forces/sensors";
export * from "./forces/interaction";
export * from "./forces/joints";
export * from "./forces/grab";
export * from "./render/trails";
export * from "./render/lines";
export * from "./render/particles";
import { IParticle } from "./interfaces";
import { Vector } from "./vector";
let idCounter = 0;
export class Particle implements IParticle {
public id: number;
public position: Vector;
public velocity: Vector;
public acceleration: Vector;
public size: number;
public mass: number;
public color: { r: number; g: number; b: number; a: number };
constructor(options: IParticle) {
this.id = idCounter++;
this.position = new Vector(options.position.x, options.position.y);
this.velocity = new Vector(options.velocity.x, options.velocity.y);
this.acceleration = new Vector(0, 0);
this.size = options.size;
this.mass = options.mass;
this.color = options.color;
}
toJSON(): IParticle {
return {
position: this.position.toJSON(),
velocity: this.velocity.toJSON(),
size: this.size,
mass: this.mass,
color: this.color,
};
}
}
/**
* Presentation shader (copy)
*
* Minimal fullscreen copy shader used to present the rendered scene texture to the canvas.
* A small pipeline built with this WGSL is cached per canvas format.
*/
export const copyShaderWGSL = `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@group(0) @binding(0) var source_texture: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var out: VertexOutput;
let positions = array<vec2<f32>, 4>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>(-1.0, 1.0),
vec2<f32>( 1.0, 1.0)
);
let uvs = array<vec2<f32>, 4>(
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 0.0)
);
let index = vertex_index % 4u;
out.position = vec4<f32>(positions[index], 0.0, 1.0);
out.uv = uvs[index];
return out;
}
@fragment
fn fs_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return textureSample(source_texture, source_sampler, uv);
}`;
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"noEmit": false
},
"include": [
"src/**/*"
],
"exclude": [
"dist",
"node_modules"
]
}/party / 200
/party/* /:splat 200
/* /index.html 200
/* Help Modal Specific Styles */
.help-modal-content section {
margin-bottom: 24px;
}
.help-modal-content section:last-child {
margin-bottom: 0;
}
.help-modal-content h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: var(--color-accent-white);
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 13px;
}
.help-modal-content ul {
margin: 0;
padding-left: 20px;
line-height: 1.7;
}
.help-modal-content li {
margin-bottom: 8px;
color: var(--color-text-primary);
font-size: 14px;
}
.help-modal-content li:last-child {
margin-bottom: 0;
}
.help-modal-content b {
color: var(--color-accent-white);
font-weight: 600;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}