What it is
A node-graph PCG library, deterministic by construction
pcg-ts generates content — scattered and filtered points, sampled surfaces and splines, resampled paths, networks over shared points, instanced geometry, streamed worlds around a moving camera — from seeded node graphs you build in code, in JSON, in the visual editor, or from the pcg command line.
Any parameter can vary across space instead of being a constant: a value can be a function of where it lands, resolved per point.
Every random decision flows from a seed through one hash chain, so the same graph and seed produce byte-identical output across runs, platforms, cook orders, and streaming paths.
The heavy work moves to the GPU: field expressions compile to WGSL, and instance matrices are composed there and handed straight to three.js — without ever touching the CPU.
It is built to be driven by AI agents as much as by humans: nodes carry machine-readable schemas, graphs serialize to stable JSON, and every error names the node, pin, or param at fault and states the fix.
Foundations
Four concepts the rest is built on
Attribute data model
Attributes live on four domains — point, vertex, primitive, detail — as SoA typed-array columns, with promote and transfer between them. The standard point carries transform, density, bounds, color, and its own seed. A 2-vertex polyline over shared points is an edge, so a network needs no fifth domain.
Deferred fields — Field<T>
A value can be a function of evaluation context, resolved only when it lands on a domain. Node params accept T | Field<T>; noise, trig, and combinators compose into expression trees that also serialize to JSON.
Graph runtime
A pull-based executor with revision-keyed memoization, time-budgeted and cancellable cooking, per-output partial cooks, and serializable subgraphs. Recook an unchanged graph and every node is a cache hit.
Streaming world
Hierarchical grid levels — 2D or 3D cells, plus an unbounded level — cook cells around a viewpoint with hysteresis and LRU eviction. Cell content is provably independent of cook order, path, and evictions.
Architecture
Layered core, optional adapters
The core has zero dependencies and never imports three.js or WebGPU — guard tests enforce that only the adapters may. Each layer depends only on the ones before it.
%%{init: {"theme": "neutral", "flowchart": {"htmlLabels": false, "curve": "basis", "subGraphTitleMargin": {"top": 12, "bottom": 6}}}}%%
flowchart TB
subgraph L1["foundation"]
direction LR
RANDOM["src/random — PCG32 · hashing"]
DATA["src/data — SoA attributes · domains"]
end
subgraph L2["values"]
direction LR
FIELDS["src/fields — deferred values · combinators"]
NOISE["src/noise — 5 noise types as fields"]
end
subgraph L3["execution"]
direction LR
GRAPH["src/graph — executor · memo cache · subgraphs"]
NODES["src/nodes — 38 types · registry · JSON"]
SPATIAL["src/spatial — uniform grid · adjacency CSR"]
end
subgraph L4["orchestration"]
direction LR
RUNTIME["src/runtime — World · streaming"]
SPAWN["src/spawn — InstanceBatch protocol"]
end
subgraph L5["authoring"]
direction LR
PRIMS["src/primitives — 34 named recipes"]
CLI["src/cli — pcg: validate · cook · inspect · render"]
end
THREE["src/three — optional adapter, the only module importing three"]
GPU["src/gpu — optional WGSL compiler + WebGPU device runtime"]
L1 --> L2 --> L3 --> L4 --> L5
L4 -. render-agnostic boundary .-> THREE
L2 -. structural resolver boundary .-> GPU
Fig. 1 — module layers; arrows are the only allowed dependency direction.
src/datatyped-array attribute storage, geometry, promote/transfersrc/randomPCG32 RNG and seed hashing — no Math.random anywheresrc/fieldsdeferred values, evaluation contexts, memoizationsrc/noisefive noise fields with published raw ranges + normalized modesrc/graphnodes, pins, scheduler, caching, subgraph compositionsrc/nodesstandard library, self-describing registry, JSON round-tripssrc/spatialuniform grid and adjacency CSR the spatial nodes sharesrc/runtimehierarchical World, partitioned cooking, invalidationsrc/spawnrender-agnostic instancing terminalsrc/primitivesnamed subgraph recipes referenced from JSON by namesrc/clithepcgbinary — validate, cook, inspect, rendersrc/gpufield-expression → WGSL compiler, WebGPU evaluator, device-resident runs, parity-budgetedsrc/threeBufferGeometry/Curve converters, InstancedMesh, scene binding
How a world gets made
From a seed to instances on screen
%%{init: {"theme": "neutral", "flowchart": {"htmlLabels": false, "curve": "basis"}}}%%
flowchart TB
SEED(["seed + viewpoint"]) --> WORLD["World: coarse to fine levels"]
WORLD --> CELLS["wanted cells: hysteresis · LRU"]
CELLS --> COOK["cook per cell, seeded by coords"]
COOK <--> CACHE[("memo cache")]
COOK --> ITEMS["DataItems: geometry · instances"]
ITEMS --> BATCH["InstanceBatch: assetId · transforms"]
BATCH --> ADAPTER["three adapter"]
ADAPTER --> MESH(["InstancedMesh in the scene"])
Fig. 2 — the streaming cook path. Budgeted, cancellable, resumable; identical results in any cook order.
// Scatter points in a box, displace them with a noise field. import { Graph, cook, firstGeometry, pointScatterInBounds, jitterPoints, fbm, perlinNoise, remap } from "pcg-ts"; const graph = new Graph(42); // every node seed derives from this const scatter = graph.add(pointScatterInBounds, { count: 500, boundsMin: [0, 0, 0], boundsMax: [50, 0, 50], }); // `amount` is field-capable: scalar noise, evaluated per point. 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); console.log(result.stats); // { cooked: 2, cached: 0, elapsedMs: … }
Cook again: both nodes are cache hits. Change one param: only its dependents recook.
Built for agents
Runtime introspection and stable serialization
- Self-describing registry — every node type exposes pins, param schemas, defaults, and prose descriptions at runtime; the node reference is generated from it, never hand-written.
- A command line that closes the loop —
pcg nodes,fields,validate,cook,inspect,render.--jsonpicks a rendering of the same result rather than a separate code path, exit codes are 0 / 1 (failure) / 2 (misuse), andrenderwrites a deterministic top-down SVG that diffs in git. - A vocabulary to reference, not rebuild — 34 named primitives ship as
pcg-ts/primitives, cited from JSON asref: { name, hash? }. A name-only ref upgrades freely; a pinned one hard-errors on mismatch. No mode warns, and no mode cooks a near-miss. - Stable JSON everywhere — graphs, subgraphs, and field expressions serialize to a versioned format that round-trips to byte-identical cooks; serialized form is stable across cooks.
- Errors that state the fix — validation names the offending node, pin, or param and lists the valid alternatives. Error messages are part of the API surface.
- Introspectable execution — cook stats report what cooked, what was cached, and how long it took; determinism makes every run reproducible.
- Agent entry points —
llms.txtcapability map, generateddocs/nodes.md/primitives.md/examples.mdwith their.jsontwins, three skills that cite every enumerable thing by path rather than inlining it, an authoring guide with recipes, and the user manual, whose second half is written for an agent driving the library programmatically. - Independently audited — each phase was reviewed by a separate agent before it landed, and the defects it found were fixed with regression tests before release.
Examples
Nine demos, live in your browser
Each one is the real library cooking real graphs — click to run. Also browsable from the demo index.
Behind them sits a corpus of 37 single-concept graphs that are data rather than pages: pcg cook examples/graphs/<name>.json. None of them uses dataInput, because its items do not survive serialization and an example an agent cannot run teaches nothing. Eight of them are one settlement pipeline — ground and wall, district centres, lots, buildings, roads, plus three edit variants — where each stage is the previous file plus nodes and nothing removed, and the earlier stages cook bit-identically inside the later ones.
Roadmap
What shipped in each release
-
v0.1.0 2026-08-05shipped
The full foundation in one unattended build: attribute data model, PCG32 + fields + five noise types, graph executor with budgeted cooking, 23-node standard library, streaming World, three.js adapter, five demos, agent docs. 429 tests; every phase independently audited.
-
v0.2.0 2026-08-05shipped
The entire post-v0.1 backlog: serializable subgraphs and dataInput, trig + orient-along-vector in the field grammar, normalized noise with published raw ranges, exact worley, string attributes for declarative multi-asset spawns, sanctioned per-cell seeding, per-output cooking, 3D cube cells, unbounded levels without dummy radii. 562 tests; old graphs cook byte-identically.
-
v0.3.0 2026-08-05shipped
The stretch tier: uv and raycast attribute transfer (barycentric interpolation, robust ray-triangle intersection, deterministic tie rules, acceleration grids provably equivalent to brute force) and the interactive graph editor — a Svelte node editor built entirely on the public registry, validation, and serialization APIs, with live cooking and byte-identical JSON round-trips. 597 tests.
-
v0.4.0 2026-08-05shipped
Editor-grade graph APIs: removeNode / disconnect / removeOutput with exact cache surgery (delete a branch, everything else serves from cache), frozen describe() and getParams introspection, registry categories across all 25 node types, and per-instance subgraph pin introspection — proven by the graph editor adopting them wholesale. 627 tests.
-
v0.5.0 2026-08-06shipped
WebGPU field kernels: the serializable field grammar compiles to WGSL compute kernels and cooks on a real device through
pcg-ts/gpu— bit-exact u32 hash/random streams, measured per-op float budgets, cache provenance across the CPU/GPU toggle, cook stats with machine-readable fallback reasons, and a million-point live demo. The CPU stays the bit-exact reference. 769 tests. -
v0.6.0 2026-08-06shipped
Pervasive GPU + device-resident pipelines: five more nodes resolve their field params on the GPU, chunked dispatch retires the element-count ceiling, and pooled buffers bound the allocation churn. On top of that, maximal linear chains of count-preserving field nodes now fuse into device-resident runs — attribute columns stay in storage buffers across member kernels and only the run terminal reads back, under a strict cache contract (terminal-only entries, splits wherever the graph observes bytes). New counters:
residentRuns,fusedNodes,readbacksSaved. The CPU stays the bit-exact reference. 842 tests. -
v0.6.1 2026-08-06shipped
Constant node params ride the run uniform instead of a device column: a plain
translate: [0, 0, 0]now costs a 16-byte slot and no dispatch, cutting the demo chain's working set 23% and its member kernels from 12 to 9. Values live in the uniform and never in the generated WGSL, so editing a constant hits the pipeline cache instead of recompiling. 853 tests. -
v0.7.0 2026-08-07shipped
Instance transforms that never reach the CPU.
spawnInstancescan now terminate a device-resident run: a WGSL kernel composes the 4×4 matrices and the batch hands back an opaque buffer handle instead of aFloat32Array. A renderer sharing the sameGPUDeviceadopts that buffer as a storage instance attribute, so a streamed world goes from compute kernel to draw call with no readback and no per-cell upload. Ownership is explicit — pool-owned, detached, then disposed by the holder — and handles are refcounted by identity, so a parent output aliased into several cells is freed once, in whichever order they evict. Single-asset only for now;assetAttrfalls back to the CPU path with a named reason. The CPU path stays the reference. 955 tests. -
v0.8.0 2026-08-07shipped
Multi-asset spawns become device-resident too — composed on the GPU and never read back, closing v0.7's one limitation. A
spawnInstancesdriven byassetAttrno longer falls back — and it needs no GPU sort, which is the interesting part. A resident run always starts from a host geometry and no resident node can produce a string attribute, so the asset key is host-resident by construction: the host plans the grouping with the same function the CPU spawner calls, uploads a permutation, and the device composes once per asset. Ordering is therefore identical by construction rather than by comparison, and it is now a documented contract — batches by ascending first-occurrence point index, ascending point index within each. A cell yields one buffer per asset, each detached and refcounted by identity. Remaining boundary, stated plainly: a stringsetAttributeis not resident-eligible, so a graph computing its key that way fuses only the spawn. 1023 tests. -
v0.9.0 2026-08-08shipped
Fields built with the ergonomic API stop being second-class. There were two ways to build a
Fieldand only one of them was supported:fieldFromJsonattached a spec, whilecomponent(position(), 1)attached nothing — so a graph holding a combinator field could not be serialized at all. Now every constructor derives its spec from its arguments, and those graphs round-trip. Only three cases still refuse, and they say which: amakeFieldclosure, anything composed over one, and a tree past the spec depth limit — derivation refuses at exactly the depth the parser enforces, so a spec that could not be read back is never written. Device eligibility is the separate, narrower question: derived-spec fields reach the GPU only underacceptDerivedSpecs, off by default, because the device path is a documented approximation of the CPU one and no existing graph should change bytes just by upgrading. 1560 tests. -
v0.10.0 2026-08-09shipped
An agent can author, validate and cook a graph without writing TypeScript. A
pcgCLI —nodes,fields,validate,cook,inspect,render— where--jsonselects a rendering of the same result rather than a separate code path, exit codes are 0 / 1 (failure) / 2 (misuse), andrenderwrites a deterministic top-down SVG that diffs in git. Subgraph nodes gain exposed params, and a primitive becomes a registered subgraph cited from JSON asref: { name, hash? }instead of an embedded payload — a name-only ref upgrades freely, a pinned one hard-errors on mismatch, so no mode warns and no mode cooks a near-miss. 29 named primitives ship aspcg-ts/primitives; node types go 25 → 32; 23 single-concept example graphs and two skills ship inside the package, every example cooking from JSON alone because an example an agent cannot run teaches nothing. The serialization format is now closed — an unknown key is a hard error at every object position, so a typo can no longer cook as something else. One cut names the gap it leaves:place/along-curvewas dropped becausesplineSampleneeds a polyline and no node produced one. 2217 tests. -
v0.11.0 2026-08-09shipped
The polyline gap, closed from the producing side. The library had a polyline consumer, a type, a render branch and an inspect branch — and no in-graph producer, so no path could exist in a serialized graph at all; the gap had been sighted five times from five directions before it was fixed.
pointsToPath,pathResampleandwriteTangentstake node types 32 → 35 and restoreplace/along-curve. Primitives 29 → 34 with the first curve family, field-grammar functions 40 → 42, corpus 23 → 34 — including six staged pipeline graphs whose superset and edit-locality properties are machine-checked rather than asserted in prose. Silent truncation of a multi-geometry collection becomes a diagnostic, and a class of silent attribute clobber is refused across six reporting-slot params: a param naming an attribute the node shapes now refuses a differently-shaped existing column instead of destroying it. Breaking against the unpublished v0.10.0:shape/ring'scountnow means exactlycount. CI arrives, on Node 20 and 22 — and the first run it ever performed failed both legs with 79 type errors that cannot appear on a developer machine, becauseexamples/**import the library by package name, which resolves through the exports map intodist/, and a clean checkout has none. 2463 tests. -
v0.12.0 2026-08-09shipped
Scale-aware declutter.
selfPrunegains a field-capableminDistance, so the radius is per point rather than per graph — big trees need more room than bushes — under amax(rA, rB)symmetry rule, so no kept point ever has another kept point inside its own radius. Apriorityattribute settles a contested spot: higher wins, ties fall to the lower index, so an authored plot beats a procedural one because of a value it carries rather than where it sits in a merge. Rewiring the corpus onto it exposed that the lesson it had been teaching never fired — the authored stage's 8-unit exclusion already exceeded the prune's 7-unit radius, so no authored and procedural pair had ever contested anything, and withprioritydeleted all 12 authored plots still survived. The exclusion is now 3, the contest is real, and a test requires that deletingprioritycosts authored plots. The topology phase this release replaced was re-surveyed and split in three: no existing source could derive a halo, becausepointScatterInBoundscomputes positions as a function of its bounds, so widening for a halo moves every point and reproduces nothing. 2484 tests. -
v0.13.0 2026-08-09shipped
World-anchored sources, and the networks they unblocked.
pointScatterInWorldreads its bounds only to pick lattice cells and clip, so a halo is just a wider query — that was the hole in the runtime pillar, and every cross-partition op depended on it. Per-point randomness now keys on point identity rather than array index, so an op behaves the same however the work was split. On top of that,connectPointsemits one 2-vertexpolylineprimitive per edge over the same points that arrived, and the survey's most useful finding was that this needs no new domain: a 2-vertex polyline over shared points already is an edge, so a junction is genuinely one point shared by every edge meeting there, and no edge payload ever crosses a pin.filterPrimitivesByBoundsis the one filter here that preserves topology, which is what makes a partitioned network cook expressible in a serialized graph rather than only in host TypeScript. Cut on measurement rather than taste:findPathand MST, because both are global — an MST edge belongs iff no lighter path connects its ends, so a chain of N plus a closing edge defeats any finite halo. The replacement is a relative-neighbourhood mode, halo-exact at the same bound, which contains the MST but leaves cycles — a network rather than a tree, which is what a road layout wants. Node types 35 → 38; the corpus gains a fifth pipeline stage: 10 segments over 9 district centres, degrees {1:1, 2:5, 3:3}, one component, two cycles. 2648 tests. -
v0.14.0 2026-08-10shipped
A gap v0.13.0 opened, closed. Per-edge values became a headline capability — a road carries a
widthpromoted from its endpoints and akindfrom the first — and every node that sampled a primitive down onto points dropped them on the floor, so the lamps placed along a road could not see the width of the road they stood on.splineSample,pathResample,surfaceSampleandplace/along-curvenow carry the source primitive's attributes onto the samples through one shared helper;pathResamplealso keeps its own output primitives' attributes, whichsetPolylineTopologyhad been deleting, so a resampled road stays a road. The carry is automatic rather than opt-in, because the demand is that an author who setroadWidthgets it back without knowing a knob exists — and the cost is stated here rather than rediscovered as a bug later: every upstream primitive attribute becomes part of a sampler's output contract, so an unrelatedlengthAttrwidens the samples. A collision with a name the node itself owns is refused, naming node, attribute and fix.transferAttributereads a primitive source inuvandraycastmodes;nearestrefuses and names the route instead.pcg render --attrreaches the primitive domain, colouring paths from primitive values. 46 of 46 planned phases; 2675 tests. -
v0.15.0 2026-08-11shipped
Cooks leave the main thread. A profile of a streamed world found the freeze nobody's budget could slice:
budgetMsyields between nodes, and one noise field over a cell's points is a ~100 ms atom — sopcg-ts/workerships aCookWorkerPoolthat cooks serialized graphs off-thread. The design was already the right shape: a graph crosses once per worker as JSON, each cook sends parameter patches, and outputs return with every typed array on the transfer list — the boundary costs under a millisecond against the cook it relocates. Byte-identity is tested rather than assumed: string tables cross in interning order sou32index columns survive exactly, errors rehydrate into real classes with the node name and the fix intact, and a pooledWorldmatches a no-poolWorldbyte-for-byte along a streaming path with evictions.LevelDefgainsbindPatches, the serializable sibling ofbind(), applied by one shared implementation on both paths so they cannot drift. Two defects fixed with names: three r0.185 keys renderer state per instanced mesh and frees it only on a material'sdisposeevent, so sharing one material across meshes leaks pipelines unboundedly — per-mesh clones disposed on every release path end it; and esbuild builds cross-chunk edges from used symbols, so a bare side-effect import of the node registry rode on chunk-ordering luck that two new entry points ran out of — an evaluation-order witness makes the edge real, and a twelve-case fresh-process smoke gate on the built package now runs before any publish. 2841 tests. -
next unscheduledexploring
All 46 planned phases are done, and the entry that stood here — a
residentdescriptor forfilterByAttribute— was surveyed and declined. It is feasible, and cheaper than the entry assumed, but it does not compose with the zero-round-trip path: at a spawner terminal the surviving count sizes a retained buffer and there is no readback for it to ride, so a run could never hold both a count-changing member and an instances terminal. And the saving it was to buy turned out to be free — movingsetAttribute("scale")ahead of the two filters inexamples/02-forestbought the same readback for no library work. What is left is small, and recorded in PLAN.md with the reasoning:fieldToJsonstill refuses with a message enumerating all three causes instead of naming the one that applies, and amaxDegreebounded within a radius is halo-exact but unbuilt.