Zig instead of Emscripten

Deep technical August 2026 · from the RingScript project

There was already a working answer. A 2025 attempt had compiled the Ring virtual machine to WebAssembly with Emscripten, and it ran — see 1+2 printed 3 in a browser. Replacing a toolchain that works is the kind of decision that eats a week and produces a rewrite of the same thing. Here is how it got made in half a day, from evidence rather than taste, and what it actually cost.

Why the question was reopened at all

Not because Emscripten was slow or wrong. Because of what the old build was: a REPL demo with four structural defects, none of them fixable without rewriting the bridge anyway.

DefectWhat it meant
Batch, not resident every call created and destroyed a whole RingState — no variable, class or loaded library survived a call
One-way, string-only output escaped through an intercepted see into a fixed 512 KB static buffer, silently truncated; no structured return values, no way for Ring to call out
No load, no files multi-file Ring could not be loaded at all — the browser has no filesystem
Fragile failure a VM error could abort the whole wasm instance

Since the bridge had to be rebuilt regardless, the toolchain was genuinely open — and every neighbouring project in the same family had moved to Zig: the CLI, the compiler, the engine, the packaging, and a vendored 9 MB SQLite amalgamation that Zig had compiled without a complaint. Zig 0.15 was installed on the machine. The Emscripten SDK was not.

The evidence, gathered by grep

Emscripten exists for good reasons, and the classic ones are specific: setjmp/longjmp, pthreads, signals, and a POSIX surface too wide for wasi-libc. The question was not "is Zig nicer" but "does this particular C source need any of that?" — and 44 files of vendored VM source can simply be asked. These are the actual commands, with the counts they still return today:

grep -rn  "setjmp\|longjmp"      ringvm/src/   # 0
grep -rn  "pthread"             ringvm/src/   # 0
grep -rn  "signal("             ringvm/src/   # 1  — state.c, a SIGSEGV handler
grep -rln "dlopen\|LoadLibrary" ringvm/       # 1  — include/dll_e.h
grep -rln "fopen"               ringvm/src/   # 4  files

Not a clean sweep, and that is the interesting part. The two hard blockers are genuinely absent. The two remaining hits are both in the corner a browser runtime does not use: a crash handler, and the macro that opens a dynamic library. Neither needed a workaround — one is emulated by a wasi-libc flag, the other is a whole source file that simply is not compiled.

The decision took the time it took to run five greps. What should have been an argument was a lookup.

Three years of "it probably needs Emscripten" collapsed into a page of compiler flags. This is the whole of it — 41 of the 44 VM sources compiled unmodified, and every environmental difference stated as a -D:

"-DRING_NODLL=1",        // a browser loads no extensions (drops dll_e.c)
"-DRING_LIMITEDSYS=1",   // no system(), no chdir()/currentdir()
"-D_WASI_EMULATED_SIGNAL",         // the SIGSEGV handler above
"-D_WASI_EMULATED_PROCESS_CLOCKS",
"-Dmkstemp(x)=(-1)",     // WASI has no temp directory
"-Dfopen=rs_fopen",      // every file open -> the embedded-map resolver
"-Dstat(a,b)=rs_stat(a,b)",   // ...and every question ABOUT a file

The last two lines are the ones worth pausing on. There is no sandbox around the filesystem here — the filesystem is redirected at the compiler level to a read-only map baked into the binary. A capability that was never linked in cannot be escaped from, misconfigured, or re-enabled by a later patch.

And the stat line is a bug fix wearing a flag's clothes: fexists, getpathtype and getfilesize ask about a file rather than opening one, so redirecting only fopen left them reporting that embedded files did not exist. It is written function-like — stat(a,b), not stat — so it expands only where stat is followed by a parenthesis, leaving every struct stat in the source untouched.

What replaced each Emscripten convenience

Emscripten is not just a compiler; it is a bundle of conveniences. Each one had to be answered explicitly, and the answers turned out to be smaller than the conveniences.

Emscripten gives youWhat we wrote instead
generated JS glue and a POSIX-ish environment a hand-written ~150-line WASI shim: clocks, randomness, fd_write — ours, vendorable, readable in one sitting
MEMFS and --preload-file @embedFile: Ring sources baked into the wasm as data, with the bridge resolving load against an embedded map. Not a virtual filesystem — no filesystem
EM_ASM for calling into JavaScript Zig extern imports declared by the bridge and provided by the same shim
your page ringscript.js the WASI shim the whole JS side imports ringscript.wasm the bridge (Zig) one resident RingState Ring VM (vendored C) compiler + VM, as shipped
Three layers, three languages, one build command. Nothing is fetched at build time — there is no dependency manifest at all.

The fallback that was never needed

The plan wrote down its own escape hatch before starting: a timeboxed spike, and if a wasi-libc gap resisted a day's work, fall back to Emscripten for the VM only — the bridge API was designed to be toolchain-agnostic precisely so nothing downstream would care.

The gaps that actually surfaced were tmpfile and mkstemp. Both were stubbed without touching a line of vendor source. The escape hatch stayed shut, and the whole repair plan — resident state, unbounded output, error trapping, the embedded payload, the two-way bridge, and a version bump of the vendored VM — went through in one session.

What it cost, honestly

Two things, and both are worth stating because a post that only lists wins is an advertisement.

The shim is ours to get right. Hand-written glue is the densest source of defects per line in this project, and its failures are the nasty kind: the VM keeps running and reports something plausible. A wrong clock conversion does not throw — it just makes every timestamp quietly wrong. So the shim has its own suite that holds it to the host's own clock, encoding and output ordering rather than to its opinion of itself: 20 checks, and they exist because guessing was not acceptable.

Some things had to be built rather than configured. No MEMFS meant writing a file-map resolver. No generated glue meant writing the imports. Each of these is a small amount of code you now own forever — which is a cost, and also the point.

What it bought

One toolchain across the whole family, so a person who can build the CLI can build the runtime. No provisioning: no Python, no Node, no emsdk environment scripts, no CMake. No dependency manifest — there is no build.zig.zon in this repository, so nothing is fetched at build time, ever. The dev server is part of the build. And the artifact is small enough to argue about: 396,935 bytes of wasm for a complete language implementation, released by default because the committed artifact must never be a debug build.

zig build            # the wasm, ReleaseSmall
zig build serve      # ...and serve it on http://localhost:8377/
zig build dist       # cross-compile the dev server for every shipped platform

Zig 0.15.2 is the only thing you need installed.

How we knew it was still Ring

Changing the compiler that builds a language implementation is exactly the sort of move that produces something 99% correct — which is another way of saying broken. The answer was not confidence, it was an oracle: every program is run through both the wasm runtime and a native ring.exe, and the output compared byte for byte.

CorpusSizeResult
Playground examples24byte-identical
the official Ring samples~284zero mismatches
code blocks lifted from the Ring documentation~550zero mismatches
permanent gates66all pass

That battery is also what made it safe, later, to patch the VM's hottest code path — the two quadratic surprises are a story that only has a happy ending because ~850 programs could be re-checked in a few minutes.

The decision as it was written down at the time, evidence and fallback included: docs/REPAIR_PLAN.md, §2.5. The layers as they ended up: docs/architecture.md.