AGENTSOLVER · UNDER THE HOOD · PART 3 OF 4

Where the
maths lives.

This is the page for anyone who read "custom physics solver" and immediately wanted to know which one, how fast, and how the pieces fit together. There will be Featherstone. There will be SIMD.

DYNAMICS Featherstone ABA
CONSTRAINTS Global Projected SOR
SIMD AVX2 · 8 lanes
LEARNING PyTorch PPO · MLP + Mamba
// 01 · writing my own solver

A physics solver built for batched simulation.

I could have used the engine's constraint solver. But training means simulating the same creature an enormous number of times, and a general-purpose solver spends most of its budget on generality I don't need. So I wrote a reduced-coordinate Featherstone Articulated Body Algorithm from scratch in C++.

Featherstone is one of those algorithms that feels like a magic trick the first time it works: it gets you forward dynamics in O(n) without ever building the mass matrix — three sweeps over the tree and you're done. The topology is a floating 6-DOF root, then 3-DOF ball joints and 1-DOF yaw joints — 41 bodies, 68 degrees of freedom.

Then I profiled it, and the part that sounds hard turned out to be the cheap part. With 64 creatures in flight, a step spends about 57% of its time in contact, 30% in damping, and 12% in the forward dynamics — the constraint solve costs roughly 4.6× what Featherstone does. Forward dynamics stays somewhere around a tenth of the frame at every batch size I measured, so it isn't an artefact of the batch size; it's just where the work is. That's 200 timed steps after 20 warmup ones, on .

01

Reduced coordinates

The body is described by its joint angles, not by 41 free-floating pieces held together with constraints. Nothing can drift apart, because there is nothing to drift.

02

Featherstone ABA

O(n) forward dynamics without ever building the full mass matrix. The 52-bone rig collapses to a 41-body tree: the skeleton's bone count and the solver's topology are two different things.

03

Data-oriented state

Structure-of-Arrays all the way down, laid out for the one thing it has to do: process many identical creatures at once.

// 02 · many at once

Many environments. One solver.

Here's the thing about RL: it wants an obscene number of simulated seconds. Running environments one at a time is hopeless. But every environment has the identical topology — same bones, same joints, same everything — which means the work is the same instruction applied to different numbers. Which is exactly what SIMD is for.

So the hot path is AVX2, eight creatures per register, with the whole batch spread across parallel jobs. The trick itself is very cool. Working with it was, honestly, a pain — SIMD bugs don't crash. One lane quietly holds a wrong number and everything carries on as if nothing happened, so you find out a week later when the training makes no sense.

Which is why the boring scalar version never got deleted. When the two disagree, the fast one is wrong.

64 CREATURES · ONE SOLVER · EIGHT PER REGISTER

The batch size that actually trains is 64 — not the biggest number the solver can survive. I measured it: going to 256 buys about 30% more samples per second, but that's more than can sustain reliably over time, and it costs four times the iteration latency. Bigger looked better; in reality, it isn't.

AVX2

8-wide SIMD

The same physics step, eight creatures at a time, in one register.

64

Environments

The batch size training actually uses — chosen by measurement, not by ambition.

TEST

Scalar reference

Slow, obvious, and correct. When the fast one disagrees with it, the fast one is wrong.

// 03 · the rewrite that paid off

From individual constraints to a global system.

My first constraint solver went row by row, Gauss-Seidel style. It worked, in the sense that it produced numbers. It also meant every constraint was solved in ignorance of the others, and a foot on the ground had no idea a knee was at its limit.

So I threw it out and assembled the whole thing globally — ground contacts, joint limits and limb self-collisions in one system, solved with Projected SOR. The neat part is that I never have to form the mass matrix to build it: I already had an impulse-response query lying around from the ABA, and that's exactly what each entry of A = JM⁻¹Jᵀ + R needs. That's the equation. Told you I was proud of it.

CONTACT Ground 41 contact points
LIMIT Joints Joint-limit constraints
COLLISION Limbs Self-collision constraints
→
A = JM⁻¹Jᵀ + R Global system Projected SOR

I expected the rewrite to cost me performance in exchange for correctness. The global solve came out 1.57× cheaper than the per-row path — while doing 64 solver sweeps against the old path's 16, because a dense sweep costs no tree traversals. It also cut peak penetration by more than a third and held the torso nearly half again as high. To be clear about the claim: that's the constraint solve, not an end-to-end framerate number, measured on .

A few solver features along the way didn't survive contact with reality. They live in the bug journal, so this page can stick to what's actually running.

// 04 · what the muscles do

