pcg-ts · user manual · v0.15.0

The pcg-ts manual

How to build worlds with a deterministic node-graph PCG library — by hand, and by machine.

npm install pcg-ts MIT · ESM · TypeScript strict zero required dependencies source on GitHub

Orientation

What this manual is

pcg-ts turns seeded node graphs into content: scattered points, filtered sets, instanced geometry, streamed worlds. It runs in the browser and in Node, has no required dependencies, and is deterministic by construction — the same graph and the same seed produce byte-identical output across runs, platforms, cook orders, and streaming paths.

The manual has two genuine halves, and they are meant to be read separately. Part I is for a person building a world: install, the mental model, the renderer adapter, a tour of the nine live demos, the GPU path, and how to debug a graph that is not doing what you meant. Part II is for an LLM or agent driving the library programmatically: discovering what exists at runtime, emitting graphs as JSON, treating error strings as an API, and closing the loop with introspection and mutation.

Every API name, signature, parameter and code sample below was executed against the published build before it was written down. Numbers quoted from a run (survivor counts, cook stats, hashes, error strings) are real output, not illustrations — with the exception of wall-clock timings, which vary by machine.

Companion documents

docs/nodes.md is the generated node reference and docs/nodes.json the same metadata machine-readable; docs/authoring.md is the format spec with recipes; llms.txt is the compact agent capability map. All four are generated from or checked against the registry. This manual explains why and when; those explain exactly what.

Part I

Building a world by hand

For the person holding the mouse: from an empty file to a streaming world on screen.

Chapter 1

Install and the first cook

The core package has zero dependencies. The renderer adapter needs three.js as an optional peer dependency; the WebGPU path needs nothing extra in a browser, and Dawn bindings in Node.

# the library
npm install pcg-ts

# optional: only if you use the pcg-ts/three adapter
npm install three

# optional: only for WebGPU cooking under Node (browsers need nothing)
npm install -D webgpu

Node 18 or newer, or any modern browser bundler. ESM only — there is no CommonJS build.

A graph that cooks

A graph is built in code by adding node instances, connecting typed pins, declaring which pins are terminal outputs, and then cooking. Cooking is pull-based: it walks back from the declared outputs and evaluates only what they need.

import {
  Graph, cook, firstGeometry,
  pointScatterInBounds, jitterPoints,
  fbm, perlinNoise, remap,
} from "pcg-ts";

const graph = new Graph(42);          // graph seed; every node seed derives from it

const scatter = graph.add(pointScatterInBounds, {
  count: 500,
  boundsMin: [0, 0, 0],
  boundsMax: [50, 0, 50],
});

// `amount` is field-capable: this scalar noise is evaluated per point
// and broadcasts across all three axes.
const jitter = graph.add(jitterPoints, {
  amount: remap(fbm(perlinNoise, { seed: 7, frequency: 0.05 }), -1, 1, 0, 1),
});

graph.connect(scatter, "out", jitter, "in");
graph.output(jitter, "out", "points");

const result = await cook(graph);
const geo = firstGeometry(result.outputs.points);
if (!geo) throw new Error("no geometry");

const P = geo.attrs.point.require("P");   // f32 column, 3 components per point
console.log(geo.pointCount, [...P.data.subarray(0, 3)]);
console.log(result.stats);

Actual output

500 [ 33.082, -0.3688, 6.8829 ]
{ cooked: 2, cached: 0, elapsedMs: 6.69 }

Cook the same graph again and both nodes are served from the memo cache — { cooked: 0, cached: 2 }. Change one parameter with graph.setParam(scatter, "count", 1000) and only that node and everything downstream of it recooks. Nothing else moves; nothing is deep-hashed.

Two details worth internalising immediately. First, the second argument to graph.add is a partial parameter object — anything you omit takes its schema default, and the defaults are published in the registry, not hidden in the source. Second, result.outputs.points is a collection of data items, not a single geometry: firstGeometry is the convenience accessor for the common case where you know there is exactly one. That "exactly one" is load-bearing inside the graph too — a node that processes a single geometry errors when a pin hands it several (as partitionByAttribute and a multi-item subgraph both can), rather than quietly taking the first and discarding the rest. The error names the three ways out; docs/authoring.md ("One pin, many geometries") works through them.

Chapter 2

The mental model

Five ideas carry the whole library. If you hold these, the rest of the API reads as consequences rather than vocabulary.

2.1 · Domains and attributes

Content lives in a Geometry, and a geometry is four domains of named typed-array columns: point, vertex, primitive, and detail (always exactly one element — the place for per-geometry facts). Columns are struct-of-arrays: one Float32Array per attribute, not one object per point. That is not an optimisation detail you can ignore, it is the shape of the data you will read.

An attribute has a name, an element type (f32, i32, u32, bool, string) and a tuple size (1 = scalar, 3 = vector, 4 = colour or quaternion). A freshly scattered point cloud carries the standard set:

AttributeTypeTupleMeaning
Pf323Position in world space.
rotf324Orientation quaternion, xyzw order.
scalef323Per-axis scale.
densityf321The channel the density filters read.
boundsMin / boundsMaxf323Per-point extents, for bounds filters.
colorf324RGBA.
seedu321The point's own seed — its slice of the hash chain.

Attributes move between domains rather than being recomputed. promoteAttribute walks the topology (first / average / sum / min / max) to lift or lower a value; transferAttribute copies from a second geometry by nearest source point, by barycentric lookup in the source triangulation's UV space, or by raycast against the source mesh — the two mesh mappings reading the source's point, vertex or primitive domain, where a per-face value is taken whole rather than blended, since interpolating a constant does not return it in floating point. Everything else — filters, spawners, the renderer adapter — reads these same columns, which is why "write an attribute, then act on it" is the dominant idiom in every graph you will build.

Four domains is the whole list, and it is worth knowing why there is no fifth one for edges. A path or a road network is polyline topology laid over points that already exist, and a two-vertex polyline over shared points already is an edge — so the primitive domain is the edge domain. A per-edge value is an ordinary primitive attribute: promoteAttribute point→primitive to carry a value onto the edges (min for a road only as wide as its weaker end, first for a categorical), setAttribute with domain: "primitive" to compute on them, and promoteAttribute primitive→point with max for the return trip that sizes each junction by the widest road reaching it. connectPoints builds such a network over the same points it was handed, so a crossroads is genuinely one point of degree 3 and everything that point carried is still on it.

And the value does not stop at the edge. Every node that samples a primitive down onto points — splineSample, pathResample, place/along-curve, and surfaceSample for the triangle case — carries the source primitive's attributes onto each point it emits, so a lamp placed along a road arrives already holding that road's roadWidth. A sample inherits the primitive it was taken from. There is no param to enable this and none to turn it off, which is the design decision rather than an omission: an author who wrote the value should get it without knowing a knob exists. Two edges of the contract are worth stating with it. primtype is never carried, being a type tag rather than a value, and no sourcePrimitive index column rides along either — primitive numbering is per-partition, so shipping one would make a single cell's output disagree with the whole region's and break the determinism invariant outright. Values carry; identity does not. The cost, recorded so nobody reports it as a bug: every upstream primitive attribute is now part of a sampler's output contract, so an unrelated per-edge length widens the samples too. It is bounded — only connectPoints and promoteAttribute make primitive columns — and losing a value the author asked for, in silence, is the worse failure.

2.2 · Fields: values that resolve late

A Field<T> is a deferred computation. It is not a number; it is a description of how to produce one column of numbers once it knows a geometry, a domain and a seed. Field-capable node parameters accept T | Field<T>, so the same slot takes 0.5 or a four-octave noise expression, and the node does not care which.

Fields compose before any geometry exists. Inputs (position(), attribute(name), index(), fraction(), randomField(key)), arithmetic and comparison combinators, the full trig set, clamp/lerp/remap/select/ramp, vector operations, and five noise families all return fields.

import { createPointCloud, capture, attribute, clamp, add, mul, worleyNoise } from "pcg-ts";

// density = clamp(0.2 + worley * 0.9, 0, 1) — still just a description.
const density = clamp(add(0.2, mul(worleyNoise({ frequency: 0.15 }), 0.9)), 0, 1);

const geo = createPointCloud(1000);
const name = capture(geo, "point", density);   // evaluates, stores as "__anon_0"
const col = attribute(name).evaluate({ geo, domain: "point", seed: 0 });
// col.data.length === 1000, col.tupleSize === 1

capture is how an intermediate result becomes an anonymous attribute you can read back later — useful when a value is expensive and consumed twice. Raw numbers and tuples coerce to constants wherever a field is accepted, and scalars broadcast against tuples, so you never write constant(0.5) by hand.

Two lifetimes to respect

An evaluated Column may alias live attribute storage. Treat it as read-only, and re-evaluate with a fresh EvalContext after mutating or resizing the geometry. Likewise, a CookResult aliases live cache internals — call cloneGeometry before you change anything in it, or you will corrupt the cache silently.

Noise, and knowing its range

Every noise field takes normalized: true, which affinely remaps its documented raw range to exactly [0, 1] — the cleanest way to feed a density channel. The raw ranges are machine-readable rather than folklore: NOISE_RAW_RANGES per noise type, and noiseOutputRange(field) per field instance, which is fBm-aware. A four-octave Perlin fBm reports [-1.875, 1.875], because the octave sum is not renormalised. Worley additionally takes exact: true to widen its cell search until provably correct, for when the fast approximation's rare artifacts matter.

2.3 · Graphs, cooking, and the cache

Validation is eager: bad pin names, kind mismatches and cycles throw at connect time, naming the node and pin. Pin kinds are geometry, value, instances and any; an input marked multi concatenates everything connected to it, in connection order.

Each node memoizes on exactly four things: node type, a structural hash of its parameters, its derived seed, and the revision ids of its input items. Data items carry monotonically assigned rev numbers, so geometry is never deep-hashed — an unchanged upstream output keeps its rev, and cleanliness propagates downstream for free.

cook(graph)
Cooks every declared output. Returns { outputs, stats } where stats is { cooked, cached, elapsedMs }.
cook(graph, { budgetMs })
Yields to the event loop between nodes once the budget elapses. Cooking always completes; it just shares the thread instead of blocking a frame.
cook(graph, { signal })
Rejects with CookCancelledError. Nodes that finished keep their caches, so the next cook resumes where the cancelled one stopped.
cook(graph, { outputs: ["a"] })
Cooks only the named outputs' upstream subgraph. Everything else is untouched and keeps its caches — this is what makes staged pipelines fit in one graph: cook an early output, bind data derived from it, cook the rest.
subgraphNode(inner, inputs, outputs)
Wraps an entire graph as one node with its own persistent inner caches. The inner seed derives from the outer node's seed, so two instances of the same subgraph produce different but reproducible content.

Per-output cooking is worth a concrete trace. Given scatter → jitter → spawn with both points (on jitter) and instances (on spawn) declared:

await cook(graph, { outputs: ["points"] });  // { cooked: 2, cached: 0 }
await cook(graph);                          // { cooked: 1, cached: 2 }

The spawner is the only thing left to do. The partial cook also returns a partial outputs object — its keys are exactly the outputs you asked for.

2.4 · The streaming World

A World streams grid cells around a moving viewpoint. Levels are ordered coarse to fine, one graph per level, and each cell is cooked independently with a seed hashed from the world seed, the level index and every cell coordinate.

import { Graph, World, pointScatterInBounds, spawnInstances } from "pcg-ts";

const level = new Graph();
const scatter = level.add(pointScatterInBounds, { count: 200 });
const spawn = level.add(spawnInstances, { assetId: "tree" });
level.connect(scatter, "out", spawn, "in");
level.output(spawn, "instances", "instances");

const world = new World({
  seed: 1234,
  levels: [{
    name: "props",
    cellSize: 32,             // world units per cell, XZ plane
    generationRadius: 96,    // a cell cooks when its centre enters this radius
    graph: level,
    bind(graph, ctx) {
      // The ONLY channel through which cell data enters the graph.
      // Contract: derive params only from ctx, and wire ctx.seed into
      // every stochastic node.
      graph.setParam(scatter, "boundsMin", [ctx.min[0], 0, ctx.min[1]]);
      graph.setParam(scatter, "boundsMax", [ctx.max[0], 0, ctx.max[1]]);
      graph.setParam(scatter, "seed", ctx.seed);
    },
  }],
  onCellReady: (level, coord, outputs) => {/* hand outputs to the renderer */},
  onCellEvicted: (level, coord) => {/* tear down renderer state */},
});

// Per frame, or on movement. Overlapping calls are serialized.
await world.update([camera.x, camera.y, camera.z], { budgetMs: 8 });

update resolves to { cooked, evicted, pending, elapsedMs }, where cooked and evicted are arrays of { level, coord } — enough to drive a renderer without a separate bookkeeping layer. Cells cook nearest-first; eviction uses a retainRadius (default 1.25× the generation radius) plus LRU, so a viewpoint hovering on a boundary does not thrash.

