Cookbook

Reading & instrumenting

A field is not only something that acts — it is something you can ask. This page is the worked version of the read side: where to probe and what the answer means, the buffers the engine will keep for you, how to put your own data into the field's read path, and how to give pooled matter identity so you can pick a record back out of it.

Shipped · every example executes at build time
The reference is next door. Every method here — its signature, its type, and which of Swift and Kotlin have it — is on the imperative API reference. This page does not repeat that; it shows the calls working, with real numbers.

The three grains, and where to point them

Reading comes at three grains: a probe answers about a point, a query about a region, and a snapshot about the whole field over time. The probe is the one people misuse, so the example takes three of them — and the lesson is that where you sample is most of the answer.

Probe, query, and snapshot → diff apps/site/src/lib/cookbook/reading.ts
Runs at build time
// READING THE FIELD OVER TIME — snapshot → tick → snapshot → diff.
//
// The reference covers the three GRAINS of reading (a probe at a point, a `query` over a region, a
// `snapshot` of the whole field). This is the worked version of the third: capture what the field
// was doing, let it run, capture again, and ask what changed. A diff is derived purely from the two
// snapshots — no recording, no instrumentation, nothing retained between them.
//
// This is the shape a change-monitor, a regression test, or an agent's "what happened since my last
// turn" all want.
import { createField, headlessHost, seededRng } from '@fundamental-engine/core';

export interface ReadingResult {
  /** bodies visible to the reading. */
  bodies: number;
  /** the probe 100px right of the well: a force VECTOR, not a scalar — it points back at the well. */
  forceBesideWell: { x: number; y: number };
  /** the probe at the well's exact centre: zero, because the pull cancels by symmetry. */
  forceAtCentre: { x: number; y: number };
  /** the probe beyond `range`: zero, because the body's influence ends there. */
  forceBeyondRange: { x: number; y: number };
  /** how far the two snapshots are apart, in frames. */
  framesBetween: number;
  /** the diff's own account of what moved. */
  bodyChanges: number;
  metricChanges: number;
  /** a diff names the two snapshots it compared. */
  diffIsIdentified: boolean;
}

export function runReading(): ReadingResult {
  const host = headlessHost({ width: 1000, height: 700 });
  const field = createField(undefined as unknown as HTMLCanvasElement, {
    host,
    render: 'none',
    density: 0.5,
    rng: seededRng(37),
  });

  field.addBody({
    tokens: ['attract'],
    strength: 3,
    range: 400,
    identity: 'well', // a stable id, so a reading can refer to it by name across frames
    rect: () => ({ left: 460, top: 310, width: 80, height: 80 }),
  });
  for (let i = 0; i < 30; i++) host.tick();

  // grain 1 — a probe at a point: the net force VECTOR there.
  // Three probes, because where you sample is the whole lesson: beside the well the force points
  // back at it; at the exact centre it cancels to zero; past `range` there is nothing to feel.
  const vec = (x: number, y: number): { x: number; y: number } => {
    const f = field.sample(x, y);
    return { x: Number(f.x.toFixed(3)), y: Number(f.y.toFixed(3)) };
  };
  const forceBesideWell = vec(600, 350);
  const forceAtCentre = vec(500, 350);
  const forceBeyondRange = vec(950, 350);

  // grain 2 — a structured question about the whole field
  const reading = field.query();

  // grain 3 — change over time
  const before = field.snapshot();
  const framesBetween = 60;
  for (let i = 0; i < framesBetween; i++) host.tick();
  const after = field.snapshot();
  const changed = field.diff(before, after);

  field.destroy();
  return {
    bodies: reading.bodies.length,
    forceBesideWell,
    forceAtCentre,
    forceBeyondRange,
    framesBetween,
    bodyChanges: changed.bodyChanges.length,
    metricChanges: changed.metricChanges.length,
    diffIsIdentified: changed.from === before.id && changed.to === after.id,
  };
}
What it returned when this page was built
bodies
1
forceBesideWell
{"x":-0.844,"y":-0.084}
forceAtCentre
{"x":0,"y":0}
forceBeyondRange
{"x":0,"y":0}
framesBetween
60
bodyChanges
1
metricChanges
1
diffIsIdentified
true

Beside the well the force points back at it; at the exact centre it cancels to zero by symmetry; past `range` there is nothing to feel. A probe that returns {0,0} usually means the point, not the field.

