Skip to content

Embedding

A built mjswan app is a static site, so embedding it is mostly a question of how much control you want over the surrounding page.

  • iframe


    One tag, zero build tooling. The app brings its own UI. Best for articles, blogs, and docs pages.

  • Google Colab Colab / Jupyter


    app.launch() detects Colab and renders an inline iframe instead of blocking.

  • JavaScript npm engine


    createEngine in your own page, with your own controls. No mjswan UI at all.

iframe

After hosting your app (Deployment) or publishing it (mjswan Cloud), drop it into any page:

<iframe
  src="https://mjswan-gentlehumanoid.pages.dev?panel=0"
  title="mjswan simulation"
  loading="lazy"
  allow="xr-spatial-tracking; fullscreen"
  style="width: 100%; aspect-ratio: 16 / 9; border: 0; border-radius: 0.4rem;">
</iframe>

That exact markup produces this — a real motion-tracking policy running in your browser, inside this page:

The GentleHumanoid demo embedded in this page. Try interacting with it.

loading="lazy" matters more here than for a normal iframe: the app downloads a MuJoCo WASM module and starts simulating as soon as it loads, so deferring that until it scrolls into view keeps the host page cheap. allow="xr-spatial-tracking" is what lets the embedded viewer enter VR.

If the frame comes up blank, check frame-ancestors

Framing is controlled by the embedded site, not the host page. If its server sends a Content-Security-Policy: frame-ancestors … (or a legacy X-Frame-Options) that does not list your origin, the browser blocks the load and logs:

Framing 'https://example.com/' violates the following Content Security Policy
directive: "frame-ancestors 'self' …". The request has been blocked.

mjswan itself never emits that header — a plain builder.build() output is frameable from anywhere, and so is GitHub Pages. It comes from the host: a Cloudflare Pages _headers file, an nginx add_header, or a dashboard setting. Widen the directive to include the origin you are embedding from, or host a copy you control.

Query parameters

The bundled app reads query parameters, so an embed can open on exactly the scene, policy and chrome you mean rather than the first thing in the build:

Parameter Effect
project Select a project by id — its sanitized name, newton_s_cradle for "Newton's Cradle". The display name works too; it is sanitized the same way. Defaults to the project marked default=True.
scene Select a scene by id.
policy Select a policy by id. Defaults to the one marked default=True.
panel panel=0 starts with the control panel hidden. Useful in a small iframe.
debug debug=0 starts with the panel's Debug Viz Enabled checkbox off, hiding every debug drawing (the motion-tracking ghost, velocity arrows, height-scan hits).
manifest Load a manifest.json from another URL entirely, relative to the page.

panel and debug are booleans read as "off only when exactly 0", and the app writes the current state back into the URL — so you can arrange a view by hand and copy the address bar into your src.

<iframe src="https://ttktjmt.github.io/mjswan/?project=showcase&scene=g1_on_street&policy=locomotion&panel=0" …></iframe>

Entering VR and AR

On a device that can start a WebXR session, the control panel has a collapsed WebXR section: Enter VR, Start AR where the device supports passthrough, and the Hand tracking switch. Where no session can start, the section is absent.

Moving around in VR

Enter VR stands you in the scene, and the controller sticks take you from there: the left stick slides you along the way you are looking, and the right stick turns you for as long as you hold it, as fast as you push it. Turns pivot on your head rather than on the play area's centre, so turning does not swing you sideways through the scene. Smooth turning is easier to aim with than snapping to fixed steps and harder on the stomach, which is why the rate is a modest 90° a second at full deflection.

You arrive where the scene's viewer config points its desktop camera, which is beside the tracked body by default — not at the world origin, which on a generated terrain is the generator's base plane rather than a place to stand. From there the view is yours: it does not follow the body around, so a robot that walks off walks off and you go after it with the stick. Your floor comes from a ray cast straight down at your feet, so you stand on the ground at your own height rather than on the headset's idea of z = 0, which is what keeps you on top of a slope instead of inside it.

Hand tracking in VR