The determinism claim here is stronger than "the same seed gives the same world". Cell content is a pure function of (world seed, level, coordinate, graph, parent cell content) — never of cook order, viewpoint path, or eviction history. Fly away and back and the identical cells return, because they were never a function of how you got there. The contract you owe in exchange is narrow: derive parameters only from ctx, and route ctx.seed into anything stochastic. Setting per-node seed parameters and calling graph.setSeed(...) inside bind are both sanctioned; bind-time writes are not counted as user edits, so neither causes phantom recooks.

Levels use square XZ cells by default. cellMode: "xyz" switches a level to cube cells addressed [cx, cy, cz], with radii measured in full XYZ distance. A leading cellSize: "unbounded" level covers the world with a single global cell and needs no radius — the natural home for landmarks and anything global. Lower levels see their parent cell's outputs through ctx.parent, typically injected via a dataInput node, and a parent recook marks its children stale automatically.

2.5 · Anchoring: content that crosses a cell boundary

The guarantee above is per cell, and a world is not made of independent cells. The moment a rock's size depends on how crowded its surroundings are, a cell has to see slightly outside itself — and the naive fix does not work. pointScatterInBounds computes its positions as a function of boundsMin and boundsMax, so widening the box to form a halo moves every point in it. The halo reproduces nothing the neighbour will see. Every neighbourhood-style operation inherits that, not only the ones near an edge.

pointScatterInWorld is the source built for this. It scatters over an infinite lattice anchored to world coordinates: a point's position and its per-point seed come from its own lattice cell and index, and the query box only decides which lattice cells to visit and clips the result. The same world position always yields the same point under any query. Three things follow that no other source can promise — a halo is just a wider query, a region cooked whole is byte-identical to the same region cooked in pieces, and two cells on a boundary agree about what is there. It is the discipline noise has always followed, applied to a source: vary it by moving through it, not by reseeding it.

