30 min

Installing MuJoCo, and the one thing macOS will not let you do

One pip command puts a physics engine on your laptop; the only real decision left is whether you want pixels in an array or a window you can drag, because on macOS those two things need different launchers.

Where you are. You know what a physics simulator does and why an unresettable world makes one worth having. This lesson puts one on your laptop and drops a ball with it.

Twelve lines of XML, and then a refusal

The install takes about fifteen seconds. You write twelve lines of XML describing a floor and a ball a metre above it, run four lines of Python, and print the ball’s height at 0.3 seconds: 0.55561 metres. The formula you learned at sixteen, h=h012gt2h = h_0 - \tfrac{1}{2}gt^2, says 0.55855. Three millimetres apart, and you can guess why: the simulator added up a hundred and fifty small steps where the formula solved the whole thing at once.

So far this is the friendliest install in robotics. Then you decide you would rather watch the ball than read about it. You add three lines to open a window, run the same file the same way, and the program refuses to start before it draws a single pixel:

RuntimeError: `launch_passive` requires that the Python script be run under `mjpython` on macOS

You have never heard of mjpython. You did not install it, you did not ask for it, and there is no such command in the docs page you were reading. It has nevertheless been sitting in your virtual environment’s bin/ directory since the moment pip finished.

The idea in one paragraph

MuJoCo installs with one command and needs nothing else: no licence key, no separate engine download, no compiler, no Homebrew. What it does need is a decision you make once and then stop thinking about. There are two ways to see a simulation running, and they have different requirements. Rendering offscreen hands your code an array of pixels and works under plain python everywhere. Opening a window you can orbit and shove things around in has to negotiate with the operating system for the process’s main thread, and on macOS that negotiation only succeeds if a small launcher called mjpython started the process. Everything else in this lesson is a command you type once and a list of advice from the internet that will hurt you.

One command, and what it leaves behind

From the repository root, with your virtual environment active:

pip install "mujoco==3.11.*"

That is the whole install on Apple silicon, on Linux, and on Windows. The wheel is a binary: it carries the compiled physics engine inside it, so nothing is built on your machine and nothing is fetched afterwards.

pip install mujoco no licence key, no engine download, no Homebrew mujoco the Python API and the native engine, in the same wheel mjpython a launcher dropped in .venv/bin, used only on macOS glfw, pyopengl windows and OpenGL, pulled in for you absl-py, etils, numpy support libraries; numpy you already had
What one pip command leaves in your virtual environment: the mujoco package with the native engine inside the same wheel, the mjpython launcher in bin, glfw and pyopengl for windowing, and three support libraries

Wider than the screen; scroll it sideways.

Check it landed:

python -c "import mujoco; print(mujoco.__version__, mujoco.mj_versionString())"

Two numbers come back and they should agree. The first is the Python package’s version, the second is what the native engine reports about itself. A disagreement means two installs are fighting, and the fix is a clean virtual environment rather than an afternoon of debugging.

Make something fall

Here is the whole first program. There is no robot in it yet, deliberately.

import mujoco

BALL_XML = """
<mujoco>
  <option gravity="0 0 -9.81"/>
  <worldbody>
    <light pos="0 0 3" dir="0 0 -1"/>
    <geom name="floor" type="plane" size="2 2 0.05"/>
    <body name="ball" pos="0 0 1.0">
      <freejoint/>
      <geom name="ball" type="sphere" size="0.05"/>
    </body>
  </worldbody>
</mujoco>
"""

model = mujoco.MjModel.from_xml_string(BALL_XML)
data = mujoco.MjData(model)

while data.time < 0.3:
    mujoco.mj_step(model, data)

print(data.time, data.qpos[2])

MjModel is the compiled description of the world, which never changes while you run. MjData is everything that does change: positions, velocities, forces, the clock. mj_step advances the whole thing by one timestep, two milliseconds by default. The next two lessons take those three objects apart properly; today they are just the three nouns you need to check that the install works.

Two ways to look at it

your loop mj_step(model, data) mujoco.Renderer offscreen, no window at all plain python everywhere, through CGL on macOS a uint8 array, so: PNG, video, a policy input, a dataset viewer.launch_passive a real window, live mjpython on macOS, plain python elsewhere you, orbiting and shoving things while the physics keeps running
The same stepping loop feeds two different ways of seeing it: an offscreen renderer that returns pixels as an array, and a passive viewer that opens a real window

