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 timeThe 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.
apps/site/src/lib/cookbook/reading.ts // 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,
};
} - 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 ✓
apps/site/src/lib/cookbook/scalar-grid.ts // 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 };
} - 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 ✓
apps/site/src/lib/cookbook/channels.ts // 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 };
} - 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 ✓
apps/site/src/lib/cookbook/seeding.ts // 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 };
} - 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 ✓
apps/site/src/lib/cookbook/agents.ts // 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,
};
} - 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.