The other half is which seed you hand it. ctx.seed is per-cell by construction, so it is exactly wrong here — it would make every cell an unrelated world and the guarantee empty. ctx carries two cell-invariant seeds beside it: ctx.worldSeed (the World's own seed, identical in every cell of every level) and ctx.levelSeed (hashCombine(worldSeed, levelIndex), identical within a level, so two levels running the same graph get unrelated worlds rather than the same one).

A haloed cell: query wide, measure wide, own narrow
bind(graph, ctx) {
  const halo = 6;  // >= the radius of the widest measurement below

  // 1. Query the cell GROWN by the halo. Anchored, so the extra ring is
  //    byte-identical to what the neighbour owns — whether or not that
  //    neighbour has ever cooked. Nothing is fetched from a sibling.
  graph.setParam(rocks, "boundsMin", [ctx.min[0] - halo, 0, ctx.min[1] - halo]);
  graph.setParam(rocks, "boundsMax", [ctx.max[0] + halo, 0, ctx.max[1] + halo]);
  graph.setParam(rocks, "seed", hashCombine(ctx.worldSeed, 1));  // NOT ctx.seed

  // 2. pointNeighborhood runs over the WIDE cloud, so a rock near the
  //    edge is measured against neighbours it will not own. Exact when
  //    halo >= its radius.

  // 3. Only now, throw the halo away. Half-open bounds are the OWNERSHIP
  //    rule: two abutting cells claim a shared-face point exactly once.
  graph.setParam(clip, "boundsMin", [ctx.min[0], -Infinity, ctx.min[1]]);
  graph.setParam(clip, "boundsMax", [ctx.max[0], Infinity, ctx.max[1]]);
}

Step 3 is filterByBounds at its default boundary: "halfOpen"min <= p < max on every axis, the same rule a cell rectangle and the scatter's own window follow. That default is what turns a selection into an ownership rule: the alternative, "inclusive", has both neighbours emit a point sitting on their shared face, which is harmless in a one-off selection and wrong in a partitioned cook, where a doubled point is invisible until two cells disagree. mode: "outside" is the exact complement under either rule, so the two modes always partition the input.

Everything between steps 1 and 3 stays anchored because per-point randomness keys on each point's identity — its stored position bits together with its seed attribute — rather than on its array index. filterByDensity, jitterPoints, randomField on the point domain and the tiebreaks in selfPrune and pointNeighborhood all work this way, so a survivor decides identically however many candidates were filtered out upstream and in whatever order they arrived. An index could not do that: a filter one node earlier renumbers everything behind it.

Anchoring is a property of a chain, not of one node

Identity keying makes those nodes indifferent to the window, never to their own seed — filterByDensity and jitterPoints still fold in the node seed. Wire a per-cell ctx.seed into one of them and the chain de-anchors one node after the source, deterministically and silently. Seed them from ctx.worldSeed / ctx.levelSeed or leave them at their defaults, and do not call graph.setSeed in a level carrying anchored content: it reaches every node at once. The per-node table is in docs/authoring.md, "Content that must NOT vary per cell".

One consequence is worth stating before it surprises you. pointScatterInWorld is the single node in the library that ignores the graph seed: its lattice is a function of its own seed param alone. That is deliberate — a graph.setSeed inside bind, a CLI --seed override, or renaming the node would otherwise de-anchor a world silently, and the exception makes that failure impossible rather than merely documented. The price is the node-id decorrelation every other node gets for free: two of these with identical params scatter identical points, exactly as two perlinNoise fields with one spec are one field. Give each layer its own value — hashCombine(ctx.worldSeed, 1) for trees, 2 for rocks.

Finally, know the restriction this buys, because it is stricter than "keep it local". A halo is exact only where the operation's reach is bounded, and the sharp way to ask is how many hops of dependency it takes before an answer is settled. Zero hops is the easiest case and the cheapest halo: connectPoints decides whether two points are an edge from two stored positions and no third point, so a cell holding every point within radius of its own rectangle is exact at a halo of exactly radius. One hop reads neighbours' stored values but never their answers — pointNeighborhood within its radius, selfPrune's local-maximum rule — and is exact at the stated width. Unbounded is a chained decision, which is not bounded even though every step of it is local. A greedy minimum-distance prune is the case to watch: this point survives because that neighbour did not, which happened because its neighbour did, and no halo width covers the chain. A minimum spanning tree is the same failure a level up — an edge belongs to the tree only if no lighter path connects its ends — which is why connectPoints ships a local lune test that contains an MST rather than an MST mode. selfPrune carries a mode for exactly this reason, and docs/nodes.md states which rule is halo-exact and what width it needs. A global rank, a total over the population, or a shortest path across the world has no reach bound at all and cannot be made cell-safe by any finite halo. Anything that fits a parameter to the data present in this cook is in that category — attributeRemap mode "fit", attributeReduce, an aggregate promoteAttribute, the fraction and index fields — because under a World "all the points" means all the points here. Compute such a quantity once on the coarse or unbounded level and push it down through ctx.parent.outputs. The performance-and-budgets skill catalogues the cases.

2.6 · Spawners: the render-agnostic terminal

The core never imports a renderer. The boundary is spawnInstances, a terminal node that converts points into instance batches: an asset id, a count, a flat Float32Array of 4×4 column-major transforms composing T(P) · R(rot) · S(scale), and — only if you ask for it — a second flat array of per-instance RGB. A thousand points become one batch of sixteen thousand floats, and it is up to the adapter what to do with them.

// From a real cook of the 1038-point cloud in chapter 9:
batch.assetId          // "rock"
batch.count            // 1038
batch.transforms       // Float32Array(16608)
batch.transforms.subarray(0, 16)
// [ 1, 0, 0, 0,  0, 1, 0, 0,  0, 0, 1, 0,  39.718, 0, 85.543, 1 ]
batch.colors           // undefined — no colorAttr was named

Multi-asset spawning stays declarative rather than becoming a loop in your application. Write a per-point string attribute with setAttribute (type: "string", a values list, and a field-capable numeric selector that indexes into it) and name that attribute in the spawner's assetAttr. The output then splits into one batch per asset id, in first-occurrence order. The selector is total: it floors, clamps into range, and maps NaN to zero, so weighting by repetition (["pine", "pine", "birch", "bush"] is 50% pine) works and no single point can throw.

Splitting into more asset ids is not the only way instances can differ, though for a long time it was the only one that survived the boundary. Point colorAttr at an f32 point attribute with tupleSize 3 or more and components 0, 1 and 2 ride along as each instance's RGB — reaching InstancedMesh.instanceColor on the CPU path and an instance-colour storage buffer on the WebGPU one — so age, health, season or a hue drift vary within one asset. Alpha is dropped: both adapters take RGB, and the standard color attribute is f32×4, so its fourth component has nowhere to go. Any colour-shaped attribute qualifies — color itself, or a tint you wrote.

Opt-in

Nothing is picked up automatically, and the asymmetry with primitive attributes — which are carried automatically onto samples — is the point. A primitive attribute exists only because an author made one, so its presence is the intent. color is minted at [1, 1, 1, 1] on every point cloud here, so its presence says nothing. The cost of enabling it anyway is not the wasted floats: setting instanceColor flips three's program variant (instanceColor !== null forces the vColor varying and a shader recompile) for zero pixels changed. Nor does anything scan the column to auto-enable when it is not all white — that is O(n) every cook and makes the renderer's shader variant depend on the data. So the accepted cost, stated rather than left to be discovered: write a colour upstream, never name it in colorAttr, and you get silence. Naming a missing attribute, or one that is not f32 with tupleSize ≥ 3, is an error — it lists the point attributes that would fit and two ways out.

One cook may spawn at most 1 048 576 instances, one per input point — 64 MiB of transforms — checked before anything is allocated, so a density typo comes back as a diagnostic naming the count and the fix rather than an allocation failure. The ceiling is per cook, never per world. A limit on instances alive would depend on which cells happened to be resident, so the same world would fail or not depending on the order it streamed in — exactly the order-dependence chapter 13's determinism contract forbids. A streamed World may hold many times the budget across its live cells, and that is correct.

Chapter 3

three.js interop

pcg-ts/three is the only module that imports three.js, which is an optional peer dependency — a guard test enforces that the core never reaches for it. The adapter is small on purpose: four functions and one binding class, covering the two directions data actually flows.

ExportDirectionWhat it does
fromBufferGeometrythree → pcgA BufferGeometry becomes a sampleable Geometry with real triangle topology.
fromCurvethree → pcgA Curve<Vector3> becomes a polyline for splineSample.
toInstancedMeshespcg → threeInstance batches become InstancedMesh objects, one per asset id.
toPointsObjectpcg → threeA point cloud becomes a Points object — debug rendering.
WorldThreeBindingbothManages one scene-graph group per live World cell.
import { BoxGeometry, MeshStandardMaterial, SphereGeometry } from "three";
import { fromBufferGeometry, toInstancedMeshes } from "pcg-ts/three";
import { Graph, cook, dataInput, makeGeometryItem, surfaceSample, spawnInstances } from "pcg-ts";

// three -> pcg: a triangle mesh becomes sampleable geometry.
const surface = fromBufferGeometry(new BoxGeometry(10, 1, 10));

const graph = new Graph(7);
const input = graph.add(dataInput, { items: [makeGeometryItem(surface)] });
const sample = graph.add(surfaceSample, { count: 2000 });
const spawn = graph.add(spawnInstances, { assetId: "rock" });
graph.connect(input, "out", sample, "in");
graph.connect(sample, "out", spawn, "in");
graph.output(spawn, "instances", "instances");

// pcg -> three: instance batches become InstancedMesh objects.
const { outputs } = await cook(graph);
for (const item of outputs.instances) {
  if (item.kind !== "instances") continue;
  const meshes = toInstancedMeshes(item.batches, {
    rock: { geometry: new SphereGeometry(0.1), material: new MeshStandardMaterial() },
  });
  // meshes[i] is a THREE.InstancedMesh — add it to your scene.
}

dataInput is the sanctioned bridge for anything produced outside the graph: it emits exactly the items in its items parameter, unchanged. Bind a fresh array to change what it emits — arrays bound into parameters are captured by reference, and mutating one in place changes stored cell outputs with no staleness signal.

For a streaming world, WorldThreeBinding removes the bookkeeping entirely: hand its cellReady and cellEvicted methods to the World's onCellReady / onCellEvicted callbacks and it maintains one group per live cell, adding and disposing meshes as cells come and go.

Chapter 4

The examples tour

Nine demos ship in examples/, each a real cook of a real graph rather than a recording. Every screenshot below was captured from the running demo. Run them all locally with npm run examples, or click through to the hosted build.

01 · Scatter basic — the minimal cook loop

The scatter-basic demo: a control panel on the left shows seed 1, a density frequency of 0.080 and a debug-points toggle above live stats reading 1267 of 2600 instances, a 10.6 ms cook, 4 nodes cooked and 0 cached, with the density field's JSON spec — a remap wrapping a four-octave Perlin fBm at frequency 0.08 — printed below. The 3D viewport shows a dark ground plane scattered with over a thousand pale blue instanced boxes, visibly clumped and thinned by the noise field.
01 · scatter basic — 1267 of 2600 candidates survived the probabilistic density filter, and the exact JSON that produced the density field is printed in the panel.

The smallest complete pipeline: scatter uniformly in a box, write a density attribute from an fBm field built with fieldFromJson, drop points probabilistically against that density, spawn the survivors. It is the demo to read first because nothing is hidden — the field spec on screen is the literal parameter the graph cooks with, and re-entering the same seed reproduces the same scatter exactly.

02 · Forest — sampling a surface, filtering by what you measured

The forest demo on the device-resident path. The panel offers a device-resident / CPU readback toggle and reports 2 live batches split 4,271 pine and 1,641 bush, 5,912 instances drawn, 2 buffers holding 369.5 KiB of matrices on device, the same 369.5 KiB of matrix uploads avoided, 0 B uploaded, 2 resident runs of 4 fused members, 8 device dispatches, no GPU fallbacks, and a 380.1 ms cook over 9 nodes, with the status line reading device-resident, no matrix readback. Below them a collapsed section is titled why the chain still breaks in three places. The viewport shows a wide dark-brown heightfield terrain seen from above and to the side, with bright green conifers of varying size covering the lower slopes and thinning out entirely on the steep faces and the high ridge.
02 · forest — 5912 trees placed from 9000 candidates: 4271 pine and 1641 bush, split by a per-point string attribute rather than by two graphs. Since v0.8 both species are composed on the GPU, one matrix buffer each, and drawn without a readback.

An fBm heightfield becomes a three.js BufferGeometry, crosses into the library through fromBufferGeometry, and is area-weighted sampled by surfaceSample — which emits a normal attribute as it goes. Height and slope are then written as attributes and used by filterByAttribute to keep trees off cliffs and above the treeline. The visible result is the point of the design: the filters read measurements the sampler already produced, so "no trees on steep ground" is a comparison, not a special case.

Species selection is where multi-asset spawning earns its keep. One string setAttribute picks pine or bush per point from a numeric selector, and spawnInstances splits the output into two batches by that attribute — one graph, one cook, two InstancedMeshes.

A word on "GPU device"

This manual talks about a GPU device — a GPUDevice, WebGPU's own term — and not a device in the everyday sense of a phone or a laptop. requestAdapter() hands you a GPUAdapter, the physical GPU and its driver; requestDevice() then hands you a GPUDevice, a logical connection to it with its own queue, limits and resource ownership. Once that is on the table the shorthands are readable: device-side or device-resident means the bytes live in GPU memory reachable through that handle, host means the CPU and JS side, and a readback is copying GPU memory back to the host.

The precision earns its keep because a GPUBuffer belongs to the device that created it. Two different GPUDevice objects on the same physical GPU still cannot share a buffer — which is why the requirement in chapter 6 is one shared GPUDevice behind both the field evaluator and the renderer, rather than the weaker "both use the GPU". The latter is true of two devices, and would still fail.

Since v0.8 that split also happens without a CPU round trip. Flip the toggle to device-resident and each species gets its own matrix buffer, composed in a WGSL kernel and drawn straight from the GPU — matrices uploaded sits at 0 B. Flip back to CPU readback and the same bytes are composed in JS and uploaded instead. The batch counts do not move between the two: 4,271 / 1,641 either way, because the device path plans its grouping with the very same function the CPU spawner calls.

Since v0.9 this demo also opts into acceptDerivedSpecs, and the effect is visible in the readouts: it used to report 1 resident run of 1 member with three derived-spec fallbacks, and now reports 2 runs of 4 members with no field falling back at all. Its fields are written with the combinator API rather than as JSON, and until v0.9 that alone kept them off the device.

The fourth member is there because of where a node is wired, which is worth more than it sounds. Nothing about scale depends on filtering, but written the obvious way — stamp the size of the trees that survived — it sat between a filter and the string setAttribute, fusable with nothing on either side. A chain of one that is not a terminal forms no run, so it resolved per node with its own readback. Moving it ahead of the filters makes it member 3 of run 1, for the price of stamping ~9000 scales instead of ~5900. Note which way the numbers move: device dispatches goes up by one, because its apply kernel is now a step in the run, while a whole device-to-host round trip disappears. Dispatches are not the cost that matters.

Three chain breaks still survive, and none is about specs — which is why the panel is titled why the chain still breaks in three places. The two filterByAttribute nodes change the point count, and a run's members must share one element count, so they can never be members whatever their fields look like. setAttribute("species") writes a string column, which no resident node can produce. Making filters resident was surveyed and declined: it cannot coexist with the zero-readback spawner path in one run, and the saving it promised here was the one the reorder above already took. See chapter 6.

03 · Spline fence — orientation from a tangent

The spline-fence demo: the panel reports 57 posts, a fence length of 90.6 units and a 2.8 ms cook, at seed 1 and a post spacing of 1.6 units. The viewport shows a closed, irregular loop of wooden fence posts joined by two horizontal rails, standing on a faint dark blue grid plane. Every post's flat face is turned to follow the curve.
03 · spline fence — 57 posts along 90.6 units of curve, each rotated so its local axis follows the tangent the sampler emitted.

A Catmull-Rom curve enters through fromCurve; splineSample walks it by arc length at a chosen spacing and emits tangent and curveU per sample. orientAlongVector then turns that tangent attribute into the standard rot quaternion, with an up hint to fix roll. The whole "posts face along the fence" behaviour is one node reading one attribute — no per-instance matrix maths in application code, and zero directions simply keep their existing rotation instead of producing NaNs. fromCurve is the three.js route in; a saved graph with no application code behind it reaches for pointsToPath instead, which lays polyline topology over points a graph already made.

04 · Infinite world — hierarchical streaming

The infinite-world demo with the camera parked at the origin: the panel has a GPU per-node / CPU toggle set to GPU per-node, and reports a rock source of pointScatterInWorld, 154 live rock cells, 155 cooked against 0 evicted, 0 pending, 11124 instances, 0 resident runs of 0 fused members, 2 device dispatches and no GPU fallbacks — with derived specs, world-anchored, halo (4 u) and cell grid all ticked, at a 20 u cell size and a 140 u generation radius. A faint blue partition grid is drawn across a dark plain scattered with thousands of small pale rocks receding to the horizon; the rocks run straight over the grid lines with no seam and no change of size, and several huge dark faceted boulders stand as landmarks near and far.
04 · infinite world — 154 live cells and 11,124 instances, nothing pending. The blue grid is the partition: rocks cross it without a seam, and their sizes agree across it because the neighbour count is measured over a haloed query.

Two levels: an unbounded level holding the landmark boulders (one global cell, no radius), and a bounded level of small rocks that streams around the camera. The counters tell the streaming story directly — cells cooked, cells evicted, and a pending queue that stays at zero because update is called with a millisecond budget every frame. The screenshot catches it at rest, with the camera parked and nothing yet outside the radius: 155 cooked against 0 evicted. Fly, and the eviction counter starts moving while the live count holds steady.

Fly out to the edge of the generation radius and back, or drag the radius slider while moving, and the same rocks return in the same places. That is the order- and path-independence guarantee being exercised rather than asserted: the cell content never depended on the route.

The rock level is built on pointScatterInWorld, and each rock is sized by how crowded its surroundings are — a measurement that does not stop at a cell border, so the level asks for its cell grown by a halo, measures over that, and clips back with filterByBounds. Tick cell grid to draw the partition itself — the blue lines in the screenshot — and two more checkboxes turn the argument of 2.5 into something you can watch fail. Untick halo and every border grows a visible band of undersized rocks: the neighbourhood ran out of world. Untick world-anchored and the source becomes pointScatterInBounds — still perfectly deterministic, no longer anchored — after which dragging the cell size slider re-rolls the whole world instead of merely re-partitioning it. Anchored, that same drag moves and resizes nothing at all, which is the strongest single demonstration in the examples that the content is not a function of the runtime's partitioning.

Since v0.9 the panel also carries a GPU per-node / CPU toggle and a derived specs checkbox, and that checkbox is the tidiest demonstration of what v0.9 changed. Every field in this example is written with the combinator API — remap(randomField("mega"), 0, 1, 5, 13) and friends — which until v0.9 could not reach the device at all. Untick the box and gpu fallbacks reads derived-spec ×5 with zero dispatches; tick it and the fallbacks vanish and the dispatches appear. The instance count does not move either way, which is the point: the flag changes where the work happens, never what comes out.

What it does not show is fusion. resident runs / fused members reads 0 / 0 on both paths, and the demo has a why nothing fuses here section saying so: filterByDensity changes the point count and can never be a run member, which leaves both graphs as singleton chains, and a chain of one is only a run when it ends in a resident terminal. Reaching the device per node and fusing into one run are different wins, and this example is honest about which one it got.

05 · Fields playground — the grammar, live

The fields-playground demo: on the left a stats panel reads 36864 elements and 49.5 ms to build and evaluate. In the centre a teal and sand coloured heightfield surface, displaced by four-octave Perlin fBm, floats against a dark background. On the right a control panel offers a noise-type dropdown set to fBm Perlin, a frequency of 0.35, 4 octaves, an output range of 0 to 1 and seed 1, above a code box showing the exact FieldSpec JSON: a remap wrapping an fbm over perlinNoise, with the fBm range -1.875 to 1.875 mapped into 0 to 1.
05 · fields playground — the panel is a spec editor: whatever JSON it prints is what fieldFromJson is being handed, evaluated over a 192×192 grid.

This demo has no node graph at all. It builds one field from JSON, evaluates it over a grid point cloud, and displaces the surface with the result — which makes it the fastest way to develop an intuition for what each noise family and each parameter actually does. Note the remap bounds in the screenshot: -1.875, 1.875 is not a magic number, it is what noiseOutputRange() reports for four-octave Perlin fBm, read from the library rather than guessed.

06 · Graph editor — the registry as a user interface

The graph-editor demo: a stats panel top-left reads 2 outputs, a 5.1 ms cook, 4 nodes cooked and 4 cached, 350 points and 350 instances, and an output hash of 26af8956. Behind it a 3D preview shows conifers scattered on a dark ground plane. The lower half is the editor: a toolbar carries the seed, layout, export and import, and repeats the same cook line; a left palette lists node types under category headings SOURCE and SAMPLER; a canvas shows four connected nodes — pointScatterInBounds, jitterPoints, orientAlongVector and spawnInstances — wired left to right, the spawn node exposing separate instances and points output pins; and the right-hand inspector has nothing selected, reading select a node to edit its params, pins connect left (in) to right (out).
06 · graph editor — the palette's category headings and every pin name on the canvas are read from the registry at runtime, and so are the parameters, type badges and prose the inspector shows once a node is selected. Nothing about the node types is hard-coded in the app.

The editor is the strongest existence proof the library has: it is built entirely on the same public API this manual documents. The palette groups by category from listNodeTypes(); the inspector renders each parameter from its schema, including whether it accepts a field; the description text under the node name is the registry's own prose. Import and export are deserializeGraph and serializeGraph verbatim.

07 · Galaxy — determinism at scale

The galaxy demo: the panel reports seed 42, a 420-unit generation radius, brightness 1.3, a fly speed of 140 units per second, a galaxy named Artor with 5 arms and radius 952, 29,372 stars across 55 star cells, 56 cooked against 0 evicted, 0 pending, a 6.4 ms last update, and a camera position of -409, 380, 426. The viewport shows tens of thousands of coloured star points spread across a black sky, densest in a glowing pale core just left of centre and thinning outward into scattered arms.
07 · galaxy — 29,372 stars live across 55 streamed cells; the last streaming update cost 6.4 ms. Seed 42 always produces this galaxy, named Artor, with five arms.

An unbounded halo and dust level above 100-unit star cells that stream around a free-flying camera. Everything is derived from the seed: the morphology (arm count, twist, bulge, palette), every star position and colour, and — one level further down — every planet of every star you click. The demo is really an argument about scope. A seed is a complete, shareable address for a universe, because nothing in it was ever a function of anything but the seed and the coordinate.

It also shows off the field grammar under load, composing atan2, ramp, length, trigonometry and simplex noise into the spiral density that decides where stars exist at all.

08 · GPU fields — one chain, cooked three ways

The gpu-fields demo after cooking all three paths cold at one million points. The right-hand panel shows results: CPU 12257.9 ms with hash 2d2c4751; GPU per-node 540.8 ms, ×22.7 versus CPU, hash 402b3a71; GPU fused 560.9 ms, ×21.9 versus CPU, hash 402b3a71 — the two device paths landing on the same hash and within twenty-one milliseconds of each other, each path cooking 6 nodes with 0 cached. Below, counters taken from the fused run read adapter nvidia blackwell, 1,000,000 points, 1 resident run over 2 fused nodes, 1 readback saved, 6 member-kernel dispatches, 0 pipelines compiled against 6 cache hits, a gpu fallback of run-partially-fused ×1, and a maximum CPU-to-GPU-fused deviation of 6.26e-7, or 6.6 range-ULP, measured over 16,384 points. The left of the screen renders the million-point cloud as a dense blue-green nebula.
08 · gpu fields — the same five-node chain over a million points: CPU 12,258 ms, GPU per-node 541 ms, GPU fused 561 ms. In this run the fused path kept part of the chain resident — resident runs / fused nodes reads 1 / 2 and gpu fallbacks reads run-partially-fused ×1 — while the members ahead of that run cooked node by node, and the two device paths returned the same hash.

Five field-driven nodes in a straight line — setAttributejitterPointstransformPointssetAttributesetAttribute — over a million points. All five are fusable kinds, so when the fused path plans a run the whole chain becomes a single device-resident run: attribute columns stay in storage buffers between member kernels and only the terminal reads back. The counters report that plan rather than assume it — resident runs / fused nodes, readbacks saved, and a dispatch count that means member kernels, not dispatchWorkgroups calls. In the capture above the whole chain did not plan, but its tail did: one member was rejected, the planner retried the suffix after it, and resident runs / fused nodes reads 1 / 2 with 1 readback saved. gpu fallbacks reads run-partially-fused ×1, which is the counter for exactly that — a partial success rather than a lost run — and the members ahead of the fused tail cooked node by node, which is why the fused path's wall time barely differs from the per-node path's and both return the same hash.

The CPU and device hashes differ, and the demo says so rather than hiding it. That much is the determinism contract working as designed — chapter 6 sets out the shape of the difference. The panel prints the measured deviation alongside the timings for the same reason: on this run 6.26e-7, or 6.6 range-ULP over the 16,384 points it compares, comfortably inside the twenty-four range-ULP chapter 6 budgets for the noise families and a shade tighter than the 8.05e-7, or 8.4 range-ULP, an earlier capture of the same measurement reported. There is no finding in that row, and there is not meant to be one. Printing it on every run is what would make a real drift visible the day it appeared.

If you run this locally

The CPU path at one million points blocks the main thread for roughly ten to twelve seconds. Drop the point count to 100k first if you want a responsive page, and read every wall time as a cold-cache cook: a node holds one memo slot, and flipping the cook path always recooks the chain by design.

09 · GPU world — matrices that never reach the CPU

The gpu-world demo running the device-resident path with the camera at rest. The left panel offers a two-way toggle, device-resident selected over CPU readback, above sliders for seed 1, speed 0 u/s, gen radius 150 u and per cell 140, with autopilot unchecked. The counters below read renderer nvidia blackwell webgpu, 120 live cells, 120 cells cooked and 0 evicted, a churn of 0.0 cooked and 0.0 evicted per second, 16,800 instances drawn, 120 buffers holding 1.0 MiB of matrices on device, the same 120 buffers and 1.0 MiB of readbacks avoided, and 0 buffers and 0 B of matrices uploaded. The status line reads device-resident, no matrix readback. The right of the screen renders a dense field of tall teal spires receding to the horizon.
09 · gpu world — a streamed world drawing 16,800 instances across 120 live cells, from 1.0 MiB of matrices that were composed on the GPU and never read back. The matrices uploaded row stays at 0 B for as long as the device path is selected.

Where 08 keeps attribute columns device-side between fused kernels, this one keeps the result device-side as well. spawnInstances acts as the terminal of a resident run: the transform, scale and rotation columns stay in storage buffers, a WGSL kernel composes them into column-major 4×4 matrices, and the batch hands back an opaque buffer handle instead of a Float32Array. A renderer sharing the same GPUDevice adopts that buffer as a storage instance attribute, so the matrices go from compute kernel to draw call without ever existing in JS.

The toggle is the point of the demo. Both paths cook the same graph with the same fields on the same device — only the ending differs. Switch to CPU readback and matrices held on device falls to zero while matrices uploaded starts climbing; switch back and the upload counter freezes where it stopped. Both meters are cumulative, so they record what each path has cost since the page loaded rather than what it is holding right now. In the screenshot the camera is parked with autopilot off, nothing has evicted yet, and the cumulative buf count still agrees with live cells at 120; start moving and the two part company, which is the honest picture of a streaming world.

That divergence is also the leak check. Retained device bytes track live cells exactly — 120 cells of 140 instances is 120 × 140 × 64 = 1.0 MiB — while the cumulative counter climbs past it and keeps going. Handles are refcounted by identity, so a parent output aliased into several child cells is freed once, by the last cell to let go of it, in whichever order they evict.

What this needs

One GPUDevice must back both the field evaluator and the renderer: a GPUBuffer belongs to the device that made it, two devices cannot share one, and a WebGL context cannot read a WebGPU buffer at all. Without that the demo says so in the status line and runs the CPU path instead of rendering something wrong. Device-side asset grouping is not implemented, so a spawn driven by assetAttr falls back to the CPU path with a named reason rather than silently producing different geometry.

Chapter 5

Using the graph editor as an authoring tool

Demo 06 is not only a demonstration; it is a usable way to build a graph without writing code, and its output is the same JSON your application loads. The workflow is short.

  1. Pick nodes from the palette. It is grouped by the registry's own categories — source, sampler, point op, filter, attribute, value, spawn, io, composite — so you can find a node by what it does rather than by remembering its name. The search box filters across all of them.
  2. Wire pins left to right. Inputs are on the left of a node, outputs on the right. The editor refuses invalid connections because it asks the live graph, and the live graph validates eagerly.
  3. Edit parameters in the inspector. Each parameter renders from its schema: enums become dropdowns with exactly the legal values, numeric parameters carry their documented range, and a field-capable parameter shows a constant / field toggle. In field mode you edit the JSON spec directly, which is the same spec the rest of the library speaks.
  4. Watch the stats line. Cook time, nodes cooked versus cached, point and instance counts, and an output hash. The hash is the fastest determinism check you have — same seed, same graph, same hash.
  5. Export. The button produces serializeGraph output. Paste it into deserializeGraph in your application and it cooks identically.

An unconnected output pin is a declared graph output — that is how you say "this is what I want out". Deleting a node cascades: every connection touching it and every output declared on it go with it, in a single version bump. Watch the cooked-versus-cached counters when you do it. Untouched branches keep serving from cache, because the editor edits the live graph through the mutation API rather than rebuilding it from JSON. A rebuild is fully validated but starts cold; mutation is what keeps a large graph pleasant to edit.

Chapter 6

GPU cooking

The GPU path is opt-in, additive, and honest about being an approximation. Nothing changes unless you pass a resolver into cook.

Turning it on

import { cook } from "pcg-ts";
import { GpuFieldEvaluator } from "pcg-ts/gpu";

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error("no WebGPU adapter");
const device = await adapter.requestDevice();
const gpu = new GpuFieldEvaluator(device, { adapterInfo: adapter.info });

const result = await cook(graph, { gpu });
console.log(result.stats.gpu);
// { dispatches, pipelinesCompiled, pipelineCacheHits,
//   residentRuns, fusedNodes, readbacksSaved, fallbacks: {...} }

The core never imports the GPU module — the graph layer sees only a structural resolver interface, injected per cook. The same option exists on WorldOptions.gpu and UpdateOptions.gpu (update wins), threading a resolver into every cell cook.

What actually compiles

The GPU surface is exactly the serializable field-expression grammar, and all of it compiles: inputs, arithmetic, trigonometry, clamp/lerp/remap/select/ramp, vector operations, and every noise family including fBm and exact worley. The dividing line is not which functions but how the field was built. A field built by fieldFromJson carries a spec and is eligible; a field composed from the code combinators has no spec and stays on the CPU. getFieldSpec(field) tells you which you have — it returns the spec, or undefined.

Six nodes resolve their field parameters on device: setAttribute, transformPoints, jitterPoints, orientAlongVector, surfaceSample and volumeSample. Subgraph nodes forward the resolver to their inner cooks. Element count is never a limit — a kernel covering more elements than one dispatch call allows splits into chunks with byte-identical output.

When a field cannot run on device it falls back to the CPU silently but countably. stats.gpu.fallbacks records machine-readable reasons, and the vocabulary is closed: no-spec, compile-error, too-many-buffers per field, and run-plan-failed, run-too-large, run-partially-fused per fused run. If you expected device execution and did not get it, that record names why. The last of those is a partial success rather than a lost run: a member was rejected, the planner retried the suffix after it, and what remained fused — so read it together with residentRuns and fusedNodes, which say how much of the chain the retry recovered.

Device-resident runs

Beyond per-node evaluation, the executor finds maximal linear chains of fusable nodes and cooks each as a single device round trip. Four node kinds fuse — setAttribute in numeric point-domain mode, transformPoints, jitterPoints and orientAlongVector — all count-preserving, one geometry in and one out. A run ends at a non-fusable node, at any fan-out, and at a node carrying a declared graph output. A chain of one is not a run.

Fusion never changes which bytes the rest of the graph observes: an interior node with external consumers simply becomes a run terminal with its own readback. Only the terminal caches, under a composite key covering every member in order, so editing any member recooks exactly that run and leaves siblings and upstream cached.

The determinism contract, in plain language

The CPU is the bit-exact reference and existing goldens never move. The GPU is a documented approximation of it, and the shape of the difference is this:

  • Integer work is bit-exact. Hash and random streams, noise lattice hashing, index, integer attribute reads, boolean-to-float reads, and pure hash-compare-select trees all produce byte-identical results on both paths.
  • Float work matches within measured budgets. The CPU computes in f64 and stores f32; WGSL computes in f32 throughout. Addition, subtraction, multiplication, clamp, min/max, floor, and comparisons are bit-exact anyway. Division, lerp, remap and dot stay within one range-ULP; ramp and vector length within two; sine and cosine within eight; the inverse trigonometric functions are the loosest, as the WGSL specification permits. Noise families sit between six and twenty-four depending on base and mode. The full table lives in llms.txt and docs/authoring.md.
  • On one device, repeated runs are byte-identical. The variability is between the CPU and the GPU, and between different adapters — not between two runs on the same machine.
  • Branchy operations can flip. Comparisons, select, ramp segment boundaries and worley cell walks may take the other branch at knife-edge inputs whose operands differ within tolerance. A point right on a filter threshold may survive on one path and not the other.
  • Every budget was measured on one adapter. Another adapter exceeding one is a finding worth reporting, not expected noise.

Because GPU output is not byte-identical to CPU output, cache provenance folds the resolver's cacheSalt — a format version plus the adapter's identity — into the memo key of any node that would resolve a live spec'd field on device. Toggling the GPU on and off therefore never serves bytes produced by the other path. It also means the chain recooks on every flip, because a node holds one memo slot. That is by design, and it is why every benchmark in demo 08 is a cold-cache cook.

When it helps, and when it does not

The GPU path pays off when the same field expression is evaluated over a very large domain — hundreds of thousands of elements and up — and pays off most when several such nodes sit in a fusable line, because then the readbacks disappear too. Demo 08's chain at a million points ran roughly twenty times faster than the CPU reference on one desktop adapter — on both device paths in the capture above, where the fused path kept only the tail of the chain resident and cooked the rest per node.

It does not help, and can cost, when the domain is small enough that dispatch and readback dominate; when the fields were composed in code and carry no spec, so every one of them falls back; when the graph's cost is in topology rather than fields, since only field evaluation moves to the device; or when you are toggling the path back and forth, which recooks the chain each time. Bit-exactness with existing goldens is a reason to stay on the CPU, and staying on the CPU is always a supported choice — it is the reference.

Scope

What is on device today is field evaluation, plus fused runs of the four count-preserving node kinds above, plus one terminal kind: spawnInstances. Under deviceInstances: true a run can end at the spawner and compose its 4×4 matrices — and, with colorAttr set, its per-instance RGB — straight into GPU buffers a WebGPURenderer draws from, with no readback at all; multi-asset spawning has been device-side since v0.8. Chapter 4's forest and gpu-world demos both run it, and docs/authoring.md carries the contract. Anything beyond that — topology, sampling, spawner-side allocation — is unshipped.

Chapter 7

Debugging a graph

Cook stats are the first thing to read

Every cook returns stats: { cooked, cached, elapsedMs }, and the ratio answers most "why is this slow" questions immediately. A graph that recooks everything on every frame has a cache-invalidation problem, not a performance problem. Common causes, in rough order of frequency: a parameter is being set to a freshly constructed array or field every frame, so its structural hash changes; a dataInput node is being rebound with a new items array when the data has not changed; or a field is being rebuilt from JSON per frame instead of once.

The inverse reading is just as useful. After an edit, cooked should be exactly the edited node plus its dependents. If it is more, something upstream is changing that you did not intend to change.

Cache hits after an edit — a real trace

Four-node graph: scatter → density → keep → spawn
await cook(graph);                          // { cooked: 4, cached: 0 }  — 1008 survivors
graph.setParam(density, "value", fieldFromJson(newSpec));
await cook(graph);                          // { cooked: 3, cached: 1 }  —  967 survivors

One node cached: the scatter, which is upstream of the edit. Three cooked: the edited node and the two that depend on it. That is exactly the contract, and seeing it hold is how you confirm a graph is wired the way you think it is.

Determinism is a debugging tool, not only a feature

Because the same seed produces the same bytes, a hash of an output column is a stable fingerprint. Demo 06 prints one in its status line for precisely this reason. Hash your output, change nothing, cook again, and compare: if the hash moved, something in your pipeline is reading state you did not declare — wall-clock time, an unhashed external array, Math.random somewhere in your own code. If the hash is stable but the picture is wrong, the bug is in the graph, and you can bisect it by declaring an output on an intermediate node and cooking only that one.

The same property makes bug reports reproducible in a way that is otherwise rare in procedural work: a seed and a serialized graph fully determine the output.

What the error messages already tell you

Error strings in this library are treated as part of the API surface: they name the offending node, pin or parameter, and they list the valid alternatives instead of leaving you to search for them. Reading the message is usually faster than reading the code. Chapter 11 shows the real strings and how a program should react to them; for a human the practical advice is shorter — the message names the thing, and the second half of the message is the fix.

The typed classes let you distinguish causes when you need to: GraphValidationError (bad structure or unknown pins), GraphCycleError, NodeExecutionError (carries .nodeId), CookCancelledError, GraphSerializationError, FieldJsonError, and WorldValidationError.

Five contracts whose violations are invisible

Most confusing behaviour in practice comes from one of five contracts, each of which fails quietly rather than loudly:

  • Cook results are immutable. They alias live cache internals. Mutating one corrupts the cache with no error — clone first with cloneGeometry.
  • Removing points destroys topology. Every filter that can drop a point rebuilds the point domain from the survivors and loses primitive topology with it, as do mergePoints and partitionByAttribute; only a node that clones preserves it. So a path that passes through one stops being a path — silently, where it happens, surfacing much later as a path consumer reporting that it found no polylines. A network from connectPoints fares worse, because nothing downstream necessarily complains at all: a filterByBounds placed after it reads like trimming the net to a rectangle and is actually demolition, and the cook succeeds with a plain point cloud where a road network was. Build the topology after the last filter, not before it; when the clip is a partition boundary rather than an authoring choice, own each edge by its lower-keyed endpoint on the primitive domain instead of filtering points at all.
  • Bound arrays are frozen by convention. An array bound into a parameter is captured by reference. Mutate it in place and stored cell outputs change with no staleness signal. Bind a fresh array.
  • Columns are transient. An evaluated column may alias attribute storage and is only valid until the geometry is mutated or resized.
  • Do not edit a graph mid-cook. Edits during an in-flight cook or World.update are detected and heal on the next pass — but that pass may return a torn mix of old and new state.

Part II

Driving the library as an agent

For an LLM or a program: everything needed to author, cook, inspect and edit a graph without reading the source — the runtime registries, the JSON format, the pcg CLI, and the loop that ties them together.

Chapter 8

Runtime capability discovery

The registry is the truth. Node types are not a list in the documentation that the code happens to match — the documentation is generated from the registry, so anything you can read in docs/nodes.md you can also ask for at runtime.

There are three registries, and an agent should know all three before emitting anything: listNodeTypes() for node types, listFieldFns() for the field-expression vocabulary, and listSubgraphs() for the named primitives — the ready-made recipes a graph can reference by name instead of rebuilding. Each has a generated twin on disk and a pcg subcommand that prints the same thing from a shell.

listNodeTypes()

Returns every registered node type. Each entry has exactly these keys: type, description, category, inputs, outputs, params. There are 38 types in the standard library, all categorized.

import { listNodeTypes } from "pcg-ts";

const types = listNodeTypes();
types.length;                                // 38
const jitter = types.find(t => t.type === "jitterPoints");

The entry, verbatim

{
  "type": "jitterPoints",
  "description": "Offsets each point by a deterministic random vector: each axis
    moves by a uniform random in [-amount, +amount], hashed from (seed, point
    index, axis) — order-independent and reproducible. amount is field-capable
    (evaluated on the input positions; tuple 1 broadcasts to all axes).",
  "category": "point op",
  "inputs":  [ { "name": "in",  "kind": "geometry", "multi": false } ],
  "outputs": [ { "name": "out", "kind": "geometry", "multi": false } ],
  "params": {
    "amount": {
      "type": "vec3",
      "default": [0.1, 0.1, 0.1],
      "description": "Maximum offset per axis, in world units. Field-capable (tuple 1 broadcasts).",
      "acceptsField": true
    },
    "seed": {
      "type": "u32",
      "default": 0,
      "description": "Extra seed folded into the node seed; change it to re-roll the jitter."
    }
  }
}

That is everything needed to emit a valid node: the type name, which pins exist and what kind they are, which parameters are legal, their defaults, and — critically — acceptsField, which tells you whether a field spec object is allowed in that slot. Parameter schemas additionally carry min / max for numeric ranges and enum for enumerated values, which is how the editor knows to render a dropdown containing exactly the legal strings.

Categories

Every standard type declares a category, so a palette or a generated document groups without heuristics. The grouping as of this writing — listNodeTypes() is the authority, and docs/nodes.md is generated from it:

CategoryTypes
sourcepointGrid, pointLine, pointScatterInBounds, pointScatterInWorld, meshPrimitive
samplersurfaceSample, splineSample, volumeSample, pathResample
point oppointsToPath, connectPoints, transformPoints, jitterPoints, copyToPoints, mergePoints, orientAlongVector, setBounds
filterfilterByDensity, filterByBounds, filterByAttribute, filterByExpression, filterPrimitivesByBounds, selfPrune, projectToPlane
attributewriteTangents, pointNeighborhood, sampleNearestPoint, setAttribute, promoteAttribute, transferAttribute, attributeReduce, attributeRemap, removeAttribute, partitionByAttribute
valuevalueConstant
spawnspawnInstances
iodataInput
compositesubgraph

Building a graph in code from a type name

Two companions make the registry usable as a factory, not only as a catalog. hasNodeType(name) is a boolean existence check. getNodeType(name) returns { def, info }info is the same metadata entry listNodeTypes() yields, and def is the node definition you can hand straight to graph.add. It throws on an unknown name (listing every registered type) rather than returning undefined, so check first if the name came from somewhere untrusted.

import { Graph, cook, getNodeType, hasNodeType, firstGeometry } from "pcg-ts";

if (!hasNodeType("pointGrid")) throw new Error("not registered");
const { def, info } = getNodeType("pointGrid");
info.type;                                // "pointGrid"

const g = new Graph(5);
const n = g.add(def, { countX: 3, countZ: 3 });   // n.id === "pointGrid_0"
g.output(n, "out", "p");
firstGeometry((await cook(g)).outputs.p).pointCount;   // 9

Note the generated id: a code-built node is named <type>_<n>, whereas a node loaded from JSON keeps the id you gave it. Both appear verbatim in error messages and in describe(), so meaningful ids in your documents pay for themselves the first time something fails.

listFieldFns()

Returns the complete field-expression vocabulary as a sorted array of 42 names. This is the closed set — an fn not in this list is not a field function, and emitting one produces the error in chapter 11.

["abs", "acos", "add", "asin", "atan", "atan2", "attribute", "clamp", "component",
 "constant", "cos", "div", "dot", "eq", "fbm", "floor", "fraction", "ge",
 "gt", "index", "le", "length", "lerp", "lt", "max", "min", "mul",
 "ne", "normalize", "perlinNoise", "position", "ramp", "randomField",
 "remap", "select", "simplexNoise", "sin", "sub", "tan", "valueNoise",
 "vec", "worleyNoise"]

listSubgraphs()

The third registry, and the one an agent gains most from: the named primitives. A primitive is a registered subgraph — a tested recipe with exposed params — that a graph references by name rather than rebuilding out of node types. 34 named primitives ship with the library, and listSubgraphs() returns them as { name, subgraph, hash, meta }: the canonical payload, its content hash, and a meta block whose title and description say what the recipe is for and how its knobs behave.

One thing about it is unlike the other two registries and will otherwise cost you an afternoon. Primitives register on import of their own subpath, so listSubgraphs() returns an empty array until something imports it:

import "pcg-ts/primitives";              // registers the whole catalog, as a side effect
import { listSubgraphs } from "pcg-ts";

const all = listSubgraphs();
all.length;                            // 34
all.find(s => s.name === "fill/scatter-even").hash;   // "3a0d277f9d3b1840"

That is a cost boundary rather than an oversight: import "pcg-ts" keeps costing nothing, and the catalog sits behind a subpath nobody pays for unless they want it. The pcg CLI imports it for you, which is why pcg run fill/scatter-even works on a clean install. Names are <family>/<kebab-case> over a closed set of seven families — shape, fill, transform, compose, filter, place, write — and the family is a promise about the pin shape. Node types are camelCase with no separator, so a name containing / can never be mistaken for a type. Chapter 9 covers how a graph references one.

The same catalogs, from a shell

Two of those registries are also commands. The pcg CLI is the library's other front door — npx pcg … when the package is installed, node bin/pcg.mjs … from a clone — and its discovery commands read the same registries this chapter has been calling:

# the whole registry, grouped by category
pcg nodes                      # -> "38 node types, by category"

# one type: its pins and its full param table, with ranges, enums and
# which params accept a field
pcg nodes pointGrid

# the field vocabulary, and one fn's allowed keys
pcg fields                     # -> "42 field fns"
pcg fields randomField         # -> usage: { fn: "randomField", key?: 0 | "salt" }

# every command takes --json and prints the machine-readable report instead
pcg nodes pointGrid --json

Two properties make this usable as a tool rather than as a convenience. Every command emits both renderings — a text one and, under --json, a structured one built from the same data — so the human reading a terminal and the agent parsing stdout are looking at one run and cannot disagree about it. And the exit codes are scriptable: 0 did what was asked, 1 a named thing does not exist or the run failed, 2 the command line itself was wrong. The distinction in the last two is the useful part — a graph that does not cook and a flag you spelled wrong are different problems and never share a code.

The primitives have no discovery subcommand of their own — they are read from docs/primitives.md, and pcg run <name> lists every registered name when it does not recognize one. Cooking a primitive standalone is pcg run too: pcg run shape/disc --param count=400 --param size=24,0,24 synthesizes a one-node wrapper around it and needs no graph file at all, which makes it the fastest way to find out what a primitive actually produces before committing a ref to a document.

The rule the graph-authoring skill states, and the reason these commands exist: never write a param name from memory. pcg nodes <type> costs a second and the deserializer will reject the guess anyway.

The generated catalogs, offline

If you would rather load a catalog than call into the library — for planning, for prompt context, for validating candidate graphs before a runtime exists — each registry has a generated file beside it. All of them ship inside the npm package, all are regenerated from the code (npm run docs), and none is ever hand-edited.

ProseMachine-readableWhat it indexes
docs/nodes.mddocs/nodes.jsonEvery node type: pins, params, defaults, ranges. The JSON is a plain top-level array of the same 38 entries in the same shape listNodeTypes() returns.
docs/primitives.mddocs/primitives.jsonEvery named primitive: its pins, its exposed params with their derived schemas, its content hash, and a pcg run line to cook it standalone.
docs/examples.mddocs/examples.jsonThe example corpus: 37 graphs under examples/graphs/, indexed by what each one teaches, with its tags, seed, node types and referenced primitives.

The corpus is worth a sentence of its own, because it is the cheapest way to start. Every file in it teaches exactly one thing, carries a meta block saying which thing, and cooks from JSON alone — none uses dataInput, whose items never survive serialization — so any entry can be copied, cooked and modified with nothing but the CLI. Reading one graph near your goal and copying its shape beats composing from schemas.

Discovery checklist

Before emitting a graph: check whether a primitive already does it (docs/primitives.md opens with a one-line summary of each) — referencing one is a single node and it arrives tested. Then, for anything you are wiring by hand: confirm the type exists (hasNodeType, or pcg nodes <type>), confirm each parameter name is in params, confirm enum values against enum and numbers against min/max, confirm a field spec is only used where acceptsField is true, and confirm pin names against inputs/outputs. Every one of those checks the deserializer will also make — but making them first turns a thrown error into a corrected emission.

Chapter 9

Authoring graphs as JSON, end to end

The format

SerializedGraph, format version 1. Field names exactly as written:

formatVersion
Always 1.
seed
Number. The graph seed; every node seed derives from it.
meta
Optional { title, description, tags }. The only place descriptive text belongs — there is no comment or annotation key. It is excluded from the content hash and does not bump the graph version, so retitling a graph cannot invalidate a cache.
nodes
Array of { id, type, params }. id is yours to choose and is what every error message and every introspection read will call the node. Omitted parameters take their schema defaults. A subgraph node additionally carries either a subgraph: { graph, inputs, outputs, params } payload — the inner graph recursively in this same format, plus the exposed pin mappings and param declarations — or a ref naming a registered primitive. The two are mutually exclusive, and no other node type may carry either.
connections
Array of { from: [nodeId, outputPin], to: [nodeId, inputPin] }. Order matters on multi inputs: it is part of determinism.
outputs
Array of { id, pin, name }. name becomes the key in result.outputs.

The format is closed, at every object position. An unrecognized key is an error, not something quietly ignored — inside the graph object, a node, a subgraph payload, a ref, a connection, a declared output, an exposed-pin or exposed-param declaration. This matters more to a generator than to a person, because the near-miss is the dangerous case: "refs" for "ref" under a lenient reader would have cooked as an ordinary subgraph node and reported success. The consequence is deliberate and permanent — a future format field arrives with a formatVersion bump, never by riding along unnoticed:

deserializeGraph: unknown key "notes"; valid keys: formatVersion, seed, meta,
nodes, connections, outputs. The format is closed — an unrecognized key is a typo,
not an extension, so a future field arrives with a formatVersion bump. There is no
annotation key: descriptive text belongs in the graph's "meta" block
({ title, description, tags })

A complete working example

This document deserializes, cooks, and round-trips as written. It scatters two thousand points over a hundred-unit square, writes a normalized four-octave fBm into the density attribute, keeps points probabilistically against that density, and spawns the survivors.

{
  "formatVersion": 1,
  "seed": 7,
  "nodes": [
    { "id": "scatter", "type": "pointScatterInBounds",
      "params": { "count": 2000, "boundsMin": [0, 0, 0], "boundsMax": [100, 0, 100] } },

    { "id": "density", "type": "setAttribute",
      "params": {
        "name": "density", "domain": "point", "type": "f32", "tupleSize": 1,
        "value": { "fn": "fbm", "base": "perlinNoise",
                   "opts": { "frequency": 0.03, "octaves": 4, "normalized": true } }
      } },

    { "id": "keep", "type": "filterByDensity", "params": { "mode": "probabilistic" } },

    { "id": "spawn", "type": "spawnInstances", "params": { "assetId": "rock" } }
  ],
  "connections": [
    { "from": ["scatter", "out"], "to": ["density", "in"] },
    { "from": ["density", "out"], "to": ["keep",    "in"] },
    { "from": ["keep",    "out"], "to": ["spawn",   "in"] }
  ],
  "outputs": [
    { "id": "keep",  "pin": "out",       "name": "points" },
    { "id": "spawn", "pin": "instances", "name": "instances" }
  ]
}
Loading and cooking it
import { deserializeGraph, serializeGraph, cook, firstGeometry } from "pcg-ts";

const graph = deserializeGraph(doc);   // validates everything, names any fault
const result = await cook(graph);

firstGeometry(result.outputs.points).pointCount;   // 1038
result.stats;                                      // { cooked: 4, cached: 0, elapsedMs: 13.8 }

const batch = result.outputs.instances
  .find(i => i.kind === "instances").batches[0];
batch.assetId;               // "rock"
batch.count;                 // 1038
batch.transforms.length;     // 16608  (16 floats per instance, column-major)

What round-tripping actually guarantees

This is the one place where an imprecise mental model causes real trouble, so be precise. serializeGraph(deserializeGraph(doc)) is not byte-identical to doc. Deserialization applies schema defaults, and serialization emits the full parameter set — so a node you wrote as { "count": 500 } comes back as { "count": 500, "boundsMin": [0,0,0], "boundsMax": [1,1,1], "seed": 0 }.

What is guaranteed is the pair of properties you actually need:

  • Semantic fidelity. The round-tripped graph cooks byte-identically to the original.
  • Idempotence after normalisation. Serialize once and you have the canonical form; serializing again produces exactly the same JSON. JSON.stringify(s1) === JSON.stringify(s2) where s2 = serializeGraph(deserializeGraph(s1)) — verified.

So the correct diffing strategy for an agent is: normalise both sides through one serialize before comparing. Comparing your hand-written emission against a serialize output will report differences that are not differences.

Referencing a primitive instead of writing one

Everything above assumes you are wiring node types by hand. Often you should not be. A subgraph node can name a registered primitive instead of carrying a payload, and then the whole recipe is one node:

{ "id": "ground", "type": "subgraph",
  "params": { "count": 400, "size": [24, 0, 24] },
  "ref": { "name": "shape/disc" } }

For a generator this is the highest-leverage line in the chapter, and the reason is arithmetic rather than aesthetic: a reference is three keys where the equivalent embedded payload is an entire nested graph, so every primitive that fits collapses a passage of JSON you would otherwise have to emit correctly, and it arrives already tested with defaults tuned to produce something on the first cook. params on a ref node are the primitive's exposed params — not the inner nodes' params — and naming one that is not exposed is refused with the exposed set listed. docs/primitives.md and pcg run <name> are the two ways to find out what a given primitive exposes. Reach past a primitive to raw node types when it does not offer the pin or the param you need, not because a flat graph looks cleaner; it does not, it just loses the tests.

What is registered is a recipe, never a live graph, and every reference materializes a fresh copy through the same path an embedded payload takes. So the contract is the one you want: a reference and an embedded copy of the same recipe cook byte-identically — same output bytes, same cache decisions. Resolution happens once, at load, which also means a name must be registered before any graph referencing it is deserialized. Import pcg-ts/primitives first (chapter 8); the CLI does it for you.

Why the hash is optional

ref takes an optional second key, hash — the primitive's content hash, as returned by listSubgraphs() or registerSubgraph. Including it is a decision, not decoration, and the two modes are the whole design:

hashWhat you are asking forWhen the primitive changes
absent (the default)"give me the library's current shape/disc"Resolves and cooks. No friction — the graph gets the improvement.
present"cook exactly what I authored against"Hard error, naming both hashes.

Optional is the load-bearing word, and it points both ways. Mandatory pinning would mean every improvement to a shipped primitive breaks every saved graph that references it — so a name-only reference is the default precisely so that it can upgrade freely. But there is no third mode, because neither mode warns. A library warning lands in a CI log or a console that the agent driving the graph never reads, which makes "resolve and warn" operationally indistinguishable from resolving silently — exactly the class of failure the determinism pillar exists to exclude. So: omit the hash while a graph is being developed against a library you control, add it when the graph is an artifact whose output you have recorded and intend to reproduce.

One boundary is worth knowing before you rely on a pin. The hash, and the byte-identity between the by-name and embedded forms, hold within one build. Across library versions neither can, by any design — an embedded payload freezes the defaults of the build that wrote it while a registry entry is re-derived from the current one, so adding a param to any node type a primitive uses moves its hash. The optional pin is what turns that divergence from silent into stated. docs/authoring.md, "Pinning: the optional content hash", has exactly what the hash covers and what it deliberately excludes.

Two nodes that serialize specially

subgraph nodes that are not references carry their inner graph as a nested payload in the same format, recursively, so composition survives a round trip with no external registry. (Editing a referenced primitive's inner graph in place and then saving is refused, naming the node: writing the reference back out would write the registry's content and silently discard the edit.) dataInput nodes serialize with an empty items list — live data items are runtime-injected by definition, so after deserializing you must re-bind them:

// A graph containing a dataInput serializes as:
{"formatVersion":1,"seed":1,"nodes":[{"id":"dataInput_0","type":"dataInput","params":{"items":[]}}],
 "connections":[],"outputs":[{"id":"dataInput_0","pin":"out","name":"geo"}]}

// After deserializing, bind a FRESH array — never mutate one in place:
graph.setParam(inputHandle, "items", [makeGeometryItem(surface)]);

Chapter 10

The field grammar as a generation target

A field spec is { "fn": <name>, ...args }. Entries inside args may be nested specs, finite numbers, or number arrays — plain values wrap into constant automatically, so you never emit a constant node by hand unless you want to.

The vocabulary below is printed here to be read whole; to look one entry up while working, pcg fields <fn> gives that function's allowed keys and its usage line, and pcg fields gives the closed set — the same answer listFieldFns() returns in process.

The complete vocabulary

Inputs

{ "fn": "constant",    "value": 1 }              // or [1, 2, 3]
{ "fn": "attribute",   "name": "density", "tupleSize": 1 }   // numeric attributes only
{ "fn": "position" }                              // reads P, tuple 3
{ "fn": "index" }                                 // 0, 1, 2, ...
{ "fn": "randomField", "key": "species" }            // per-element hash random in [0,1)

Elementwise combinators — exact arity, scalars broadcast

ArgsFunctionsNotes
2add sub mul div min max lt le gt ge eq ne dot atan2Comparisons emit 1 or 0, and ne is the exact complement of eq. dot reduces tuples to a scalar. atan2 takes [y, x], radians.
1abs floor length normalize sin cos tan asin acos atanTrigonometry in radians, elementwise.
3clamp (x, lo, hi) · lerp (a, b, t) · select (cond, a, b)
5remap (x, inMin, inMax, outMin, outMax)Unclamped — wrap in clamp if you need the bound.

Structure

{ "fn": "vec",       "args": [x, y, z] }               // 1+ args, concatenates components
{ "fn": "component", "args": [tupleField], "index": 0 }  // extract one component
{ "fn": "ramp",      "args": [scalarField],
                     "stops": [[0, 0], [0.5, 1], [1, 0]] }   // strictly ascending, clamped at the ends

Noise

All noise functions are scalar fields of the sample position, and are pure functions of seed plus position — the evaluation context's seed does not affect them.

{ "fn": "valueNoise" | "perlinNoise" | "simplexNoise",
  "opts": { "seed": 0, "frequency": 1, "offset": [0,0,0], "position": spec, "normalized": false } }

{ "fn": "worleyNoise",
  "opts": { ...same, "output": "f1" | "f2" | "f2-f1", "exact": false } }

{ "fn": "fbm", "base": "perlinNoise" | "valueNoise" | "simplexNoise" | "worleyNoise",
  "opts": { ...same, "octaves": 4, "lacunarity": 2, "gain": 0.5 } }

Raw output ranges: valueNoise is [0, 1); Perlin and simplex are approximately [-1, 1]; worley f1 is [0, ~1.73). The fBm sum is not renormalised, so its range grows with octaves. Rather than hardcoding constants, ask: NOISE_RAW_RANGES gives per-type ranges and noiseOutputRange(field) gives the range of a specific field instance, fBm-aware. Or sidestep the arithmetic entirely with "normalized": true, which affinely remaps the documented raw range to exactly [0, 1] and works on fBm too.

A nested example

{ "fn": "clamp", "args": [
    { "fn": "remap", "args": [
        { "fn": "fbm", "base": "perlinNoise", "opts": { "frequency": 0.03, "octaves": 4 } },
        -1, 1, 0, 1 ] },
    0, 1 ] }

What an agent can emit — and what it cannot

This is the boundary that matters most, because it is asymmetric and easy to get wrong in the safe-looking direction.

You can emit any field the grammar describes. Those 42 functions, nested arbitrarily, are a complete generation target. Fields built from JSON via fieldFromJson carry their spec: getFieldSpec(field) returns it, fieldToJson(field) gives the JSON back, and the round trip is exact — fieldToJson(fieldFromJson(spec)) deep-equals spec, verified.

You cannot emit a code-authored field, and you cannot serialize one. A field composed with the code combinators — clamp(add(0.2, mul(worleyNoise(...), 0.9)), 0, 1) — cooks perfectly well but has no spec. getFieldSpec returns undefined for it, and serializeGraph on a graph containing one cannot represent that parameter. There is no specification for arbitrary code-authored fields, and there will not be one, because there is nothing finite to specify.

Two consequences follow directly, and both are practical rather than theoretical:

  • Serializability is a property of construction, not of content. The identical expression is serializable when built from JSON and not when built in code. If you are generating graphs that must round-trip, build every field through fieldFromJson — including ones you could write more tersely in code.
  • GPU eligibility follows the same line. A field is GPU-eligible if and only if it carries a spec. Code-authored fields fall back to the CPU and are counted under the no-spec reason. So the same discipline that keeps graphs serializable also keeps them device-eligible.
Setting a field parameter on a live graph

setParam does not convert JSON for you. Passing a raw spec object stores the object, and the cook fails at that node with value is not iterable. Wrap it: graph.setParam(handle, "value", fieldFromJson(spec)). The value in the serialized document is a spec object; the value on a live graph is a Field.

Chapter 11

Errors as an API

Validation messages name the offending node, pin or parameter and list the valid alternatives. That is a deliberate design commitment, not a courtesy — it means a failed emission carries the information needed to fix itself, and a correction loop does not need to consult external documentation.

Every string below is real output, produced by feeding the named mistake to the published build.

Unknown node type — the message enumerates the entire registry
GraphSerializationError: node "a": unknown node type "pointScatterInBox";
registered types: attributeReduce, attributeRemap, connectPoints, copyToPoints,
dataInput, filterByAttribute, filterByBounds, filterByDensity,
filterByExpression, filterPrimitivesByBounds, jitterPoints, mergePoints,
meshPrimitive, orientAlongVector, partitionByAttribute, pathResample, pointGrid,
pointLine, pointNeighborhood, pointScatterInBounds, pointScatterInWorld,
pointsToPath, projectToPlane, promoteAttribute, removeAttribute,
sampleNearestPoint, selfPrune, setAttribute, setBounds, spawnInstances,
splineSample, subgraph, surfaceSample, transferAttribute, transformPoints,
valueConstant, volumeSample, writeTangents
Unknown parameter — the message enumerates that type's parameters
GraphSerializationError: node "a": unknown param "counts" for type
"pointScatterInBounds"; valid params: count, boundsMin, boundsMax, seed
Range and enum violations — the bound and the legal set are both stated
GraphSerializationError: node "a" param "count": -5 is below the minimum 0

GraphSerializationError: node "a" param "mode": expected one of
"threshold", "probabilistic", got "probablistic"
Wiring — the connection index, the node, and the valid pins
GraphSerializationError: connections[0]: node "a" has no output pin
"output"; valid output pins: out

GraphCycleError: connecting jitterPoints_1.out to jitterPoints_0.in
would create a cycle

GraphValidationError: node "jitterPoints_0" has no input pin "input";
input pins: "in"

GraphValidationError: unknown output "pts"; declared outputs: "points"
Field grammar — the fn list and the exact arity
FieldJsonError: $: unknown field fn "perlin"; valid fns: abs, acos, add, asin,
atan, atan2, attribute, clamp, component, constant, cos, div, dot, eq, fbm,
floor, fraction, ge, gt, index, le, length, lerp, lt, max, min, mul, ne,
normalize, perlinNoise, position, ramp, randomField, remap, select,
simplexNoise, sin, sub, tan, valueNoise, vec, worleyNoise

FieldJsonError: $: fn "clamp" expects exactly 3 args, got 2

The $ prefix is a path into the spec: nested failures report the position of the offending sub-expression rather than only the root.

Execution — the node id is both in the message and on the error object
NodeExecutionError: node "spawnInstances_0" failed: spawnInstances:
input pin "in" has no geometry connected
  err.nodeId === "spawnInstances_0"
World construction — validated before a single cell cooks
WorldValidationError: level 0 ("a"): a bounded level requires generationRadius
(a positive finite number); only an unbounded level may omit it

WorldValidationError: level 0 ("a"): cookOutputs names unknown output
"nope"; the level graph declares: "x"

How a program should react

These messages are structured enough to act on without natural-language parsing, and the right reaction differs by class:

Error classMeansCorrect reaction
GraphSerializationErrorThe document is invalid. Nothing was built.Repair the document. The message's second clause is the candidate set — pick from it, do not guess.
GraphValidationErrorAn operation on a live graph referenced something that is not there.Re-read describe(); your model of the graph has drifted from the graph.
GraphCycleErrorThe connection would close a loop.Do not retry. Change the topology.
FieldJsonErrorThe field spec is malformed at the reported path.Fix that sub-expression. Arity and fn-name errors are both fully determined by the message.
NodeExecutionErrorThe graph was structurally valid but a node failed at runtime.Read .nodeId, inspect that node's parameters and inputs. Usually a missing connection or a parameter combination the schema permits but the node rejects.
CookCancelledErrorYour abort signal fired.Not a failure. Completed nodes kept their caches; cook again to resume.
WorldValidationErrorA level definition is inconsistent.Fix the level, at construction time — this one is raised before any cell cooks.

The general rule for a correction loop: never retry an identical emission. Every error above is deterministic and total — the same input always produces the same message, so a retry without a change is guaranteed to fail again. The message tells you what to change; change that.

The same errors from the CLI

The pcg commands print the library's own message verbatim — they do not summarize it, so nothing above is lost by driving the library from a shell instead of from a process. What the CLI adds is a coarse classification the message does not carry: exit 1 means a named thing does not exist or the run failed (every error class in the table above lands here), and exit 2 means the command line itself was wrong — an unknown or repeated flag, a missing flag value, a number of the wrong shape, or contradictory targets like --node together with --output. A correction loop should branch on that first: a 2 is your invocation and never the graph, so re-reading the graph is wasted work.

The registry errors get the same enumerate-the-alternatives treatment for primitives that they get for node types: pcg run fill/scatter-uneven exits 1 with unknown subgraph "fill/scatter-uneven"; registered subgraphs: followed by every registered name. And pcg validate is the cheapest error surface there is — it deserializes and reports structure without cooking anything, so it catches unknown types, unknown params, bad pins, unknown keys and cycles before any work happens. Run it after every edit.

Chapter 12

Introspection and mutation for closed-loop editing

JSON is the interchange format, not the only way to change a graph. A tool that keeps one live graph edits it in place and reads it back — preserving the caches a rebuild would discard.

Reading: describe() and getParams()

graph.describe() returns a frozen structural snapshot in insertion, creation and declaration order. It is cheap enough to call every UI frame, and it offers no path that could mutate the graph behind its version counter.

graph.describe()

{
  "nodes": [
    { "id": "scatter", "seed": 3953054466, "defType": "pointScatterInBounds" },
    { "id": "density", "seed": 2454708624, "defType": "setAttribute" },
    { "id": "keep",    "seed": 1901719706, "defType": "filterByDensity" },
    { "id": "spawn",   "seed": 2435855515, "defType": "spawnInstances" }
  ],
  "connections": [
    { "from": ["scatter", "out"], "to": ["density", "in"] },
    { "from": ["density", "out"], "to": ["keep", "in"] },
    { "from": ["keep", "out"], "to": ["spawn", "in"] }
  ],
  "outputs": [
    { "id": "keep", "pin": "out", "name": "points" },
    { "id": "spawn", "pin": "instances", "name": "instances" }
  ]
}

The seed field is the derived per-node seed the executor actually uses, not the graph seed — useful when reproducing a single node's randomness outside the graph. defType is undefined when a definition carries no usable type string. Note that describe() reports structure verbatim, which for a subgraph-wrapped graph includes injected plumbing that serialization deliberately excludes; the two views differ on purpose.

graph.getParams(handle) returns a frozen shallow copy of a node's parameters at call time. It gives you runtime values — a field parameter comes back as a live Field object with its stable key, not as a JSON spec. When you want the spec, serialize.

Handles from ids

A NodeHandle is { readonly id: string }. describe() reports ids, and the mutation and parameter APIs take handles — so an agent that only has a snapshot can reconstruct what it needs: const byId = Object.fromEntries(graph.describe().nodes.map(n => [n.id, { id: n.id }])), then graph.getParams(byId.density). Verified working. Passing an id string directly does not work, and fails with unknown node "undefined".

For composites, describeSubgraphPins(def) resolves a subgraph definition's per-instance pins — the exposed name plus the concrete kind of the inner pin, resolved live through nested subgraphs. It returns {"inputs":[],"outputs":[{"name":"out","kind":"geometry"}]} for a single-output wrapper, undefined for a non-subgraph definition, and throws (naming the pin and inner node) when later edits have broken the wrapper.

Writing: the mutation API and its exact cache semantics

Every mutation bumps the graph version. The cache consequences are specified, not incidental:

OperationCascadeCache effectFailure mode
removeNode(handle) The node, every connection touching it, and every output declared on it — one version bump. The removed node's cached outputs are dropped immediately. Former downstream nodes recook next cook; untouched branches keep serving. Unknown handle (another graph's, or already removed) throws.
disconnect(from, pin, to, pin) One matching connection. Remaining connections on a multi input keep insertion order — order is part of determinism. Same as removeNode: target and downstream recook, untouched branches stay cached. Unknown nodes or pins throw. A missing connection between two valid endpoints returns false and bumps nothing.
removeOutput(name) Undeclares one terminal output; the name is free to redeclare. Node caches untouched. An output changes what a cook pulls, not any memo key — so the next cook serves every unchanged node from cache. Unknown name throws, listing the declared outputs.
setParam(handle, name, value) None. That node and its dependents recook; upstream stays cached. Unknown handle or param throws. Field params need a real Field, not a spec object.

