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.
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;
},
};
}
}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 dampingBoundary: bounds, warp/kill, tangential friction, optional repulsionCollisions: pairwise collision response with position correctionBehavior: boids-like steeringFluids: SPH-like density and pressure with state + applySensors: trail/color sampling steeringInteraction: mouse-driven attract/repelJoints: distance constraints with collision options and momentum preservationGrab: single-particle grabbing during drag
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" }(seeLines)
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 particlesTrails: compute-like two-pass effect (decay + blur) or canvas equivalents on CPULines: instanced line quads on GPU, stroke lines on CPU
- Declare array inputs with
DataType.ARRAY(e.g., index lists forLines/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.
- 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.
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.
- 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.
- See also:
user-guide.mdfor how users wire modules into an engine. - See also:
maintainer-guide.mdfor internal architecture details.