Hand tracking in the WebXR section makes a headset's tracked hands part of the physics rather than a pair of floating models. In VR, a Quest 3 can bat a scene's objects around, rest one on an open palm, and pick one up: pinch to grab, release to drop. What you see is three.js's own hand model, and the physics under it is sized to the wearer's hand rather than to a nominal adult one.

The hands are bodies the scene is compiled with, so flipping the switch does nothing on its own: the scene is rebuilt with them when you enter VR or AR, or at the next scene you open, whichever comes first. That rebuild keeps your policy, motion and camera and starts the simulation over. The switch is off every time the page opens, and nothing about it goes into the URL.

It is off by default: a scene with the hands costs roughly 1.6x per physics step, and the headset must grant the hand-tracking feature. Meta Quest is the recommended headset, since its browser ships WebXR hand tracking with nothing to install. When you drive the engine yourself, engine.xr.setHandTracking(true) is the same switch (see WebXR).

Some scenes cannot take the hands, and the switch is greyed there with the reason on hover: a scene added as add_scene(model=...) (a compiled .mjb, with no MJCF to add them to), and a policy scene whose model does not namespace its elements, whose policy would otherwise read the extra bodies.

Passthrough AR

A device that supports immersive-ar gets Start AR beside Enter VR in the WebXR section. The simulation is the same, physics and policy included, but the skybox and the ground planes stop being drawn, so the room shows through and the robot stands on your own floor rather than on MuJoCo's checkerboard. The session asks for the local-floor reference space, which is what puts the model's z = 0 at floor level instead of at eye level; the sticks move you as they do in VR, so you can walk the scene to where you want it. Hand tracking works the same here, under the same switch.

What is not there yet: nothing occludes the scene, so a robot behind your real sofa is drawn in front of it, and no shadow falls on your floor.

Google Colab

app.launch() detects Colab and renders an inline iframe in the output cell instead of starting a blocking server, so the notebook version of a build script is the same script:

import mujoco
import mjswan

builder = mjswan.Builder()
project = builder.add_project(name="Demo")
project.add_scene(spec=mujoco.MjSpec.from_file("model.xml"), name="Scene")

app = builder.build()
app.launch(height=600)  # inline iframe in Colab; local server elsewhere

If you need to control the server yourself — a different port, or serving a dist/ you built earlier — the headers are the only subtle part:

import http.server
import socketserver
import threading

from google.colab import output

PORT = 8000
DIRECTORY = "dist"


class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=DIRECTORY, **kwargs)

    def end_headers(self):
        # Required for SharedArrayBuffer (multi-threaded MuJoCo WASM)
        self.send_header("Cross-Origin-Opener-Policy", "same-origin")
        self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
        super().end_headers()


threading.Thread(
    target=lambda: socketserver.TCPServer(("", PORT), Handler).serve_forever(),
    daemon=True,
).start()

output.serve_kernel_port_as_iframe(PORT, height=600)

➔ Full notebooks: examples/colab/demo.ipynb and anymal_c_velocity.ipynb.

npm engine

When you want your own UI — your own play button, your own sliders, your own layout — skip the iframe and drive the engine directly. It renders into any element and never fetches anything itself, so you decide where the bytes come from:

import { createEngine } from 'mjswan';
import { parseManifest } from 'mjswan/manifest';

const base = 'https://example.com/myapp/';
const bytes = (path: string) => async () =>
  (await fetch(new URL(path, base))).arrayBuffer();

const engine = await createEngine(document.getElementById('viewer')!);
const catalog = parseManifest(
  await (await fetch(new URL('manifest.json', base))).text(),
  bytes,
);

const scene = catalog.projects[0].scenes[0];
await engine.loadScene(await scene.buildScene());

// Your UI, driven off the engine's snapshot.
engine.subscribe((state) => renderControls(state.commands, state.commandValues));
engine.commands.set('velocity:lin_vel_x', 0.8);

The library build is a single self-contained ESM, so it also works straight from a CDN without a bundler:

const { createEngine } = await import(
  'https://cdn.jsdelivr.net/npm/mjswan/dist/mjswan.js'
);

Or serve the package's dist/ directory from your own origin: the bundle resolves its WebAssembly relative to itself, so that one directory needs no other host, no configuration, and a single script-src prefix under a strict CSP.

See the Engine API for the full surface.