The removeOutput row is the one most likely to surprise, so here it is measured. On a three-node graph with two declared outputs:

await cook(g);                       // { cooked: 3, cached: 0 }
await cook(g);                       // { cooked: 0, cached: 3 }
g.removeOutput("points");
await cook(g);                       // { cooked: 0, cached: 3 }   ← nothing recooked

g.disconnect(jitter, "out", spawn, "in");   // true
g.disconnect(jitter, "out", spawn, "in");   // false — already gone, no version bump
await cook(g);
// NodeExecutionError: node "spawnInstances_0" failed:
//   spawnInstances: input pin "in" has no geometry connected

And the cascade, measured on the four-node JSON graph from chapter 9:

g.removeNode(byId.spawn);
g.describe().outputs;   // [ { id: "keep", pin: "out", name: "points" } ]
                       //   the "instances" output went with the node
await cook(g);         // { cooked: 0, cached: 3 }  ← every survivor served from cache

Mutate or rebuild?

Mutate while a live graph is being edited and warm caches matter — tweaking one branch of an expensive graph must not recook its siblings. Rebuild through deserializeGraph when loading a document or handing a graph across a boundary: a rebuilt graph is fully validated but starts with cold caches. The two views stay consistent, so this is a performance decision rather than a correctness one — after any mutation, serializeGraph(graph) reflects the current structure and round-trips.