Wider than the screen; scroll it sideways.

The first way never opens a window at all.

with mujoco.Renderer(model, 360, 480) as renderer:
    renderer.update_scene(data)
    pixels = renderer.render()      # (360, 480, 3) uint8

This is the path to prefer for anything you want to reproduce. It runs under plain python, it runs inside a notebook, it runs on a cloud box with no monitor, and its output is data rather than an experience. Every figure and every dataset later in this course comes out of it.

The second way opens a real window, and it is worth having for the hour when you cannot work out why your arm is behaving strangely. mujoco.viewer.launch_passive(model, data) gives you a handle, and your loop stays in charge: you call mj_step yourself, then viewer.sync() to push the new state to the screen. There is also a managed mode, mujoco.viewer.launch(model, data), which blocks and runs the physics itself, and a standalone app, python -m mujoco.viewer --mjcf=path/to/scene.xml, for when you only want to look at a model.

offscreen Rendererpassive viewermanaged viewer / standalone app
You geta uint8 arraya live windowa live window
Who steps the physicsyouyouthe viewer
macOS launcherpythonmjpythonpython
Good fordatasets, figures, trainingdebugging your own controllerlooking at a model

The passive viewer is the one this course teaches, because it is the only interactive mode where your controller code is still the thing driving. That is also exactly the mode macOS objects to.

Why macOS asks for a different launcher

Every desktop operating system has a rule about which thread may talk to the window system. macOS has the strictest version: Cocoa calls must happen on the process’s original main thread, and no other thread will do.

Run python your_script.py and that main thread is the one executing your Python. It is busy in your while loop. When the viewer asks for the main thread to draw a window on, the thread it needs is already taken, and there is no way to hand it over halfway through.

python script.py your script runs on the main thread macOS main thread taken the window refuses to open RuntimeError mjpython script.py your script runs on a worker thread macOS main thread left free, on purpose the window opens, and stays and your loop keeps stepping
Under plain python your script occupies the macOS main thread so the window cannot open; under mjpython a native launcher keeps the main thread free and runs your script on a worker thread

Wider than the screen; scroll it sideways.

mjpython reverses the arrangement. It is a small launcher installed into your virtual environment’s bin/ by the same wheel, and it starts a native binary that keeps the macOS main thread free for the window while running the CPython interpreter on a separate thread. Your script still believes it is on Python’s main thread; it simply is not the thread the operating system cares about. So you use it exactly like python:

mjpython module-02-simulation/code/watch_it_fall.py

Three corollaries, all worth knowing before you waste an evening:

  • mujoco.Renderer does not need it. Offscreen rendering never touches Cocoa. On macOS it goes through CGL, Apple’s lower-level OpenGL interface, which does not require the main thread.
  • The managed viewer and the standalone app do not need it either. They block, so they can own the main thread from the start.
  • Only launch_passive needs it, and only on macOS. On Linux and Windows every mode runs under plain python.

Advice from the internet that will hurt you

Search “MuJoCo headless” and the first answer will tell you to set MUJOCO_GL=osmesa. That is correct on Linux and on Colab. On macOS it is worse than wrong.

The other trap is age. MuJoCo was commercial software before it was free, and the fossils are still the top search results. This table is the whole test:

If a tutorial mentionsThen it predates MuJoCo 3, and
import mujoco_pyit is using the old OpenAI wrapper. The modern package is mujoco, and the API is different.
~/.mujoco/mjkey.txtit is from the licence-key era. There is no key any more.
mujoco210, a separate engine downloadthe engine ships inside the pip wheel now. Delete the folder.

One more that is not a fossil but is not the native binding either: dm_control. It wraps the same engine with a different Python API, still exists, and still shows up in tutorials. Code written against it will not drop into code written against mujoco, so check which one you are reading before you copy a line out of it.

Check yourself

1. You run a script that calls mujoco.viewer.launch_passive on a Mac with plain python and it fails before drawing anything. What is the actual conflict, in one sentence?

Your script is occupying the process’s original main thread, and macOS will only accept window and Cocoa calls from that exact thread, so the viewer has nowhere to draw. mjpython fixes it by keeping the macOS main thread free for the window and running your Python on a separate thread instead.

