26. Browser JavaScript UI Backend
MALDA also supports a browser-hosted UI model where MALDA source is transpiled to JavaScript and runs with the MALDA JS runtime. This is separate from server-side @PAGE, @AIPAGE, and server-component flows.
26.1 Chapter Map
- 26.2 What the JavaScript backend provides
- 26.3 Compile commands and output modes
- 26.4 Required script loading order
- 26.5 API mode DOM helpers
- 26.6 Sum types and pattern matching in JS mode
- 26.7 Actors in JS mode
- 26.8 Template mode syntax
- 26.9 Canvas/game API
- 26.10 three.js scene API
- 26.10.1 Shader kernels (MALDA to GLSL)
- 26.11 Common pitfalls and practical guidance
26.2 What This Mode Provides
- API mode (
.malda): write MALDA that manipulates the DOM withdom.*helpers. - Template mode (
.malda.html): write HTML-first templates with inline MALDA blocks. - Language coverage: JavaScript backend supports sum types and full
matchpattern matching. - Actor support (local): JS mode supports local actors with mailbox semantics compatible with MALDA actor behavior.
- 3D scene support: JavaScript mode includes a curated
three.*wrapper for browser-hosted 3D scenes, plus@shader()kernels compiled to GLSL (see 26.10). - Runtime expectation: generated scripts require
malda-js-runtime.jsbefore MALDA output runs. - Desktop IDE debug: F5 on
dom.*/game.*/three.*programs transpiles to JavaScript, opens Web Preview, and maps editor breakpoints through the source map. Full-stack files (@client()plus@server()or a route) debug the host interpreter and Web Preview together (see 2.6.6).
26.2.1 Target decorators in JS output
When compiling to JavaScript, target decorators can be used to keep browser and server concerns in one source file:
@client()and@javascript()declarations are included in JavaScript output.@server()and@csharp()declarations are excluded from JavaScript output.@shared()declarations are included in both C# and JavaScript outputs.
Route decorators are still server-oriented. A declaration such as @GET(...) is treated as server code and is not emitted in JavaScript output.
26.3 Compile Commands
# API mode (.malda source)
malda compile Examples/Web/js/hello_dom.malda --mode js -o Examples/Web/js/hello_dom.js
# Alias form (equivalent)
malda compile Examples/Web/js/counter.malda --target js -o Examples/Web/js/counter.js
# PWA output directory
malda compile Examples/Web/js/hello_dom.malda --target pwa -o dist/hello-dom
# Template mode (.malda.html source)
malda compile Examples/Web/js/hello_template.malda.html --mode js -o Examples/Web/js/hello_template.generated.js
--target js is an alias for --mode js. --target pwa is an alias for --mode pwa and produces a directory containing the app shell, runtime, and transpiled JavaScript.
The inner loop for canvas games is malda play. It compiles --mode js, writes a host page, and serves a local URL so you do not have to author HTML. Packaging for itch.io stays --mode pwa.
malda new game my-game
cd my-game
malda play app.malda
Canvas plus a score API is malda new game --fullstack. That emits @client() / @GET / @POST and schema Score in one file. Compile with --mode fullstack; malda play refuses those sources.
malda new game my-scores --fullstack
cd my-scores
malda compile app.malda --mode fullstack -o dist
26.4 Required Script Loading Order
The host page must include a container such as #app, then load scripts in this order. Same file: Examples/Web/js/host.html.
- Any required browser dependency for the chosen namespace, for example
three.jsforthree.* malda-js-runtime.js- The compiled MALDA script
- The bootstrap call, such as
MaldaApp.main()
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MALDA JS host</title>
</head>
<body>
<div id="app"></div>
<script src="../wwwroot/malda-js-runtime.js"></script>
<script src="./counter.js"></script>
<script>
if (window.MaldaApp && typeof window.MaldaApp.main === "function") {
window.MaldaApp.main();
}
</script>
</body>
</html>
println is a JavaScript-backend console helper. The interpreter has no println (use io.print / print there — see 12. Input/Output).
26.5 API Mode DOM Helpers
The JavaScript runtime exposes browser helpers under dom for direct DOM manipulation. Same program: Examples/Web/js/counter.malda (compile with the command in 26.3, then open the host page from 26.4).
dom.query(selector, root?)dom.create(tagName)dom.append(parent, child)dom.clear(target)dom.setText(target, text)dom.html(target, markup)dom.on(target, eventName, handler, options?)
var root = dom.query("#app");
if (root == null) {
println("No #app container found.");
} else {
dom.clear(root);
var count = 0;
var title = dom.create("h1");
dom.setText(title, "Counter example");
dom.append(root, title);
var valueLabel = dom.create("p");
dom.append(root, valueLabel);
function renderCount() {
dom.setText(valueLabel, "Count: " + string(count));
}
var button = dom.create("button");
dom.setText(button, "Increment");
dom.append(root, button);
function onIncrementClick() {
count = count + 1;
renderCount();
}
dom.on(button, "click", onIncrementClick);
renderCount();
}
26.6 Sum Types and Pattern Matching in JS Mode
You can use the same sum-type and pattern-matching model in JavaScript backend mode as in interpreted mode.
type Result = Ok(value) | Err(message);
function divide(a, b) {
if (b == 0) return Err("divide by zero");
return Ok(a / b);
}
var outcome = divide(10, 2);
var text = match outcome {
case Ok(v): "ok: " + v;
case Err(msg): "error: " + msg;
};
println(text);
All pattern families are supported in JS mode: literals, identifiers, wildcard, variant payloads, array patterns with ...rest, and nested object patterns.
26.7 Actors in JS Mode
The JavaScript backend includes local actor runtime support through mlRuntime.actors. This provides browser-side or Node-local actor concurrency with mailbox semantics aligned to MALDA actor behavior.
- Supported features:
actor,spawn, call-stylesend, callback and timeout sends,reply(),receive(),self, and stop routing. - Per-actor FIFO mailbox processing is preserved.
send target.stop();routes through actor-aware stop behavior.
Boundary: this is local actor runtime support only. Direct transparent communication between browser JS actors and server-side actors is not enabled by default.
26.8 Template Mode Syntax
Template mode uses .malda.html files. Use {{ ... }} for interpolation and {% ... %} for statements.
<div class="hello-template">
<h1>Hello from template mode</h1>
<p>User: {{ "Andrea" }}</p>
{% var count = 2; %}
<p>Count value: {{ count }}</p>
</div>
See Examples/Web/js/host.html for a minimal static page that demonstrates runtime loading and compiled-script bootstrap.
26.9 Game Canvas API
The JavaScript backend includes a browser canvas/game API under game.* for simple 2D interactive loops.
- Canvas and lifecycle:
game.createCanvas,game.setBackground,game.start(updateFn, renderFn?),game.startFixed(updateFn, renderFn?, tickMs?),game.stop(),game.save(key, value),game.load(key),game.removeSave(key) - Drawing:
game.clear(),game.fillRect(...),game.fillCircle(...),game.drawText(...),game.drawLine(...),game.strokeRect(...),game.setAlpha(a) - Images and camera:
game.loadImage(url),game.imageIsReady(handle),game.drawImage(...),game.drawImageRect(...),game.setCamera(x, y),game.getCameraX(),game.getCameraY() - Pixel buffer / blit:
game.createPixelBuffer(width?, height?),game.setPixel(x, y, r, g, b, a?),game.blitPixels(pixels?, destX?, destY?) - Collision:
game.overlapRect(x1, y1, w1, h1, x2, y2, w2, h2),game.overlapCircle(x1, y1, r1, x2, y2, r2),game.pointInRect(px, py, x, y, w, h),game.pointInCircle(px, py, x, y, r) - Input:
game.isKeyDown(key),game.wasKeyPressed(key),game.wasKeyReleased(key),game.getMouseX(),game.getMouseY(),game.isMouseDown(button?),game.getTouches(),game.isGamepadConnected(index?),game.getGamepadAxis(index, axis),game.isGamepadButtonDown(index, button),game.wasGamepadButtonPressed(index, button) - Audio:
game.audioInit(),game.audioIsReady(),game.audioSetMasterVolume(v),game.audioPlayTone(...),game.audioPlayNoise(...),game.audioPlayPattern(pattern),game.audioStopPattern(),game.audioPlaySample(url, volume?, options?),game.audioStopSample(url?),game.audioLoadTrack(...),game.audioPlayTrack(),game.audioStopTrack(),game.audioSetTrackOptions(options),game.audioTrackIsReady(),game.audioGetTrackInfo(),game.audioStopAll()
Image and camera guidance: game.loadImage returns a handle immediately; decode is async. Draws no-op until game.imageIsReady is true (missing files stay unready, no throw). game.setCamera subtracts from subsequent world draws (fillRect, drawImage, lines, text). It does not move setPixel / blitPixels. Mouse coordinates stay in canvas pixels. See Examples/Games/game_sprite_smoke.malda.
Input guidance: Key and gamepad edges are snapshotted at the start of update and are false in render. Read wasKeyPressed / wasKeyReleased / wasGamepadButtonPressed in update only (same key names as isKeyDown). getTouches() returns { id, x, y } in canvas pixels (empty when none). The first active touch still aliases mouse button 0. Missing Gamepad API or disconnected pads return disconnected / axis 0. game.stop() clears keys and button edges. See Examples/Games/game_input_smoke.malda.
Collision guidance: Overlap helpers are inclusive (touching edges count) and return false when any width, height, or radius is ≤ 0. They are pure functions: no canvas, and they do not subtract setCamera. Not swept AABB or physics. See Examples/Games/game_collision_smoke.malda.
Loop guidance: updateFn receives dtMs. Scale movement by dtMs so animation stays frame-rate independent. game.startFixed(updateFn, renderFn?, tickMs?) defaults tickMs to 1000 / 60, accrues wall time, and calls update(tickMs) zero or more times per frame (max 5), then render once. It cannot run at the same time as game.start. See Examples/Games/game_fixed_save_smoke.malda.
Save guidance: game.save / game.load / game.removeSave store JSON in origin-scoped localStorage under a malda.game. prefix (not files). Missing storage, quota errors, or corrupt JSON: load returns null and save is a no-op (no throw).
Audio guidance: call game.audioInit() after a user gesture to satisfy browser autoplay policies. game.audioPlaySample(url, volume?, options?) decodes a WAV/OGG once per URL and caches it (volume default 1, clamp [0, 1]; { loop: true } repeats). Overlapping one-shots are allowed. game.audioStopSample(url?) stops samples only — it does not stop the v1 track, pattern, or tones. game.stop() still does not implicit-stop audio. Samples share the 32-node cap with tones. See Examples/Games/game_audio_sample_smoke.malda.
For a full quick start and best practices, see docs/javascript-backend.md. For a complete sample, see Examples/Games/game_bounce.malda. For PNG atlas blit plus a scrolling camera, see Examples/Games/game_sprite_smoke.malda. For key edges, touches, and gamepad, see Examples/Games/game_input_smoke.malda. For AABB and circle overlap, see Examples/Games/game_collision_smoke.malda. For overlapping WAV one-shots next to a looping pattern, see Examples/Games/game_audio_sample_smoke.malda. For a fixed timestep plus a high score that survives reload, see Examples/Games/game_fixed_save_smoke.malda. For a short side-scroller that uses the G1–G5 kit together (atlas tiles, camera, AABB, key edges, sample SFX, startFixed), see Examples/Games/malda_platform.malda. For a canvas client plus schema / validate scores, use malda new game --fullstack. For a Boulder Dash-style tile cave (dirt, gravity rocks, diamonds, fireflies), see Examples/Games/maldadash.malda. For a CPU ray tracer that writes pixels into an ImageData buffer and blits once per frame, see Examples/Games/ray_tracer.malda.
26.10 three.js Scene API
The JavaScript backend includes a curated 3D scene API under three.*. Use it when you want a browser-hosted scene in MALDA without writing raw THREE.* calls. The namespace is JavaScript mode only; interpreter and C# transpile do not run these helpers. Calls map to mlRuntime.three.* in the generated script.
Build a scene in this order: renderer, scene, camera, then meshes and lights, then three.start. three.createRenderer(width, height, mountSelector?) creates the WebGL canvas and appends it to the host element (default "#app"). Call it once, before three.render or three.start. If you need a new size or a second canvas, call three.stop() first — do not create another renderer while the loop is running.
- Renderer and lifecycle:
three.createRenderer,three.setClearColor,three.setRendererSize,three.start(updateFn, renderFn?),three.stop() - Scene graph:
three.createScene(),three.createPerspectiveCamera(fovDeg, aspect, near, far),three.createOrthographicCamera(left, right, top, bottom, near, far),three.setCameraAspect(camera, aspect),three.createGroup(),three.createMesh(geometry, material),three.add(parent, child),three.loadGLTF(url),three.modelIsReady(handle) - Primitives and lighting:
three.createBoxGeometry(width, height, depth),three.createPlaneGeometry(width, height),three.createSphereGeometry(radius, widthSegments?, heightSegments?),three.createTexture(url),three.createStandardMaterial(options),three.createShaderMaterial(options),three.setUniform(material, name, value),three.createDirectionalLight(color, intensity),three.createAmbientLight(color?, intensity?) - Transforms and input:
three.setPosition(object, x, y, z),three.setRotation(object, x, y, z),three.setScale(object, x, y, z),three.lookAt(object, x, y, z),three.render(renderer, scene, camera),three.isKeyDown(key),three.getMouseX(),three.getMouseY(),three.isMouseDown(button?)
Positions, scales, and camera near/far are world units. Rotations are radians (a floor lying in XZ is about -1.5708 on X). Standard-material options that the wrapper understands include "color" (#rrggbb), "roughness", "metalness", and "map" (a three.createTexture handle). createTexture returns immediately; decode is async and missing files stay unready (no throw). The material leaves map unset until the handle is ready. three.loadGLTF(url) returns a group you can add immediately; children appear when three.modelIsReady(handle) is true (JSON .gltf or .glb; failures stay unready). The runtime owns the loader — host pages do not add a fourth <script>. three.lookAt(object, x, y, z) requires lookAt on the three.js object. Groups parent meshes so you can move a cluster with one setPosition / setRotation. Input keys use the same names as the canvas API ("arrowleft", "arrowright", …).
A first scene with a spinning cube, one key light, and a dtMs loop:
var width = 800;
var height = 500;
var aspect = width / height;
var renderer = three.createRenderer(width, height, "#app");
three.setClearColor(renderer, "#101722");
three.setRendererSize(renderer, width, height);
var scene = three.createScene();
var camera = three.createPerspectiveCamera(70, aspect, 0.1, 100.0);
three.setCameraAspect(camera, aspect);
three.setPosition(camera, 0, 0, 5);
var geometry = three.createBoxGeometry(1, 1, 1);
var material = three.createStandardMaterial({ "color": "#44aaff" });
var cube = three.createMesh(geometry, material);
three.add(scene, cube);
var light = three.createDirectionalLight("#ffffff", 1.1);
three.setPosition(light, 2, 3, 4);
three.add(scene, light);
var angle = 0.0;
function update(dtMs) {
angle = angle + ((dtMs * 1.2) / 1000);
three.setRotation(cube, angle, angle * 0.8, 0.0);
}
function render() {
three.render(renderer, scene, camera);
}
three.start(update, render);
Loop guidance: updateFn receives dtMs, same as game.start. Scale spin and motion by dtMs / 1000 so animation stays frame-rate independent. Cap huge dtMs values after a tab was backgrounded if a single step would jump too far. Put drawing in renderFn (or omit it and render at the end of update).
Loading order: the host page must load a compatible browser bundle that defines globalThis.THREE before malda-js-runtime.js and before the compiled MALDA script, then call MaldaApp.main(). The repository includes Examples/Web/wwwroot/vendor/three.min.js for this purpose.
Examples/Web/wwwroot/vendor/three.min.jsmalda-js-runtime.js- compiled MALDA script
MaldaApp.main()
Resize guidance: if the host page changes the render viewport after setup, update both the renderer and the camera: three.setRendererSize(renderer, width, height) and three.setCameraAspect(camera, width / height).
MVP scope: orbit controls and generic raw JS interop stay outside this API. Textures and glTF loading are in: see Examples/Games/three_textured.malda. Lit meshes use three.createStandardMaterial. Custom GLSL uses three.createShaderMaterial and three.setUniform — see 26.10.1.
Compile the cube demo with:
malda compile Examples/Games/three_cube.malda --mode js -o Examples/Games/three_cube.js
See Examples/Games/three_cube.malda and Examples/Games/three_runtime_smoke_test.html for the end-to-end host page. For a PNG material map, a glTF cube, and lookAt, see Examples/Games/three_textured.malda. A shorter listing is in 33.15 JavaScript Backend (three.js scene API). Working notes: docs/javascript-backend.md.
26.10.1 Shader kernels (MALDA to GLSL)
When CPU game.setPixel is too slow, put per-pixel work on the GPU. This is a JavaScript compile-time subset, not a fourth execution backend. Host MALDA still owns the three.* loop and uniform updates. The kernel owns the rays.
Mark GPU functions with @shader() and gather them into a GLSL string with glsl.compile({ ... }). Those functions are not emitted as JavaScript or C#. Do not call them from host MALDA. Interpreter and C# transpile skip them.
@shader()
function vertexMain() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
var vert = glsl.compile({
varyings: ["vec2 vUv"],
functions: ["vertexMain"],
main: "vertexMain"
});
var material = three.createShaderMaterial({
"vertexShader": vert,
"fragmentShader": frag,
"uniforms": { "uTime": 0 }
});
three.setUniform(material, "uTime", t);
glsl.compile takes a single object literal. Keys:
varyings/uniforms/consts— arrays of GLSL declaration strings, for example"vec3 uCamPos"or"float EPSILON = 0.0015"functions— names of@shader()functions in this file to emit, in ordermain— which of those functions becomes GLSLvoid main()(use this when the MALDA name isvertexMain/fragmentMain, because a file cannot declare two MALDA functions namedmain)header— optional raw GLSL prefix
Pass the resulting strings to three.createShaderMaterial as "vertexShader" / "fragmentShader". Uniform values from host MALDA are wrapped as { value }. Arrays of length 2/3/4 become Vector2 / Vector3 / Vector4. #rrggbb strings become Color. Optional flags: "depthWrite": false, "depthTest": false, "transparent": true.
For a fullscreen pass, use three.createOrthographicCamera(-1, 1, 1, -1, 0, 1) and three.createPlaneGeometry(2, 2).
Types in kernels
vec2, vec3, vec4, and other GLSL vector/matrix names are not MALDA types. They are GLSL names stored as type hints and copied into the shader. Outside @shader(), var p: vec3 = ... is an unknown-hint Error. Names that already exist in both languages — float, int, bool, void — remain ordinary MALDA hints.
Shader parameters and locals need a GLSL type hint. Parameters that GLSL would write with out use a two-word hint: tHit: out float. Host uniforms stay MALDA arrays such as [camX, camY, camZ], not vec3 values.
Language subset
Allowed: if / else, while, for, return, arithmetic and comparisons, and / or / not (emitted as && / || / !), member swizzles such as position.xy, GLSL constructors and builtins (vec3, dot, normalize, mix, clamp, …), and math.sqrt → GLSL sqrt (same for the other mapped math.* names).
Not allowed in kernels: actors, prompts, match, strings, foreach, await, classes, and MALDA I/O. A construct the GLSL emitter does not know fails JS compile with a GLSL transpile error at the MALDA line.
IDE
Because kernels are ordinary MALDA function declarations, Desktop IDE and LSP rename, find references, highlights, and outline apply to identifiers in the kernel — unlike raw GLSL inside a """ string, which is one token. Rename still matches the lexeme across the file (same as the rest of MALDA). It does not rewrite string entries in glsl.compile (functions: ["hitSphere"], uniforms: ["vec3 uCamPos"], main: "fragmentMain"). After renaming a kernel or uniform identifier, update those strings. GLSL builtins (dot, gl_FragColor, type name vec3) have no MALDA declaration, so go to definition has nowhere to go.
Raw GLSL
Triple-quoted GLSL strings still work when you need syntax the subset does not emit. Use """, not $""", so braces stay literal. Prefer @shader() when the kernel fits the subset so rename and outline can see it.
Compile the realtime GPU sphere tracer with:
malda compile Examples/Games/three_shader_raytracer.malda --mode js -o Examples/Games/three_shader_raytracer.js
malda compile Examples/Games/three_shader_path_tunnel.malda --mode js -o Examples/Games/three_shader_path_tunnel.js
Load three.min.js first, same as other three.* demos. A short listing is in 33.15.1 Shader materials. Working notes: docs/javascript-backend.md. The path-tunnel sample is CC-BY-NC-SA-4.0 (Frostbyte).
26.11 Common Pitfalls and Guidance
- Do not edit generated JavaScript directly when there is a matching
.maldasource. Change the MALDA file and recompile. - Load
malda-js-runtime.jsbefore the compiled script or the generated app will fail at runtime. - Load
Examples/Web/wwwroot/vendor/three.min.jsbeforemalda-js-runtime.jswhen using thethree.*namespace. three.*is JavaScript mode with a browser host. Callthree.createRendererbeforethree.startorthree.render. Do not create a second renderer while the loop is running;three.stop()first.- Rotations are radians. After a viewport change, call both
three.setRendererSizeandthree.setCameraAspect. - Keep browser-only concerns in JS mode and keep server-only concerns in
@PAGE/@AIPAGEor server components. - Do not combine
@client()with route decorators such as@GETor@PAGE; this is an invalid target combination. - Use
@shared()only for logic that is safe in both runtimes; keep filesystem/database/server APIs and browser-only APIs out of shared declarations. - Create the canvas before drawing or starting a game loop.
- Use
malda new gameandmalda play app.maldainstead of writing a host HTML file.malda playserves.malda-play/, not the source tree. Packaging is still--mode pwa. Canvas plus scores ismalda new game --fullstackthenmalda compile --mode fullstack;malda playrefuses those sources. - Interpreter F5 cannot debug
dom.*/game.*/three.*programs. Use Desktop IDE F5 (Web Preview) or browser DevTools on the compiled.js+.map. - Use API mode when you want full DOM control and template mode when the page is mostly HTML with light MALDA logic.
@shader()functions are GLSL kernels, not host callables. Compile them withglsl.compileand pass the string tothree.createShaderMaterial.- Rename Symbol updates MALDA identifiers, not the string names inside
glsl.compile({ ... }). Keep those lists in sync. - Do not use
vec3/vec2as MALDA types outside@shader(). Host uniforms are arrays such as[x, y, z].
See Also
- 23. Web UI Overview - Which UI model to pick
- 24. Web UI Server Components - Server-rendered UI with fragments, live updates, and
ui.* - HttpServer & HTML UI Generation - Route hosting and AI HTML generation
- 33.15 JavaScript Backend (three.js) - Cube listing and shader-material sketch
- Full-Stack Development with MALDA - Choosing between server-rendered, API-first, and browser-hosted UI