← All posts

A trig table you can check by hand

Combat went in end to end this day: firing, hit registration, per-region hitboxes, armour plates with penetration rolls, replication, rendering. Also gamepad support, and a long detour into why movement did nothing.

The part worth a post is smaller than any of those. It is how the simulation learned to do trigonometry after being forbidden from it.

The ban, and the thing that needed it

Trigonometry has been on the deny-list since the first commit, because the platform maths library is not guaranteed to give identical results on every machine, and a simulation that must run identically everywhere cannot afford "almost the same".

Yaw-relative movement is the first thing that genuinely needs it. Pressing forward has to mean forward relative to where you are looking.

Three options, and two are traps. Using the platform library moves the problem into the binary. Building a table from the platform library at compile time moves it into whichever machine did the build, which is worse because it is invisible.

So the table is built at compile time from a Taylor polynomial defined in that source file. It is a quarter turn, and angles are already the integer type used on the wire, where a full turn is 65536, so range reduction disappears entirely.

Accuracy is half a table step, which is the theoretical best at that resolution, and the error is identical on every machine. That last property is the one being bought. Being slightly wrong everywhere in the same way beats being more accurate in machine-dependent ways.

Verified by writing it twice

The thousand-tick hash was checked three ways: on two processor architectures, and against an independent reimplementation of the entire run written from the specification: table, acceleration, friction, gravity, rounding, hash.

That third check earns its keep here specifically. A trig table built from a different polynomial would be perfectly self-consistent. Every internal test would pass. Only a derivation from outside the implementation catches it.

While writing that reference code, the deny-list caught me calling the platform's own sine function in my test. I rewrote it as an explicit series rather than silencing the lint. The lint exists for exactly that call, and suppressing it in the one place it fires would blunt the guard permanently.

Why movement has weight

Acceleration only adds the shortfall along the direction you are pushing. Push perpendicular to your velocity and you get full authority; push along it at speed and you get none.

That asymmetry is what produces momentum. The naive model, which adds the input and then clamps the total to a maximum speed, rotates your velocity vector instead of fighting it, and the result feels like being on rails. Same maximum speed, completely different game.

Gravity is 20, roughly twice real. Real gravity at the arc heights a shooter uses feels floaty; the genre has converged on about double for good reason.

The test that was designed to fail, and passed

One test asserted that yaw did not yet affect the simulation. It was written to fail on this commit, as the marker that yaw had become simulation-affecting.

It passed. And the reason is worth keeping.

The table has 4096 entries per turn against 65536 possible angle values, so sixteen adjacent angles share an entry. The test perturbed the yaw by one, which is quantised away to nothing. Yaw genuinely was simulation-affecting, and a perturbation of 64 moves the hash, but the test was probing below the resolution of the thing it was testing.

It is now replaced by one that pins both halves: sub-step changes do nothing, above-step changes steer. Any future change to table resolution or index rounding fails there loudly.

A test that passes when you expected failure is information. It is tempting to note it and move on, since the code was right. In this case the code was right and the test was measuring nothing, which is exactly how a vacuous test survives to give false confidence for a year.

Also fixed, found by that investigation

The quarter-table index truncated where it should have rounded, which broke odd symmetry: the sine of a negative angle was not quite the negative of the sine of the positive one, by more than quantisation could explain.

Nobody would have noticed that in play. It would have sat there making left turns very slightly different from right turns.