JS ✓ Swift ✓ Kotlin ✓ the point probe

Note what a diff is: it is derived purely from two snapshots. Nothing is recorded between them and nothing is retained — which is what makes it safe to use as an agent's "what changed since my last turn".

Scalar grids — a buffer the field keeps for you

grid(name) hands you a persistent, viewport-sized scalar buffer: deposit into it, sample it back bilinearly, take its gradient, decay or clear it. Nothing is allocated until first access.

The detail that surprises people is that the name chooses the physics: a grid called wave… runs the wave scheme, memory… decays slowly, and anything else diffuses. A force of the same name shares the same buffer — so a host can read what a force writes, and a distinct name like 'scent' keeps a field of your own private.

JS ✓ Swift ✓ Kotlin ✓

Deposit, diffuse, follow the gradient, clear apps/site/src/lib/cookbook/scalar-grid.ts
Runs at build time
// SCALAR GRIDS — a persistent buffer the field carries for you.
//
// `field.grid(name)` returns a viewport-sized scalar buffer: `deposit` adds, `sample` reads it back
// bilinearly, `gradient` gives the up-slope direction, `decay`/`clear` fade it. The grid is created
// on first access (nothing is allocated until then) and advanced once per frame by a mode INFERRED
// FROM ITS NAME: `wave…` runs the wave scheme, `memory…` decays slowly, anything else diffuses.
//
// A force of the same name shares the same buffer — so a host can read what a force writes. Pick a
// distinct name (`'scent'`) to keep an authored field of your own.
import { createField, headlessHost, seededRng } from '@fundamental-engine/core';

export interface GridResult {
  /** value at the deposit point, right after depositing. */
  atSource: number;
  /** the same point after the grid's per-frame diffusion has run. */
  afterDiffusion: number;
  /** value 80px away — diffusion has carried some of it outward. */
  nearby: number;
  /** the up-slope direction at the nearby point: it points back toward the source. */
  gradientPointsToSource: boolean;
  /** after `decay(1)` the buffer is cleared. */
  afterDecay: number;
}

export function runScalarGrid(): GridResult {
  const host = headlessHost({ width: 1000, height: 700 });
  const field = createField(undefined as unknown as HTMLCanvasElement, {
    host,
    render: 'none',
    rng: seededRng(3),
  });

  // A named buffer of your own. 'scent' matches no force, so nothing else writes to it.
  const scent = field.grid('scent');

  const SX = 500;
  const SY = 350;
  scent.deposit(SX, SY, 100);
  const atSource = Number(scent.sample(SX, SY).toFixed(3));

  // let the grid's own per-frame stepping (diffusion, for this name) run
  for (let i = 0; i < 20; i++) host.tick();

  const afterDiffusion = Number(scent.sample(SX, SY).toFixed(3));
  const nearby = Number(scent.sample(SX + 80, SY).toFixed(4));

  // ∇ points up-slope — from a point to the right of the source, back toward it (negative x).
  const g = scent.gradient(SX + 80, SY);
  const gradientPointsToSource = g.x < 0;

  scent.decay(1); // 1 = clear
  const afterDecay = Number(scent.sample(SX, SY).toFixed(3));

  field.destroy();
  return { atSource, afterDiffusion, nearby, gradientPointsToSource, afterDecay };
}
What it returned when this page was built
atSource
58.594
afterDiffusion
1.73
nearby
1.3058
gradientPointsToSource
true
afterDecay
0

The deposit spreads: the source value falls while a point 80px away rises, and the gradient there points back toward the source — the read a forage-by-gradient behaviour needs.

Field channels — your data on the engine's read path

A grid is a buffer the engine owns. A channel is the opposite: data you already own, registered so the field can read it. addField(name, sampler) takes a pull-based (x, y) => number — called on demand, never cached — so there is nothing to invalidate and the data stays yours.

JS ✓ Swift ✓ Kotlin ✓

Register, sample, swap live, remove apps/site/src/lib/cookbook/channels.ts
Runs at build time
// FIELD CHANNELS — register data you already own as something the field can read.
//
// `addField(name, sampler)` is the open INPUT analog of the bundled output surfaces: instead of
// keeping a parallel grid beside the field and syncing the two by hand, hand the engine a
// `(x, y) => number` and read it back through `sampleField(name, x, y)`. The sampler is PULL-based
// — called on demand, never cached — so the data stays yours and is always current.
//
// An unregistered name reads 0, so a `sampleField` call is always safe.
import { createField, headlessHost, seededRng } from '@fundamental-engine/core';