Chapter 13

Determinism as a contract

Same seed, identical bytes. This is the property everything else is built to preserve, and for an agent it is the difference between generation that can be verified and generation that can only be looked at.

What is promised

  • Same graph plus same seed produces byte-identical output across runs and platforms. All randomness flows from seeds through PCG32 and murmur-style hash combining. There is no Math.random anywhere in the library.
  • The seed chain is specified, and it has exactly one exception. Graph seed → node seed = hashCombine(graphSeed, hashString(nodeId)) → per-point hashCombine(seed, index, axis). Cell seeds are hashCombine(worldSeed, levelIndex, ...coord), with every coordinate hashed; ctx.worldSeed and ctx.levelSeed are the cell-invariant anchors beside them. The exception is pointScatterInWorld, whose lattice derives from its own seed param alone so that reseeding cannot silently de-anchor a world — see 2.5.
  • Order and path independence. Cook order, streaming order, cancellation, eviction and recooks never change the bytes produced. Per-point randomness is hashed from a key, never drawn from a stream, which is why it does not matter what was generated before it.
  • Window independence, where the node promises it. On the point domain the key is the point's identity — its stored position bits plus its seed attribute — not its array index, in filterByDensity (probabilistic), jitterPoints, randomField and the tiebreaks of selfPrune and pointNeighborhood. An upstream filter that renumbers everything therefore cannot move a survivor's draw, which is what makes a halo reproduce a neighbouring cell exactly. Other domains have no position and still key on index; surfaceSample does too, because it manufactures its own candidates. The trap to know: identity keying makes those nodes indifferent to the window, never to their own seed — wire a per-cell ctx.seed into one and the chain de-anchors one node after the source, silently. See 2.5.
  • Execution is introspectable. Cook stats, cache hit and miss counts, and per-node progress callbacks expose what actually ran.
