zero2robot · Phase 3 · Advancedch3.5-contact · contact.py

Chapter 3.5

Build a Physics Engine IIIContact

By the end you can

  1. See that CONTACT is a one-sided constraint (an INEQUALITY, not ch3.4's equality): the table may only PUSH, never pull, so gap>=0, force>=0, and gap*force=0 — a COMPLEMENTARITY problem, not a force you can write down
  2. Build a PENALTY contact (a stiff spring-damper on penetration depth, clamped push-only) that is a plain ADDED force, so ch3.3's integrator steps it unchanged — and measure the price: it sinks by mg/k, rings while it settles, and explodes past dt_crit ~ 2*sqrt(m/k)
  3. Build an LCP-FLAVORED contact (solve the complementarity for the contact IMPULSE via a fixed-iteration projected Gauss-Seidel) that HOLDS the body on the table with almost no penetration, no phantom energy, and stays stable where penalty diverges
  4. Measure the CONTACT-QUALITY honesty metric (penetration, phantom energy, jitter, and stability-vs-dt) parallel to ch3.3's energy drift / ch3.4's constraint drift — and connect the penalty-vs-complementarity trade you just fought to WHY MuJoCo uses a SOFT, regularized, convex contact model (the principled middle path)

See it work

live · P2

recorded run · seed 0 · cpu — replayed, not live: contact.py's engine is ~400 lines of pure numpy, matched to meta.yaml

the drop · a ball onto the table · penalty vs lcp
≈0penaltylcptable surface
1.60s
penetration depth over time · penalty allows real penetration by design
0surface0.10.2depth (×radius)time (s)
the dt cliff · drag the timestep past dt_crit and only penalty detonates
0100×10k×phantom energy gaindt_crit = 2√(m/k)lcp holds · bounded0.0010.0020.0040.0080.0120.020.03timestep dt (s)
0.002s
penalty stable to dt≈0.008s · lcp to ≈0.064s · poster reads with JS off

Honest limits: frictionless normal contact only (point masses with a radius — no rotation, no friction cone), and the LCP solve is a fixed-iteration projected Gauss-Seidel, not a true LCP pivot. LCP still penetrates ~one step of approach velocity on a fast impact (no continuous collision detection) and leans on a hand-tuned Baumgarte push — it is better, not exact. The penalty-vs-complementarity trade is the payoff: penalty is trivial but stiff and brittle; hard complementarity holds but is a solve; MuJoCo's soft convex contact is the principled middle.

Settled: the penalty ball rests 0.0098 of a radius INSIDE the table (mg over k); the LCP ball rests exactly on the surface, zero penetration.
Timestep dt 0.002 seconds, below dt_crit 0.02. Penalty is stable: energy gain bounded. The LCP solve stays bounded and holds the ball.

A ball, dropped a meter onto a table. Two of them, actually, drawn in the same window. Same gravity, same drop. One falls, meets the table, and rests on it — sits exactly on the surface and stays there, still. The other falls, and where the table should stop it, it keeps going: it drives a quarter of its own radius into the table before something shoves it back, then bounces, rings, and finally settles — but settles a hair's-width below the surface, resting inside the solid table like a coin pressed into dough.

Nothing about the two balls differs except how each one answers a single question: what force does the table apply? The sinking one models the table as a very stiff spring — squeeze it and it pushes back. The other one solves, at every step, for the exact push that stops the ball without letting it through. The sinking, the ringing, the ball that ends up buried a millimeter deep — none of it is a bug in the physics. It is contact, computed the easy way, and the easy way is not good enough. That is the whole chapter.

And here is the part that should make you sit up: that jitter, that sinking, that occasional catastrophic explosion where a body is flung off to infinity — you have been watching those artifacts since chapter 0.1, every time a MuJoCo sim did something faintly wrong. This is where they come from, and this is where they get explained.

Open in Colabsoon

Free-tier notebook — the button goes live when the course repository is published.

The problem

Chapter 3.3 built the inside of mj_step for a free body. Chapter 3.4 added joints — constraints g(q) = 0 you cannot write as a force but must solve for, as a Lagrange multiplier. A joint is a constraint that is always on: the pendulum rod is always exactly length L, this step and every step.

Contact is the constraint that switches on and off. The table pushes up on the ball only while the ball is touching it, and it never pulls it down. Write the gap between ball and table as phi. Then contact is three conditions at once:

phi >= 0            the ball may not penetrate the table
lambda >= 0         the table may only push (never pull)
phi * lambda = 0    no push unless they are touching (phi = 0)

That last line is the whole difficulty. It says the force and the gap are complementary: at least one of them is zero at all times. Either the ball is off the table (phi > 0) and there is no force (lambda = 0), or the ball is on the table (phi = 0) and there can be a force. This is not an equation you solve with np.linalg.solve — it is an inequality, a complementarity problem, and it is a genuinely different and harder kind of math than anything in 3.3 or 3.4.

The chapter builds the two honest ways to cope, from scratch, in about 400 lines of numpy on top of the last two chapters' engine — and measures exactly what each one gets wrong.

Build

Setup

contact.py#setupsha256:b84fc777c9…
import argparseimport hashlibimport jsonimport sysfrom pathlib import Path import numpy as npimport rerun as rr # Loose script from the repo root (same pattern as ch3.3/3.4); put the root on# sys.path so curriculum.common resolves. Pure numpy — no torch, no mujoco for# the dynamics — so every run is bitwise reproducible on CPU: same seed, same# bytes. Contact stays deterministic because the contact LIST is built in a fixed# order (floor contacts by body index, then body-body pairs by (i, j)) and the# Gauss-Seidel solve runs a FIXED iteration count — no data-dependent branching.sys.path.insert(0, str(Path(__file__).resolve().parents[3]))from curriculum.common.device import banner  # noqa: E402 parser = argparse.ArgumentParser(description=__doc__)parser.add_argument("--seed", type=int, default=0, help="seeds the drop-height jitter; load-bearing but the artifact ORDERING (penalty penetrates/jitters >> lcp holds) is seed-robust")parser.add_argument("--scene", choices=["drop", "bounce", "stack"], default="drop", help="drop=settle on the table; bounce=restitution; stack=a few bodies (floor + body-body contacts)")parser.add_argument("--contact", choices=["all", "penalty", "lcp"], default="all", help="'all' runs the penalty-vs-lcp comparison — the whole point")parser.add_argument("--stiffness", type=float, default=1.0e4, help="penalty spring constant k; bigger k = less sinking but a smaller stable dt (dt_crit ~ 2*sqrt(m/k))")parser.add_argument("--damping", type=float, default=-1.0, help="penalty spring DAMPER c; the ONLY thing setting penalty's (uncontrollable) restitution. <0 uses the scene default (drop/stack: 80 settles; bounce: 0 rings and INJECTS energy)")parser.add_argument("--restitution", type=float, default=-1.0, help="lcp bounce coefficient e in [0,1]; <0 means use the scene default (drop/stack: 0, bounce: 0.7)")parser.add_argument("--baumgarte", type=float, default=0.2, help="lcp position-correction gain beta; feeds penetration back as a separation velocity so the body does not sink")parser.add_argument("--iters", type=int, default=20, help="projected Gauss-Seidel sweeps per lcp step (fixed for determinism); enough for the stack's few contacts")parser.add_argument("--dt", type=float, default=0.002, help="timestep in sim seconds; raise it past penalty's dt_crit and penalty EXPLODES while lcp holds")parser.add_argument("--steps", type=int, default=2000)  # any laptop: millisecondsparser.add_argument("--smoke", action="store_true", help="fixed 800-step run for CI; two runs must match byte-for-byte")parser.add_argument("--device", choices=["cpu"], default="cpu", help="pure-numpy CPU; the flag exists for banner/tier parity")parser.add_argument("--out", type=Path, default=Path("outputs/ch3.5-contact"))parser.add_argument("--rerun", dest="rerun", action="store_true", default=True)  # recording is the default; opt OUT, not inparser.add_argument("--no-rerun", dest="rerun", action="store_false", help="skip the .rrd recording (CI smoke)")args = parser.parse_args() banner("ch3.5-contact", device=args.device)  # tier + measured wall-clock, printed firstnum_steps = 800 if args.smoke else args.steps  # smoke length is FIXED so CI can diff runs exactlyargs.out.mkdir(parents=True, exist_ok=True)rng = np.random.default_rng(args.seed)  # PCG64 — the only source of randomness in this file GRAVITY = np.array([0.0, -9.81])  # 2D world; the table is the line y = 0, normal points +yFLOOR_N = np.array([0.0, 1.0])

Same house conventions as 3.3 and 3.4: free-tier defaults first, a --smoke mode with a fixed step count so CI can diff two runs byte-for-byte, no GPU tier, no training. The full run takes about 0.02 minutes on a laptop CPU — a point mass, a scalar contact solve per step, and a small stability sweep; there is nothing to wait for. The knobs that carry the lesson are --contact (the comparison), --stiffness (the penalty spring you will learn to distrust), and --dt (raise it past a critical value and watch penalty detonate). Every headline below is an ordering — penalty sinks and can explode, LCP holds — and the --seed nudges only the drop height by a couple of centimeters, so that ordering is robust to it.

We drop to two dimensions and model bodies as point masses with a radius — pucks, effectively. No rotation, which means no friction cone: this chapter is about the normal direction, the push perpendicular to the surface. Friction and sliding are chapter 3.6's problem. Being honest about that scope is part of the lesson; contact is hard enough one axis at a time.

The scenes

contact.py#scenessha256:8a6389bbeb…
# A "scene" is a set of point masses, each with a collision RADIUS (a puck/ball —# no rotation, so no friction cone: normal contact is the whole lesson). We keep# ch3.3's (q, v, m) shape and stack it to N bodies: q, v are (N, 2), m is (N, 1)# so force / m broadcasts per body exactly as ch3.3's scalar divide did. The new# fields are the geometry `radius` (N,) and the material `restitution` (the LCP# bounce coefficient). The seed jitters ONE knob — the drop height — so --seed is# load-bearing while the physics, and the penalty-vs-lcp ordering, stay put.  def build_scene(name: str, rng: np.random.Generator, restitution_override: float, damping_override: float) -> dict:    """Return {q0, v0, m, radius, restitution, damping} for a contact test scene.     The material `restitution` is what lcp uses directly; penalty has no such knob    — it can only APPROXIMATE a bounce through its `damping`, badly, which is the    whole point (drop/stack want it settled and damped; bounce wants it to ring).    """    radius = 0.1    jitter = rng.uniform(-0.02, 0.02)  # the only randomness: a small drop-height nudge    if name == "stack":        # Three equal balls stacked exactly touching, released from rest. A good        # solver HOLDS the tower (floor + two body-body contacts, solved together);        # penalty lets it settle by sinking and jittering. This is the body-body        # contact path ch3.6 reuses (a pusher on a T-block is the same math).        n, rest, damp = 3, 0.0, 80.0        q0 = np.array([[0.0, (2 * i + 1) * radius + jitter] for i in range(n)])    else:  # drop / bounce — one ball falling onto the table        n = 1        q0 = np.array([[0.0, 1.0 + jitter]])  # a metre up, straight above the table        # bounce: restitution 0.7 and NO penalty damping (so the discrete spring        # rings and INJECTS energy — the classic penalty artifact). drop: settle.        rest, damp = (0.7, 0.0) if name == "bounce" else (0.0, 80.0)    v0 = np.zeros((n, 2))  # released from rest    m = np.ones((n, 1))    # unit point masses    rest = restitution_override if restitution_override >= 0.0 else rest    damp = damping_override if damping_override >= 0.0 else damp    return {"q0": q0, "v0": v0, "m": m, "radius": np.full(n, radius), "restitution": rest, "damping": damp}

Three scenes, the same (q, v, m) shape from 3.3 stacked to N bodies: a drop (one ball, settles on the table), a bounce (one ball with restitution), and a stack (three balls in a tower — the case with several contacts at once, and the one chapter 3.6 will reuse when a pusher leans on a T-block). The material's restitution is a number the LCP solver uses directly; the penalty contact has no such knob, which is a tell we will come back to.

The geometry: finding contacts

contact.py#contactssha256:7484e6335f…
# THE geometry step, shared by BOTH solvers and by ch3.6. A contact is a place# where two surfaces overlap (or nearly do): who is involved (body i, and body j# or the floor as j = -1), the unit NORMAL n along which they may only push apart,# and the PENETRATION depth (how far they already overlap, >= 0). We detect a# little BEFORE touching (a small margin) so a resting contact — depth ~ 0 — is# still on the list and the solver can hold it there. Order is fixed (floor by# body index, then pairs by (i, j)) so the whole run is bitwise deterministic. CONTACT_MARGIN = 1.0e-3  # detect contacts this far before actual overlap (holds resting bodies)  def detect_contacts(q: np.ndarray, radius: np.ndarray) -> list[dict]:    """Return the list of active contacts for the current positions."""    contacts = []    for i in range(q.shape[0]):  # body vs the floor (y = 0)        gap = q[i, 1] - radius[i]  # signed distance from the ball's underside to the table        if gap < CONTACT_MARGIN:            contacts.append({"i": i, "j": -1, "n": FLOOR_N, "depth": -gap})    for i in range(q.shape[0]):  # body vs body        for j in range(i + 1, q.shape[0]):            d = q[i] - q[j]            dist = float(np.linalg.norm(d))            overlap = radius[i] + radius[j] - dist            if overlap > -CONTACT_MARGIN and dist > 1e-12:                contacts.append({"i": i, "j": j, "n": d / dist, "depth": overlap})    return contacts  def _normal_velocity(v: np.ndarray, c: dict) -> float:    """Relative velocity of the contact pair along the normal (>0 separating)."""    vn = float(v[c["i"]] @ c["n"])    if c["j"] >= 0:        vn -= float(v[c["j"]] @ c["n"])    return vn

Both solvers start here, and so will chapter 3.6. A contact is a place two surfaces overlap: who is involved (a body, and either another body or the floor), the unit normal they may only push apart along, and the penetration depth. We look a hair before the surfaces actually touch (a small margin) so that a resting contact — depth essentially zero — is still on the list and the solver can hold it there. And we build the list in a fixed order, floor contacts then pairs, so the whole run stays bitwise deterministic even though contact is famously the part of a physics engine where determinism goes to die.

Way one: penalty — a contact is a spring

contact.py#penaltysha256:3f54fbfb09…
# PENALTY contact: pretend every contact is a stiff spring-damper pushing the# surfaces apart, force = k*penetration - c*(closing speed), and CLAMP it to# push-only (max(0, .)) so it never pulls — the one-sidedness, enforced by hand.# That is it: a contact becomes an ADDED force, so ch3.3's integrator steps it# with ZERO changes. The price is everywhere in the metrics: the body sinks to# where k*depth balances gravity (depth = mg/k), it rings while it settles, and a# dt past ~2*sqrt(m/k) turns the spring into a bomb. Simplicity you pay for later.  def penalty_force(q: np.ndarray, v: np.ndarray, m: np.ndarray, radius: np.ndarray,                  k: float, c: float) -> np.ndarray:    """Gravity + the spring-damper contact force, in ch3.3's force(q, v) shape."""    f = m * GRAVITY  # (N,1)*(2,) -> (N,2), same broadcast as ch3.3    for contact in detect_contacts(q, radius):        depth = contact["depth"]        if depth <= 0.0:            continue  # a spring only pushes while actually compressed (not in the margin)        vn = _normal_velocity(v, contact)        fn = max(0.0, k * depth - c * vn)  # push-only: never yank a separating body back        f[contact["i"]] += fn * contact["n"]        if contact["j"] >= 0:            f[contact["j"]] -= fn * contact["n"]  # equal and opposite on the partner    return f  def semi_implicit_step(q, v, m, force, dt):    """ch3.3's symplectic Euler, reused VERBATIM (repetition is the lesson): new    velocity first, then step position with it. The integrator never learns that    `force` now hides contact detection — a contact is just a force to it.    """    a = force(q, v) / m    v_next = v + dt * a    q_next = q + dt * v_next  # NEW velocity, not old    return q_next, v_next

The penalty method is almost too simple to believe. Pretend the surfaces are a very stiff spring: the deeper they overlap, the harder they push apart, force = k * depth, minus a damper so the bounce dies down. Clamp it to be push-only — max(0, ...) — because a spring would otherwise pull a separating body back, and the table must never pull. That single max is the one-sidedness, enforced by hand.

And that is all. A penalty contact is just an added force, so chapter 3.3's integrator — copied in here verbatim, the repetition is the point — steps it with zero changes. The integrator never learns that force now runs a collision check.

You pay for the simplicity everywhere else, and the metrics name the bill:

  • The ball sinks. To hold a ball of weight mg up, the spring must compress until k * depth = mg, so it rests at depth = mg/kinside the table. With our stiff k = 1e4 that is 0.0098 of the radius: small, but never zero, and it grows the moment you soften the spring.
  • The ball rings. A spring plus a mass is an oscillator; on impact it bounces and jitters before the damper wins. In the bounce scene it visibly never stops.
  • Worst of all, the ball can explode. An explicit integrator can only hold a spring of stiffness k stable while dt < dt_crit ~ 2*sqrt(m/k). For our spring that is 0.02 s. Step at dt = 0.03 and the spring pumps in energy faster than the damper removes it: the ball is flung away, its energy blowing up by a factor of 1409. That is the timestep-instability from chapter 0.1, in the flesh.

You can fix any one of these by tuning k — but stiffening the spring to sink less shrinks the stable timestep, and softening it to allow a bigger timestep makes it sink more. There is no setting that wins. That trap is the reason we need way two.

Way two: LCP-flavored — solve the complementarity

contact.py#lcpsha256:b99d2bd682…
# LCP-FLAVORED contact: instead of a spring, solve the complementarity directly.# Predict the velocity under gravity, then find the contact IMPULSE lambda >= 0# that leaves each contact non-penetrating: the post-solve normal velocity must# be >= a small target (separate, or hold, plus a Baumgarte push that bleeds off# any leftover penetration) AND lambda >= 0 (push-only) AND they are complementary# (no impulse once separating). For one contact that is a single clamp; for the# stack's few contacts we sweep them Gauss-Seidel style, a fixed number of times.# This is NOT a force added to the integrator — it is a velocity projection that# REPLACES the plain step. That structural difference is the chapter's point.  def solve_contacts(v: np.ndarray, minv: np.ndarray, contacts: list[dict],                   restitution: float, baumgarte: float, dt: float, iters: int) -> np.ndarray:    """Projected Gauss-Seidel for the contact impulses. Mutates and returns v."""    vn_pre = [_normal_velocity(v, c) for c in contacts]  # approach speed, for restitution    lam = np.zeros(len(contacts))  # accumulated normal impulse per contact, clamped >= 0    for _ in range(iters):  # FIXED count -> deterministic; enough sweeps for a few contacts        for idx, c in enumerate(contacts):            i, j, n = c["i"], c["j"], c["n"]            k_eff = minv[i] + (minv[j] if j >= 0 else 0.0)  # normal effective inverse-mass            # Target normal velocity: bounce (-e*approach, only if it was closing)            # plus a gentle Baumgarte push proportional to how deep we already are.            target = -restitution * min(vn_pre[idx], 0.0) + (baumgarte / dt) * c["depth"]            vn = _normal_velocity(v, c)            dlam = (target - vn) / k_eff              # impulse to hit the target this sweep            new = max(0.0, lam[idx] + dlam)           # project: total impulse stays push-only            dlam, lam[idx] = new - lam[idx], new            v[i] += (dlam * minv[i]) * n              # apply the delta impulse to both bodies            if j >= 0:                v[j] -= (dlam * minv[j]) * n    return v  def lcp_step(q, v, m, radius, restitution, baumgarte, dt, iters):    """One contact-solved step: gravity predict, then project the velocity onto    the non-penetration set, then integrate position with the corrected velocity.    """    v = v + dt * GRAVITY                       # semi-implicit predict (gravity is mass-independent)    contacts = detect_contacts(q, radius)    if contacts:        minv = (1.0 / m).reshape(-1)           # diag(M^-1) as a (N,) vector        v = solve_contacts(v, minv, contacts, restitution, baumgarte, dt, iters)    return q + dt * v, v

Instead of faking contact with a spring, solve the complementarity conditions directly — but at the velocity level, which is what real-time engines do. Take a gravity step to get a predicted velocity, then find the contact impulse lambda >= 0 that leaves the body no longer moving into the table. For a single contact that is one clamp; for the stack's several contacts we sweep them one at a time, a fixed number of passes — projected Gauss-Seidel, the humble workhorse inside a great many physics engines. Each sweep, each contact asks for the impulse that would hit its target normal velocity, and then we project: the accumulated impulse may never go negative, because the table may only push. That max(0, ...) is the complementarity condition, exactly as the clamp in the penalty force was — the same idea, in the two different languages of the two solver families.

The crucial structural difference: this is not a force added to the integrator. It is a velocity projection that replaces the plain step. Penalty bends to fit chapter 3.3's force(q, v); the complementarity solve does not. That is why contact is where the tidy "a constraint is just an added force" story of 3.4 finally breaks.

A small baumgarte term (yes — the same Baumgarte from 3.4) feeds any leftover penetration back as a gentle separating velocity, so the body is nudged back out to the surface rather than left buried. It is a hand-tuned gain, and admitting that is part of the honesty.

Simulate and measure

contact.py#simulatesha256:212c0d82c6…
# Run one contact model over the whole horizon and record, at every step: the# body HEIGHT (what you watch settle or bounce), the worst PENETRATION depth (the# honesty metric — a body should NOT sink through the table), and the total ENERGY# (a good inelastic contact only ever REMOVES energy; a penalty spring INJECTS it# on impact and rings). Pure function of its inputs — bitwise reproducible.  def energy(q: np.ndarray, v: np.ndarray, m: np.ndarray) -> float:    """Total mechanical energy: kinetic + gravitational potential (U = m g y)."""    kinetic = 0.5 * float(np.sum(m * v * v))    potential = float(np.sum(m[:, 0] * 9.81 * q[:, 1]))    return kinetic + potential  def max_penetration(q: np.ndarray, radius: np.ndarray) -> float:    """Worst overlap in the scene right now: floor penetration or body-body."""    worst = 0.0    for i in range(q.shape[0]):        worst = max(worst, radius[i] - q[i, 1])            # into the floor        for j in range(i + 1, q.shape[0]):            dist = float(np.linalg.norm(q[i] - q[j]))            worst = max(worst, radius[i] + radius[j] - dist)  # into each other    return worst  def simulate(step, scene: dict, dt: float, steps: int):    """Integrate for `steps`. Returns (heights, penetrations, energies) arrays."""    q = scene["q0"].astype(float).copy()    v = scene["v0"].astype(float).copy()    m, radius = scene["m"], scene["radius"]     heights = np.empty((steps + 1, q.shape[0]))    penetrations = np.empty(steps + 1)    energies = np.empty(steps + 1)    heights[0], penetrations[0], energies[0] = q[:, 1], max_penetration(q, radius), energy(q, v, m)    for t in range(steps):        q, v = step(q, v)        heights[t + 1] = q[:, 1]        penetrations[t + 1] = max_penetration(q, radius)        energies[t + 1] = energy(q, v, m)    return heights, penetrations, energies  def contact_quality(penetrations: np.ndarray, energies: np.ndarray, radius: float) -> dict:    """The honesty numbers, parallel to ch3.3 energy drift / ch3.4 constraint drift.     max_penetration_frac: worst sink as a fraction of the body radius (want ~0).    rest_penetration_frac: the STATIC sink once settled (penalty: ~mg/k; lcp: ~0).    energy_excess: worst energy ABOVE the start, over the start (a correct                   inelastic contact never exceeds it; a penalty spring does).    rest_jitter: penetration wobble over the settled tail (penalty rings; lcp flat).    """    e0 = energies[0]    scale = abs(e0) if e0 != 0.0 else 1.0    tail = penetrations[3 * len(penetrations) // 4:]  # last quarter — "settled" window    finite = np.isfinite(energies).all() and np.isfinite(penetrations).all()    return {        "max_penetration_frac": float(np.max(penetrations) / radius) if finite else float("inf"),        "rest_penetration_frac": float(penetrations[-1] / radius) if finite else float("inf"),        "energy_excess": float(np.max(energies - e0) / scale) if finite else float("inf"),        "rest_jitter": float(np.std(tail) / radius) if finite else float("inf"),        "blew_up": not finite,    }  COLORS = {"penalty": [217, 76, 64], "lcp": [76, 175, 80]}  # red sinks/jitters, green holds  def log_rerun(name: str, heights: np.ndarray, penetrations: np.ndarray, energies: np.ndarray) -> None:    """Stream the body height, penetration (the hero panel), and energy curves."""    for t in range(len(penetrations)):        rr.set_time("step", sequence=t)        rr.log(f"height/{name}", rr.Scalars([float(h) for h in heights[t]]))        rr.log(f"penetration/{name}", rr.Scalars([float(penetrations[t])]), colors=[COLORS[name]])        rr.log(f"energy/{name}", rr.Scalars([float(energies[t])]), colors=[COLORS[name]])

The loop is 3.3's, recording the contact-quality numbers at every step: the worst penetration (a body should not sink through a table), the total energy (a correct inelastic contact only ever removes energy — it never invents any), and, from those, the jitter and the phantom energy. These play exactly the role energy drift played in 3.3 and constraint drift played in 3.4.

The measurement

Drop a ball for four seconds of sim time, penalty versus LCP-flavored, and read the contact quality (seed 0; penetration as a fraction of the ball's radius):

contact max penetration rest penetration stable up to dt
penalty 0.251 0.0098 0.008 s
LCP-flavored 0.057 0.0 0.064 s

The penalty ball drives 4.4× deeper on impact and comes to rest inside the table; the LCP ball penetrates a quarter as much on impact — about one step's worth of approach velocity, which no velocity-level solver can avoid without continuous collision detection — and then holds exactly on the surface. And penalty is stable only up to a timestep eight times smaller than the one LCP shrugs off.

The bounce scene adds the phantom-energy artifact. There, the penalty spring gains energy out of nothing — energy_excess = +0.006 at the default timestep, climbing to +0.148 (a 15% energy gain, the ball bouncing higher than it was dropped) at dt = 0.005 — while the LCP contact, which is told the restitution directly, never exceeds the energy it started with. The stack scene adds the several-contact case: penalty lets the tower sink and settle into itself (worst overlap 0.059 of a radius), while the LCP sweep holds all three balls to within five-millionths of a radius — half a micron.

Break It

Penalty sank and rang, but at the default step it never exploded — so push the one knob it cannot survive. Raise the timestep past the spring's stability limit:

python contact.py --contact penalty --dt 0.03

Predict first: the spring constant did not change, only how big a step you ask the explicit integrator to take. An explicit integrator holds a spring of stiffness k stable only while dt < dt_crit ~ 2*sqrt(m/k); step past it and the spring pumps energy in faster than the damper removes it. The measurement is the ×1409 energy blow-up from the build section, in the flesh — the ball flung toward infinity, the timestep-instability you first met in chapter 0.1.

Now try to escape it the way you'd reach for first — stiffen the spring with --stiffness so the ball sinks less. It makes the cliff worse: a bigger k shrinks dt_crit, so the step that was borderline now detonates. Soften k to widen the stable step and the ball sinks deeper instead, at depth = mg/k. There is no single k that sinks little and survives a big step — the artifacts trade against each other, and that wall, not any one number you failed to find, is why way two abandons the spring entirely. Run the same --dt 0.03 with --contact lcp and it holds.

Why MuJoCo looks the way it does

Sit with the trade you just fought. Penalty is trivial to write and folds into any integrator — and it sinks, rings, needs a punishingly small timestep, and can blow up. Hard complementarity holds the body honestly — and it is a solve, it has no clean notion of a slightly-soft contact, and a true LCP is expensive and brittle when contacts are many. Neither is what you want.

MuJoCo's answer is to sit deliberately between them: a soft, regularized, convex contact model. It builds the very same per-contact structure you built — normals, effective masses, a projected sweep — but instead of a hard phi >= 0 it allows a little give, governed by physical stiffness and damping parameters (solref, solimp) rather than a raw spring constant, and solves a convex problem that always has a unique, stable answer. Every strange-looking knob in MuJoCo's contact documentation is an answer to a pain you just felt in your own hands: the softness is penalty's give made principled; the reference parameters are the Baumgarte gain made physical; the convex solve is the LCP made cheap and robust. That is the reading for this chapter (see meta.yaml rtrt).

The honest limits

Say plainly what this little engine does not do, because the lesson is the trade, not a finished solver:

  • No friction. Bodies are frictionless pucks; there is no rotation and no friction cone. The whole chapter is the normal direction. Sliding, stiction, and the friction pyramid are chapter 3.6's job.
  • The LCP-flavored solve is a fixed-iteration projected Gauss-Seidel, not a true LCP pivot. It is enough to hold a small stack; it is not a research contact solver.
  • Even the LCP contact penetrates on fast impact (about one step of approach velocity) and leans on a hand-tuned Baumgarte gain. It is better, not perfect. Do not let the green curve fool you into thinking contact is solved. It is managed.

Read the real thing

The rtrt block in meta.yaml pins google-deepmind/mujoco at tag 3.10.0 — the same MuJoCo the whole course runs on. Two files hold the production answer to the trade you just measured by hand. Read them against contact.py.

Start with what you built. Our penalty region is one function, penalty_force: fn = max(0, k*depth - c*vn), a stiff spring clamped push-only, and that raw k is exactly what forces the dt_crit ~ 2*sqrt(m/k) cliff you watched detonate. Our lcp region is solve_contacts — a fixed-iteration projected Gauss-Seidel sweep, one max(0, ...) clamp per contact — wrapped by lcp_step, which predicts the velocity and then projects it. Hold those two shapes in your head.

Now src/engine/engine_core_constraint.c. MuJoCo does not pick spring-or-solve; it builds a soft reference and hands it to a solver. mj_makeImpedance (with its helper getimpedance) turns each constraint's solref and solimp into a stiffness K, a damping B, and an impedance d in [0, 1] — the standard-format comment spells the map, K = 1/(d_width^2 * timeconst^2 * dampratio^2) and B = 2/(d_width * timeconst). Then mj_referenceConstraint computes a reference acceleration aref = -B*vel - K*I*(pos-margin) — read that slowly. It is our penalty spring (k*depth) and our Baumgarte push (feeding pos back as a separating velocity) fused into one physically-parameterized target, with solimp's impedance softening the constraint smoothly from free to rigid instead of our hard max. solref is k and damping made principled; solimp is the softness we never had.

Then src/engine/engine_solver.c. mj_solPGS (core solPGS) is your solve_contacts grown up — the same per-constraint projected sweep — but MuJoCo's default is the convex primal solver, mj_solNewton (or mj_solCG) via mj_solPrimal, minimizing a regularized cost (the efc_R diagonal that mj_makeImpedance also builds) with a linesearch, so the answer is unique and stable where a hard LCP turns brittle.

What they add is everything we scoped out: real friction cones (our pucks have none), a convex regularizer our bare PGS lacks, and the softness that keeps one dt safe across a whole scene. Ours is honest, not finished — frictionless, and a fixed-sweep Gauss-Seidel, not a true LCP pivot.

Read next: open src/engine/engine_core_constraint.c and find mj_referenceConstraint's one line, aref = -B*vel - K*I*(pos-margin). That is your penalty spring and your Baumgarte push, written once and physically — the whole chapter in a single formula.

What you built, and what comes next

You built contact — the one-sided, on-off, complementarity constraint that is the hard heart of every physics engine. You built it twice, the easy way and the honest way, and you measured the difference: penetration, phantom energy, jitter, and the timestep cliff that has been haunting your sims since chapter 0.1. You can now look at any contact artifact and name its cause.

Chapter 3.6 cashes this in. PushT — a circular pusher shoving a T-shaped block across a table — is nothing but bodies in contact: the pusher contacting the block is exactly the body-body path the stack scene exercised, detect_contacts and solve_contacts reused wholesale. With a contact model in hand, you can build the environment, generate demonstrations, and train behavior cloning on it — closing the loop from raw dynamics back to the policies of Phase 1.

The print table this report region produces is the deliverable — the two rows that say, in order, "sinks and can explode" and "holds":

contact.py#reportsha256:ebc8fd5dc7…
# Run the chosen model(s), print the contact-quality table, write metrics.json.# The deliverable is the comparison: penalty SINKS + JITTERS (and, past dt_crit,# EXPLODES) while the LCP-flavored solve HOLDS the body on the table with almost# no penetration and no phantom energy — the measured reason a real engine never# ships a bare penalty contact, and the reason MuJoCo softens the hard LCP.scene = build_scene(args.scene, rng, args.restitution, args.damping)radius0 = float(scene["radius"][0])  def run_penalty(dt: float):    def force(q, v):        return penalty_force(q, v, scene["m"], scene["radius"], args.stiffness, scene["damping"])    return simulate(lambda q, v: semi_implicit_step(q, v, scene["m"], force, dt), scene, dt, num_steps)  def run_lcp(dt: float):    def step(q, v):        return lcp_step(q, v, scene["m"], scene["radius"], scene["restitution"], args.baumgarte, dt, args.iters)    return simulate(step, scene, dt, num_steps)  RUNNERS = {"penalty": run_penalty, "lcp": run_lcp}names = list(RUNNERS) if args.contact == "all" else [args.contact] if args.rerun:    rr.init("zero2robot/ch3.5-contact", spawn=False)    rr.save(str(args.out / "contact.rrd"))    rr.log("world/table", rr.LineStrips2D([[[-1.0, 0.0], [1.0, 0.0]]]), static=True)  # the table line with np.errstate(all="ignore"):  # a diverging penalty spring overflows on purpose; we measure it, not crash on it    quality: dict[str, dict] = {}    for name in names:        heights, penetrations, energies = RUNNERS[name](args.dt)        quality[name] = contact_quality(penetrations, energies, radius0)        if args.rerun and np.isfinite(energies).all():            log_rerun(name, heights, penetrations, energies)     # Stability-vs-dt probe: the headline artifact. Penalty is stable only while    # dt < ~2*sqrt(m/k); lcp has no such spring, so it holds at every dt here.    stability = {}    if args.contact == "all":        dt_crit = 2.0 * np.sqrt(float(scene["m"][0, 0]) / args.stiffness)        for name in names:            biggest_stable = 0.0            for mult in (1, 2, 4, 8, 16, 32):                _, _, e = RUNNERS[name](args.dt * mult)                if np.isfinite(e).all() and np.max(e - e[0]) / (abs(e[0]) or 1.0) < 10.0:                    biggest_stable = args.dt * mult            stability[name] = biggest_stable        stability["dt_crit_penalty"] = float(dt_crit) metrics = {    "scene": args.scene, "contact": args.contact, "dt": args.dt, "steps": num_steps,    "seed": args.seed, "stiffness": args.stiffness, "restitution": scene["restitution"],    # 12 decimals: a good lcp run's penetration is ~1e-3 of the radius and rounding    # it away would erase the very signal the exercises measure.    "quality": {k: {kk: (round(vv, 12) if isinstance(vv, float) else vv) for kk, vv in v.items()} for k, v in quality.items()},    "stability": {k: (round(v, 12) if isinstance(v, float) else v) for k, v in stability.items()},}(args.out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n") print(f"scene {args.scene} ({num_steps} steps of {args.dt} s = {num_steps * args.dt:.2f} s sim), radius {radius0}")print(f"{'contact':<10}{'max pen/r':>12}{'rest pen/r':>12}{'energy+':>12}{'jitter/r':>12}{'stable?':>10}")for name in names:    r = quality[name]    flag = "EXPLODED" if r["blew_up"] else "ok"    print(f"{name:<10}{r['max_penetration_frac']:>12.4e}{r['rest_penetration_frac']:>12.4e}{r['energy_excess']:>+12.3e}{r['rest_jitter']:>12.3e}{flag:>10}")if args.contact == "all":    # The headline is an ORDERING, so it survives any seed/tier: penalty drives the    # DEEPER impact penetration AND rests INSIDE the table, while lcp never sinks past    # it. (Energy and jitter are the bounce scene's headline; penetration is the    # robust rock — it holds this ordering on every scene and seed.)    ok = quality["lcp"]["max_penetration_frac"] < quality["penalty"]["max_penetration_frac"] and \        quality["lcp"]["rest_penetration_frac"] <= quality["penalty"]["rest_penetration_frac"] + 1e-9    verdict = "as expected" if ok else "UNEXPECTED — investigate"    print(f"contact quality  penalty sinks+jitters >> lcp holds:  {verdict}")    print(f"stability: penalty stable up to dt={stability['penalty']:.4g} s (dt_crit~{stability['dt_crit_penalty']:.4g}); lcp up to dt={stability['lcp']:.4g} s")print(f"metrics: {args.out / 'metrics.json'}")if args.rerun:    print(f"recording: {args.out / 'contact.rrd'} — open it with: rerun {args.out / 'contact.rrd'}")# config hash of the run knobs, for the wallclock ledger (author: paste into wallclock.csv)_cfg = f"{args.scene}|{args.dt}|{args.stiffness}|{args.damping}|{args.baumgarte}|{args.iters}|{num_steps}"print(f"config_hash: {hashlib.md5(_cfg.encode()).hexdigest()[:12]}")

One closing distinction worth carrying into 3.6. The contact solve here works at the velocity level — it computes an impulse that corrects the velocity in a single step — where chapter 3.4's joint constraint worked at the acceleration level. That is not an accident: an impact is a sudden jump in velocity, and an acceleration-level solve cannot represent a jump. Impulses can. And friction, when you meet it in 3.6, is a second complementarity problem stacked on this one — the friction cone — solved in the same projected sweep. The spine you built here is the one the next chapter extends.

Practice

practice · candidate exercisesdrafted by the exercise generator, pending human promotion. Answers reveal only after you predict — honor system.

  1. predict-then-run

    Exercise 1

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch3.5.

    Objective tested: contact quality as the honesty metric. The table is a one-sided constraint — it may only PUSH a body up, never pull it down, and only while they touch. A PENALTY contact fakes that with a stiff spring on penetration depth; an LCP-FLAVORED contact solves the complementarity for the exact contact impulse. The SHAPE of what each one gets wrong is the whole story.

    THE EXPERIMENT: drop a ball onto the table and integrate for the smoke horizon (contact.py --smoke), once with the PENALTY contact and once with the LCP one, and read how deep the ball drives in on impact (max penetration, as a fraction of its radius) and how deep it sits once at REST (rest penetration).

    PREDICT before you run: what does the PENALTY contact do that the LCP one does not?

    • A) nothing different — both hold the ball exactly on the surface; a contact is a contact

    • B) the penalty ball bounces forever but never penetrates; the lcp ball penetrates but settles

    • C) the penalty ball drives much DEEPER on impact AND comes to rest INSIDE the table (a static sink of mg/k); the lcp ball holds on the surface

    Before you run, write one sentence: WHY — what must a stiff spring compress to in order to hold a weight mg up, and where does that leave the ball resting?

    Estimated learner time: 10 minutes.

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

  2. code-completion

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — code-completion, ch3.5.

    Objective tested: the two lines that ARE contact. Both are about the ONE-SIDEDNESS of a contact — the table pushes but never pulls — expressed twice, once in each solver family:

    1. the PENALTY push-only clamp — a spring force kdepth - c(closing speed) that is CLAMPED to be non-negative, so a separating body is never yanked back. Drop the clamp and the "spring" glues the body to the floor (a contact that pulls).
    2. the LCP non-penetration PROJECTION — the accumulated normal impulse must stay

      = 0 (push-only) as we sweep it. That single max(0, .) is the complementarity condition; without it the solve is an equality constraint (a weld), not a contact.

    The geometry (detect, normals, effective masses) is given, complete and correct. You fill in the two clamps. checks.py compares your completed functions against the reference on random configurations; while a blank is unfilled it raises NotImplementedError and the check SKIPS. Estimated learner time: 25 minutes.

    Run it locally:

    pytest curriculum/phase3_advanced/ch3.5_contact/exercises/suggested/checks.py -k ex2
  3. predict-then-run

    Exercise 3

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch3.5.

    Objective tested: the dt-stability CLIFF — the sim artifact you have watched blow up since ch0.1, now explained. A penalty contact is a stiff spring, and an explicit integrator can only hold a spring stable while the timestep stays under dt_crit ~ 2*sqrt(m/k). Cross it and the spring pumps energy faster than it can bleed off: the body is flung to infinity. The LCP-flavored solve has no spring — it projects the velocity — so it has no such cliff.

    THE EXPERIMENT: run the DROP scene at a timestep well PAST penalty's dt_crit (dt = 0.03 s, where dt_crit ~ 0.02 s for the default k = 1e4), with BOTH contact models, and read the phantom energy each one gains (energy_excess, relative to the drop energy).

    PREDICT before you run: at dt past dt_crit, what happens?

    • A) both blow up — no explicit contact method survives too large a timestep

    • B) the PENALTY body explodes (energy_excess enormous) while the LCP body stays bounded (energy_excess ~ 0) — the cliff is the spring's, not contact's

    • C) neither blows up — dt_crit is a myth; the body just penetrates a bit more

    Before you run, write one sentence: WHY — what does an explicit integrator do to a stiff spring once dt passes 2*sqrt(m/k), and does the velocity-projecting LCP even have a spring to blow up?

    Estimated learner time: 12 minutes.

    Predict, then commit

    Pick the outcome you expect from the options above. The answer and the local run command reveal only after you commit — predicting after you know teaches nothing.

wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopexpected wall-clock on cpu-laptop: ~0.02 min (measured)measured
mpswall-clock on mps: not yet measuredpending
t4expected wall-clock on t4: ~0.05 min (measured)measured
4090wall-clock on 4090: not yet measuredpending

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromcontact.py, and these fingerprints are its sha256, the same ones check_prose_code_drift re-checks on every PR. Edit a shown region without re-rendering and CI turns red.

#setup
b84fc777c9f06b9072d798035adb48dc03501c0151bf58c77792ccafd886154f
#scenes
8a6389bbeb82ed0dbe7c80fed9ff4b55f2b261d3f2fc30e6d1023369ed16e412
#contacts
7484e6335f6d7db3cf1d72ca800a9b29d8fa8ab258632658ce8ca89fd0c240f3
#penalty
3f54fbfb09f7e933bf9d9a0a60c66e33b66560857c57d55cbdfb6a0366afd9f5
#lcp
b99d2bd682ceb54343e26904130a7039d47788380c0d4c3d59a6019614a36d96
#simulate
212c0d82c63eb0856f06c531a7e320e5d26f5e6cc07dcb393026643230641404
#report
ebc8fd5dc738443b71deb7798a1ae6fce057dec09dec30809af831b79060e634