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');

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.
On the element. When you use <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() → number experimental
Live size of the particle pool (store.size forwarded). Use for budget monitors that need the count without walking the array (which inspectBudget does 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.
Shipped-but-unfrozen. Both accessors ship today and are safe to use. They are not part of the frozen 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();

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 sampleScalar on 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. focusAt also highlights the particle (hold affordance before a click); clearFocus releases 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 / memory forces.
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);
Shipped-but-unfrozen. These sampling APIs are live and stable for consumers today, but their signatures may refine (e.g. the 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.
Event bus
// 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.

readParticles / readParticleIds / readParticleChannels
// 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.

addBody / addEdge / readEdges
// 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's clientX/clientY work directly. To track a document element, read its getBoundingClientRect(); 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.
The methods don't translate for you. 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 });

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.