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 page

Scoping 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
});

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.

A field scoped to this box live on this page
a body inside the contained field

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.

Why the pool is field-wide. It keeps the cost of a field predictable: dropping a widget on a page adds a known, bounded amount of matter rather than an amount that scales with layout. The trade is that density means "how much matter", not "how dense it looks" — see performance tuning.

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.

Registration event
// 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`.
  },
}));