28. 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.
malda play. Smallest file: Examples/Web/js/hello_dom.malda. Start from 25. Web UI Overview. Course: Learn MALDA — After the core path.
malda play app.malda (see 2.0). Canvas is 28.9, 3D and @shader() are 28.10. Catalog: 35.3. Playable showcase: 39 (screen-only). Folder: Examples/Games/.
28.1 Chapter Map
- Games (first-class JS surface) — 28.9
game.*· 28.10three.*· 28.10.1@shader() - 28.2 What the JavaScript backend provides
- 28.3 Compile commands and output modes
- 28.4 Required script loading order
- 28.5 API mode DOM helpers
- 28.6 Sum types and pattern matching in JS mode
- 28.7 Actors in JS mode
- 28.8 Template mode syntax
- 28.11 Common pitfalls and practical guidance
28.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 28.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).
28.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.
28.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. Edits to the source, sibling index.html, and assets/ rebuild and reload the browser (--no-watch serves the first compile only). 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
28.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).
28.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 28.3, then open the host page from 28.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();
}
28.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.
28.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.
28.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.
28.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.strokeCircle(...),game.drawText(...),game.measureText(text, font?),game.drawLine(...),game.strokeRect(...),game.setAlpha(a),game.setBlend(mode),game.getBlend(),game.setPixelated(enabled),game.getCanvasWidth(),game.getCanvasHeight() - Images and camera:
game.loadImage(url),game.imageIsReady(handle),game.imageWidth(handle),game.imageHeight(handle),game.drawImage(...),game.drawImageRect(...),game.drawImageEx(handle, x, y, options?),game.drawTiles(handle, cells, tileW, tileH, options?),game.setCamera(x, y),game.followCamera(targetX, targetY, viewW, viewH, worldW, worldH, options?),game.getCameraX(),game.getCameraY(),game.setCameraZoom(z),game.getCameraZoom(),game.pushCamera(),game.popCamera(),game.screenToWorld(x, y),game.worldToScreen(x, y) - 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),game.sweepRect(x, y, w, h, dx, dy, ox, oy, ow, oh),game.sweepRects(x, y, w, h, dx, dy, obstacles),game.tileAt(cells, col, row, options?),game.sweepTiles(x, y, w, h, dx, dy, cells, tileW, tileH, options?) - Input:
game.isKeyDown(key),game.wasKeyPressed(key),game.wasKeyReleased(key),game.getMouseX(),game.getMouseY(),game.getMouseWorldX(),game.getMouseWorldY(),game.isMouseDown(button?),game.wasMousePressed(button?),game.wasMouseReleased(button?),game.getTouches(),game.isGamepadConnected(index?),game.getGamepadAxis(index, axis, deadzone?),game.isGamepadButtonDown(index, button),game.wasGamepadButtonPressed(index, button),game.wasGamepadButtonReleased(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.audioCreateVoice(options?),game.audioSetVoice(voice, options),game.audioStopVoice(voice),game.audioSetListener(position, forward?, up?),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.imageWidth / game.imageHeight return the bitmap size, or 0 until ready. game.drawImageEx(handle, x, y, options?) adds atlas source, dest size, origin, rotation, flip, and tint: { sx?, sy?, sw?, sh?, w?, h?, ox?, oy?, angle?, flipX?, flipY?, tint?, tintFill? }. x/y are the origin (default dest top-left). angle is radians; canvas Y+ down so positive is clockwise. flipX around ox = 0 draws to the left of x — set ox to w / 2 for in-place facing. tint is a CSS color on an offscreen copy (omit / empty → no tint). Default tint is multiply (white is identity). tintFill: true replaces RGB and keeps alpha — white fill is a hit-flash. game.setBlend(mode) / game.getBlend() set the canvas composite for subsequent world draws: "alpha" (default), "add", "multiply", "screen" (unknown → "alpha"). Blend sticks until changed, like setAlpha; clear() always composites as alpha and does not reset the mode; createCanvas resets to "alpha". Blend does not affect setPixel / blitPixels. game.setCamera subtracts from subsequent world draws (fillRect, drawImage, drawImageEx, lines, text, strokeCircle). game.followCamera(targetX, targetY, viewW, viewH, worldW, worldH, options?) sets pan so the target sits at (screenX, screenY) then clamps the view inside (0, 0)–(worldW, worldH) (options { screenX?, screenY?, snap? }; default screen is view center; snap: true floors pan; does not change zoom). game.setCameraZoom(z) then scales those world sizes (default 1; clamp [0.05, 100]; non-positive → 1). Camera does not move setPixel / blitPixels. game.measureText(text, font?) returns { width, height } in unscaled font pixels (ignores pan/zoom — use it for HUD after zoom 1). game.setPixelated(true) disables smoothing and sets CSS image-rendering: pixelated; createCanvas resets it. getMouseX / getTouches stay in canvas pixels; getMouseWorldX / getMouseWorldY / screenToWorld honor pan and zoom. HUD: pushCamera, setCamera(0, 0), setCameraZoom(1), draw, popCamera (empty pop is a no-op; the stack stores pan and zoom). See Examples/Games/game_sprite_smoke.malda.
Input guidance: Key, mouse, and gamepad edges are snapshotted at the start of update and are false in render. Read wasKeyPressed / wasKeyReleased / wasGamepadButtonPressed / wasGamepadButtonReleased / wasMousePressed / wasMouseReleased in update only (same key names as isKeyDown; mouse default button 0). getTouches() returns { id, x, y } in canvas pixels (empty when none). The first active touch still aliases mouse button 0, so a new touch also edges wasMousePressed(0). Missing Gamepad API or disconnected pads return disconnected / axis 0. game.getGamepadAxis(index, axis, deadzone?) keeps the two-arg form raw; optional deadzone (clamp [0, 1]) returns 0 when |v| ≤ deadzone with no radial rescale. game.stop() clears keys, mouse edges, 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. They do not stop a mover that ends the tick past a thin wall. game.sweepRect(x, y, w, h, dx, dy, ox, oy, ow, oh) returns { hit, t, nx, ny, x, y } at the first contact along the delta (miss: t 1 and the end pose). game.sweepRects(x, y, w, h, dx, dy, obstacles) is the same against an array of { x, y, w, h } (earliest t; ties keep the first; empty/non-array is a miss). game.tileAt(cells, col, row, options?) returns the id at cell coordinates (nested rows or a flat array plus columns; out of range is out, default 0). game.drawTiles(handle, cells, tileW, tileH, options?) blits non-empty ids from an atlas (id 0 skips; atlas index is id - firstId, default 1; unready no-op; camera applies). game.sweepTiles(...) is sweepRect against solid cells (default: any id other than empty; optional solids array). Canvas Y+ is down, so a floor normal is ny = -1. Already penetrating (positive-area overlap) yields t 0 and a minimum-translation normal. Walking along a touching floor while sweeping X is not a hit. Not a physics engine or Tiled/LDtk. See Examples/Games/game_collision_smoke.malda and Examples/Games/game_tiles_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]; options { loop?: bool, pan?: number, playbackRate?: number }; pan clamp [-1, 1], default 0; playbackRate default 1, clamp [0.25, 4]). 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.
Voices and 3D sound. A voice is a sound that keeps playing until you stop it, such as an engine, wind, or a hum. game.audioCreateVoice({ "wave": "sawtooth", "freq": 80, "cutoff": 900, "volume": 0.2 }) returns a handle. "wave" is sine, square, triangle, sawtooth (default), or noise. "cutoff" is a low-pass filter in Hz (default 20000, so no filtering), and "glide" is how many seconds a change takes to settle (default 0.03). game.audioSetVoice(voice, { "freq": 140, "volume": 0.3 }) glides to the new values, so you can call it every update without clicks. game.audioStopVoice(voice) fades the voice out. Voices do not count toward the 32 one-shot cap, and game.audioStopAll() stops them too. Give a voice "position": [x, y, z] when you create it, and it plays through a 3D panner instead of the stereo pan. It is at full volume within "refDistance" (default 5), then quieter with distance, faster when "rolloff" is higher (default 1). Move it with audioSetVoice(voice, { "position": [...] }). game.audioPlaySample takes the same position / refDistance / rolloff options for a placed one-shot. game.audioSetListener(position, forward?, up?) places the ears, usually at the camera (default facing −Z with +Y up). These calls work from a three.* game as well; Examples/Games/three_shader_race_car_neural.malda gives each car its own engine voice and puts the listener on the camera.
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, setCameraZoom, drawImageEx flip/rotate/tint/tintFill, setBlend add/multiply, pushCamera HUD, measureText / imageWidth / setPixelated / strokeCircle, and a click-to-world marker, see Examples/Games/game_sprite_smoke.malda. For key edges, wasMousePressed, touches, and gamepad (including wasGamepadButtonReleased and analog deadzone), see Examples/Games/game_input_smoke.malda. For AABB overlap plus swept AABB (sweepRects), see Examples/Games/game_collision_smoke.malda. For a 2D id grid (drawTiles / tileAt / sweepTiles), see Examples/Games/game_tiles_smoke.malda. For overlapping WAV one-shots with pan / playbackRate 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 kit together (atlas tiles, followCamera, sweepRects, key edges, sample SFX, startFixed) plus spinning coins, a facing player via drawImageEx, coin-hit tintFill flash, and an additive spark, 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 that uses the kit together (drawTiles / tileAt / sweepTiles, followCamera, drawImageEx flip/tint, setBlend add/multiply, key/gamepad/touch, sample SFX, startFixed, saved high score), see Examples/Games/maldadash.malda. For a turn-based chess board (click/touch moves, sliding pieces with a knight hop, legal-square highlights, castling / en passant / queen promotion, checkmate, optional 1–3 ply CPU, game.save), see Examples/Games/chess.malda. For a CPU ray tracer that writes pixels into an ImageData buffer and blits once per frame, see Examples/Games/ray_tracer.malda.
28.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.setRenderScale(renderer, scale),three.setAutoRenderScale(renderer, targetFps?, minScale?, maxScale?),three.getRenderScale(),three.getStats(),three.showStats(visible?),three.start(updateFn, renderFn?),three.startFixed(updateFn, renderFn?, tickMs?),three.getFixedAlpha(),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.createInstancedMesh(geometry, material, count),three.setInstance(mesh, index, x, y, z, options?),three.setInstanceCount(mesh, count),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.createCylinderGeometry(radiusTop, radiusBottom?, height, radialSegments?),three.createTexture(url),three.createDataTexture(width, height, pixels, options?),three.updateDataTexture(handle, pixels),three.mandelbrotOrbit(real, imag, maxIter),three.createStandardMaterial(options),three.createShaderMaterial(options),three.setUniform(material, name, value),three.createDirectionalLight(color, intensity),three.createAmbientLight(color?, intensity?),three.setShadows(renderer, enabled),three.setShadow(object, cast, receive?),three.setLightShadow(light, options?),three.setFog(scene, color, near, far),three.setFogExp(scene, color, density) - 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, up?),three.render(renderer, scene, camera, options?),three.isKeyDown(key),three.wasKeyPressed(key),three.wasKeyReleased(key),three.getMouseX(),three.getMouseY(),three.isMouseDown(button?),three.wasMousePressed(button?),three.wasMouseReleased(button?),three.isGamepadConnected(index?),three.getGamepadAxis(index, axis, deadzone?),three.isGamepadButtonDown(index, button),three.wasGamepadButtonPressed(index, button),three.wasGamepadButtonReleased(index, button)
Fixed step and input edges. three.startFixed(update, render?, tickMs?) is the same loop as game.startFixed: update(dtMs) runs at a fixed tick (default 60 Hz, clamped to 1–1000 ms, at most 5 catch-up ticks per frame), and render() runs once per animation frame. Physics and training then do not depend on the display rate. In render, three.getFixedAlpha() returns how far the frame sits between the last tick and the next (0 to 1), which you can use to interpolate positions. The press, release, and gamepad calls have the same names and rules as their game.* twins. A press or release is visible in exactly one update, under three.start as well as three.startFixed, and reads in render return false. Gamepad reads outside a loop poll the pad directly and see no edges. three.stop() clears all input.
Instancing, shadows, and fog. three.createInstancedMesh(geometry, material, count) draws up to count copies of one mesh in a single draw call. Every copy starts at the origin. three.setInstance(mesh, index, x, y, z, options?) places copy index. The options are "rotation" (a Y angle, or [rx, ry, rz]), "scale" (a number or [sx, sy, sz]), and "color" (#rrggbb or [r, g, b] from 0 to 1, multiplied with the material color). Copies that never get a color keep the material color. three.setInstanceCount(mesh, n) draws only the first n copies. Shadows need three switches. three.setShadows(renderer, true) turns on the shadow map. three.setLightShadow(sun, { "extent": 20 }) makes a directional light cast shadows over a square that reaches extent world units from its target, with optional near, far, mapSize (256 to 4096, default 1024), bias, and normalBias. three.setShadow(object, cast, receive?) marks a mesh, group, or glTF model and all its children; receive defaults to cast. three.lookAt(light, x, y, z) aims a directional or spot light by moving its target. Move the light and its target with the player to keep a small, sharp shadow square around the action. three.setFog(scene, color, near, far) fades to color between two distances, three.setFogExp(scene, color, density) fades exponentially, and a null color clears the fog. Use the clear color as the fog color so the horizon blends into the sky. Switching shadows or fog on or off rebuilds the scene's materials on the next three.render. Shader materials ignore fog and shadows. See Examples/Games/three_forest.malda.
Two passes: shader plus meshes. A fullscreen shader can draw the main scene while ordinary meshes add the rest on top. Have the fragment shader write gl_FragDepth for its first hit, using the projection of a perspective camera with the same near / far: (far + near) / (far - near) - 2 * far * near / ((far - near) * d), mapped from −1..1 to 0..1, where d is the distance along the view direction (write 1.0 for the sky). Then call three.render(renderer, meshScene, perspectiveCamera, { "clear": false }): without the clear, the meshes keep the shader's colors and depth-test against them, so shader objects and meshes hide each other correctly. Aim that camera with three.lookAt(camera, x, y, z, [upX, upY, upZ]), using the shader camera's up vector, which also works when the camera looks straight down. three.createCylinderGeometry(radiusTop, radiusBottom, height, segments?) is an upright cylinder centered on the origin (radiusTop 0 makes a cone). Examples/Games/three_shader_race_car_neural.malda ray-traces the track and cars, then draws trees, buildings, and signs as instanced meshes this way.
Render scale and stats. The drawing buffer is the renderer size times devicePixelRatio (capped at 2) times the render scale. The canvas keeps its size on the page and the browser stretches the picture. three.setRenderScale(renderer, 0.5) draws a quarter of the pixels, which matters most for per-pixel shaders such as a ray tracer. The range is 0.25 to 2. three.setAutoRenderScale(renderer, 60, 0.5, 1) adjusts the scale for you. It drops by 15% when the frame rate stays under 90% of the target, and it rises by 0.05 after 3 seconds at the target. After a drop the scale holds for 4 seconds, and the first second after the loop starts or the tab comes back is ignored. Keep targetFps at or below the display rate, or the scale only goes down. three.setRenderScale turns the automatic mode off. three.getStats() returns fps, frameMs, renderScale, autoScale, the drawing-buffer width / height, drawCalls, and triangles, measured over half-second windows. three.showStats(true) shows the same numbers in the canvas corner. Opening the page with #malda-stats in the URL does the same without code. three.getMouseX() / getMouseY() are in renderer units (the size passed to createRenderer or setRendererSize), so neither the render scale nor a HiDPI screen changes them.
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.createDataTexture(width, height, pixels, options?) is ready immediately (RGBA; default float32, or "type": "byte"; nearest, no mipmaps). three.updateDataTexture(handle, pixels) rewrites that buffer. three.mandelbrotOrbit(real, imag, maxIter) is JavaScript-only BigInt: pass decimal strings for C (MALDA numbers are IEEE double), walk Zn+1 = Zn² + C at 256 fraction bits, and pack (re, im, alive, 1) into a 1-row float texture. Shader-material uniforms unwrap those handles for sampler2D. 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 28.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. Folder index: 35.3 Game. Working notes: docs/javascript-backend.md.
28.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. Texture handles from createTexture / createDataTexture / mandelbrotOrbit unwrap to THREE.Texture for sampler2D. 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).
Uniform arrays. Declare one with a size, for example "vec3 uCars[CAR_COUNT]" beside consts: ["int CAR_COUNT = 8"], and index it in a kernel: return uCars[index];. glsl.compile writes consts before uniforms so a const can size an array. createShaderMaterial reads the uniform T name[N] declarations from the shader strings, so three.setUniform(material, "uAngles", carH) fills a float[8] instead of turning the list into a Vector4. Supported element types are float, int, bool, vec2/vec3/vec4, and ivec2/ivec3/ivec4. Vector arrays take a nested list ([[x, y, z], ...]) or a flat one ([x, y, z, x, y, z, ...]). Missing entries become zero, extra entries are ignored, and an array that is never set starts at zero. Updates write into the existing values, so calling it every frame does not allocate new vectors.
var frag = glsl.compile({
uniforms: ["vec3 uCars[CAR_COUNT]", "float uAngles[CAR_COUNT]"],
consts: ["int CAR_COUNT = 8"],
functions: ["carPosition", "fragmentMain"],
main: "fragmentMain"
});
three.setUniform(material, "uCars", [[0, 0, -5], [-3, 0, -8]]);
three.setUniform(material, "uAngles", carH);
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 ray 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_billiards.malda --mode js -o Examples/Games/three_shader_billiards.js
malda compile Examples/Games/three_shader_path_tunnel.malda --mode js -o Examples/Games/three_shader_path_tunnel.js
malda compile Examples/Games/three_shader_mandelbrot.malda --mode js -o Examples/Games/three_shader_mandelbrot.js
Load three.min.js first, same as other three.* demos. Samples live under Examples/Games/ — see 35.3. Working notes: docs/javascript-backend.md. The billiards sample is playable: host MALDA steps 2D physics (zoom, orbit stop, and sliders for cushion/ball restitution and felt friction) and the kernel traces ball/cue uniforms. The compiled table is an appendix: 39. Appendix: GPU Billiards. The path-tunnel sample is CC-BY-NC-SA-4.0 (Frostbyte). The Mandelbrot sample is an infinite Seahorse Valley zoom: the host builds a BigInt reference orbit (three.mandelbrotOrbit) and the kernel applies scaled perturbation so the dive stays sharp past float32 ulp. Page sliders set iterate budget and wrap decades.
28.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]. three.mandelbrotOrbitis JavaScript BigInt, not the interpreter. Pass decimal strings for the center; MALDA numbers are IEEE double and will round a deep C.- Numbers follow JavaScript in four places where the interpreter and C# transpile agree with each other:
1 == 1.0istruein JS andfalseelsewhere (an integer never equals a float);math.round(2.5)is3in JS (half up) and2elsewhere (banker's rounding, half to even);x / 0isInfinityorNaNin JS and a runtime error elsewhere;NaN == NaNisfalsein JS andtrueelsewhere. For portable code, compare floats with a tolerance or convert both sides first, writemath.floor(x + 0.5)for half-up rounding, and check the divisor before dividing. The same on every backend:0,""and[]are truthy, and the third argument ofstr.substring(s, start, length)is a count, not an end index.
See Also
- 2.0 CLI command index —
malda new/malda play - 25.5 Browser games — chooser card
- 25. Web UI Overview - Which UI model to pick
- 26. Web UI Server Components - Server-rendered UI with fragments, live updates, and
ui.* - HttpServer & HTML UI Generation - Route hosting and AI HTML generation
- 35.3 Game —
Examples/Games/catalog - Full-Stack Development with MALDA - Choosing between server-rendered, API-first, and browser-hosted UI