This is a runnable PoC of the scenario: a C application/game, compiled to WebAssembly, targeting the web, that embeds the lua2wasm compiler so it can run Lua scripts. It proves the whole loop and makes the real constraints concrete.
examples/embed/build.sh # build engine.wasm (needs clang + wasm-ld)
node --experimental-wasm-exnref examples/embed/demo.mjs # run the three demos(The --experimental-wasm-exnref flag is what Node needs to run the Lua
modules' exception-handling opcodes — the same flag the rest of the project's
runtime uses. A browser supports them natively.)
| File | Role |
|---|---|
engine.c |
A stand-in C "engine" with the lua2wasm compiler linked in. Compiles Lua → wasm bytes in its own linear memory (engine_build); receives numbers from running scripts (engine_on_value). |
build.sh |
Reuses the compiler's freestanding object files (scripts/build-wasm.sh), compiles engine.c, links engine.wasm. Plain clang/wasm-ld, no Emscripten. |
broker.mjs |
The JS orchestrator: instantiates produced modules, wires imports/exports, marshals values, and wraps the host-call ABI (callLua). |
demo.mjs |
Three demos: script→engine, engine→script per-frame, and the engine calling named Lua functions on a persistent instance (lua_call). |
┌──────────────── Browser / Node: JS broker ────────────────┐
│ │
engine.wasm ──(1) engine_build(src) → script bytes──► broker │
(C + compiler (compiled in engine's linear memory) │ │
linked in) ▼ │
▲ (2) WebAssembly.instantiate
│ │ │
│ (3) host.print(luaval) ──► broker ──► lua_get_int(luaval) │
│ ◄── engine_on_value(double) ◄──── (reduce GC→prim) │
│ script.wasm (WasmGC) │
│ (3') host.read_num() ◄── broker ◄── lua_make_int(frame) │
└───────────────────────────────────────────────────────────┘
Three facts the PoC makes concrete:
-
The compiler links into a C host with no glue.
engine_buildjust calls the compiler'slua2wasm_compile_ex+lua2wasm_assemble(linked fromsrc/). Because both are the same freestanding wasm build, they share one module and one linear memory. -
A wasm module can't instantiate another — JS must.
engine.wasmproduces the script bytes but cannot run them; the broker callsWebAssembly.instantiate. Your engine being wasm doesn't remove the JS layer, it adds a second module for JS to broker. (The wiring is one-time; you can pass one module's exports as another's imports so the per-call path is direct wasm↔wasm — except for value marshaling, see #3.) -
The engine can't touch Lua values; JS reduces them to primitives. Lua values are WasmGC objects in the script's heap. A linear-memory C function can't even name that type, so it deals only in
double/i64. The broker bridges by calling the script's ownlua_get_int/lua_get_float(GC→scalar) andlua_make_int/lua_make_float(scalar→GC). That'sengine_on_valuereceiving a plaindoublein demo 1, andread_numreturning a freshly boxed number in demo 2.
Demo 1 and 2 are run-a-script: call main(), and the script talks to the
world through host imports. Demo 3 is the real scripting model — the engine
invokes a named Lua function with arguments and reads its result, against
one persistent instance whose state survives between calls
(damage(30) then damage(25) sees hp decrement). That's what game-style
scripting needs (on_update(dt), on_collision(a, b), ...).
It's enabled by compiling with --embed-api (here engine_build passes
embed_api = 1). That exports a small host-call ABI on the produced module:
| Export | Use |
|---|---|
lua_str_new(n) / lua_str_setb(s,i,b) |
build a Lua string (a name or a string arg) |
lua_make_int / lua_make_float |
build a Lua number |
lua_get_global(name) |
fetch a global (your function) by name |
lua_args_new(n) / lua_args_set(a,i,v) |
build an argument list |
lua_call(fn, args) |
invoke; returns a results array (Lua errors throw the LuaError tag) |
lua_pcall(fn, args) |
protected invoke; returns [ok, ...results-or-error] — no host-side try/catch needed |
lua_args_len(r) / lua_args_get(r,i) |
read the results (handles multiple returns) |
lua_table_new() / lua_table_get/set(t,k[,v]) / lua_table_len(t) |
build/read/write Lua tables |
lua_call/lua_pcall accept any callable and set up the call frame error()
needs; lua_pcall also restores call depth on a caught error, so it's safe to
keep calling after a script throws.
broker.mjs wraps these as callLua(S, name, ...args). Because the ABI can
reach any global and call anything, --embed-api keeps the whole stdlib live
(no tree-shaking) and runs stdlib_init, so the modules are larger than a
tree-shaken run-once script — the trade you make for a callable script.
The general harness for this lives in tests/test_embed_api.mjs.
Capability sandboxing. A script can only do what you wire. Instantiate an
untrusted mod's module without the os_*/fs_* imports (see
instantiateScript — unwired imports throw on use) and it physically cannot
touch the filesystem or clock. Combined with the engine's own memory/time
limits, that's a strong sandbox with no extra code.