2. Your teammate’s headless training script exports MUJOCO_GL=osmesa and works fine on their Linux box. You copy it to your Mac and it dies on import mujoco. What do you change?

Delete the environment variable. osmesa and egl are Linux backends; macOS accepts only cgl, which is already the default, so offscreen rendering needs no configuration at all. The reason it fails at import rather than at render time is that the GL backend is chosen when the package loads, which is why it reads as a broken install.

3. Which of these need mjpython on macOS: mujoco.Renderer, viewer.launch_passive, viewer.launch, python -m mujoco.viewer --mjcf=scene.xml?

Only launch_passive. Renderer draws offscreen through CGL and never touches the window system. launch and the standalone app block, so they hold the main thread from the moment they start and never need to ask for it back. And on Linux or Windows, none of the four need anything special.

4. Your simulated ball is at 0.55561 m when the formula says 0.55855 m. Is this a bug?

No. The formula solves the trajectory exactly in one shot; the simulator advances the state in 150 discrete steps of 2 ms and accumulates a small error at each one. A few millimetres over 0.3 seconds is what that costs. It is worth checking anyway, because if the gap were metres rather than millimetres you would have a genuinely broken install or a wrong gravity setting, and this is the cheapest test that would catch it.

5. You are writing a script that will produce the figures for a report, and a second script for poking at a model interactively when something looks wrong. Which rendering path does each one get, and why?

The report script uses mujoco.Renderer: it runs under plain python on any machine, works with no display attached, and its output is an array you can save deterministically. The debugging script uses the passive viewer under mjpython, because the value there is your hands on the mouse rather than reproducibility. Keeping the two paths in separate files is the point; a script that opens a window cannot run in a place with no window.

6. Why is pinning mujoco==3.11.* rather than mujoco better advice than it would be for most Python packages?

Because MuJoCo adopted semantic versioning at 3.5.0 and ships minor releases roughly monthly, so a minor bump is allowed to change behaviour and sometimes does. Recent releases have removed fields, changed function signatures, and flipped defaults. An unpinned install means the physics under your experiment can change between two runs on two machines, which is the one thing a simulator exists to prevent.

Do this

About twenty minutes, and the second half is the part that pays.

1. Install and verify. From the repository root:

pip install "mujoco==3.11.*"        # Intel Mac: "mujoco==3.10.*"
python -c "import mujoco; print(mujoco.__version__, mujoco.mj_versionString())"

2. Finish code/verify_mujoco.py. Four # TODO(you) markers: report the environment, compile the scene, check free fall against h012gt2h_0 - \tfrac{1}{2}gt^2, and render one frame offscreen to ball.png. It runs under plain python. The assertion at the bottom fails if free fall is off by more than a centimetre, so a passing run is a real check rather than a green tick. Compare against solutions/verify_mujoco.py once yours prints numbers.

3. Deliberately trigger the macOS error. Finish code/watch_it_fall.py, then run it the wrong way first:

python module-02-simulation/code/watch_it_fall.py

Read the error. Now run it properly:

mjpython module-02-simulation/code/watch_it_fall.py

A ball drops onto a ramp and rolls off, resetting every three simulated seconds. Drag to orbit, scroll to zoom, double-click the ball to select it, then ctrl-drag to shove it while the physics keeps running. That last gesture is the reason to have a viewer at all.

4. Delete the pacing sleep. In the loop you just wrote, remove the time.sleep(slack) at the bottom and run it again. The ball becomes a blur, because nothing was ever making the simulation run at the speed of reality; you were adding that yourself. Put the sleep back. Then write one sentence in your notes about which of your future scripts should have it and which should not.

What you can now do

You can install MuJoCo on any machine you own, tell in one command whether the install is sound, and write a scripted simulation that checks its own physics against arithmetic you already trust. You can render frames with no window attached and know that path works on a server. You can open an interactive viewer on macOS, explain to somebody else why it needs a different launcher, and recognise on sight the two pieces of internet advice, MUJOCO_GL=osmesa and anything mentioning mjkey.txt, that would have cost you an evening.

What you can now do

You can install MuJoCo, run a scripted simulation that checks its own physics, render a frame without any window, and open an interactive viewer on macOS without being stopped by the mjpython error.