Cookbook
Contained fields
A field does not have to own the window. Scope it to a single element and it becomes a component-level primitive: a card with its own physics, a widget you can drop twice on a page, a preview that cannot reach the content around it.
Shipped · demo runs live on this pageScoping a field to an element
Pass bounds and everything moves into that element's local space: the scan root is
the element (not the document), viewport() returns its box, and scroll, resize and
input are observed on it rather than on the window.
import { createField } from '@fundamental-engine/vanilla';
// `bounds` scopes the field to ONE element: bodies are scanned inside it, positions
// live in its local coordinate space, and the canvas is drawn in that same space.
const field = createField(canvas, {
bounds: card, // → containerHost(card) under the hood
render: 'dots',
density: 4, // a small field needs MORE density, not less — see below
}); import { createField } from '@fundamental-engine/core';
import { containerHost } from '@fundamental-engine/dom';
// The same thing, wired by hand — useful when you already hold a host, or want to
// wrap one. `bounds` is just this call.
const field = createField(canvas, { host: containerHost(card) }); JS ✓ Swift ✓ Kotlin ✓ the host seam a contained field is built on
Two fields, no fight
This page already runs a page-wide field. A contained field inside it would be a second engine
writing --d onto the same elements every frame — the per-frame flicker that
nearest-enclosing-field ownership exists to prevent.
So containerHost marks its bounds element as a field boundary when
it attaches: the outer field's scan skips everything the contained field owns, and
detach() removes the marker so the outer field re-adopts those bodies on the next
rescan. You get this by construction — there is nothing to configure.
The density surprise — and the fix
The most common contained-field complaint is "my card barely glows". The cause is not the
contained path; it is that the particle pool is 130 × density field-wide,
not per-area. A card-sized field still holds the same matter as a page-sized one,
spread over its whole volume, so each body gathers less and --d reads low.
The fix is the opposite of most people's instinct: raise density
for a small field. The demo below rebuilds its field at density: 4 and
density: 0.5 so you can watch the same body's --d move.
starting…
The readout is the live --d the contained field writes onto the card — not a number this page computed. Switch the density and watch it change.
Components in a shadow root, and portals
A component that hides its internals in a shadow root cannot be found by a light-DOM scan. It registers itself instead, by dispatching a composed registration event that crosses the boundary — the field registers the host element and writes state back onto it.
Two controls decide which field adopts it. scope: 'nearest' applies the same
nearest-enclosing-field ownership the scanner uses, so a component inside a contained field
joins that field. An explicit field target is a portal:
it names a field by its scan root and beats scope entirely — and matches no field
at all if none exists yet, staying inert rather than falling back to the page.
// A component in a shadow root registers its host element as a body by
// dispatching a composed registration event. Two controls decide WHICH field adopts it:
el.dispatchEvent(new CustomEvent('field:register-body', {
bubbles: true, composed: true,
detail: {
tokens: ['attract'],
scope: 'nearest', // only the nearest enclosing field adopts it (not every field)
// field: '#card-2', // …or name a field explicitly — a portal. Beats `scope`.
},
}));