Cookbook

Signals-first fields

The field's most useful mode draws nothing at all. render: 'none' runs the whole force pipeline and writes every feedback channel without ever acquiring a canvas context — so the field becomes a measurement substrate, and what you consume is signals: CSS variables on your own elements, per-body channels in a callback, or anything your own feedbackSink routes them to.

Shipped · examples execute at build time
This is the default, not a special mode. render defaults to 'none' (#538): a bare <field-root> is already signals-only, and no canvas context is allocated until you ask for one. The twelve invisible-field examples are all built this way — real datasets, no particle swarm. JS ✓ Swift ✓ Kotlin ✓

In the DOM: markup in, CSS out

The loop is closed entirely in the browser's own vocabulary. An element declares itself a body; the field measures what gathers on it; the measurement comes back as a custom property your stylesheet reads. No framework, no imperative code, nothing drawn.

<!-- The same idea in the DOM: no canvas, no particles, no drawing.
     'none' is the DEFAULT render mode (#538) — a bare <field-root> is already
     signals-only. The simulation runs; what you get back is CSS. -->
<field-root></field-root>

<article data-body="attract" data-feedback data-hot>
  <h2>Quarterly report</h2>
</article>

The attribute contract and every channel the field writes back are documented once, on the declarative reference — including which of them Swift and Kotlin actually have.

Without a DOM at all: the headless host

headlessHost binds the engine to nothing — an abstract volume you size, an empty scan root (bodies arrive through addBody, not [data-body]), and a manual loop you drive with tick() instead of requestAnimationFrame. That makes the field usable from a Node service, an agent turn, or a deterministic test.

JS ✓ Swift ✓ Kotlin ✓ the programmatic body — the only way in without a DOM

A field with no canvas and no document apps/site/src/lib/cookbook/signals-first.ts
Runs at build time
// A SIGNALS-FIRST field: the full simulation, no drawing, no DOM.
//
// `render: 'none'` is the engine's default (#538): it runs the whole force pipeline and writes
// every feedback channel, but never acquires a canvas context. Paired with `headlessHost` — which
// binds the engine to nothing and hands the caller a manual `tick()` — the field becomes a pure
// signal substrate a Node service, an agent, or a test can read.
//
// This module RUNS at build time (the cookbook page imports and calls it) and under `node --test`.
import { createField, headlessHost, seededRng } from '@fundamental-engine/core';

export interface SignalsFirstResult {
  /** frames advanced by hand — no requestAnimationFrame is involved. */
  frames: number;
  /** the body's eased gathered density, the `--d` channel, after the run. */
  density: number;
  /** how many particles the pool holds (`130 × density`, rounded). */
  particles: number;
}

export function runSignalsFirst(): SignalsFirstResult {
  const host = headlessHost({ width: 1200, height: 800 });

  // The canvas argument predates the headless path: under `render: 'none'` the engine never
  // touches it (it acquires no 2D context and sizes no backing store), but the signature still
  // asks for one. The cast is the honest spelling until the type admits the headless case.
  const field = createField(undefined as unknown as HTMLCanvasElement, {
    host,
    render: 'none',
    density: 0.5,
    rng: seededRng(7), // a seeded source makes the run reproducible
  });

  // A body with no element: `rect()` is the position source, `onFeedback` the read-back.
  let density = 0;
  field.addBody({
    tokens: ['attract'],
    strength: 1,
    range: 260,
    rect: () => ({ left: 500, top: 350, width: 200, height: 100 }),
    onFeedback: (ch) => {
      density = ch.density ?? 0;
    },
  });

  const frames = 60;
  for (let i = 0; i < frames; i++) host.tick();

  const result: SignalsFirstResult = {
    frames,
    density: Number(density.toFixed(3)),
    particles: field.particleCount(),
  };
  field.destroy();
  return result;
}
What it returned when this page was built
frames
60
density
0.389
particles
65

The density is non-zero: matter genuinely gathered on a body that has no element, in a field that drew nothing.

One rough edge, stated plainly. createField's first parameter is typed HTMLCanvasElement, but under render: 'none' the engine never touches it — it acquires no context and sizes no backing store. The honest spelling today is the cast you see above; the engine's own record module does exactly the same thing internally. The type has not yet caught up with the headless path.

Sending the signals somewhere else: feedbackSink

Every feedback write in the engine flows through one contract — (el, channels) => void. The default implementation (cssFeedbackSink) writes --d, --load, --lit and the measured thermodynamics onto the element. Install your own and the same values go wherever you want instead: a store, a socket, a native bridge, a test recorder.

JS ✓ Swift ✓ Kotlin — the sink seam itself

A sink that routes channels away from CSS apps/site/src/lib/cookbook/custom-sink.ts
Runs at build time
// A CUSTOM FEEDBACK SINK — send the field's per-body channels somewhere that isn't CSS.
//
// Every feedback write in the engine flows through ONE contract:
//   type FeedbackSink = (el: HTMLElement, channels: FeedbackChannels) => void
// The default (`cssFeedbackSink`) writes `--d` / `--load` / `--lit` / … onto the element. Install
// your own and the same values go wherever you want instead — a store, a socket, a native bridge,
// a test recorder — with no DOM involved.
import { createField, headlessHost, seededRng, type FeedbackChannels } from '@fundamental-engine/core';

export interface SinkResult {
  /** how many times the sink was invoked (once per body per frame it has channels for). */
  writes: number;
  /** the channel names the engine actually populated — the sink's real payload shape. */
  channels: string[];
  /** the last density the sink received. */
  lastDensity: number;
}

export function runCustomSink(): SinkResult {
  const host = headlessHost({ width: 1000, height: 700 });

  let writes = 0;
  let lastDensity = 0;
  const seen = new Set<string>();

  // The sink replaces the CSS write path entirely — nothing is written to any element.
  const field = createField(undefined as unknown as HTMLCanvasElement, {
    host,
    render: 'none',
    density: 0.5,
    rng: seededRng(11),
    feedbackSink: (_el, channels: FeedbackChannels) => {
      writes++;
      for (const [key, value] of Object.entries(channels)) {
        if (value !== undefined) seen.add(key);
      }
      if (channels.density !== undefined) lastDensity = channels.density;
    },
  });

  field.addBody({
    tokens: ['attract', 'sink'],
    strength: 1,
    range: 240,
    rect: () => ({ left: 420, top: 300, width: 160, height: 100 }),
  });

  for (let i = 0; i < 40; i++) host.tick();

  const result: SinkResult = {
    writes,
    channels: [...seen].sort(),
    lastDensity: Number(lastDensity.toFixed(3)),
  };
  field.destroy();
  return result;
}
What it returned when this page was built
writes
40
channels
["coherence","density","entropy","load","temperature"]
lastDensity
0.015

The channel list is the sink's real payload — read off a live run, not transcribed from the type.

Installing a sink replaces the CSS write path; nothing is written to any element. That is the point on a non-DOM host, and the trap on a DOM one — if your stylesheet stopped reacting after you installed a sink, this is why.

When to reach for this

  • Real content, no ornament. A reading surface where importance should be felt in type and ink rather than watched as motion — the whole invisible-fields family.
  • An agent or a service. The field as a salience substrate that never renders; read it through the capability-scoped agent view.
  • Tests. A manual tick plus a seeded rng makes a run reproducible — which is what every example on this page relies on.