The engineering

How Confluon is built

A small Rust/WebAssembly core owns the simulation. One compact state then feeds the WebGPU image, the Web Audio instrument, and production capture. Each part can change independently without creating a second version of the field.

The whole system at a glance

The production site is a static application. There is no server-side simulation, streaming service, or account state. A browser downloads HTML, JavaScript, and a small WebAssembly module.

Input + URL seed · settings · gestures Rust / WebAssembly engine particles · metrics · formations JS composition root commands · frame · lifecycle WebGPU / Canvas the image Web Audio sound · production tap API
Simulation state has one owner. Rendering, audio, and capture consume it through a narrow boundary.

web/app.js is the browser composition root: it translates input into engine commands, reads one frame of state, then passes that state to image and sound. Either projection can be replaced without creating a second version of the field.

A deliberately small Rust core

The Rust crate has only two runtime dependencies: wasm-bindgen for the JavaScript boundary and console_error_panic_hook for useful browser failures. Release builds use link-time optimisation and one code-generation unit. There is no graphics or audio dependency in Rust.

rustsrc/lib.rs - the public engine boundary
#[wasm_bindgen]
pub struct Engine {
    simulation: Simulation,
}

impl Engine {
    pub fn step(&mut self, dt: f32, x: f32, y: f32,
                strength: f32, twist: f32, settle: bool);
    pub fn snapshot(&self) -> Vec<f32>;
    pub fn metrics(&self) -> Vec<f32>;
    pub fn formations(&self) -> Vec<f32>;
}

Ordinary Rust tests cover the properties that matter: identical seeds produce identical motion, particle count is conserved except when explicitly seeding, output stays finite and bounded, the neighbour grid agrees with brute force, and gestures alter the trajectory.

Fixed simulation time, fluid display time

Display refresh and missed frames should not change the dynamics. The browser accumulates elapsed wall time and advances Rust in fixed 1/30-second steps:

jsweb/app.js - fixed-step frame coordination, abridged
accumulator = Math.min(
  accumulator + elapsed * settings.flow,
  FIXED_STEP * MAX_CATCH_UP_STEPS,
);

let steps = 0;
while (accumulator >= FIXED_STEP && steps < MAX_CATCH_UP_STEPS) {
  const interaction = pointerInteraction();
  engine.step(FIXED_STEP, pointer.x, pointer.y,
              interaction.strength, interaction.twist, settle);
  decayPointerInteraction(FIXED_STEP);
  accumulator -= FIXED_STEP;
  steps += 1;
}

const snapshot = engine.snapshot();
const metrics = engine.metrics();
const visual = smoothVisualState(snapshot, metrics, elapsed);
renderer.render(visual.snapshot, visual.metrics, time);
audio.update(metrics, engine.formations());

Catch-up stops at two steps per display frame. Under sustained load the instrument slows instead of building an endless backlog. Flow scales simulation time before it enters the accumulator; it does not change display cadence.

Pointer behaviour is sampled on those same fixed steps. A poke leaves a short decaying alarm envelope, so even a tap shorter than one step changes the field. Mouse hover adds a weaker presence; movement speed decides whether nearby organisms recoil or investigate.

Finding neighbours without testing everyone

Field, repulsion, ecology, and formation depend only on particles inside a finite radius. Testing every possible pair would inspect more than eight million candidates at the 4,096-particle cap, then reject almost all of them.

Confluon divides its wrapped world into an 8 × 8 counting-sort grid. Cell boundaries are recorded in a flat prefix-sum array, and particle indices are written into one contiguous entry buffer. Because the cell width matches the largest support radius, a cell needs only itself and its adjacent cells.

A four-offset half stencil visits each neighbouring cell pair once; a triangular loop handles pairs in the same cell. Accepted pairs keep their wrapped delta and distance for reuse by field, derivative, force, and union-find passes.

Why one pair list matters

Reused vectors avoid allocating on every step. More importantly, motion and formation analysis cannot drift into different definitions of a neighbour: both read the same pair list.

A compact state boundary

JavaScript does not receive Rust structs. It receives three flat arrays with stable, documented records:

Snapshot
Four floats per particle: x, y, packed species + energy, and normalised speed.
Metrics
Seven floats: energy, coherence, activity, density, formations, transition, encounters.
Formations
Four floats per reported cluster: toroidal x, y, population share, and species.

At the default population, the visual snapshot is 8,000 floats. The renderer uploads it once per display frame; audio reads seven metrics and at most 32 formation floats. Neither output needs to inspect particle neighbourhoods. Named indexes, strides, and the contract version live in web/simulation-contract.js, rather than being repeated as magic numbers in each consumer.

