Core concept

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.

Visual intuition

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 = “"; for (let i = 0; i < 200; i++) xml += `<body pos="${i} 0 0"></body>`; xml += "”; const before = stored.length; const m2 = mj.MjModel.from_xml_string(xml); const d2 = new mj.MjData(m2); // a large arena: the heap grows big.push(m2, d2); console.log(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) need new mj.DoubleBuffer(n) (or IntBuffer, FloatBuffer, Uint8Buffer), read back with .GetView(). A plain Float64Array is 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 is DoubleBuffer.FromArray(array); the package README’s example passes an array to the constructor and calls getView(), which does not match the 3.14.0 typings (GetView, capital G). Trust the .d.ts file 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 includes MjModel, MjData, buffers, named accessors (model.jnt("hinge") returns a handle) and elements returned by .get(i). The exception is subtle and dangerous: data.contact returns a copy of the contact vector (delete it), but data.warning (and data.solver) return a reference into mjData. Deleting that reference frees memory mjData still owns, and in this course’s tests the next access aborted the WebAssembly module with a double free. Delete the elements you get, never the vector.

[!implementation] Rule 3: boolean arrays need accessors In 3.14.0, reading 18 mjModel arrays 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 on mjData, eq_active and bvh_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’s runtime.js has a flag() helper for exactly this.

[!implementation] Rule 4: never keep a view across an allocation data.qpos returns a fresh Float64Array view over WebAssembly memory each time you read it. If the heap grows (compiling a big model, allocating mjData), the old ArrayBuffer is 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 with Cannot 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 uses SharedArrayBuffer and therefore needs the page to be cross-origin isolated (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers). Static hosts that cannot set headers, such as GitHub Pages where this course lives, must use the single-threaded build.

The smallest useful page

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 –>

Minimal MuJoCo page
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>&lt;size memory=\"...\"/&gt;</code> to what the scene needs.</p>"}
]}

Next

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.