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 honestly

Conditions — 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.

HTML
<!-- 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 ✓
A gate that only opens when engaged live on this page
gated body — hover or focus me
ungated neighbour

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.

The vocabulary is closed, and that is worth saying plainly. The engine's registry does carry a 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>

JS ✓ Swift ✓ Kotlin ✓

Switching the posture is visible in the matter apps/site/src/lib/cookbook/formations.ts
Runs at build time
// 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),
  };
}
What it returned when this page was built
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.

A documentation gap this page closes. 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.

Which axis do I want?

  • One element should behave differently in some state → a condition.
  • The whole page should change character in a section → a formation.
  • Only some matter should respond → a selective gate (fast, hot, …).
  • The rule depends on your data → neither: a channel, or a metric through bindData.