Two muscles per joint, not one number.

For a long time the policy's output was one signed torque per degree of freedom: positive pulls one way, negative the other, zero does nothing. It's the obvious encoding, and there is exactly one important sentence it cannot say — stiffen this joint without moving it. A real body does that constantly. Bracing is not the absence of force, it's two forces cancelling.

So every DOF now has two channels, an extensor and a flexor, and the action space doubled to 136 outputs for 68 degrees of freedom. Each muscle has its own authored strength curve, evaluated against the joint's current angle — muscles are weaker at the ends of their range, like the real thing.

01

Activation dynamics

Activation chases its command as a first-order lag: 30 ms to rise, 60 ms to relax. Muscle is quicker to fire than to let go, so a command that flips every frame arrives at the joint attenuated instead of whole.

02

Antagonistic sum

The wanted torque is extensor minus flexor, each scaled by its own strength at the current angle. Two pulls, one joint, and the difference is what moves.

03

Co-contraction damping

Whatever the two muscles have in common — min(extensor, flexor) — feeds the joint's damping. Fire both and the limb stiffens without moving. Fire one alone and nothing is added.

My favourite part is how little the solver knows about any of this. It still takes exactly one signed torque per DOF, as it always did. The muscle layer computes the antagonistic sum, then divides it by whichever strength curve the result's sign is about to select — so the solver, applying its usual strength scaling, reproduces the exact number the muscles asked for. Antagonism is real everywhere above the solver and invisible inside it.

That also fixed the twitching, and more honestly than a smoothing filter would have. The old bang-bang behaviour — the policy slamming a muscle between ±1 every frame — isn't rejected by a rule, it's just no longer worth doing, because the activation lag means a flipping command never fully arrives. The jitter has its own bug entry.

// 05 · letting it learn

Learning to stay upright before learning to walk.

The learning layer is my own PPO, written in PyTorch: clipped surrogate, GAE, KL early-stop, advantage normalisation, value clipping. No RL framework underneath it, which means the numbers that tell you whether PPO is actually healthy — loss, KL, entropy — are right there for me to read. If it's wrong, it's wrong in a way I can see.

It runs as a separate process. Python trains, a headless Unreal commandlet simulates, and the two talk over TCP on loopback with a small binary framed protocol.

PHYSICAL STATE
→
991 OBSERVATIONS
→
TCP
→
PYTORCH PPO
→
136 ACTIONS
→
MUSCLE TORQUES
→
PHYSICS

What the policy sees grew a lot. It used to be 228 floats — a torso summary, joint angles and velocities, contacts. It's now 991, assembled from blocks I can switch on and off independently: root state, full per-body state for all 41 bodies (615 floats on its own), joint positions and velocities, the muscle activations, contacts, a small airborne block, and the objective. It used to infer the shape of its own body from joint angles. Now it's simply told — including the state of its own muscles, which closes the loop back to the previous section.

Two details in there matter more than they look. Rotations go in as a 6D continuous representation rather than quaternions or Euler angles, because the tidy encodings have discontinuities a network has to spend capacity learning its way around. And the observation clamp — which squashes every channel into a fixed range — used to be 2.0, which the height and velocity channels hit constantly in the air: across its whole standing range, the creature read a flat 2.0. It was blind to how high it was, in the most literal sense. Raising the clamp to 10 and adding an explicit airborne block — ground clearance, time to impact, a flag — handed that information back. That one has its own bug entry too.

The layout is also hashed into the handshake now, so a policy can't quietly be loaded against an environment that arranges its observations differently. That mistake produces a creature that looks broken rather than one that errors, which is the worst kind.

The reward says reach the target height, keep your limbs on the ground, hold a sensible pose, and stop flailing — that last one is a torque penalty plus a cost on rapid changes in muscle intensity. Without those, and without smoothing activation before it reaches the solver, you get a creature that solves standing by vibrating. Which it did, for a while.

991Observations
136Actions
60 HzControl rate
0RL frameworks


Two policy architectures share the same trainer: a 256×256 MLP, and a pure-PyTorch Mamba with recurrent PPO. The recurrent one is meaningfully faster early — roughly 20–30% fewer samples to reach the first survival milestones — and then the two converge once the episode cap starts to bind.

How fast does it actually learn?

The run behind the clip at the top of my front page is 1346 iterations, and an iteration is 8192 env-steps — a 128-tick horizon across all 64 creatures. That's 11,034,624 simulated steps, and about seven hours on , spread across three sessions because I restarted it twice.