Verifying it, the way an agent should
const fingerprint = async (doc) => {
  const r = await cook(deserializeGraph(doc));
  const P = firstGeometry(r.outputs.points).attrs.point.require("P");
  const bytes = new Uint8Array(P.data.buffer, P.data.byteOffset, P.data.byteLength);
  let h = 0x811c9dc5 >>> 0;                    // FNV-1a over the raw column
  for (const b of bytes) { h ^= b; h = Math.imul(h, 0x01000193) >>> 0; }
  return h.toString(16).padStart(8, "0");
};

await fingerprint(doc);   // "9608b6f4"
await fingerprint(doc);   // "9608b6f4"  — identical, from a cold graph each time

Verifying it without writing any code

The fingerprint above is the in-process form. From a shell the same check is two commands and a cmp, because both the SVG renderer and the JSON reports are deterministic functions of the cook:

# the picture is a deterministic function of the graph
pcg render g.json --out a.svg
pcg render g.json --out b.svg
cmp a.svg b.svg

# the numbers too — but strip the timings, which are the one thing that varies
pcg inspect g.json --json | grep -v elapsedMs > a.json
pcg inspect g.json --json | grep -v elapsedMs > b.json
cmp a.json b.json

Then the half that is easy to forget, and without which the first half proves nothing: pcg cook g.json --seed <other> must produce different output. If it does not, nothing in the graph is actually seeded, and the reproducible result is reproducible for the wrong reason — a graph of pure pointGrid and arithmetic passes every identity check above while having no randomness to be deterministic about. Assert both directions or assert neither.

