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.
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.
CONTACTGround
41 contact points
LIMITJoints
Joint-limit constraints
COLLISIONLimbs
Self-collision constraints
→
A = JM⁻¹Jᵀ + RGlobal 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.