TERMINAL·DESTINY PLAY ▸
← ALL POSTS

Every attack animation leaned the wrong way for two months

BUILD NOTES8 September 2026 · 6 min read

An agent in TERMINAL DESTINY has a frame — a body you pick when you deploy it. Seven of them, and each has its own signature move when it swings: the soldier plants and shoots, the slim frame dashes in on its heel thrusters, the titan slams overhead, the specter blinks out and cuts from somewhere else.

Every one of those moves was playing backwards. Not upside down, not broken — mirrored, in a way that reads as merely limp. For two months — every swing between 10 July and 7 September.

Why two months

Nothing about it looks like a bug. There is no weapon to swing; the bodies are merged meshes with no separate arm to watch. The whole move lasts under 600ms, because it has to fit inside the 800ms server attack cooldown.

Play a 300ms bow and a 300ms lunge side by side on a body with no arms, at combat distance, and tell me which is which. I couldn't, and I wrote both.

What I did notice, for months, was that five of the seven frames felt like the same attack with different numbers, and that two of them were flatly indistinguishable. I filed that under "the moves need more range" and kept tuning amplitudes — which, given they were all playing in reverse, is a very funny thing to have spent evenings on.

The thing that caught it

The deploy panel got a live preview: a turntable that auditions the frame you're hovering over, so you can see what your agent will do before you spend anything. It calls the same signature function the world does.

And in the preview, the moves looked right. The titan slammed forward. The slim frame dashed at you.

Same function, same numbers, two different results — which is the most useful kind of bug report you can get, because it means the code is fine and one of the two callers is lying about what it's driving.

It was mine. The preview drove the model's own root node, whose local +z is genuinely the direction the frame faces. The world drove the outer node that follows the network position, and that one is rotated to face a target like this:

rotation.y = Math.atan2(-fdx, -fdy);

which points its local +z away from the target — the model inside it is flipped another π to face out. So the outer root's +z is the agent's back.

Babylon's positive rotation.x tips +y toward +z. On that node, toward +z is backward. Every "lean into it" in the table was a lean away.

The measurement

Once I knew where to look, I stopped arguing about what it looked like and read vertex positions at the apex of each move.

The soldier's lunge: its head had travelled 1.24 units backward. The titan's comment says SLAM forward — it was a slam away, and its wind-up: lean back was a lean in. The anticipation and the blow had swapped places, which is why the moves had no weight: a strike reads as a strike because it draws back first, and these drew forward and struck backward.

The fix is a convention, not a minus sign

The cheap fix is to flip the sign where it's wrong. I didn't, because the reason this survived two months of daily work on these bodies is that nobody could tell what the numbers meant, and flipping them fixes the output while leaving that intact.

So there's now one rule, stated at the top of the file in capitals: every number in the table is written from the fighter's point of view. Positive means toward the enemy. lean: +0.4 tips at them. step: +1.0 dashes at them. A recoil is a negative one of either. That's the language a person editing a move thinks in.

And the negation happens exactly once, in the one function that drives the animation:

// ── the ONE negation (see THE ONE SIGN RULE at the top) ──
root.rotation.x = -lean * f;

The preview stopped being the odd one out too. It now builds the same node hierarchy the world does — a pivot with the frame flipped inside — so there is one convention and no preview-specific branch. Two renderers of the same data are only useful as a cross-check if they're actually rendering it the same way.

What the fix made visible

With the sign sorted, the thing I'd originally complained about turned out to be a separate and much simpler problem: the attacks had rotation and nothing else. Every frame's signature was a pitch. Of course five of them were the same move.

The reason nothing translated is a rule worth keeping: the outer node's position belongs to the network, and writing to it fights the position stream every frame. So the moves never moved. Adding translation meant doing it on the model root inside it — local, network-free — and once that was unlocked, a spec is eight numbers and the frames stopped being interchangeable:

soldier  step −0.12   plants, and the shot kicks it back off the target
slim     step  1.00   a full unit forward in ~70ms, cut, snap back
titan    step  0.25   a quarter-unit into an overhead slam
specter  step  2.20   travels as a JUMP, hidden inside a blink

The specter's is the one I like: its travel is a step function rather than a curve, and the body is scaled out around each jump, so you never see it cross the ground. Scaled — deliberately not faded. Flipping a material into the alpha-blend path mid-fight is a shader recompile per agent, and this codebase has been burned by shader thrash before.

What I'd do differently

A sign convention that lives only in your head is not a convention. Mine was real and I followed it consistently in the preview, where the node happened to agree with it. The moment a second node with the opposite handedness showed up, nothing in the code could tell them apart, because the numbers were bare.

Put the coordinate-space flip in exactly one place and make every other file speak human. The table now says "toward the enemy" and a reader can check it against the animation without knowing anything about Babylon's rotation handedness. Before, checking a number meant reasoning about a π flip two nodes up.

A second view of the same data is the cheapest auditor you will ever build. The turntable was built to sell frames to players. What it actually did was render the same function against a different hierarchy, which is a differential test I never would have written on purpose, and it found a two-month-old bug in an afternoon.

Small, fast, and ambiguous is where bugs live. Anything under half a second on a body with no limbs is effectively unobservable — and "unobservable" is not the same as "fine". If you can't tell two outputs apart by eye, measure the vertices, or accept that you are not testing it at all.