Every lab in this course runs on MuJoCo’s official JavaScript bindings: the MuJoCo C library compiled to WebAssembly with Emscripten and wrapped with Embind, published by Google DeepMind on npm as @mujoco/mujoco, with package versions that track MuJoCo releases. They make it possible to put a real MuJoCo simulation in a web page, a paper’s project page or a teaching tool, with no server.
The API mirrors Python’s: mj.MjModel.from_xml_string(xml), new mj.MjData(model), mj.mj_step(model, data), data.qpos, named access with model.jnt("hinge"). The differences are the ones a C library has when it lives inside a JavaScript runtime: memory you must free yourself, output arrays that need special buffers, and typed-array views that can go stale. The bindings’ own README calls them a work in progress; the facts below were measured on 3.14.0 while building this course.
The inspector in the side panel is a JavaScript program reading mjData through these bindings every frame. Everything it shows is a typed array view over WebAssembly memory, re-read each time it is drawn.
```lab inspector {“dock”: true, “model”: “pendulum”, “key”: 0, “height”: 220, “camera”: {“azimuth”: -90, “elevation”: 5, “distance”: 1.8, “target”: [0, 0, 0.75]}, “tabs”: [“state”, “bodies”, “ctrl”, “sensors”, “sizes”]}
## The rules, measured
```io
INPUT: `@mujoco/mujoco` 3.14.0 from npm and a small model written in the script
PROCESS: check the version; call a function with output arrays two ways; read contacts and warnings; read a boolean field two ways; store a view and grow the heap; free everything
OUTPUT: one line per step
```javascript file=js/bindings_tour.mjs // Lesson 3.4: a tour of MuJoCo’s JavaScript (WebAssembly) bindings, and their traps. // // INPUT @mujoco/mujoco 3.14.0 from npm; a small model written below // PROCESS (1) load the module and check its version; // (2) call a function with an output array the wrong way and the right way; // (3) read contacts (a copy, delete it) and warnings (a live reference, do not); // (4) read a boolean model field directly and through the named accessor; // (5) store a view of qpos, grow the WebAssembly heap, and look at the view again; // (6) free everything // OUTPUT printed results for each step // // Run: npm install && node bindings_tour.mjs
import loadMujoco from “@mujoco/mujoco”;
const XML = `
`;
const mj = await loadMujoco(); console.log(“1. version:”, mj.mj_versionString());
const model = mj.MjModel.from_xml_string(XML); const data = new mj.MjData(model); mj.mj_forward(model, data);
// 2. Output arrays. A plain typed array is copied in and the result is thrown away. const site = mj.mj_name2id(model, mj.mjtObj.mjOBJ_SITE.value, “tip”); const plain = new Float64Array(3 * model.nv); mj.mj_jacSite(model, data, plain, null, site); const buf = new mj.DoubleBuffer(3 * model.nv); // size in numbers; read with GetView() mj.mj_jacSite(model, data, buf, null, site); // The tip moves along -z when the hinge (axis +y) turns, so row z (the third row) is the informative one. const rowZ = (arr) => Array.from(arr.slice(2 * model.nv, 3 * model.nv)).map((v) => v.toFixed(3)).join(“ “); console.log(“2. Jacobian row z, plain Float64Array:”, rowZ(plain)); console.log(“ Jacobian row z, DoubleBuffer: “, rowZ(buf.GetView()));
// 3. Contacts are returned as a copy: read, then delete. Warnings are a live reference: never delete.
for (let i = 0; i < 300; i++) mj.mj_step(model, data);
const contacts = data.contact; // copy of the contact vector
const first = contacts.get(0);
console.log(3. ncon = ${data.ncon}; contact 0 between geoms ${first.geom1} and ${first.geom2}, dist ${(1000 * first.dist).toFixed(3)} mm);
first.delete();
contacts.delete();
const warnings = data.warning; // reference into mjData: do NOT call warnings.delete()
const badqacc = warnings.get(mj.mjtWarning.mjWARN_BADQACC.value);
console.log(“ bad-acceleration warnings so far:”, badqacc.number);
badqacc.delete(); // the element is a copy and may be deleted
// 4. Boolean model fields: the array getter throws in 3.14.0, the named accessor works. try { console.log(“4. model.jnt_limited:”, model.jnt_limited); } catch (err) { console.log(“4. model.jnt_limited throws:”, String(err.message).slice(0, 60)); } const joint = model.jnt(“hinge”); console.log(“ model.jnt(‘hinge’).limited =”, joint.limited, “range =”, Array.from(joint.range)); joint.delete();
// 5. Views over WebAssembly memory go dead when the heap grows.
const stored = data.qpos;
const big = [];
let xml = “5. stored view length before: ${before}, after the heap grew: ${stored.length}; fresh view length: ${data.qpos.length});
// 6. Free what we created: nothing here is garbage collected. for (const obj of [buf, d2, m2, data, model]) obj.delete(); console.log(“6. freed”);
Output under Node.js 22:
```text
1. version: 3.14.0
2. Jacobian row z, plain Float64Array: 0.000 0.000 0.000 0.000 0.000 0.000 0.000
Jacobian row z, DoubleBuffer: -0.500 0.000 0.000 0.000 0.000 0.000 0.000
3. ncon = 4; contact 0 between geoms 0 and 2, dist -0.108 mm
bad-acceleration warnings so far: 0
4. model.jnt_limited throws: _emval_take_value has unknown type N10emscripten11memory_vie
model.jnt('hinge').limited = true range = [ -1, 1 ]
5. stored view length before: 8, after the heap grew: 0; fresh view length: 8
6. freed
Each line is a rule.
[!implementation] Rule 1: output arrays must be MuJoCo buffers Functions that write their result into an argument (
mj_jacSite,mj_contactForce,mj_fullM,mj_ray’s geom id) neednew mj.DoubleBuffer(n)(orIntBuffer,FloatBuffer,Uint8Buffer), read back with.GetView(). A plainFloat64Arrayis accepted without error, copied into WebAssembly memory, written to, and the result is discarded: line 2 shows the Jacobian as all zeros. In 3.14.0 the constructor takes a size (new DoubleBuffer(n)), and there isDoubleBuffer.FromArray(array); the package README’s example passes an array to the constructor and callsgetView(), which does not match the 3.14.0 typings (GetView, capital G). Trust the.d.tsfile shipped in the package.
[!implementation] Rule 2: free what you create, and only that Objects created through the bindings live on the WebAssembly heap and are not garbage collected; call
.delete()exactly once. That includesMjModel,MjData, buffers, named accessors (model.jnt("hinge")returns a handle) and elements returned by.get(i). The exception is subtle and dangerous:data.contactreturns a copy of the contact vector (delete it), butdata.warning(anddata.solver) return a reference intomjData. Deleting that reference frees memorymjDatastill owns, and in this course’s tests the next access aborted the WebAssembly module with a double free. Delete the elements youget, never the vector.
[!implementation] Rule 3: boolean arrays need accessors In 3.14.0, reading 18
mjModelarrays of byte-sized booleans throws_emval_take_value has unknown type:actuator_actearly,actuator_actlimited,actuator_ctrllimited,actuator_forcelimited,eq_active0,flex_centered,flex_flatskin,flex_internal,flex_rigid,flexedge_rigid,jnt_actfrclimited,jnt_actgravcomp,jnt_limited,light_active,light_castshadow,mat_texuniform,tendon_actfrclimited,tendon_limited; and onmjData,eq_activeandbvh_active. The list comes from a script that read every getter. The named accessors return them correctly:model.jnt(i).limited,model.actuator(i).ctrllimited. This course’sruntime.jshas aflag()helper for exactly this.
[!implementation] Rule 4: never keep a view across an allocation
data.qposreturns a freshFloat64Arrayview over WebAssembly memory each time you read it. If the heap grows (compiling a big model, allocatingmjData), the oldArrayBufferis detached and every view you stored becomes length 0 (line 5). Re-read views every frame, as the course’s viewer does. The heap is capped at 2 GB in this build: a test that compiled several 3000-body models failed withCannot enlarge memory ... the limit is 2147483648 bytes, and a single 300-free-body scene requested a 227 MB arena by default, so large scenes in the browser should set<size memory="..."/>explicitly.
[!established] Single-threaded and multi-threaded builds The package ships a single-threaded build (the default import) and a multi-threaded one (
@mujoco/mujoco/mt). The multi-threaded build usesSharedArrayBufferand therefore needs the page to be cross-origin isolated (Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corpheaders). Static hosts that cannot set headers, such as GitHub Pages where this course lives, must use the single-threaded build.
The page below is a complete MuJoCo web application in about 60 lines of JavaScript: it compiles a model, steps it in real time, and draws it with three.js. Serve the folder with any static server; opening the file directly from disk does not work, because browsers block module imports from file:// pages.
```html file=js/minimal.html <!doctype html> <!– Lesson 3.4: the smallest useful MuJoCo page. A box falls on a plane; MuJoCo (WebAssembly, from jsDelivr) computes the physics, three.js draws it.
INPUT nothing; open the file through any static web server (python -m http.server, then http://localhost:8000/minimal.html) PROCESS compile an MJCF string, step mj_step in real time, copy geom poses to three.js OUTPUT an animated canvas and the box height in the page title –>
Tested in headless Chromium: after 16 s of simulated time the title reads `box z = 0.100 m`, the box resting flat on its 0.1 m half-size.
Three details carry over from the rest of the course. **MuJoCo is z-up**, so the three.js camera gets `up = (0, 0, 1)`. **Rotation matrices are row-major**, and `Matrix4.set` takes its arguments in row-major order too, so the nine numbers copy straight across. **Box sizes are half-sizes**, so three.js boxes get twice the MuJoCo size.
## How this course is built on top
The course's own runtime is in `learn/mujoco/js/` of the site repository and follows the same pattern at larger scale: `runtime.js` loads the module once and compiles models (fetching any `<include>`, `<model>` or mesh files into a MuJoCo virtual file system first), `sim.js` owns one `MjModel` and `MjData` and the real-time stepping loop, `viewer.js` builds one three.js mesh per geom and copies poses each frame, and the labs (`widgets/*.js`) combine those with sliders and plots. All the rules above are applied there; three of them were discovered by the course's own browser tests failing.
## Debugging
**The lab shows numbers that never change.** A view was stored before an allocation and has been detached (length 0, every read `undefined`). Re-read it.
**`function mj_ray called with 8 arguments, expected 9`.** Signatures follow the C API of the version you load: release 3.5.0 added a `normal` output argument to `mj_ray` and the other ray-cast functions, so code written for older releases passes one argument too few. In Python the new argument defaults to `None`; in JavaScript you must pass it (`null` is accepted). Check the `.d.ts` file.
**Memory grows every time the user resets a lab.** Something created per reset is never deleted: models from recompiling, buffers allocated in a loop, accessors from `model.jnt(...)`. Allocate buffers once and keep them.
**The page works locally but not when opened as a file.** Module imports and `fetch` need `http://`. Serve the folder.
## Exercise
Extend `minimal.html` with a slider that sets `model.opt.gravity[2]` and a button that resets the simulation with `mj.mj_resetData`. Then add a second box and make sure nothing leaks: run the reset 1000 times in a loop and watch the WebAssembly memory size (`data.qpos.buffer.byteLength`) stay constant.
## Challenge
Build a page that loads `cartpole.xml` from this course's repository URL, balances it with the LQR gains from Lesson 3.2's viewer script, and plots the pole angle with a canvas. Measure steps per second in the browser and compare with Python's `mj_step` loop on the same machine.
## Research connection
A browser simulation is the most accessible way to share a result: a project page where readers can perturb the robot, change a parameter, and see whether the claim survives. Two cautions apply. Physics from the WebAssembly build matches Python only if the MuJoCo version matches, and a page that pulls "the latest" bindings will drift; pin the version as this course does. And the rendering is your own three.js code, not MuJoCo's renderer, so any image-based claim must be made from MuJoCo's renderer, not from a screenshot of a web page.
```quiz
{"id": "3.4-check", "title": "Knowledge check", "questions": [
{"kind": "mcq", "q": "You call <code>mj.mj_contactForce(model, data, 0, out)</code> with <code>out = new Float64Array(6)</code>. What does <code>out</code> contain afterwards?",
"options": ["The contact force", "Zeros: the result was written to a temporary copy", "An exception is thrown", "NaN"],
"answer": 1,
"explain": "<p>Plain typed arrays are copied in; use <code>new mj.DoubleBuffer(6)</code> and <code>GetView()</code>.</p>"},
{"kind": "mcq", "q": "Which of these must you NOT call <code>.delete()</code> on?",
"options": ["The vector returned by <code>data.contact</code>", "The vector returned by <code>data.warning</code>", "A <code>DoubleBuffer</code> you created", "An <code>MjData</code> you created"],
"answer": 1,
"explain": "<p><code>data.warning</code> is a reference into <code>mjData</code>; deleting it frees memory MuJoCo still owns.</p>"},
{"kind": "open", "q": "Your project page simulates fine on a laptop but shows a frozen robot on a phone after loading a second scene. What is the most likely cause, and how do you confirm it?",
"reference": "<p>A stored view was detached when loading the second scene grew the WebAssembly heap, so the render loop reads a length-0 array. Confirm by logging <code>view.length</code> (and <code>view.buffer.byteLength</code>) before and after loading; fix by reading <code>data.qpos</code>, <code>data.geom_xpos</code> and similar inside the loop. A second candidate on phones is hitting the memory cap; set <code><size memory=\"...\"/></code> to what the scene needs.</p>"}
]}
Level 4 is the mathematics of rigid bodies, starting with rotations: matrices, quaternions and Euler angles in MuJoCo’s conventions, checked against MuJoCo’s own conversion functions.