feature Zach Shallbetter

One Engine, Four Runtimes — The Zero-DOM Core

Most “cross-platform” engines are not one engine. They are a reference implementation and a pile of ports that drift apart the moment someone fixes a bug in only one of them. The physics forks. The behavior forks. The word “same” quietly becomes “similar.”

Fundamental took a harder line. There is exactly one engine, and it lives in @fundamental-engine/core — a package that imports zero DOM. No document, no window, no requestAnimationFrame, no ResizeObserver. Not “tries not to.” Not “mostly avoids.” Zero, enforced by a test that fails the build if a single DOM-global call-site appears anywhere in the package.

That one constraint is what makes the field portable. The same force math runs on the web, inside a Three.js scene, headless with no screen at all, and natively on Apple platforms — because none of those runtimes are a reimplementation. They are the same core, handed a different host.

The seam: FieldHost

The core never reaches for the environment. Everything it would otherwise read from the browser — the viewport size, the scroll offset, whether the tab is hidden, whether the user prefers reduced motion, the next animation frame, the subtree to scan for bodies — comes through one injected interface, FieldHost:

export interface FieldHost {
  root: ParentNode;                  // what to scan for [data-body]
  viewport(): HostViewport;          // width, height, dpr
  scrollY(): number;
  reducedMotion(): boolean;
  hidden(): boolean;
  raf(cb: (t: number) => void): number;   // schedule a frame
  cancelRaf(id: number): void;
  createCanvas(): HTMLCanvasElement;
  onResize(cb): () => void;
  onScroll(cb): () => void;
  // …visibility, input, body events
}

createField(canvas, opts) builds the renderer-agnostic engine, then drives it entirely through whatever host you give it. The interface is pure types — no globals — which is precisely why field.ts can import zero DOM and still know how to ask “how big is the surface?”

A new runtime, then, is not a new engine. A new runtime is a new FieldHost. That is the whole trick, and the rest of this essay is just the four hosts that already exist. The host-driven runtime paper is the formal version of this argument; the platform docs are the practitioner’s map of the seam.

The boundary is real, not aspirational

It is easy to say a core is renderer-agnostic. The honest version is mechanical. The boundary is a test — dom-boundary.test.ts — and its allowlist is the empty set:

const ALLOW = new Set<string>();

It walks every source file in the package and matches DOM-global access patterns: document.querySelector, window.devicePixelRatio, new ResizeObserver, a bare requestAnimationFrame( call. The patterns are written to catch call-sites, not prose — so a comment that says “scan the document” survives, but document.querySelector( does not. If any match appears, the test fails. There is no exception list to quietly grow.

This is what keeps the layering from rotting. The browser adapter, browserHost(), lives one package up in @fundamental-engine/dom — not in core. The download helpers live there too. Core stays clean not by good intentions but because the build refuses to ship otherwise.

Runtime one: the web

browserHost() is the obvious host. It binds the engine to window, document, and rAF: scan the real DOM for [data-body], measure rects, schedule frames off the browser’s clock, pause when the tab backgrounds. This is the host behind <field-root> and behind every page on this site. It is the host everyone meets first — and it is just one implementation of an interface, not the engine itself.

Runtime two: Three.js

@fundamental-engine/three (shipped and published in 0.8.1) runs the same core inside a WebGL scene. It supplies threeHost() plus a threeBackend() for drawing, and projects the field into 3D — PlaneProjection for a flat field in a scene, VolumeProjection for a true volumetric one. The particles become an instanced ParticlePool; meshes become bodies via FieldBodyRegistry.

The point worth holding onto: the forces are not reimplemented in GLSL or re-derived for 3D. The same core resolves them; Three.js is a projection and a backend, a host and a renderer pair bolted onto the unchanged engine. A gravity well behaves identically whether it is bending DOM text or bending an instanced mesh, because it is literally the same code resolving it.

Runtime three: headless, no document at all

This is the one that proves the architecture. headlessHost() binds the engine to nothing:

const host = headlessHost({ width: 1920, height: 1080 });
const field = createField(undefined, { host, render: 'none' });
field.addBody({ tokens: ['attract'], rect: () => box, onFeedback: (ch) => read(ch.density) });
host.tick(); // advance one frame — on a schedule, or per agent turn

There is no canvas. There is no scan root — bodies arrive through addBody, not [data-body]. There is no animation loop the environment owns; the caller drives the clock with tick(). The viewport is an abstract volume you declare. createCanvas() deliberately throws, because a headless field has nothing to draw and shouldn’t pretend.

What you get back is the field’s state — density, attention, coherence, pressure — through onFeedback, sampleScalar, and readParticles. The full simulation runs. It just runs for a reader that isn’t a screen: an agent treating the field as a salience substrate, a Node service, a deterministic test, a native sidecar.

And here is where it connects to the rest of the system. The engine default is render: 'none' — signals-first. A field created without a render mode runs the entire simulation and draws nothing, writing its signals out for whatever wants to sample them. headlessHost() is simply that idea taken to its limit. If the field can run drawing nothing, it can run with no screen at all. Invisible-first on the web and document-free in a Node process are the same fact viewed from two distances.

Runtime four: native Swift

The Swift port is not a wrapper around the JS. It is the same model, expressed natively — and it honors the same seam. FundamentalCore defines its own FieldHost, and the platform hosts plug into it the way browserHost plugs into the web: UIKitFieldHost, AppKitFieldHost, and a RealityFieldHost for spatial scenes, plus a ManualFieldHost that is the Swift analogue of headless. Same architecture, same host contract, a different language and platform underneath.

This is why engine fixes are a discipline, not a scramble: a physics or render bug is fixed in the JS core and the Swift port in a focused change, because both planes implement the same model. They are allowed to be the same because the seam is the same.

The cost, stated plainly

The zero-DOM core is not free, and a system that hid the bill would be the kind of dishonest architecture this project exists to avoid.

The dependency direction is a tax you pay forever. Core ← dom ← {elements, react, vanilla, three}; core never depends up. Every time the engine wants something the environment knows — a new piece of viewport state, a new event source, a new way to measure — you cannot just reach for it. You have to widen the FieldHost interface, then implement that method in every host: browser, headless, Three.js, and the Swift equivalent. The indirection is real work, and it is felt most exactly when you are in a hurry. The payoff is that nothing can quietly couple core to a browser; the price is that adding capability is never a one-line reach.

The non-DOM renderers are still maturing. browserHost is the battle-tested path — it runs this whole site. The Three.js backend shipped in 0.8.1 and is real, but it is younger; the projection and particle paths have less production mileage than the DOM renderer. The Swift port runs the same model and has its own hosts, but it is earlier on the curve than the web runtime it mirrors. “One engine, many runtimes” is true at the level of the model. At the level of polish, the runtimes are not all the same age, and pretending otherwise would set you up for surprise.

What the architecture guarantees is the thing that actually matters: a force resolves the same way everywhere, because there is one place it is resolved. The hosts are how it reaches each surface. The core is why it is the same field every time — and why the interface is a field, not a screen no matter what surface it lands on.