export interface ChannelResult {
  /** the registered channel's value mid-field. */
  moisture: number;
  /** an unregistered name — safe, reads 0. */
  unregistered: number;
  /** after `handle.set(...)` swapped the sampler live (a season changed the map). */
  afterSwap: number;
  /** after `handle.remove()` — the channel is gone, so it reads 0 again. */
  afterRemove: number;
}

export function runChannels(): ChannelResult {
  const host = headlessHost({ width: 1200, height: 800 });
  const field = createField(undefined as unknown as HTMLCanvasElement, {
    host,
    render: 'none',
    rng: seededRng(5),
  });

  // your data, sampled on the engine's own read path — a dry east, a wet west
  const summer = (x: number, _y: number): number => 1 - x / 1200;
  const winter = (x: number, _y: number): number => Math.min(1, (1 - x / 1200) + 0.4);

  const channel = field.addField('moisture', summer);

  const moisture = Number(field.sampleField('moisture', 300, 400).toFixed(3));
  const unregistered = field.sampleField('not-registered', 300, 400);

  channel.set(winter); // swap the sampler live — nothing to invalidate
  const afterSwap = Number(field.sampleField('moisture', 300, 400).toFixed(3));

  channel.remove();
  const afterRemove = field.sampleField('moisture', 300, 400);

  field.destroy();
  return { moisture, unregistered, afterSwap, afterRemove };
}
What it returned when this page was built
moisture
0.75
unregistered
0
afterSwap
1
afterRemove
0

An unregistered name reads 0 rather than throwing, so a sampleField call is always safe — including after remove().

The win is the single path: force, density and now your terrain all answer to sample* calls on one field, so a consumer never threads a second structure through every function that already has the field in hand. The field-channels page tells the longer story, including the Habitat integration that motivated it.

Seeding — giving pooled matter identity

Particles are a pool; ordinarily they mean nothing individually. seed(atoms) attaches your records to them, readParticleIds reads their stable ids back into a buffer you own (same pool order as readParticles, so index i lines up), and atomAt(x, y) picks up the record on the nearest seeded particle within about 24px — the hover-to-inspect affordance.

JS ✓ Swift ✓ Kotlin ✓ JS ✓ Swift ✓ Kotlin ✓

Records into the matter, and back out again apps/site/src/lib/cookbook/seeding.ts
Runs at build time
// SEEDING + IDENTITY — putting your records into the matter, and picking them back out.
//
// Pooled particles normally have no meaning. `seed(atoms)` attaches your records to particles;
// `readParticleIds` reads their STABLE IDS back each frame (parallel to `readParticles`, same pool
// order), and `atomAt(x, y)` picks up the record on the nearest seeded particle within ~24px —
// the hover-to-inspect affordance.
//
// The engine carries the identity; you keep the payload.
import { createField, headlessHost, seededRng } from '@fundamental-engine/core';

export interface SeedingResult {
  /** live particles in the pool. */
  particles: number;
  /** stable ids read back into a caller-owned buffer — one per live particle. */
  idsRead: number;
  /** ids are unique (identity, not position). */
  idsUnique: boolean;
  /** the label of the record found at the first seeded particle's position. */
  foundLabel: string | null;
  /** a point far from any seeded particle returns null rather than a nearest-anything guess. */
  emptySpaceIsNull: boolean;
}

