Cookbook
Conditions & formations
Two axes sit beside the forces, and both are about when rather than what. A condition gates one body: its forces act only when the gate passes. A formation is field-wide: one global posture every free particle carries. Neither is a force, and neither can be expressed as one.
Shipped · closed vocabularies, stated honestlyConditions — gating one body
data-when takes one of 6 gate names (plus the empty default,
"always"). The distinction that matters when authoring: active and
scrolling read the world — is this body engaged, is the page moving —
while fast, slow, hot and cool are
selective: they are evaluated per particle, so one body can act on agitated
matter and ignore calm matter in the same frame.
<!-- A gate decides WHEN a body's forces act. The body is always a body;
the gate only decides whether its force applies this frame. -->
<button data-body="repel" data-when="active" data-hot data-strength="1.4">
Delete
</button>
<!-- Selective gates read each PARTICLE, not the body: 'fast' acts only on
fast-moving matter, so the same body treats calm and agitated matter differently. -->
<div data-body="viscosity" data-when="fast" data-strength="0.8">Settling zone</div> -
data-when="active"per body / frame - Only while the body is engaged. JS ✓ Swift ✓ Kotlin ✓
-
data-when="fast"per particle - Only on fast-moving matter (v² > 0.9). JS ✓ Swift ✓ Kotlin ✓
-
data-when="slow"per particle - Only on calm matter (v² < 0.22). JS ✓ Swift ✓ Kotlin ✓
-
data-when="hot"per particle - Only on energized matter (heat > 0.3). JS ✓ Swift ✓ Kotlin ✓
-
data-when="cool"per particle - Only on calm, un-energized matter (heat < 0.08). JS ✓ Swift ✓ Kotlin ✓
-
data-when="scrolling"per body / frame - Only while the page is scrolling. JS ✓ Swift ✓ Kotlin ✓
starting…
Hover or focus the gated body: data-when='active' means its force applies only while it is engaged. The readout reports the gate state and the live --d.
condition(id, fn) method, but createField constructs its
registry internally and accepts no registry option — so there is no public API for
registering a custom gate today. Authoring a condition means composing the built-ins
with the force set and the data-affects / data-species selectors.
Reach for a field channel when the
predicate you want is really external data.
Formations — the one global posture
A formation is five numbers — drift, wander, orbit, spread, convergence — applied as a bias to every free particle. The engine eases toward the target each frame, so a change glides rather than snaps. There are 5, and the presets themselves are engine-owned: authoring means choosing one, and deciding when it applies.
<!-- The conductor: as a section crosses mid-viewport it eases the
WHOLE field into that posture. After ~6s of no input the field drifts back to 'ambient'. -->
<section data-formation="wells"> … work … </section>
<section data-formation="lanes"> … writing … </section> field.setFormation('lanes'); // …or drive it directly JS ✓ Swift ✓ Kotlin ✓
apps/site/src/lib/cookbook/formations.ts // FORMATIONS — the one global bias every free particle carries.
//
// A formation is not a force and not a body: it is a field-wide posture, five numbers
// (`driftX, wander, orbit, spread, conv`) the engine EASES toward so a change glides rather than
// snaps. `setFormation(name)` switches it at runtime; `<section data-formation="lanes">` switches
// it as a section crosses mid-viewport (the conductor), and after ~6s of no input the field drifts
// back to the calm `ambient` posture on its own.
//
// The catalog is closed — five formations. Authoring means CHOOSING one and tuning the two
// declared `ambient` dials; the preset numbers themselves are engine-owned.
import { createField, headlessHost, seededRng, FORMATIONS } from '@fundamental-engine/core';
export interface FormationResult {
/** the catalog — the whole authorable vocabulary. */
available: string[];
/** mean horizontal speed under `ambient` (resting drift). */
ambientDriftX: number;
/** …and under `lanes`, whose preset carries a real `driftX`: a current now carries the matter. */
lanesDriftX: number;
/** the switch is observable in the matter itself, not just in a flag. */
lanesDriftsFaster: boolean;
}
/** mean signed horizontal velocity across the live pool. */
function meanVx(field: ReturnType<typeof createField>): number {
const n = field.particleCount();
const vx = new Float32Array(n);
field.readParticleChannels(['vx'], [vx]);
let sum = 0;
for (let i = 0; i < n; i++) sum += vx[i]!;
return n ? sum / n : 0;
}
export function runFormations(): FormationResult {
const host = headlessHost({ width: 1200, height: 800 });
const field = createField(undefined as unknown as HTMLCanvasElement, {
host,
render: 'none',
density: 1,
rng: seededRng(23),
});
field.setFormation('ambient');
for (let i = 0; i < 90; i++) host.tick();
const ambientDriftX = Number(meanVx(field).toFixed(4));
// the formation eases in — give it frames to glide, exactly as a reader would see
field.setFormation('lanes');
for (let i = 0; i < 180; i++) host.tick();
const lanesDriftX = Number(meanVx(field).toFixed(4));
field.destroy();
return {
available: FORMATIONS.map((f) => f.id),
ambientDriftX,
lanesDriftX,
lanesDriftsFaster: Math.abs(lanesDriftX) > Math.abs(ambientDriftX),
};
} - available
- ["ambient","wells","lanes","scatter","accretion"]
- ambientDriftX
- -0.0071
- lanesDriftX
- 0.192
- lanesDriftsFaster
- true
Mean horizontal velocity across the live pool, measured under each posture: `ambient` rests, `lanes` carries. The formation is not a flag — it moves the matter.
data-formation is read by the engine —
the conductor scans [data-formation] sections and eases the field as each crosses
mid-viewport — but it appears in no attribute table on this site. It escaped the
check:docs body-attribute surface because that gate reads the scanner, and this
attribute is handled in the frame loop instead. Filed with the Phase 5 PR.