The WASM driver: packer + JsBridge cookbook
The WebAssembly build turns FluffOS into something you can hand out as a
URL: the full driver (compiler, VM, efuns, telnet) runs inside the page,
your mudlib rides along in a packed filesystem image, and the jsbridge
package lets LPC call into the browser — fetch(), canvas/WebGL, audio,
storage — and get called back.
This page is the what can I build with it guide. For building the
driver itself (deps, presets, CI) see Build (WebAssembly);
for the internals see
src/wasm/README.md.
The three pieces
| Piece | What it gives you |
|---|---|
the wasm driver (cmake --preset wasm) | fluffos.js + fluffos.wasm: a driver instance per browser tab, driven by exported functions |
the packer (tools/wasm/pack-mudlib.sh) | your mudlib + the driver + a web terminal as one static bundle — no server-side code at all |
the jsbridge package | js_eval() / js_call(): LPC ↔ page JavaScript, with async callbacks |
Recipe: publish a playable mudlib as a static site
tools/wasm/pack-mudlib.sh --mudlib /path/to/mylib --config etc/config --out dist/
dist/ is entirely static — serve it from GitHub Pages, Netlify, an S3
bucket, or python3 -m http.server. Every visitor gets their own
driver instance booting a pristine copy of the mudlib (nothing is shared
between tabs). That makes it ideal for:
- a playable demo of your MUD linked from its website — no login server exposure, no abuse surface, throw-away worlds;
- mudlib development tutorials — readers edit LPC in the packed lib
(
edworks in the terminal!),updatethe object, and see the result without installing anything; - regression sandboxes — pin a bundle per release and bisect behavior in a browser tab.
A minimal GitHub Pages deploy job (after the wasm CI job's build
steps):
- run: tools/wasm/pack-mudlib.sh --mudlib mylib --config etc/config --out site/
- uses: actions/upload-pages-artifact@v3
with: { path: site }
- uses: actions/deploy-pages@v4
Recipe: a custom frontend (xterm.js or your own UI)
The bundled index.html is deliberately small. Any UI can drive the
driver through five exported functions and two callbacks:
const M = await createFluffOS({ locateFile: (f) => f });
M.FS.chdir('/mylib');
M.fluffos = {
onOutput: (id, bytes) => term.write(bytes), // server->client telnet bytes
onDisconnect: (id) => term.write('*** gone'),
};
M.ccall('fluffos_boot', 'number', ['string'], ['etc/config']);
setInterval(() => M.ccall('fluffos_tick', 'number', ['number'],
[performance.now()]), 50);
const id = M.ccall('fluffos_connect', 'number', [], []);
term.onData((s) => { // client->server bytes
const b = Array.from(new TextEncoder().encode(s));
M.ccall('fluffos_input', null, ['number','array','number'], [id, b, b.length]);
});
xterm.js pairs especially well: it already understands ANSI colors, and
its AttachAddon-style byte interface maps 1:1 onto
onOutput/fluffos_input. The driver speaks real telnet (ECHO for
password masking, NAWS, GMCP…), so either strip IAC sequences with the
~60-line client in src/www/wasm/index.html or handle the options you
care about (GMCP gives you a structured out-of-band channel to your UI
for free — health bars, maps, inventory panels).
fluffos_connect() can be called more than once: several "players" in
one tab (e.g. a split-screen tutorial where the reader plays one
character and a script drives another).
JsBridge: the contract
// synchronous: evaluate JS, get the result (or "JS error: ...") as a string
string ua = js_eval("navigator.userAgent");
// asynchronous: call a handler the page registered; the callback runs on
// a later driver tick, like call_out(0) or the resolve() efun
int id = js_call("handler_name", ({ "arg1", "arg2" }), (: my_callback :));
void my_callback(string result, int success, int id) { ... }
// page side: plain functions or async functions / Promises
M.fluffos.handlers = {
handler_name: async (arg1, arg2) => { ... return value; },
};
And the reverse — the page calling into LPC:
// LPC: export a function under a name
private mixed lpc_add(string *args, int id) { return to_int(args[0]) + to_int(args[1]); }
js_export("add", (: lpc_add :));
// JS: a Promise of the (string) return value
const sum = await M.fluffos.callLPC("add", "2", "3"); // "5"
Rules of the road:
- Strings across the bridge. Args are an array of strings; the return value is stringified (objects become JSON). For structured data, JSON-encode/decode on both sides.
- Callbacks run without a command context. Like
call_out,this_player()is 0 inside the callback — capture the player in the closure:object me = this_player(); js_call(..., (: on_done, me :))and usetell_object(me, ...). (Seetestsuite/command/jsdemo.lpc.) - Failure is a flag, not a crash. A missing handler, a thrown JS
exception, or a rejected Promise all deliver
success == 0with the error text as the result.js_evalreturns"JS error: ..."text. - The page owns the handlers. LPC can only call what the embedding
page chose to expose — a bundle's capability surface is exactly its
handler table (plus
js_eval, which you can neuter with a CSP that forbidseval). - The mudlib owns the exports. Symmetrically, the page can only call
LPC names registered with
js_export(); each export runs on the next driver tick and holds a reference on its object until unregistered.
Recipe: a UI that drives the game (js_export)
js_export turns page chrome into game input without going through the
terminal. Buttons, inventory panels, or a clickable minimap call LPC
directly and render what it returns:
// LPC (in the player object): expose game state and actions to the page
private mixed ui_inventory(string *args, int id) {
return json_encode(map(all_inventory(this_object()), (: $1->short() :)));
}
private mixed ui_go(string *args, int id) {
return do_go(args[0]) ? "ok" : "blocked";
}
void setup_ui() {
js_export("inventory", (: ui_inventory :));
js_export("go", (: ui_go :));
}
// JS: a sidebar that refreshes after every move
document.querySelectorAll('button.dir').forEach((b) =>
b.onclick = async () => {
await fluffos.callLPC('go', b.dataset.dir);
render(JSON.parse(await fluffos.callLPC('inventory')));
});
The same channel works for tooling: a "run tests" button
(js_export("run_tests", ...)), a live object inspector, or a debug
panel querying query_load_av() — all without touching the telnet
stream.
Recipe: pull the web into the game (fetch)
M.fluffos.handlers.fetch_json = async (url) =>
JSON.stringify(await (await fetch(url)).json());
void got_weather(string body, int ok, int id) {
if (!ok) return;
mapping data = json_decode(body); // your mudlib's JSON package
tell_object(me, "It is " + data["temperature"] + " degrees outside.\n");
}
js_call("fetch_json", ({ "https://api.example.com/weather" }),
(: got_weather, this_player() :));
Live weather in the tavern, a news board fed by an RSS proxy, real GitHub issues as quest mobs — anything with a JSON API. CORS applies (it is the page doing the fetching), so use APIs that allow it or a same-origin proxy.
Recipe: graphics from LPC (canvas / WebGL)
The bundled terminal registers a canvas_draw handler (try
jsdemo canvas); the pattern generalizes to any rendering the page can
do — a <canvas> 2D minimap, a WebGL scene, or a DOM panel:
// LPC decides WHAT to draw; JS decides HOW.
void draw_room_map(object room) {
js_call("canvas_draw", ({ "clear" }));
foreach (object ob in all_inventory(room))
js_call("canvas_draw", ({ "circle", "" + ob->query_x(),
"" + ob->query_y(), "8" }));
}
For anything nontrivial, send one JSON scene description per frame
instead of one call per primitive, and let the JS handler render it —
js_call("render_scene", ({ json_encode(scene) })). Heartbeats make a
fine frame clock for turn-based visuals; for smooth animation let JS
interpolate between LPC-sent keyframes.