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.
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 reciprocal half — the field writes, your CSS reads.
--d rests SMALL by design (fractions of 1); amplify it, don't
bake a magic number into every consumer. */
[data-feedback] {
--d-amp: clamp(0, calc(var(--d, 0) * 12), 1);
}
article {
border-color: color-mix(in srgb, var(--accent) calc(var(--d-amp) * 100%), transparent);
transform: translateY(calc(var(--d-amp) * -3px));
} 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
apps/site/src/lib/cookbook/signals-first.ts // 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;
} - 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.
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
apps/site/src/lib/cookbook/custom-sink.ts // 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;
} - 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
rngmakes a run reproducible — which is what every example on this page relies on.