What a reduced-coordinate robotics simulator actually does — and the parts of it worth having in zimr, given that zimrphysics.zig already exists.
That is the whole of MuJoCo in one line, and it is not the equation zimrphysics solves. Understanding why is most of the work; the rest of this document is the consequences.
zimrphysics.zig is a Jolt port. It thinks in maximal coordinates: every body carries a full 6-DOF pose in world space, joints are constraints that remove unwanted freedom, and a solver iterates until the constraint violations are small. That is the right model for a game — ragdolls, crates, vehicles, thousands of loose objects.
MuJoCo thinks in generalized coordinates. A robot arm with four hinges has exactly four numbers, not four bodies × seven numbers with constraints holding them together. The joints are not enforced; they are structurally impossible to violate, because the unwanted freedom was never represented. This is the difference between a simulator that drifts and one that cannot.
| Maximal (zimrphysics / Jolt) | Generalized (MuJoCo) | |
|---|---|---|
| State of a 4-hinge arm | 4 bodies × (pos+quat+vel+angvel) = 52 numbers | 4 angles + 4 rates = 8 numbers |
| A joint is… | a constraint the solver enforces each step | a coordinate; nothing to enforce |
| Joint error | small but nonzero, drifts, needs stabilization | exactly zero, always, by construction |
| Mass matrix | block-diagonal and trivial (per-body) | dense-ish nv×nv, must be computed |
| Hard part | the constraint solver | the dynamics (M, c) and the constraint solver |
| Loops (closed chains) | free — everything is a constraint anyway | need explicit equality constraints |
| Good at | many loose bodies, games, destruction | articulated mechanisms, contact-rich manipulation, control |
| Cost scaling | O(bodies), solver dominates | O(nv) tree passes + solver |
Neither is better. They are good at different things, which is exactly why robot.zig should be a sibling of zimrphysics rather than a replacement or a rewrite of it.
MuJoCo's single most transferable architectural idea has nothing to do with physics. It splits everything in two:
mjModel — constant. The robot's structure: tree topology, joint types and axes, body inertias, actuator parameters, solver settings. Compiled once from XML, then never written. One mjModel can be shared by many simulations.mjData — everything that changes. Positions, velocities, all intermediates, and every derived quantity. One per simulation instance.This is why MuJoCo can run thousands of parallel rollouts of the same robot cheaply (the whole premise of MJX, its JAX/GPU sibling), and why its API is a flat list of free functions mj_xxx(m, d) that read m, write d, and hold no state of their own.
qpos and the Cartesian body positions are simply stale until you call mj_kinematics. There is no invalidation, no dirty flag, no lazy recompute. The docs call this out because it surprises people. It should not surprise anyone reading zimr's code, which makes the same bet everywhere.
nq ≠ nvBodies form a tree. Each body carries zero or more joints, and each joint contributes coordinates. There are exactly four joint types:
| Joint | qpos (nq) | qvel (nv) | Meaning |
|---|---|---|---|
free | 7 | 6 | position + quaternion, relative to world |
ball | 4 | 3 | quaternion, relative to parent |
slide | 1 | 1 | translation along a body-fixed axis |
hinge | 1 | 1 | rotation about a body-fixed axis |
A body with no joint is welded to its parent — free and correct, no constraint required. This is how you build a rigid multi-geom link.
Position and velocity have different dimensions. Orientations live on the unit sphere in 4D (SO(3)); velocities live in its 3D tangent space. So qpos has length nq, qvel has length nv, and nq > nv in any model containing a ball or free joint. You cannot subtract two qpos vectors to get a velocity — MuJoCo provides mj_differentiatePos for that, and mj_integratePos for the reverse. Every quaternion-aware operation in the codebase exists because of this one fact.
The "smooth" part of MuJoCo — everything except constraints — is five tree traversals, and it is much smaller than you would guess.
Walk the tree parent→child, composing each body's local pose with its joint's contribution, producing global position and orientation for every body, geom and site. Normalizes quaternions on the way through.
MuJoCo does its dynamics in global frames (so collision detection shares them) but translated to each kinematic subtree's center of mass. That translation is purely for floating-point accuracy: inertias expressed about a distant origin lose precision badly. This produces cdof (each DOF's motion axis as a 6D spatial vector) and cinert (each body's 10-parameter inertia).
Composite Rigid Body. Backward pass accumulating each subtree's total inertia into its parent, then for each DOF i, walk up the DOF ancestor chain writing M[i][j] = cdof_j · (crb_i × cdof_i). The entire algorithm is about sixty lines of C. Two things fall out of it:
M is sparse, and its sparsity pattern is exactly the ancestor relation in the tree — M[i][j] is nonzero only if DOF i and j lie on a common root path. This is fixed by the model, not by the state.Because M is needed as M-1x repeatedly, it is factorized once per step into LTDL, in an elimination order that preserves the tree sparsity exactly — no fill-in. Then every M-1x is two sparse back-substitutions.
Recursive Newton-Euler run with acceleration set to zero, which yields exactly c(q,v): Coriolis, centrifugal and gravitational forces combined. Forward pass propagates velocities, backward pass accumulates forces. RNE is also how inverse dynamics is computed, with the real acceleration instead of zero.
M and c in hand, forward dynamics is a single line: v̇ = M⁻¹(τ + JTf − c). Everything before this point is computing M and c; everything after is computing f.
J maps joint velocities into constraint (or task) space, and JT maps forces back the other way. This one object serves several jobs that look unrelated until you see them written down:
J in the contact normal direction;J that is just a unit vector on that DOF;∂r/∂q for its residual;∇l(q), the gradient of its transmission length;Getting Jacobians right once buys all five. In a tree, the Jacobian of a point is assembled by walking that body's ancestor chain and taking each DOF's contribution — the same chain the CRB walk uses.
This is MuJoCo's real signature, and the thing most worth stealing.
Every other rigid-body engine treats contact as a hard complementarity problem: either the bodies touch and there is force, or they separate and there is none. That is an LCP, it is NP-hard with friction, and it has no unique solution in the general case.
MuJoCo drops strict complementarity and makes every constraint soft, giving a convex optimization problem instead — always solvable, always unique, differentiable. Contacts can push back slightly before touching, and penetrate slightly under load. That is not a bug being tolerated; it is the model.
Each scalar constraint gets three numbers:
d ∈ (0,1) — how hard the constraint is. d→1 is rigid, d→0 is absent. It interpolates the achieved acceleration between the reference and the unconstrained one.solref = (timeconst, dampratio), so you specify how fast and how bouncy, not raw gains.The crucial trick is the  in the regularizer: R is scaled by an approximation of the constraint-space inertia, precomputed at the reference pose. That is what makes solref mean the same thing on a 1 g finger and a 100 kg torso — the penetration under load becomes independent of effective mass. Without that scaling, every contact would need hand-tuned gains. It is a small idea with an enormous usability payoff.
Constraints are assembled in a fixed order — equality, friction loss, limit, contact — into one system of nc rows, and one solver handles all of them uniformly. Limits are literally frictionless contacts internally.
MuJoCo factors an actuator into three parts you configure separately, and the combinations give you everything from a DC motor to a muscle:
joint, a tendon, a site (Cartesian thrust: jets, propellers), a body (adhesion, vacuum grippers), or a slider-crank. Each defines a scalar length l(q); its gradient ∇l is the moment-arm vector that maps scalar force to joint forces.w with first-order dynamics, making the system third-order. Types: integrator (ẇ = u), filter (ẇ = (u−w)/τ), filterexact (the same, integrated analytically so it cannot diverge when τ < timestep), muscle, and DC-motor/PID variants.p = a·(w or u) + b₀ + b₁l + b₂l̇. Choosing gain and bias turns the same machinery into a torque motor (b=0), a position servo (b₁ = −kp), or a velocity servo (b₂ = −kv).Because force generation is affine, inverse dynamics can recover the controls from the required force with a pseudo-inverse. That property is the reason the whole model is deliberately kept affine.
A tendon is a scalar length defined over the model — either a fixed linear combination of joint coordinates, or a spatial path through sites, wrapping around spheres and cylinders with proper minimal-length geodesics. It can have its own spring, damper, friction and limits, and actuators can pull on it.
Two joints coupled by a fixed tendon is how you build a differential, a car's steering linkage, or a coupled finger — without introducing a closed kinematic loop. In the car model above, "forward" and "turn" are tendon actuators over the two wheel joints, so two controls drive a differential drive directly.
Sensors are declared in the model and evaluated at three points in the pipeline — after position, after velocity, after force. That is not a convenience feature: a physically meaningful accelerometer or force/torque reading is only available at the right stage, and computing it afterwards from finite differences would be both wrong and noisy. For a robotics simulator the sensor set is part of the physics, not part of the application.
| Integrator | Cost | Use when |
|---|---|---|
Euler | 1 evaluation | default; semi-implicit (position uses the new velocity) |
RK4 | 4 evaluations | smooth systems, long-horizon energy conservation |
implicitfast | 1 + a Cholesky | damping, drag, stiff actuators — usually the best choice |
implicit | 1 + an LU | as above, plus fast-tumbling free bodies |
Implicit-in-velocity is worth understanding because it is cheap and it buys a lot. Instead of v←v+h·a(v), it solves v←v+h·a(vnew) by one Newton step, giving (M − hD)vnew = … where D = ∂(forces)/∂v. Velocity-dependent forces — damping, fluid drag, velocity servos — stop being an explicit-integration stability hazard. You get to raise damping without lowering the timestep.
26 stages, strictly ordered, each consuming the last. The numbers are the point: this is a dependency chain, and the reason MuJoCo's API exposes mj_step1/mj_step2 is so a controller can run between stage 19 and 20 — after everything derived from state, before anything derived from force.
■ port into robot.zig ■ reuse zimrphysics / zimrmath ■ out of scope
qpos/qvel for divergence mj_checkPos, mj_checkVelA faithful transliteration of MuJoCo would be a mistake. Most of its complexity is a consequence of two constraints zimr does not have: it is C, and its models arrive as XML at runtime. Both facts force the same thing — everything must be dynamically sized, so everything becomes an index into a flat array with a runtime-built sparsity table. That is where mjModel's 900 lines of parallel arrays come from.
A robot's structure does not change while it runs. Its tree, its joint types, nq, nv, and therefore the entire sparsity pattern of M, are all known before the program starts. Zig can know them at compile time.
const Arm = robot.Model(.{
.bodies = .{
.{ .name = "base", .parent = null, .inertia = … },
.{ .name = "upper", .parent = "base", .joint = .{ .hinge = .{ .axis = zm.vec3(0,0,1) } } },
.{ .name = "fore", .parent = "upper", .joint = .{ .hinge = .{ .axis = zm.vec3(0,1,0) } } },
.{ .name = "hand", .parent = "fore", .joint = .{ .ball = .{} } },
},
.actuators = .{
.{ .name = "shoulder", .on = .{ .joint = "upper" }, .kind = .{ .position = .{ .kp = 40 } } },
},
});
// nq, nv and the sparsity of M are comptime constants of the TYPE.
var data: Arm.Data = .init; // fixed-size arrays. no allocator.
Arm.step(&data, dt);
What this buys, concretely:
Data is a plain struct of [nv]f32-shaped fields. It can live on the stack, in an arena, or in a GPU buffer.M_rownnz, M_rowadr, dof_parentid and walks them at runtime. With a comptime tree, the ancestor chains are known and the CRB inner loop can be an inline for over exactly the nonzero entries. The sparse structure stops being data and becomes control flow.@compileError with a message, not a runtime validation pass.zp.createBody(world, .{ … }): the same shape, one level more comptime.Data and the algorithms generic over a Model interface, so a runtime-built model can implement the same shape at the cost of runtime-sized slices. Design for that from the start; build only the comptime path first.
This is the hinge that makes the two feel like one library rather than two. MuJoCo contains a whole collision engine — GJK, box-box, convex, height fields, SDFs, broad-phase. zimrphysics already has all of that, device-tested, and it is the single largest chunk of MuJoCo by volume.
So robot.zig should not port collision at all. It should consume it:
// zimrphysics owns: shapes, GJK/SAT, the BVH broad-phase, manifolds.
// robot.zig owns: the tree, M, c, J, actuators, the constraint solve.
//
// The seam is one function: a contact manifold in, constraint rows out.
const contacts = zp.collideAll(&scene, robot_geom_poses, scratch);
robot.addContactConstraints(&data, contacts);
The same seam works in the other direction later: a robot can be an actor in a zimrphysics world, pushing loose objects around, with the robot solved in generalized coordinates and the debris solved in maximal ones. That is a genuinely good architecture and it is what most robotics stacks converge on anyway.
Likewise zimrmath already provides Vec, Quat, Mat, and the quaternion vocabulary. robot.zig adds only what is missing: spatial (6D) motion and force vectors, and the 10-parameter inertia. Those are genuinely new — and they belong in robot.zig rather than zimrmath, because zimrmath is shader-safe and must stay small.
MuJoCo's names are terse C (qfrc_bias, cdof, efc_J). zimr's rules say otherwise. A translation table, applied consistently:
| MuJoCo | robot.zig | why |
|---|---|---|
d->qpos, d->qvel | data.pos, data.vel | the q prefix meant "generalized" in a C namespace that had no namespaces |
d->qfrc_bias | data.bias_force | spell words; the linter enforces it |
d->qacc | data.acc | |
d->efc_* | data.constraint.* | a struct, not a prefix |
mj_step(m,d) | Robot.step(&data, dt) | model is the type; free fn on the namespace, like zimrphysics |
solref/solimp | .softness = .{ .time_const_s, .damp_ratio } | named units; zimr rule 13 — options struct, not a float array |
| angles in radians | _rad suffix | zimr's radians sweep; non-negotiable |
mjcb_control is a global function pointer — a mutable module global, which zimr bans outright. Passing the controller as a comptime parameter to step is faster and legal: it inlines, and there is no global.Data struct that maps directly onto a GPU buffer, and a house tradition of side-by-side CPU|GPU demos. A batched rollout demo is a natural flagship, not a stretch.Ordered so that every phase ends somewhere demonstrable, and each one is verifiable on the host before the next begins.
| # | Phase | Ends when |
|---|---|---|
| 0 | Spatial algebra: 6D motion/force vectors, 10-param inertia, the comptime Model and generated Data | nq/nv and the ancestor chains are comptime constants; a bad model is a compile error |
| 1 | Forward kinematics + COM frames | a 3-link arm's tip lands where a hand-computed transform says it should |
| 2 | CRB mass matrix + LTDL factor/solve | M matches a dense reference; M·M-1x = x |
| 3 | RNE bias forces + semi-implicit Euler + RK4 | a double pendulum swings, and conserves energy under RK4 to a tight bound |
| 4 | Jacobians (body, site, joint) | finite-difference agreement to 1e-5 — the cheapest strong test in the whole project |
| 5 | Actuators: transmission, affine gain/bias, activation dynamics | a position servo holds a load against gravity with the expected steady-state error |
| 6 | Soft constraints: joint limits + equality, impedance/aref, the convex solve | a joint stops at its limit with the softness the model asked for |
| 7 | Contacts via the zimrphysics seam | the arm pushes a zimrphysics box across a table |
| 8 | Tendons (fixed first, spatial after) | a differential drive from two joints and two tendon actuators |
| 9 | Sensors + inverse dynamics + the fwd/inv round-trip check | stage 25's diagnostic passes on every example model |
| 10 | implicitfast integrator | a heavily damped model stays stable at a timestep that explodes under Euler |
Phases 0–4 are the irreducible core and are worth having even if nothing after them is ever built: they are what turns zimr from "a game physics engine" into "a thing that can simulate a robot arm correctly".
The soft constraint solver (phase 6) is the part that looks small in the equations and is not. MuJoCo's engine_core_constraint.c is 3,500 lines, it has four solver algorithms, two friction cone types, and the  diagonal approximation that makes solref behave sanely across mass scales. Everything before it is textbook and well-conditioned; this is the part with the taste in it.
The mitigation is ordering, and it is already in the plan: phases 0–5 produce a genuinely useful articulated-body simulator with no constraint solver at all — forward and inverse dynamics, Jacobians, actuators, integrators. A robot arm moving under torque control needs none of phase 6. Start the solver with a single algorithm (projected Gauss-Seidel over a pyramidal cone), get joint limits working, and only reach for the elliptic cone and the Newton solver if something demands it.
Sources: MuJoCo doc/computation/index.rst (the authoritative algorithm description), src/engine/engine_core_smooth.c, engine_core_constraint.c, engine_forward.c, include/mujoco/mjtype.h, mjmodel.h, mjdata.h, and the model/car and model/humanoid examples. Read alongside src/zimrphysics.zig's CharacterVirtual header for the house conventions on porting an upstream engine and recording the divergences.