World-building with WASM
Last updated on June 15, 2026. Published on June 8, 2026. · 7 min read
How a Rust physics engine compiled to WebAssembly quietly carries an entire walkable world, and how Runek builds those worlds on top of it.
I’ve been building Runek, a source
registry of procedural 3D components for React Three Fiber. Think “shadcn
for 3D worlds”: you pull a component’s source into your project
(npx runek add bookshelf), and it generates its own geometry from props
and a seed. No models, no textures, no CDN. Its showcase,
Helicon, is a walkable island
whose entire scene is one JSON file.
A walkable browser world needs three things: something to generate the world, something to draw it, and something to make it solid. Generation is plain TypeScript. Drawing is three.js on WebGL. But the third one, the part where you collide with a wall instead of ghosting through it, where you climb a staircase and jump off a roof, is the part that runs on WebAssembly.
Physics is the worst possible workload for JavaScript
Rendering gets all the attention, but the GPU does that heavy lifting. Physics runs on the CPU, on the main thread budget, every single frame:
- Broad-phase: which of the N bodies might be touching?
- Narrow-phase: exact contact points between candidate pairs.
- Constraint solving: iterate until contacts, joints, and friction agree with each other.
- Integration: advance every body by the timestep.
At 60fps you have ~16ms per frame for everything (React, the scene graph, draw-call submission), so physics realistically gets a few milliseconds. And the workload itself is exactly what JavaScript engines struggle to make fast consistently:
- It’s allocation-hungry by nature. Naïve engines create contact manifolds, vectors, and solver scratch data every step. In JS that means garbage, and garbage means GC pauses. A 10ms collection in the middle of a walk cycle is a visible hitch. Physics stutter is uniquely jarring because your brain expects motion to be continuous.
- It’s cache-sensitive. Solvers want bodies packed in flat, contiguous arrays (structure-of-arrays), marched through in order. JS objects are pointer-chasing machines; even well-optimized JIT code can’t fully control memory layout.
- It’s numerically picky. A JIT can deopt a hot function mid-frame and change your performance profile; the same code can run at different speeds minute to minute.
The budget is 16.6ms, every frame, forever. One GC pause is a visible stutter.
The browser ecosystem’s history tells the same story. The classic options were ammo.js, the Bullet engine (C++) machine-translated to JS/WASM via Emscripten (powerful, but with an API that feels like manually managing C++ from JavaScript), and cannon-es, a hand-written JS engine that’s pleasant but simply can’t compete on solver robustness or scale.
Enter Rapier
Rapier (by Dimforge) takes the other path: it’s a physics engine written in Rust, designed from day one to compile to WebAssembly rather than being a port of something else. That buys exactly the things JS couldn’t guarantee:
- No GC, no pauses. Rapier manages its own memory inside WASM linear memory. Frame times are flat.
- Layout control. Rust structs compile to the packed, cache-friendly data the solver wants. WASM executes it at near-native speed.
- Reproducibility. WASM’s float semantics are pinned to IEEE 754: the same build of the engine performs the same arithmetic everywhere. Rapier even offers an enhanced cross-platform determinism mode. For a project whose core promise is same seed, same world, this matters more than raw speed: a world should not just look identical on every machine, it should feel identical.
The JS↔WASM boundary is the tax you pay, and Rapier’s design minimizes it:
you don’t call into WASM per object per frame. You describe the world once
(bodies, colliders, joints), then call world.step(), a single boundary
crossing, and the entire broad-phase/narrow-phase/solver pipeline runs
inside WASM. JavaScript only reads back the transforms it needs to render.
In React land, @react-three/rapier
wraps this declaratively. A physical object is just JSX:
<RigidBody type="fixed" colliders={false} position={position} rotation={rotation}>
{/* one collider for the whole footprint; books stay visual-only */}
<CuboidCollider args={[w / 2, h / 2, d / 2]} />
{/* …meshes… */}
</RigidBody>
Even the player is WASM all the way down: ecctrl, the character controller, is built on Rapier’s kinematic character controller, so every step, slope, and jump in Helicon is resolved by Rust-compiled collision code.
World-building on top: Runek
With solidity handled, the interesting design space opens up one level higher: what is a world made of?
Runek’s answer is a component contract. Every component (a bookshelf, a lake, a whole house) is a pure, deterministic function of its props:
<World>
<Terrain size={[40, 40]} relief={2} seed={9} />
<Bookshelf position={[0, 1, 0]} seed={42} fill={0.8} />
<Player />
</World>
The contract has a few load-bearing rules:
- All randomness flows from
seed, through a tiny deterministic RNG.seed: 42builds the identical bookshelf on every machine, forever: same books, same spines, same lean.
seed 42, seed 42, seed 42, seed 7. The seed isn’t a randomizer; it’s an
address: it names one specific world in the space of possible worlds, and
the generator can find it again on any machine. (This figure is itself
generated by the same RNG.)
- No assets. Geometry, materials, and palette come from code. There is
no
.glbto download, no texture CDN, nothing to host but static files. - Every component owns its colliders, and collider count is proportional to gameplay surface, not visual detail. A bookshelf is one WASM-side cuboid, not forty book-shaped bodies; terrain with procedural relief registers a trimesh so the collision matches the visuals exactly. This rule is what keeps the WASM side fast: the renderer can afford thousands of instanced meshes, but the physics world stays lean.
Left: what WebGL draws. Right: what WASM knows about: one cuboid for a
whole bookshelf.
And because every component is a deterministic function of plain props, a whole world collapses into data:
{
"version": 1,
"palette": { "wood": "#75563c", "foliage": "#557d3c" },
"fog": { "color": "#dfe9f5", "near": 35, "far": 110 },
"nodes": [
{ "type": "Terrain", "props": { "size": [120, 120], "relief": 3, "seed": 9 } },
{ "type": "House", "props": { "position": [0, 0.02, 0] } },
{ "type": "Trees", "props": { "position": [-12, 0.02, -7], "seed": 4 } },
{ "type": "Player", "props": { "position": [-1, 3, 13] } }
]
}
The JSON isn’t a description of the world. It is the world.
That JSON is Helicon. Diff it, fork it, review it in a pull request. The runtime editor reads and writes the same structure: walk the world, flip to edit mode, move a house, download the world, commit.
There’s no autosave and no backend, by design. Edits live in memory as you drag; the source of truth stays the JSON file in the repo. When a change is worth keeping, you download the world and commit it. The world grows by pull request, not by a database write.
Here’s where the two halves of this post meet. The pipeline is:
JSON → deterministic JS generation → meshes for WebGL + colliders for WASM
JavaScript decides what exists. WebGL decides what you see. WASM decides what is real: what stops you, holds you up, pushes back. Each layer does the thing it’s actually good at, and the seams are invisible when you’re walking around inside the result.
The quiet takeaway
Nobody playing with Helicon knows WASM is involved, which is exactly the point. WebAssembly’s win here isn’t a flashy port of a game engine; it’s that a hard, latency-sensitive, allocation-hostile workload runs flat at 60fps inside a static website, leaving JavaScript free to do the expressive part: describing worlds as data and growing them one component at a time.
A walkable, editable, physically solid world: built from seeds, shipped as static files, with a Rust solver humming inside it. Worlds, one rune at a time.
Links:
- Runek Repo: github.com/nullorder/runek
- Runek Docs: runek.nullorder.org
- Helicon: github.com/nullorder/helicon