The update's share grew as the run went on, which took me a moment to understand and then made complete sense: PPO stops early once an update has moved the policy too far, and late in the run each pass was moving it less — so six epochs fitted under the same KL budget where four had before. The policy wasn't static; it was still changing from iteration to iteration, just taking smaller steps inside each update. The better behaved the learning gets, the more of it you pay for.

The genuinely annoying number is idle time. The two sides strictly alternate: collect the full 128 steps, then run the update, then collect again. Nothing is double-buffered. So Unreal spends 37% of the run doing nothing, waiting for Python to finish. That isn't an estimate either; the server counts its own waiting. It could sustain 8.5 steps per second and delivers 5.4. Overlapping collection with the update would get most of that third back, and I haven't done it yet.

One honest note on the numbers above and the ones further up this page: the solver costs meaningfully more per step during training than it does in the benchmark. They're not the same measurement. The benchmark runs a deliberately clean configuration — no drag, no friction, random torque babble — while training runs the real physical setup with a policy that spends its time in exactly the contact-heavy states a constraint solver finds expensive. Same code path, different worlds.

Launching, watching and replaying all of this happens through the editor, and that's on the tools page.

// 06 · making it hittable

Nothing ever pushed back. Now something does.

Until now, every force on Muto came from Muto itself: gravity, muscles, ground contact, joint limits. Nothing ever pushed back, so the policy could learn a stance that only stayed up because nobody touched it. Time to fix that.

SHOVES · SHOTS · ONE DELIVERY PATH
TRAINING

Random pushes

Each environment gets shoved on its own random timer. The push strength scales with bodyweight, so it survives mass changes. The direction is roughly horizontal, and the push lands on the pelvis or a random limb, spread over 80 ms instead of one substep.

GAMEPLAY

You can shoot it

Kinematic collision proxies built from the solver's own capsules, re-posed every frame. What you can hit is exactly what the creature collides with.

F·T

One path, tested

Every push, random or not, goes through the same function inside the substep loop, and a test checks the momentum it adds.

The policy is never told a push is coming. It has to feel it in its own body state, because a creature that only stays up when warned isn't really balancing.

The gameplay side had a catch: Muto's bodies are rows in my batched solver, not Chaos bodies, so Unreal physics couldn't touch them at all. Hence the proxies. Hits arrive two ways, Chaos contacts (projectiles, physics props) and Blueprint calls (ApplyWorldImpulse, ApplyRadialImpulse) for hitscan, explosions and melee, and both feed one queue. Units stay in Unreal's kg·cm/s, with a scale factor because Muto weighs several tonnes, not 80 kg.

Random pushes, gameplay hits and manual shoves all go through that one delivery function, which carries each impulse through the articulated tree. An automated test launches the real rig in zero gravity and checks that a force F over time T changes its momentum by exactly F·T, at any substep size. That catches the bugs you'd never see in the viewport, like a push three times too strong that still looks like a believable big shove.

// 07 · proving it is not lying to me

Every fix used to quietly break two older ones.

This was the loop I kept falling into: change something, fix the thing in front of me, silently break something I'd already fixed last week, and only notice much later once three of those had stacked up and the whole thing was on fire. Then I get to work out which of the last twenty changes did it. Not fun.

A physics system makes that worse, because a broken change still produces motion that looks perfectly convincing. Your eyes will cheerfully sign off on nonsense. So now there are tests, they run on their own, and I find out I've made a mistake in seconds instead of a fortnight.

The ones I trust most are invariant tests. Momentum has to be conserved whether or not the result looks nice, so I assert on that rather than on my own judgement. One of them hangs the real rig a kilometre up, hits it with full-strength random torques, and checks that its momentum changes by exactly gravity and nothing else.

14

Automation tests

Permanent tests covering solver and system behaviour, alongside a rotating set of temporary diagnostics.

PARITY

Fast vs. obvious

SIMD against the scalar reference. C++ policy playback against PyTorch.

PHYSICS

Invariant testing

Momentum doesn't care about my opinion of how the sim looks. Best bug-catcher I have.


The solver also has to pass a stability gate before anything trains on it: 50 seconds of continuous ground contact with all 41 bodies, under a passive drop and under random torque babble, with no divergence and no NaNs. That sounds unimpressive until you've watched a solver explode at second three for a fortnight. It is also, as the creature later demonstrated, not at all the same thing as the physics being right.

// next

Tests catch a lot. Not everything.

Everything on this page works. The bug journal is the list of all the times I was sure of that and wrong — including the time the creature found a hole in the physics and flew out of it.

Next: the bug journal →