Can this operation be partitioned at all?

Determinism across runs is free; determinism across partitions is not, and the boundary between them is the thing most likely to be assumed rather than checked. Two distinctions carry it.

First, a cook budget partitions time and a World cell partitions data, and only the second changes what a node can see. Node bodies are atomic under a budget, so --budget can never alter an output; a cell can, because "all the points" now means all the points here. Second, within a partitioned cook, an op is safe only where its reach is bounded, and bounded is a stronger claim than local — every step of a greedy chain is local and the chain is not. The test is how many hops of dependency it takes before an answer is settled: zero hops reads only the stored values of the elements it names, one hop reads its neighbours' stored values but never their answers, and unbounded reads another element's answer. The first two are halo-exact at a stated width; the third is exact at no width at all. Section 2.5 works the ladder through with the shipped nodes on each rung, and the determinism skill states it as a table.

The practical consequence for a generator is a short list to recognize rather than a rule to derive. Hashed randomness is always safe — randomField, filterByDensity in probabilistic mode, jitterPoints, the per-point seed — because it hashes a key instead of measuring a population. Anything that fits a parameter to the data present in this cook is not: attributeRemap mode "fit" (mode "range" is its partition-safe form), attributeReduce, an aggregate promoteAttribute, and the fraction and index fields. Those have no reach to widen and no halo helps; compute the quantity once on a coarse or unbounded level and push it down. The performance-and-budgets skill catalogues them.

