API reference
The FieldHandle
Stable handle · experimental substrate methods
Every entry point — createField frozen · @fundamental-engine/core, mountField, the
FieldField class, the <field-root> element (proxied), and
the React onReady — hands you a FieldHandle. Use it to drive the
field after it mounts.
How you get one
Every entry point returns the same handle — pick the one that matches your stack. With the web component it's also proxied onto the element, so the element is the handle.
import '@fundamental-engine/elements';
// the <field-root> element proxies the entire FieldHandle:
const field = document.querySelector('field-root');
field.setFormation('wells'); import { FieldField } from '@fundamental-engine/react';
// onReady hands you the live handle once the field mounts:
<FieldField onReady={(field) => field.setPalette('heatmap')} /> import { createField } from '@fundamental-engine/vanilla'; // wires the browser host
const field = createField(canvas); // ← createField returns the handle
field.setRender('links'); Methods
All 44 methods ship on Three.js, Swift, and Kotlin (44/44/44); a few behave differently by platform. See the parity matrix for the per-method, per-platform breakdown.
-
guarantees - Read-only reproducibility envelope: determinism classification, which inputs are controlled, which are not, the requirements for repeatability, and the cross-plane numeric tolerance. Read this before building replay, shared state links, or server-authoritative simulation — the answer is conditionally-deterministic, and the conditions are listed rather than implied.
-
scan() - Re-scan the document for [data-body] bodies after a DOM change.
-
rescan() - Alias of scan().
-
setAccent(hex) - Recolor the travelling accent.
-
setPalette(name | hex[]) - Swap the accent color template live.
-
setFormation(name) - Switch the global formation.
-
setWaveStyle(style) - Switch the wave current layout style live ('linear' | 'circular').
-
setWaveCenter(center) - Set the custom wave center coordinate ({x, y} or function) live.
-
setSeparation(strength) - Set particle-to-particle separation/repulsion force strength live.
-
setAttention(on) - Toggle conserved attention live (one finite strength budget).
-
setCausality(on) - Toggle cross-boundary causality live (density spills to neighbours).
-
setHeatmap(on) - Toggle the density heatmap layer live (a glow of where matter pools).
-
setDprCap(cap) / opt dprCap - Backing-store DPR ceiling (#410) — the dominant fill-rate lever. Effective DPR = min(devicePixelRatio, dprCap), default 2; capping at ~1.5 buys ~1.8x headroom on retina for a small softening. As a FieldOptions key (createField / <field-root dpr-cap>) or the runtime setDprCap setter (re-sizes immediately). Shipped-but-unfrozen.
-
setPolicy(policy) / policy / opt policy - Runtime FIELD POLICY — what THIS host/session/user/app PERMITS (distinct lane from governance = what doctrine allows via static lint). setPolicy REPLACES (not merges) the live policy ({} clears to the unbounded default); field.policy reads a frozen copy. FieldPolicy carries allowBodyDataInSnapshots / allowMotionProjection / maxMotionBudget (0..1) / budgets (Partial<FieldBudgets>: motion, force, attention, thermal, render, privacy, accessibility, agentRead). WIRED: the motion budget folds (via min) with reduced-motion + perf pressure into the effective motion the integrator/easing reads — reduced-motion always wins (a policy can lower motion, never raise it); the privacy budget (+ allowBodyDataInSnapshots) gates body data in snapshot(). Other budgets declared-not-yet-enforced. Purely additive. Shipped-but-unfrozen.
-
setQualityTier(tier) - Adaptive quality (#413): drop the field to a cheaper tier (0 full → 3 lightest) reversibly. Maps the tier to the engine's own fill levers — caps the effective backing-store DPR (1.5 / 1.25 / 1) and skips the heaviest ambient layer (the heatmap glow) at tier 2+. <field-root> wires this automatically from the QualityGovernor on sustained frame-budget overruns; call it yourself to drive quality from your own signal. Shipped-but-unfrozen.
-
setRender(mode) - Switch the underlay render mode (behind content): dots / trails / links / metaballs / voronoi / streamlines / flow / knockout / redshift / blackbody / depth.
-
setOverlay(mode | mode[]) - Field Surfaces: render overlay reading(s) in front of content — one reading or an additive stack (the readings compose). The vocabulary: streamlines / force-vectors / field-lines / grid / temperature / energy / path / data, or off. Pairs with setRender.
-
setBackground(mode) - Switch the substrate live: 'transparent' clears to transparent so the underlay composites over light content; 'opaque' restores the near-black substrate. Additive.
-
threads(list | null) - Wire glowing connector lines between an engaged set, or clear with null.
-
burst(x, y, hex?) - A one-shot shove + heat near a point, optionally tinting the matter.
-
flowTo(x, y, opts?) - Place/move a dynamic flow focus the field bends toward — pulls matter in and curves the streamlines. Retarget it each frame to follow the pointer, an element, or a path. opts: { strength?, radius? }.
-
clearFlow() - Remove the flow focus — the field relaxes back to its bodies-only shape.
-
seed(atoms) - Bind a data record to each base particle, round-robin. Each record's weight ∈ [0,1] scales that particle's mass + size. Re-applied across resize/density rebuilds.
-
atomAt(x, y) - The seeded record on the nearest particle to (x, y) within ~24 px, or null. For hover-to-inspect.
-
focusAt(x, y) - Hold + highlight the nearest seeded particle; return its record — the dwell affordance before a click. Returns null if no particle is in range.
-
clearFocus() - Release the focused particle; it resumes drifting.
-
version (property) - Readonly string — the running engine version (= FIELD_VERSION), i.e. which build this field is on (#547). For a consumer that wants to assert or log the engine it loaded. Shipped-but-unfrozen.
-
particleCount()experimental - Live size of the particle pool. Use for external budget monitors or debug overlays without walking the particle array. Shipped-but-unfrozen.
-
energy()experimental - Per-frame energy snapshot: { kinetic, thermal, total, count }. Forwards to energyReport() without requiring a reference to the internal particle array. Shipped-but-unfrozen.
-
readParticles(out) - Copy live particle state into a caller-owned Float32Array (stride 5: x, y, z, heat, size — z is the optional depth lane, 0 in a flat field); returns the count written = min(particleCount(), floor(out.length/5)). Zero-alloc and read-only — the render-agnostic swarm read-out an alternative surface (e.g. @fundamental-engine/three) draws from. Shipped-but-unfrozen; the stride may widen (a color lane) before 1.0.
-
readParticleIds(out) - Copy each live particle's stable id into a Uint32Array, parallel to readParticles (same order, same agent skip), so ids[i] is the identity of the particle at stride offset i*5. Lets a host track a seeded entity across frames and key its own opaque payload off the id. Zero-alloc, read-only. Shipped-but-unfrozen.
-
sample(x, y) - The net field force a still test particle would feel at (x, y), as { x, y } in field-pixel space — every visible body superposed (wells, dipole structure, flow bias). Pure and read-only, samplable at any resolution; the seam external visualizers consume for vector grids, streamline tubes, or mesh displacement. Shipped-but-unfrozen.
-
sampleScalar(x, y) - The smooth diffused density scalar ∈ [0,1] at (x, y) — the heatmap grid, bilinear-sampled, so its gradient stays meaningful at a source (forage-by-gradient), unlike a nearest-body readout. Requires the heatmap layer (createField({ heatmap: true }) / setHeatmap(true)); returns 0 when off. Read-only, updated each frame including under render:none. Shipped-but-unfrozen.
-
sampleGradient(x, y) - The gradient ∇ {x,y} of the density field at (x, y) — direction + steepness (1/px) of increasing matter density. The analytic companion to sampleScalar, off the same diffused heatmap grid, so it stays non-degenerate at a source (a real uphill slope where a nearest-body density flattens to zero) — the cue reliable forage-/flee-by-gradient steers by. Requires the heatmap layer; returns { x: 0, y: 0 } when off or empty. Pure, read-only, maintained under render:none. Shipped-but-unfrozen.
-
grid(name) - Open a named host-authorable ScalarGrid — the engine field-buffer primitive (the same one diffuse/memory/propagate run on), promoted to a public surface for application fields the simulation composes with (a scent map, a wear/desire-path layer, a goal attractor). { sample, deposit, gradient, decay, clear } in field px. Created on first access, kept viewport-sized, advanced each frame by its mode (wave… = wave, memory… = slow decay, else diffuse); a same-named force shares the buffer. Shipped-but-unfrozen.
-
on(type, cb)experimental - Subscribe to a discrete field event — the host-agnostic push bus, for reacting to occurrences instead of polling feedback channels each frame. Returns an unsubscribe fn; plain data, no DOM. Discrete events are thresholded, debounced, and lazy. `captured`/`released` report sink accretion; `enter`/`exit`/`met` report proximity and contact; `focus` reports a focus() deposit (the write-back channel); additional `field:*` events report threshold crossings (attention, entropy, memory, saturation) where supported. `settle` remains reserved/planned. Shipped-but-unfrozen.
-
focus(target, input?) - EXPERIMENTAL focus/attention substrate. Deposit source-tagged, decaying focus onto a body by identity — the WRITE side of the shared attention channel. focus('file:src/auth.ts', { source: 'operator' }); operator/host attention is an input, an agent's is an output (the host relays field.focus(id, { source: 'agent' }) so the read-only agent view can never write). input: { amount?=1, source?='system', halfLife?≈8s (env.t seconds), at?=env.t }. Deposits accumulate decay-then-add per source (decay = temporal.freshness, the env.t clock — no Date.now); each fires the `focus` event and surfaces as metrics.salience + in focusState(). A string target always records (retained identity-keyed until a body with that id appears, then binds). On DOM custom elements reach it via el.handle (HTMLElement.focus stays DOM focus). Shipped-but-unfrozen.
-
focusState(opts?) - EXPERIMENTAL focus/attention substrate. Read the current-focus digest: the ranked, thresholded, capped SHARP TIP (a few hundred bytes), small enough to push into an agent turn. opts: { limit?=8, threshold?=0.05, source? }. Each FocusEntry is { target, identity, salience 0..1, sources (per-source provenance), updatedAt }. Net breadth — any body's focus magnitude — rides query()/snapshot() via metrics.salience under the base grant; this is the tip, and the per-source split (who is focused) is gated in an agent view by the read:focus capability. Shipped-but-unfrozen.
-
addAgent(spec) - Add an engine-stepped agent — a participant the integrator MOVES (vs sample(), where you integrate yourself). It lives in the particle pool, so it feels every force the swarm feels (body forces AND particle-level hunt/align/cohesion); each step its report(p) fires so an external transform (a THREE.Object3D) follows it. spec: { x, y, z?, mass?, maxSpeed?, species?, report }. maxSpeed caps it, species lets tagged bodies (data-affects) steer it selectively; it edge-bounces (not wraps) and is excluded from readParticles. Returns { particle, remove() }. The creatures primitive @fundamental-engine/three's layer.addAgent binds over. Shipped-but-unfrozen.
-
addBody(spec) - Add a programmatic body (no DOM) from a spec — the sanctioned alternative to the [data-body] scan for a non-DOM host (Three.js mesh, native view). { tokens, strength?, range?, spin?, angle?, color?, rect:()=>box, data?, onFeedback? }; rect() samples the box in field px each frame. The body carries a data record and takes per-body feedback (channels demuxed from the global sink); survives rescan. Returns { data, channels, set(params), remove() } — set({ strength?, range?, angle?, spin?, color? }) mutates the force params live on the measure cadence (no rescan, no remove+re-add; a token change still needs remove+addBody). Shipped-but-unfrozen.
-
addEdge(a, b, opts?) - Relate two programmatic bodies — the non-DOM relationship counterpart of addBody (a, b are addBody handles). The edge carries a live RelationshipAgent: it STRENGTHENS while its source body is salient (gathering matter) and decays while idle, accumulating memory — so a non-visual consumer (an agent modelling file↔meeting↔app) gets the relationship layer + its longitudinal warmth, with no DOM. opts: { type?, strength?, direction? }. Returns an EdgeHandle { set({ strength?, type? }), remove() }; read the live graph back with readEdges(). Shipped-but-unfrozen.
-
readEdges() - The live programmatic-edge read-out (addEdge) for a non-visual consumer — an array of { from, to (the endpoint bodies' data records), type, strength, memory, active }. Pure, read-only: the relationship graph + its dynamics the way readParticles is the swarm. Shipped-but-unfrozen.
-
query(q?) - Ask the live field a structured question and get back plain, serializable data — the agent-/tool-/test-readable surface. q = { at?, radius?, include? }: at is a point ({x,y}), a DOMRect-shaped rect ({x,y,width,height}, so el.getBoundingClientRect() drops straight in), or omitted for a whole-field query; radius (default 240) sizes a point query; include picks sections (bodies | metrics | relationships | influences). Returns { frame, time, region?, bodies, metrics, relationships, influences } — bodies carry id/rect/tokens/metrics/dimensions/activeFormations, relationships are the edge graph by id, and influences attribute per-force contribution at the point (from the impulse accumulator) — each carries a `channel` (`linear` Δv or `thermal` heat, doc 04 §Step 6). Read-only and render-agnostic (works headless). EXPERIMENTAL — not yet in the frozen surface.
-
snapshot(opts?) - Capture what the field is DOING at this frame — a portable, serializable FieldSnapshot (vs a screenshot of what it looked like). Returns { id, createdAt, frame, version, formations, bodies, relationships, metrics, particles? } — bodies carry id/rect/position/tokens/metrics/dimensions (+ data with includeData). opts: { includeParticles?, includeRelationships? (default true), includeData? (default false), includeInfluences? (per-body force attribution, for replay force steps), profile? }. profile (SnapshotProfile = debug | agent | bug-report | public) is a concrete inclusion preset composed with the explicit include* flags + the privacy policy, always resolving to the TIGHTEST (most private) result — a profile can never widen past what policy or an explicit deny allows (debug = everything; agent = ids + metrics + relationships + influences + projections, NO opaque body data; bug-report = structural + versions; public = ids + shape). Read-only; works headless; format is versioned (FIELD_VERSION). Pair with diff(). EXPERIMENTAL.
-
forAgent(opts) - Derive a scoped, READ-ONLY AgentFieldView — the safe surface a Software Agent uses to read the field (agent-readable is NOT agent-writable). forAgent({ capabilities, redactions? }) returns a facade exposing ONLY scoped query() / snapshot() (+ replay() only when read:replay is granted); it has NO mutation methods (no applyForce/addBody/setPolicy), enforced by the facade shape. AgentCapability = read:metrics | read:relationships | read:influences | read:snapshots | read:body-data | read:projections | read:diagnostics | read:replay — an allow-list; a dimension not granted is stripped from every reading (tightens, never widens: no read:influences → influences stripped; no read:body-data → body.data withheld even if a profile/includeData asked for it). redactions?: string[] strips dotted paths (body.data, host.user, metrics.temperature) AFTER capability scoping. Respects FieldPolicy: budgets.agentRead === 0 closes the surface to the most-restricted view (the fractional gradient is a declared seam). EXPERIMENTAL.
-
diff(a, b) - Compare two snapshots and report what changed in the field, by lane: { from, to, bodyChanges (added/removed/changed metrics), relationshipChanges (strength/active deltas), metricChanges, formationChanges (activated/deactivated) }. Pure (operates on the two snapshots, ignores live state) — the standalone diffFieldSnapshots(a, b) is also exported. EXPERIMENTAL.
-
replay(a, b, opts?) - Explain HOW the field changed between two snapshots — an ordered, narrated sequence of causes derived from the diff: { from, to, focus?, steps } where each step is { frame, time, cause (formation | relationship | metric | measurement | force), source?, target?, description, contribution? }. e.g. "Formation 'wells' activated", "Relationship A→B strengthened 0.10→0.40", "Body claim-3 density rose 0.20→0.60". opts.focus scopes it to one body id. Pure (derived from the two snapshots) — the standalone replayFieldSnapshots(a, b, opts) is also exported. When both snapshots were captured with includeInfluences, replay also emits cause=force steps (which force grew/weakened/engaged/released, by channel incl. thermal). The substrate explainability layer (Causal Replay). EXPERIMENTAL.
-
projections - The projection registry (substrate 05) — a property, not a method. A projection maps field STATE to an output surface (CSS / dom-attribute / annotation / agent-json / reduced-motion / sound / haptic / …), declaring its channels, surfaces, and reducedMotion/accessibility equivalents. register(p) → unregister fn; unregister(id); get(id); list() → serializable metadata; apply(id, reading, target) writes the reading to a surface; lint() runs governance accessibility checks over the registry (field/reduced-motion-equivalent-required = error, field/accessibility-equivalent-required = warning; the standalone lintProjections() is also exported). Governance principle: projection REVEALS state, it never changes it (no forces). query()/snapshot() report the registered projections. EXPERIMENTAL.
-
addField(name, sampler) - Register a named field CHANNEL — an external scalar field the engine samples on its own read path (terrain height, soil moisture, a heat map). The open INPUT analog of the render surfaces (setRender/setOverlay are bundled output layers): instead of bolting a parallel grid alongside the field, hand it a pull-based sampler (x, y) => number and read it back through sampleField, so a consumer queries ONE field, not two. The sampler is called on demand (never cached) — keep it cheap. Returns a FieldChannelHandle { name, set(sampler), remove() } to swap the sampler live or unregister. (Force coupling — a force reading a channel as a potential — is a separate opt-in; this is the read substrate.) Shipped-but-unfrozen.
-
sampleField(name, x, y) - Sample a channel registered with addField at (x, y) in field-pixel space; returns 0 for an unregistered channel. Pure, read-only. Shipped-but-unfrozen.
-
readParticleChannels(channels, out) - Read multiple named channels from live particles into caller-owned Float32Array buffers (column-wise: all particles' first channel, then second, etc.). Returns the particle count written. Channels: 'x' | 'y' | 'z' | 'vx' | 'vy' | 'heat' | 'size' | 'm' | 'id' | 'age' | 'charge'. Unknown channels write 0. Mirrors readParticles() agent-exclusion behavior. Zero-alloc alternative to readParticles when you need a subset of channels. Shipped-but-unfrozen.
-
registerOverlay(name, drawFn) - Register a named custom overlay function — extends setOverlay beyond the built-in reading stack. Called each frame when name is in the active overlay stack (via setOverlay). drawFn receives the active RenderBackend, current Env, and canvas W/H. Returns an unregister function. Lets third-party packages (e.g. @fundamental-engine/three) publish custom overlay modes the host switches in by name. Shipped-but-unfrozen.
-
scrollV()experimental - The engine's eased page-scroll velocity — the same EMA the scrolling condition gate reads: (prev × 0.7) + (|Δscroll| × 0.3) per frame. Units are px/frame at the display refresh rate (refresh-rate dependent — roughly half on 120 Hz; may normalize to px/ms before 1.0). Mirrored to --field-scroll-v on :root by the platform runtime. Pull-based: read on demand, don't poll in tight loops. Shipped-but-unfrozen.
-
setVisible(on) - Element-level visibility hint: setVisible(false) skips all draw work (render + overlay) each frame while the simulation and its feedback signals stay live — scrollV(), --d, --load, capture events keep flowing. Distinct from the tab-level pause (visibilitychange already stops the loop entirely). <field-root> wires it automatically from an IntersectionObserver on the host. Shipped-but-unfrozen.
-
destroy() - Stop the loop and release listeners.
<field-root>, these are proxied onto
the element itself — document.querySelector('field-root').setFormation('wells').
Diagnostics
Two read-only accessors give external tools — debug overlays, the DataConsole, Inspector panels — access to engine state that is otherwise private to the engine:
particleCount() → numberexperimental- Live size of the particle pool (
store.sizeforwarded). Use for budget monitors that need the count without walking the array (whichinspectBudgetdoes internally). energy() → { kinetic, thermal, total, count }experimental- Per-frame energy snapshot. Forwards to
energyReport(store.particles)— the function exists in@fundamental-engine/core/diagnostics/energy; this accessor exposes it without requiring a reference to the internal particle array.
0.x contract — their signatures may refine before 1.0 as the
FieldPerf surface is designed. See the performance
docs for the full observability gap analysis.
A worked example
Drive the field from your own code — recolor it, reshape it, thread engaged elements together, react to events, and clean up:
const field = document.querySelector('field-root');
// recolor + reshape the whole field at runtime
field.setPalette('infrared');
field.setFormation('accretion');
field.setRender('metaballs');
// glowing threads between engaged elements (real Element refs, not selectors)
const a = document.querySelector('#node-a');
const b = document.querySelector('#node-b');
field.threads([{ a, b, color: '#4da3ff' }]);
// a one-shot shove + heat at the cursor
addEventListener('click', (e) => field.burst(e.clientX, e.clientY));
// re-scan after a route change adds or removes [data-body] elements
field.scan();
// release the loop + listeners when you're done
field.destroy(); import { FieldField } from '@fundamental-engine/react';
export function Background() {
// onReady hands you the same handle once the field mounts
return (
<FieldField
onReady={(field) => {
field.setPalette('infrared');
field.setFormation('accretion');
field.setRender('metaballs');
const a = document.querySelector('#node-a');
const b = document.querySelector('#node-b');
field.threads([{ a, b, color: '#4da3ff' }]);
addEventListener('click', (e) => field.burst(e.clientX, e.clientY));
}}
/>
);
} import { createField } from '@fundamental-engine/vanilla';
const field = createField(canvas); // the handle
// recolor + reshape the whole field at runtime
field.setPalette('infrared');
field.setFormation('accretion');
field.setRender('metaballs');
// glowing threads between engaged elements (real Element refs)
const a = document.querySelector('#node-a');
const b = document.querySelector('#node-b');
field.threads([{ a, b, color: '#4da3ff' }]);
// a one-shot shove + heat at the cursor
addEventListener('click', (e) => field.burst(e.clientX, e.clientY));
field.scan(); // re-scan after the DOM changes
field.destroy(); // release the loop + listeners Sampling the field
Three read-only paths let you query the field's state at a point — without walking the particle array. All take coordinates in the field's own pixel space (see coordinate space below for the window vs contained distinction).
sampleScalar(x, y)- Smooth bilinear density ∈ [0,1] from the heatmap grid. Stays well-defined at a source (non-degenerate gradient). Requires
heatmap: true. sampleGradient(x, y)- Density gradient — direction and steepness (1/px) of increasing density. The analytic companion to
sampleScalaron the same grid. Use for "forage-by-gradient" agents. sample(x, y)- Net force vector
{ x, y }a still test particle would feel — all visible body wells superposed. Use for custom visualizers (vector grids, streamline tubes, mesh displacement). atomAt(x, y)/focusAt(x, y)/clearFocus()- Identify the nearest seeded particle within ~24 px and return its data record.
focusAtalso highlights the particle (hold affordance before a click);clearFocusreleases it. grid(name)- Open a named host-authorable ScalarGrid — deposit signal, let the engine diffuse it, read the level and gradient back. The same grid type used by
diffuse/memoryforces. addField(name, sampler)/sampleField(name, x, y)- Register an external scalar field (terrain height, soil moisture) and sample it through the same read path as the engine's built-in layers.
// heatmap density at any point — requires heatmap: true
field.setHeatmap(true);
const density = field.sampleScalar(x, y); // 0 → 1 (bilinear; stays smooth at a source)
const grad = field.sampleGradient(x, y); // { x, y } in 1/px — steepest-ascent direction
const vec = field.sample(x, y); // net force vector { x, y } a still particle would feel
// read a named external channel
field.addField('terrain', (px, py) => heightmap[py][px]);
const height = field.sampleField('terrain', x, y); // seeded hover-to-inspect: bind data records, then hover to identify
field.seed(records); // round-robins records onto base particles
addEventListener('pointermove', (e) => {
const atom = field.atomAt(e.clientX, e.clientY); // nearest seeded particle within ~24px
if (atom) tooltip.textContent = atom.title;
});
// dwell-before-click affordance
const rec = field.focusAt(e.clientX, e.clientY); // returns record + highlights the particle
field.clearFocus(); // release — particle resumes drifting // host-authorable scalar grid — the same grid type diffuse/memory/propagate use
const scent = field.grid('scent'); // created on first access, viewport-sized
// deposit + diffuse each frame
rAF(() => {
scent.deposit(x, y, 0.8); // add signal at a point
// grid advances itself each frame (diffuse mode by default)
const level = scent.sample(x, y); // read back
const grad = scent.gradient(x, y); // steepness + direction
}); grid mode enum, the addField
coupling syntax) before 1.0.
Discrete events
on(type, cb) is the host-agnostic push bus for reacting to occurrences instead
of polling feedback channels each frame. It returns an unsubscribe function; subscribing to a
type with no active bodies is free (lazy evaluation).
absorb- A sink body captured matter — the rising edge of accretion. Payload:
{ body, count }. release- A sink body reached capacity and released (supernova) — the falling edge. Payload:
{ body, count }. enter/exit- A body crossed into / out of proximity of another — thresholded and debounced so a hover jitter doesn't spam the bus.
met- Two bodies came into contact. Additional
field:*events report threshold crossings (attention, entropy, memory, saturation) where supported.
// react to a sink body capturing / releasing matter — discrete, not polled
const off = field.on('captured', ({ body, count }) => {
body.el?.dispatchEvent(new CustomEvent('field:captured', { detail: count }));
});
field.on('released', ({ body, count }) => {
console.log(`sink ${body.el?.id} released ${count} particles`);
});
// unsubscribe
off(); // the return value of on() is a cleanup fn absorb/release and enter/exit/met
ship today on the same on bus; only settle remains reserved/planned (#441).
Swarm readout
Three zero-allocation methods copy live particle state into caller-owned typed arrays — the interface a WebGL renderer or a Three.js layer uses to draw the field without coupling to the internal particle store.
// zero-alloc swarm read-out — caller owns the Float32Array
const N = field.particleCount();
const out = new Float32Array(N * 5); // stride 5: x, y, z, heat, size
const n = field.readParticles(out); // returns count written
for (let i = 0; i < n; i++) {
const x = out[i * 5];
const y = out[i * 5 + 1];
const heat = out[i * 5 + 3];
// drive a WebGL point at (x, y) coloured by heat
}
// stable ids — key external state to a particle across frames
const ids = new Uint32Array(N);
field.readParticleIds(ids); // ids[i] is stable across frames
// multi-channel read-out — subset of channels, column-wise
const xs = new Float32Array(N);
const ys = new Float32Array(N);
field.readParticleChannels(['x', 'y'], [xs, ys]); // fills xs then ys Programmatic bodies & edges
The [data-body] scanner is the primary web authoring surface — declarative and DOM-backed.
For non-DOM hosts (a Three.js mesh, a native view, a game entity) the addBody /
addEdge family gives the same physics without requiring a DOM element.
// programmatic body — no DOM, mirrors [data-body] for non-DOM hosts (Three.js mesh, native)
const b = field.addBody({
tokens: ['attract'],
strength: 1.5,
range: 120,
rect: () => ({ left: mesh.x, top: mesh.y, width: 32, height: 32 }), // sampled each frame
onFeedback: ({ density }) => { mesh.scale = 1 + density * 0.4; },
});
// live update (no remove+re-add)
b.set({ strength: 2.0, color: '#ff4400' });
b.remove(); // unregister
// relate two programmatic bodies
const edge = field.addEdge(bA, bB, { type: 'affinity', strength: 0.8 });
const graph = field.readEdges(); // [ { from, to, type, strength, memory, active } ]
edge.remove(); Coordinate space: flowTo, burst, and sample
The position-taking methods — flowTo(x, y), burst(x, y), and the
sampling reads (sample(x, y), sampleField(name, x, y),
atomAt, focusAt) — all take coordinates in the field's own
pixel space, the same space its particles and bodies live in. What that space
is depends on how the field was created:
- Window field —
browserHost/<field-root> -
Field space equals viewport space (the canvas is fixed and full-viewport, so
the origin is
0,0). Pass viewport pixels — a pointer event'sclientX/clientYwork directly. To track a document element, read itsgetBoundingClientRect(); because that rect is viewport-relative, you must recompute on scroll as the element moves under the fixed field. - Contained field —
containerHost(el)/new FieldField({ bounds: el }) -
The field, its bodies, and its canvas all live in the container's local space:
the engine subtracts the container's origin when it measures bodies, so the args you pass must
be container-local (
clientX − rect.left), not viewport. These local coordinates are scroll-invariant — the container and its contents move together, so a focus pinned to a child stays put without recompute as the page scrolls.
flowTo/burst store the raw
(x, y) and compare them against particle positions directly — there is no
viewport→local conversion inside the engine. A window field has a zero origin so viewport pixels
happen to be correct; a contained field does not, so passing viewport pixels there places the
focus in the wrong spot. Convert to the field's space before you call.
// WINDOW field (browserHost / <field-root>): args are VIEWPORT pixels — the same
// space as a pointer event. clientX/clientY drop straight in.
addEventListener('pointermove', (e) => field.flowTo(e.clientX, e.clientY));
// To track a DOM element, read its viewport rect — and recompute on scroll, because
// getBoundingClientRect() is relative to the viewport, which moves as the page scrolls.
function focusOn(el) {
const r = el.getBoundingClientRect();
field.flowTo(r.left + r.width / 2, r.top + r.height / 2);
}
addEventListener('scroll', () => focusOn(target), { passive: true }); // CONTAINED field (containerHost / new FieldField({ bounds: el })): args are
// CONTAINER-LOCAL pixels — measured from the container's top-left, NOT the viewport.
// Subtract the container's rect; these coords are scroll-invariant (container and its
// contents move together), so no recompute on scroll of the page.
const box = container.getBoundingClientRect();
container.addEventListener('pointermove', (e) => {
field.flowTo(e.clientX - box.left, e.clientY - box.top);
});
// To focus a child element inside the contained field, take both rects local-relative:
function focusChild(child) {
const c = container.getBoundingClientRect();
const r = child.getBoundingClientRect();
field.flowTo(r.left - c.left + r.width / 2, r.top - c.top + r.height / 2);
} The vocabulary behind this — host space, field space, and the one-way adapter that carries one into the other — is the canonical coordinate-spaces doc.