The WebGPU image is a render graph

WebGPU does not run the simulation. It receives the current particle snapshot and builds the image in a sequence of render passes:

  1. Kernel field. One instanced quad per particle splats its exact species shell into a one-third-resolution rgba16float texture using additive blending.
  2. Motion memory. Two half-resolution HDR textures ping-pong. One frame's history fades with a frame-rate-independent exponential before current particles are deposited.
  3. Scene composition. A full-screen pass combines background, field, growth-response contours, and trails into a full-resolution HDR target.
  4. Particle cores. Instanced luminous points are added over the field, preserving the actual material that made the membranes.
  5. Bloom. Bright matter is soft-knee extracted at quarter resolution, then blurred horizontally and vertically.
  6. Final grade. Scene and bloom are combined, ACES tone-mapped, gently lifted, vignetted, and given fine animated grain before reaching the canvas.

Halo and Memory alter the render graph without feeding back into Rust. Ecology and gestures alter the engine. Visual controls therefore cannot become hidden physics.

The Canvas fallback

WebGPU is preferred, but it is not assumed. If no adapter is available - or if ?renderer=canvas is present - the app uses Canvas 2D. Cached radial sprites avoid rebuilding thousands of gradients every frame, a second canvas accumulates trails, and additive compositing keeps the field luminous.

It is simpler and visually different, but still shows particle-built matter, three populations, energy, and speed from the same simulation.

Sound reads the same state

The sound graph is built from standard Web Audio nodes after an explicit user gesture. It contains sustained oscillators, filtered procedural noise, panners, delay, convolution reverb, waveshaping, compression, limiting, and an analyser. JavaScript decides the musical targets; the browser's audio thread renders them.

The first click, touch, key press, or wheel gesture starts audio. Visibility and pointer/keyboard events also attempt to resume a suspended context, which matters on mobile devices that interrupt sound when the app loses focus.

The limiter feeds both the speakers and a MediaStreamAudioDestinationNode. Recording receives the mastered playback signal, not an approximate second mix.

A URL is part of the instrument state

The initial seed comes from the query string or is generated once and written there. Ecology, flow, touch strength, halo, memory, tone, level, population, and selected gesture mode use the same mechanism. Changing a control writes the complete current settings back to the address bar. Their keys, defaults, and bounds have one schema in web/performance-state.js.

Reproduction needs the engine revision, seed, particle count, fixed timestep, performance settings, and step-indexed gesture commands. The current URL covers an untouched field and the starting state of a performance. A future take bundle can add gestures without changing the engine boundary.

Producing synchronized video

The local production command builds the release artifact, opens a clean Chrome profile at the requested dimensions, and calls a narrow capture API exposed by the app. Preparation starts the audio graph silently, resets the seeded engine, and clears visual history. Capture releases simulation and master gain together on the first recorded frame.

The canvas stream and post-limiter audio stream enter one MediaRecorder. Chunks are sent back to a local Node process as they arrive so a long recording does not live entirely in browser memory. FFmpeg can retain a VP9/Opus WebM or transcode H.264/HEVC with AAC and fast-start metadata.

Each landscape, square, portrait, story, 4K, or custom aspect ratio is a fresh run from the same seed. The tool also writes a PNG preview and JSON manifest with the source revision, canonical performance URL, settings, browser and platform, measured cadence, duration, and probed codecs.

What the checks defend

npm run check is the release gate. It verifies formatting, compiles Rust with Clippy warnings treated as errors, runs the native test suite, builds optimised WebAssembly, and creates the production Vite bundle. A passing push to main deploys that exact artifact through GitHub Actions and Cloudflare Workers Static Assets.

Native tests concentrate on creative invariants: same-seed reproduction, different-seed divergence, stable dense fields, bounded metrics, finite long runs, pair-grid agreement, gesture effect, species presence, formation records, and particle conservation.

A useful division of labour

Rust owns reproducible rules. WebGPU interprets them as light; Web Audio interprets them as music. The browser owns timing and consent. The production tool owns delivery formats.

Standards and source

  1. W3C GPU for the Web Working Group. WebGPU specification.
  2. WebAssembly Community Group. WebAssembly core specification.
  3. W3C Web Audio Working Group. Web Audio API 1.1.
  4. Confluon source. github.com/tre-systems/confluon - simulation, renderer, audio graph, tests, and production tooling.