Why this matters for generation you can trust

A fingerprint over an output column turns three otherwise awkward problems into equality checks.

Reproducibility. A serialized graph plus its seed is a complete, portable description of its output. Nothing else needs to travel — no assets, no cached results, no record of the order operations happened in. Two parties running the same document get the same bytes, which makes a generated world citable in the way a hash is citable.

Verification. An agent that emits a graph, cooks it, and records the fingerprint can prove later that a given output came from a given graph — and can detect any drift in its own pipeline. If the fingerprint changes and the document did not, the change came from somewhere undeclared, and that is a bug worth finding.

Regression testing without goldens for everything. An eight-character hash stands in for a megabyte of positions. Change a node, expect exactly the branches you touched to move, and confirm it by hashing outputs rather than by looking at pictures.

The four caveats, stated plainly

  • The GPU path is a documented approximation. Chapter 6 covers the budgets. Fingerprints from the CPU and GPU paths differ, legitimately and by measured amounts. Cache provenance guarantees the two never serve each other's bytes, but it does not make them equal — so fingerprint comparisons must be within one path.
  • The caller's mutation contracts are load-bearing. Determinism assumes you did not mutate a cook result, mutate a bound array in place, or hold an evaluated column across a geometry resize. All three fail quietly.
  • Live data is outside the seed. A dataInput node's items come from your program. Determinism covers what the graph does with them, not where they came from.
  • A serialized document is not a lockfile for the library. The guarantee is that a given version cooks a given document identically. Existing goldens are not moved by design, but "same seed" is a statement about the graph, not a version pin.

Chapter 14

A worked end-to-end agent loop

The loop is validate → cook → inspect → render, run from a shell against a file on disk. It is the loop the graph-authoring skill teaches, and the four steps are four different questions: is this a graph, does it run, are the numbers right, does it look like the thing. Every value shown below is real output from running exactly this sequence.

1 · Discover

Look up what you are about to type rather than recalling it. From a shell that is two commands, and the answers are the same registries chapter 8 describes:

pcg nodes pointScatterInBounds   # pins, and every param with its range
pcg fields fbm                   # the field fn's allowed keys and usage line

Before either, though, ask whether the thing already exists. docs/primitives.md opens with a one-line summary of each named primitive and docs/examples.md indexes 37 corpus graphs by what each one teaches — and copying the shape of a graph near your goal beats composing one from schemas. A primitive that fits collapses this whole step into one ref node.

2 · Emit

Write the document to a file. Use the chapter 9 example verbatim — four nodes, three connections, two outputs — saved as g.json. From here on the file on disk is the artifact; every command below reads it and none of them modifies it.

3 · Validate

Deserialization without cooking. It is nearly free and it catches unknown types, unknown params, unknown keys, bad pins and cycles before any work happens, so it belongs after every edit rather than at the end.

$ pcg validate g.json

ok  g.json
seed 7  4 nodes  3 connections  2 outputs

nodes:
  scatter  pointScatterInBounds
  density  setAttribute
  keep     filterByDensity
  spawn    spawnInstances

outputs:
  points     <- keep.out
  instances  <- spawn.instances

Exit 0. Note what this does not tell you: the graph is structurally sound, which is a statement about the JSON and not about the content. A path built before a filter instead of after it validates perfectly and is already wrong — see chapter 7.

4 · Cook

--stats adds the per-node breakdown, which is the line that tells you whether your model of the graph matches the graph.

$ pcg cook g.json --stats

cooked g.json (seed 7)
4 cooked, 0 cached, 11.4 ms

outputs:
  points (1 item)
    [0] geometry   points 1038  vertices 0  primitives 0
        bounds 0.004381,0,0.246978 .. 99.973503,0,99.986687
        point attrs: P(f32x3), rot(f32x4), scale(f32x3), density(f32),
        boundsMin(f32x3), boundsMax(f32x3), color(f32x4), seed(u32)
  instances (1 item)
    [0] instances  1038 instances in 1 batch (cpu) — rock x1038

per-node:
  id       type                  state   elapsed
  scatter  pointScatterInBounds  cooked  1.5 ms
  density  setAttribute          cooked  7.4 ms
  keep     filterByDensity       cooked  1.6 ms
  spawn    spawnInstances        cooked  0.7 ms

1038 survivors of 2000 scattered, one batch of rocks, and the cost is concentrated in one node. Two flags matter here beyond --stats: --seed <n> overrides the graph seed without editing the file, and --budget <ms> bounds a pass. Neither can change what comes out — a budget partitions time, not data.

5 · Inspect

inspect is the debugger. Point it at an intermediate node with --node <id> and it cooks just enough to produce that pin, then prints per-attribute min/max/mean, a non-finite count, and the first rows:

$ pcg inspect g.json --node density --rows 3

g.json — node "density" pin "out"
1 item, cooked 2, cached 0

item 0: geometry   points 2000  vertices 0  primitives 0
  point — 2000 elements:
    attr       type  tuple  min                  max                    mean       non-finite
    P          f32   3      0.004381,0,0.117069  99.973503,0,99.986687  50.5,0,50.1  0
    density    f32   1      0.340792             0.690972               0.509424     0
    seed       u32   1      4539884              4292361164             2146090434   0

  first 3 of 2000 point rows:
    #  P                          density   seed
    0  [86.869583, 0, 11.79151]   0.437551  2646749418
    1  [39.718246, 0, 85.543388]  0.381043  2141006151
    2  [22.63518, 0, 82.748413]   0.42236   4147278842

That one table settles the question the survivor count could not. The density channel spans 0.34 to 0.69 with a mean of 0.51 — a four-octave fBm normalized to [0, 1] does not use the whole range, which is exactly why a probabilistic filter kept about half. If you wanted a quarter, the fix is the field, not the filter. When a graph is wrong, bisect it: inspect the first node, then the next, until the numbers stop being what you expect. That node is the bug.

6 · Render

A deterministic top-down SVG. --attr colors by an attribute — a scalar through a ramp, a vec3 or wider as RGB, strings categorically — which turns "is the density doing what I meant" into something you can see rather than infer:

$ pcg render g.json --output points --attr density --out g.svg

wrote g.svg  800x798
g.json — output "points"
1038 of 1038 points, 0 of 0 primitives, 0 of 0 instances
colored by "density" — point values on the circles
bounds (world) x 0.004381..99.973503  z 0.246978..99.986687

The first count is the one to read: 1038 of 1038 means nothing was decimated. Past --max-points (50,000 by default) the picture is a sample and the report says so. A name that lives on both the point and primitive domains colors both — circles from the point column, paths from the primitive one — and --attr-domain narrows it when that is not what you want.

7 · The machine-readable half

Everything above has a --json twin built from the same data, so an agent and a person reading the same run cannot end up with different facts about it:

$ pcg inspect g.json --output points --json

{
  "path": "g.json",
  "target": "output \"points\"",
  "stats": { "cooked": 3, "cached": 0, "elapsedMs": 11.38 },
  "domain": "point",
  "items": [ { "kind": "geometry", "tags": [],
              "geometry": { "points": 1038, "vertices": 0, "primitives": 0,
                            "domains": [ /* per-attribute min / max / mean / nonFinite */ ] },
              "sample": /* the printed rows, as data */ } ]
}

Two edges worth knowing. elapsedMs is the one field that varies between identical runs, so strip it before diffing two reports (chapter 13 shows the two-line form). And render --json requires --out, exiting 2 otherwise — without a file to write, the SVG is the output and there is nowhere for a report to go.

What "correct" means here

A cook that errors on nothing is not evidence. Before calling a graph done, check five things: the counts are in the range you expected, the attributes you meant to write are present with the right type and tuple size, the bounds are where the geometry should be, non-finite is 0, and the picture looks like the thing. pcg inspect answers the first four and pcg render the fifth — which is the whole reason the loop has four steps instead of two.

8 · Edit, and keep the caches

The shell loop rebuilds from the file every time, which is correct for a one-shot and wasteful for a tool that stays up: a rebuild starts with cold caches. A long-running agent holds one live graph instead and edits it in place with the chapter 12 API. Reconstruct handles from the snapshot, then change exactly one parameter — the density field's frequency, from 0.03 to 0.09.

const byId = Object.fromEntries(
  graph.describe().nodes.map(n => [n.id, { id: n.id }])
);

graph.getParams(byId.density).name;      // "density"  — confirm the target first

graph.setParam(byId.density, "value", fieldFromJson({
  fn: "fbm", base: "perlinNoise",
  opts: { frequency: 0.09, octaves: 4, normalized: true },
}));                                     // fieldFromJson, NOT the raw spec object

9 · Recook and compare

const r2 = await cook(graph);

r2.stats;                                       // { cooked: 3, cached: 1 }
firstGeometry(r2.outputs.points).pointCount;    // 1005  (was 1038)

// The live graph and its serialized form stay in step:
serializeGraph(graph).nodes[1].params.value;
// { fn: "fbm", base: "perlinNoise",
//   opts: { frequency: 0.09, octaves: 4, normalized: true } }

Read the stats as a check on your own model. cached: 1 is the scatter node — upstream of the edit, so untouched. cooked: 3 is the edited node plus its two dependents. Had the numbers come back { cooked: 4, cached: 0 }, something changed that you did not intend to change, and that is worth investigating before trusting the output.

10 · Close the loop

graph.removeNode(byId.spawn);       // documented cascade: the node, its connections,
                                   // and the "instances" output declared on it
graph.describe().outputs;          // [ { id: "keep", pin: "out", name: "points" } ]

await cook(graph);                 // { cooked: 0, cached: 3 } — survivors all served warm

const finalDoc = serializeGraph(graph);   // canonical, and idempotent from here on

Write finalDoc back to g.json and the shell loop picks up where the in-process one left off. That is the point of running both halves against one file: validate / cook / inspect / render is the outer loop that decides whether a graph is right, and the mutation API is the inner one that gets it there without throwing away caches between attempts.

The loop in one sentence: discovery gives you the legal moves, validation rejects the illegal ones with the fix attached, cook stats tell you what actually happened, inspect and render tell you whether it was what you meant, mutation applies the next edit without discarding warm caches, and serialization hands back a canonical document. No step requires reading the library's source, and no step requires guessing.

Reference

Where to look next

DocumentWhat it isBest for
llms.txtThe compact agent capability map: the mental model, the JSON field names, the invariants, and the full measured GPU budget tables. It ends in a pointer list to everything below.The single file to load as context for an LLM.
skills/Three doctrine skills in the standard Agent Skills format — graph-authoring (the loop, and choosing a primitive over raw nodes), determinism (seeding, anchoring, the hop ladder, how to verify), performance-and-budgets (what a budget does and does not fix). None lists a node or a param: they carry how to work, and cite the generated references for what exists.Deciding what to do, and in what order.
docs/nodes.md · .jsonGenerated node reference: every pin, parameter, default and range.Looking up one node exactly; offline validation.
docs/primitives.md · .jsonGenerated catalog of the named primitives: pins, exposed params with derived schemas, content hash, and a pcg run line each.Finding a recipe before building one.
docs/examples.md · .jsonThe corpus under examples/graphs/, indexed by what each graph teaches. Every file cooks from JSON alone.Copying a working shape near your goal.
docs/authoring.mdGraph JSON spec, the full field grammar, named subgraphs and hash pinning, recipes, transfer mappings, GPU eligibility rules.The precise format, and worked recipes.
README.mdThe human API tour.A second angle on anything in Part I.
Project overviewArchitecture and pipeline diagrams, roadmap.How the modules layer, and what shipped when.
Live demosAll nine examples, running.Seeing it before reading about it.

The order in that table is the order to read it in. llms.txt first, because it is the map; then the skill that matches what you are about to do; then a generated reference, and only for the one entry you need. Nothing hand-written in this project embeds a listing that a generator already owns — which is why this manual explains why and when, and those files answer exactly what.

Development commands, for working in the repository itself: npm test runs the unit, integration and determinism suites; npm run build produces dist/ with the ., ./three, ./gpu, ./cli and ./primitives subpath exports; npm run check type-checks; npm run examples starts the demo server; npm run docs regenerates the node, primitive and corpus references and restamps the site. The CLI itself is node bin/pcg.mjs from a clone (it runs the built dist/, so build first) and npx pcg from an install.