export function runSeeding(): SeedingResult {
  const host = headlessHost({ width: 1000, height: 700 });
  const field = createField(undefined as unknown as HTMLCanvasElement, {
    host,
    render: 'none',
    density: 0.3,
    rng: seededRng(13),
  });

  // your records become the matter — `weight` drives the particle's mass/size
  field.seed([
    { weight: 0.9, label: 'alpha' },
    { weight: 0.5, label: 'beta' },
    { weight: 0.2, label: 'gamma' },
  ]);
  host.tick();

  const particles = field.particleCount();

  // zero-allocation read-back into buffers you own
  const ids = new Uint32Array(particles);
  const idsRead = field.readParticleIds(ids);
  const idsUnique = new Set(ids.slice(0, idsRead)).size === idsRead;

  // find where a seeded particle actually is, then pick its record up from there
  const xs = new Float32Array(particles);
  const ys = new Float32Array(particles);
  field.readParticleChannels(['x', 'y'], [xs, ys]);

  let foundLabel: string | null = null;
  for (let i = 0; i < particles && foundLabel === null; i++) {
    const atom = field.atomAt(xs[i]!, ys[i]!);
    if (atom && typeof atom.label === 'string') foundLabel = atom.label;
  }

  // far outside the volume there is nothing to find
  const emptySpaceIsNull = field.atomAt(-5000, -5000) === null;

  field.destroy();
  return { particles, idsRead, idsUnique, foundLabel, emptySpaceIsNull };
}
What it returned when this page was built
particles
39
idsRead
39
idsUnique
true
foundLabel
"alpha"
emptySpaceIsNull
true

The ids are unique — identity, not position. Empty space returns null rather than a nearest-anything guess, which is what makes atomAt safe to call on every pointer move.

The division of labour: the engine carries the identity, you keep the payload. Key your own record off the id rather than asking the engine to store your object.

Agents without Three.js

Most people meet addAgent through @fundamental-engine/three, where it binds a mesh — which leaves the impression that agents are a 3D feature. They are not: the agent lane is core. Give it a position and a report callback and the engine integrates it under the net field every frame, handing you its live particle. Drive a mesh, an SVG node, a native view — or nothing, and just read where the field takes it.

JS ✓ Swift ✓ Kotlin ✓

An engine-stepped agent, no renderer involved apps/site/src/lib/cookbook/agents.ts
Runs at build time
// AGENTS WITHOUT THREE.JS — an engine-stepped participant you drive anything with.
//
// `addAgent` is usually met through `@fundamental-engine/three`, where it binds a mesh. But the
// agent lane is core, not Three: give it a position and a `report` callback and the engine
// integrates it under the net field every frame, handing you its live particle. Drive a mesh, an
// SVG node, a native view, a robot — or nothing at all, and just read where the field takes it.
import { createField, headlessHost, seededRng } from '@fundamental-engine/core';

export interface AgentResult {
  /** how many times `report` fired — once per frame. */
  reports: number;
  /** the agent's straight-line distance from the attractor at the start. */
  startDistance: number;
  /** …and at the end: the field pulled it in. */
  endDistance: number;
  /** it genuinely moved under the force, not by assignment. */
  movedTowardAttractor: boolean;
}

const distance = (ax: number, ay: number, bx: number, by: number): number =>
  Math.round(Math.hypot(ax - bx, ay - by));

export function runAgent(): AgentResult {
  const host = headlessHost({ width: 1000, height: 700 });
  const field = createField(undefined as unknown as HTMLCanvasElement, {
    host,
    render: 'none',
    density: 0.2,
    rng: seededRng(17),
  });

  // something for the agent to fall toward
  const ATTRACT_X = 500;
  const ATTRACT_Y = 350;
  field.addBody({
    tokens: ['attract'],
    strength: 6,
    range: 900,
    rect: () => ({ left: ATTRACT_X - 40, top: ATTRACT_Y - 40, width: 80, height: 80 }),
  });

  let reports = 0;
  let lastX = 120;
  let lastY = 120;
  const agent = field.addAgent({
    x: 120,
    y: 120,
    mass: 1,
    maxSpeed: 6,
    report: (p) => {
      reports++;
      lastX = p.x;
      lastY = p.y;
    },
  });

  const startDistance = distance(120, 120, ATTRACT_X, ATTRACT_Y);
  for (let i = 0; i < 120; i++) host.tick();
  const endDistance = distance(lastX, lastY, ATTRACT_X, ATTRACT_Y);

  agent.remove();
  field.destroy();
  return {
    reports,
    startDistance,
    endDistance,
    movedTowardAttractor: endDistance < startDistance,
  };
}
What it returned when this page was built
reports
120
startDistance
444
endDistance
8
movedTowardAttractor
true

The distance to the attractor fell over 120 frames: the position was integrated by the field, not assigned by the example.

An agent differs from a body: a body emits force, an agent is moved by it. Reach for addBody with authority: 'dynamic' when the thing should do both.