Authoring across surfaces
Four surfaces, one contract. A body authored in plain HTML, as a web component, in React, or in a
Three.js scene is the same body in the field — each is a different way to reach the same
[data-body] frozen · attribute contract contract. Pick the surface your stack uses;
Fundamental does not ask you to adopt a framework.
A body, four ways
Each of these authors one attractor of strength 0.8. They produce identical bodies in the engine.
<!-- the page is the medium; any element can be a body -->
<canvas id="field"></canvas>
<article data-body="attract" data-strength="0.8">
Pulls matter inward
</article>
<script type="module">
import { createField } from '@fundamental-engine/vanilla'; // wires the browser host for you
// mounts the field and scans the page for [data-body] elements
createField(document.getElementById('field'));
</script> <!-- <field-root> is the singleton field; it scans [data-body] for you -->
<script type="module">import '@fundamental-engine/elements';</script>
<field-root accent="#4da3ff"></field-root>
<article data-body="attract" data-strength="0.8">
Pulls matter inward
</article> import { FieldField } from '@fundamental-engine/react';
export function Page() {
return (
<>
<FieldField accent="#4da3ff" />
{/* the same [data-body] contract — the field reacts to it */}
<article data-body="attract" data-strength="0.8">
Pulls matter inward
</article>
</>
);
} import * as THREE from 'three';
import { createFieldLayer, PlaneProjection } from '@fundamental-engine/three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const layer = createFieldLayer({
projection: new PlaneProjection({ relief: 2 }), // field on a plane; z lifted from heat
renderer,
accent: '#4da3ff',
});
scene.add(layer.object); // a THREE.Points swarm
// any THREE.Object3D becomes a body — the analogue of [data-body="attract" data-strength="0.8"]
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
layer.addBody(mesh, { tokens: 'attract', strength: 0.8, range: 260 });
renderer.setAnimationLoop(() => {
layer.tick(); // engine self-steps; tick() pulls the latest swarm
renderer.render(scene, camera);
}); attract, strength 0.8 — across all four surfaces. The Three.js door uses layer.addBody(mesh, spec) instead of data-body markup.Live
These cards carry real data-body attributes, so the page's field reacts to them right
now. Move your pointer near one and watch the field gather around it.
The shared contract
Every surface writes the same data-* attributes. This is the contract:
| Attribute | Meaning | Example |
|---|---|---|
data-body | force tokens, space-separated | attract repel swirl |
data-strength | source mass / pull (default 0.5) | 0.8 |
data-range | reach in px (default 280) | 220 |
data-preset | a named bundle of virtual bodies | dipole |
data-intent | authored intent, compiled to forces | gather |
data-field-role | semantic role → a default force | source, sink, anchor |
Element consumers — react to the field
The contract above makes an element a force source. An element can instead consume
the field — react to the matter around it (the element side of the Body Matter Interaction model).
Each pairs with data-move so the engine may relocate or collapse the node.
<!-- capture: a chip that collapses into a sink when the field pulls it in -->
<span class="chip" data-move data-dock>tag</span>
<!-- relocate: an element that teleports to its paired exit through a warp throat -->
<div data-move data-warp data-pair="#exit" data-twist="0.5" data-scale="0.8">in</div>
<div id="exit"></div>
<!-- emit: clone this template node as the body emits matter -->
<button data-body="spawn" data-emit>
<template><span class="spark">·</span></template>
</button> | Attribute | Behavior | Example |
|---|---|---|
data-dock | collapse into a sink when captured; fires field:captured / field:released | data-move data-dock |
data-warp | teleport to a paired body through a warp throat; fires field:relocated | data-warp data-pair="#exit" data-twist="0.5" data-scale="0.8" |
data-emit | clone a decorative template node as matter is emitted | data-emit |
data-warp needs a paired body (data-pair) to relocate to — a single body
can't show relocation. See the agent consumption model for the
captured / relocated / emitted events.
Reading the field — CSS feedback channels
The engine writes CSS custom properties onto elements during the write phase of
every frame. Add data-feedback to receive them; consume them in CSS to make an
element visually react. No polling, no event listeners — the engine is a CSS property writer.
<!-- 1. Add data-feedback to receive --d and the other engine vars -->
<h1 data-body="attract" data-feedback class="hero-mass">
The field is here
</h1> /* 2. Scale the raw [0,1] fraction before driving reactions */
[data-feedback] {
--d-amp: clamp(0, calc(var(--d, 0) * 12), 1);
}
/* 3. React off the amplified value */
.hero-mass {
transform: translateY(calc(var(--d-amp) * -6px));
text-shadow: 0 0 calc(var(--d-amp) * 20px) color-mix(in srgb, var(--accent) 60%, transparent);
} --d is the canonical density var ∈ [0,1]. Scale it before driving properties — the raw fraction is small by design.
The full set of feedback channels — --d, --load, --lit,
--entropy, --coherence, --temperature — is in the
writeback reference. Sink accretion is a common
pattern — --load fills from 0 (empty) to 1 (saturated):
<!-- a sink body; data-absorb starts accretion, data-max caps it -->
<ul data-body="sink" data-absorb data-max="40" data-feedback class="queue">
<!-- items dock here when captured -->
</ul> /* --load fills from 0 (empty) to 1 (saturated) as matter accretes */
.queue {
outline: 2px solid color-mix(in srgb, var(--accent) calc(var(--load, 0) * 60%), transparent);
} Correctness note: drive reactions off --d, not
--field-density. When a pattern's metric pipeline runs, it also writes
--field-density as a pattern output — so on pattern elements,
--field-density holds the pattern metric, not the engine's raw particle density.
--d is always the engine value.
Conditional forces — data-when
A force can activate only while a named condition holds, by adding data-when to
the body element. The condition is evaluated by the engine each frame; the force is
multiplied by 0 when the condition is false, 1 when true. No JavaScript required.
<!-- data-when gates the force on a named condition -->
<nav data-body="attract" data-when="active">
<!-- force activates only while this element is hovered / focused -->
</nav>
<!-- 'slow' = --field-scroll-v < 2 (reading pace); force sleeps during fast scroll -->
<section data-body="swirl" data-strength="0.5" data-when="slow">
...
</section>
<!-- combine data-hot + data-when: hover triggers, condition gates -->
<button data-hot data-body="attract" data-spin="1" data-when="active">
hover me
</button>
Built-in conditions: active (the element is hovered/focused), slow
(--field-scroll-v < 2 — reading pace), idle (no pointer
activity in the last second), visible (intersection > 0.1). The full list
is on the conditions reference.
Formations — setFormation + data-formation
A formation shapes where the field's particles want to be — a global geometry imposed on top of the local force field. Formations are page-scope and transition smoothly.
<!-- divide the page into named sections; a formation snaps particles to each -->
<section data-formation="arc">
<h2>chapter one</h2>
</section>
<section data-formation="ring">
<h2>chapter two</h2>
</section> // switch formations from code — smooth transition, same particles
import { mountField } from '@fundamental-engine/vanilla';
const { handle } = mountField(canvas);
// on route change, on scroll into section, or on user action:
handle.setFormation('wells'); // a stable multi-well attractor grid
handle.setFormation('cascade'); // a downward pour
handle.setFormation('ring'); // a closed orbit setFormation() switches on any event.The built-in formations — arc, ring, wells, cascade, drift, and more — are on the formations reference. Formations coexist with local body forces; the field blends both.
Beneath the surfaces: the platform
The surfaces above author bodies. When you need the page itself to participate — to
measure elements, hold state, and write feedback on a disciplined loop — reach for
createFieldPlatform() frozen · @fundamental-engine/dom from
@fundamental-engine/dom directly. It is framework-agnostic; the same code runs under any
surface.
import { createFieldPlatform } from '@fundamental-engine/dom';
// the substrate beneath every surface: measure → state → write, on one scheduler
const platform = createFieldPlatform(document.querySelector('article'));
const card = document.querySelector('[data-body]');
platform.measure.register(card); // read phase
platform.feedback.bind(card, { attention: '--field-attention' }); // write phase
platform.on('compute', () => { // compute phase
const m = platform.measure.for(card);
if (m) platform.state.set(card, 'attention', m.visibilityRatio);
});
const tick = (t) => { platform.tick(t); requestAnimationFrame(tick); };
requestAnimationFrame(tick);