Creating my own physics types system (Battlebeyz part 5)
Written from the December 2024 code. Later changes are dated inline. Last updated 2026-09-17.
In Part 3 I said the physics “came out quite nicely.” What I meant was that beys moved around the bowl in a way that looked right, which is not the same thing. Every quantity in the engine was a double or a glm::vec3. Angular velocity in radians per second, mass in kilograms, a coefficient of friction, and a random recoil sample all had the same type, so the compiler was perfectly happy to let me multiply any of them by any other. It was equally happy with the line from Part 3 where operator precedence turned my whole spin term into a bare 1. Nothing about the types said that was wrong, because nothing about the types said anything.
So in the middle of December I stopped adding features and gave the physics a type system.
Three days in December
The order matters, because the first step wasn’t templates at all. On December 13 I converted the whole codebase from double to float. glm is float, OpenGL is float, and every boundary between my physics and the renderer had been a silent narrowing. Two commits, “Middle of refactoring double->float” and “Mapped Slider+Input and double->float globally,” and every number in the project agreed on a width.
December 14 was the first attempt at units, split across five headers: Dimensions.h, Quantity.h, Vector3Quantity.h, Literals.h, and a UnitsSystem.h that included them all, plus a UnitsTest.cpp that was really just a scratch file of expressions I expected to compile. It worked, but templates that reference each other across five files fight you on include order and on where the definitions have to live. On December 17 I collapsed it into Units.h for declarations and Units.tpp for the template bodies, then converted the physics over in one commit that touched 27 files. The two commits after it are called “attempted to fix physics” and “done with fixing physics (for the most part),” which is an honest summary of what happens when you make the compiler check something for the first time.
How it works
A dimension is four integer exponents, one each for length, mass, time, and angle:
template <int L, int M, int T, int A> struct Dimension {
static constexpr int length = L;
static constexpr int mass = M;
static constexpr int time = T;
static constexpr int angle = A;
};
using DimM_S = Dimension<1, 0, -1, 0>; // m/s
using DimKgM2 = Dimension<2, 1, 0, 0>; // kg·m², moment of inertia
A quantity is a float tagged with one of those:
template <typename D, typename T = float> class Quantity {
public:
constexpr explicit Quantity(T value);
constexpr T value() const;
// +=, -=, unary minus, ...
private:
T value_;
};
using M_S = Quantity<DimM_S>;
using KgM2 = Quantity<DimKgM2>;
That’s the whole trick. Addition and subtraction are only defined between two quantities of the same dimension. Multiplication and division are defined between any two, and the result type is computed by adding or subtracting the exponents:
template <typename D1, typename D2, typename T>
constexpr Quantity<Dimension<D1::length + D2::length, D1::mass + D2::mass,
D1::time + D2::time, D1::angle + D2::angle>, T>
operator*(const Quantity<D1, T>& a, const Quantity<D2, T>& b);
So 1.5_kg * 2.0_m_s2 has type Quantity<Dimension<1, 1, -2, 0>>, which is a force, and the compiler worked that out from the exponents without my writing a rule for “mass times acceleration.” Powers and roots do the same to the exponents, with a static_assert that a square root of something with an odd exponent is a compile error. Those literal suffixes are user-defined literals, one per unit, so 250.0_g and 1.0_km both come out normalized to SI.
Then the same again for vectors. Vector3Quantity<D> holds three floats and a dimension, dot doubles the exponents, cross does too, and normalize returns a Dimensionless vector, so a direction can never accidentally carry units into a product. The naming scheme is what you see all over Part 3: Vec3_M_S2 is a vector of meters per second squared, Vec3_R_S is radians per second. Sixty aliases in all.
What it feels like to use
Get it right and the code reads like the physics. Get it wrong and you get this. I compiled this against the real header while writing this post:
error: no match for 'operator+' (operand types are 'Units::M' {aka 'Units::Quantity<Units::Dimension<1, 0, 0, 0>, float>'}
and 'Units::S' {aka 'Units::Quantity<Units::Dimension<0, 0, 1, 0>, float>'})
6 | M bad = 3.0_m + 2.0_s;
| ~~~~~ ^ ~~~~~
...
• template argument deduction/substitution failed:
• deduced conflicting types for parameter 'D' ('Units::Dimension<1, 0, 0, 0>' and 'Units::Dimension<0, 0, 1, 0>')
Thirty lines of candidate functions for one wrong plus sign. But the useful line is in there: it deduced conflicting dimensions, meters versus seconds, and you learn to read straight to it. The precedence bug from Part 3 went away in this port, and I’m fairly sure this is why: angularComponent became an acceleration type, the ternary was producing a bare 1, and there is no conversion from an integer to a Quantity, so a line that had been silently wrong for three months had to be looked at.
The cost is that every boundary with the rest of the world needs a .value(). glm wants floats, ImGui sliders want floats, the shader uniforms want floats. There are also two ways to take a vector’s length, length() which returns a plain Scalar and lengthTyped() which keeps the units, and the test file has a comment reminding me that only the typed one is safe to compare against a threshold. That’s the kind of thing a units library is supposed to make impossible, and mine only makes it a naming convention.
The radian problem
The one decision I’d think hardest about again is making angle a fourth dimension. Physically an angle is a ratio of lengths, so it’s dimensionless, and most units libraries treat it that way. I tracked it separately because the whole point was to stop mixing angular and linear velocity, and if radians vanish then R_S and 1/S are the same type.
It works for that. It also means that ω·r, which is a speed, comes out as radian-meters per second, and every place the physics converts between spin and linear motion has to say so explicitly. Two lines from Physics.cpp, lightly trimmed:
// w*r is a speed (rad cancels); dividing by one radian-second turns it into the
// acceleration this model wants, without silently dropping the units on the way.
M_S2 angularComponent = (w.lengthTyped() * driver->contactRadius) / (1.0_rad * 1.0_s) * combinedCOF * sign;
// Adrag = (-b * w^2 / moi) * unit w (Note: must manually correct radian overcount here)
Vec3_R_S2 angularDrag = -1.0_sc / R(1.0) * angularDragConstant * dot(w, w) / momentOfInertia * normalize(w);
Dividing by 1.0_rad is a unit conversion that changes no number, only the type. It’s ugly, and it’s also the single most honest line in the engine, because it’s the compiler making me write down that I know I’m crossing from rotation to translation. Before December that conversion happened implicitly in a dozen places and I could not have listed them.
Since then
Two things worth adding with dates. In April 2026 the project got a real test suite, and the scratch file became eleven doctest cases for the units alone, checking that literals normalize, that length over time is a velocity, that mass times acceleration is a force, and that the two length functions behave the way the comment says. And on April 16, 2026, the first time this header met clang, it refused to compile. I had named the dimensionless literal operator"" __ and the newton literal _N. Identifiers containing a double underscore, or an underscore followed by a capital letter, are reserved for the implementation, and MSVC had let both through without a word for sixteen months. They’re _sc and _newton now. clang also insisted the literal definitions move below the template bodies, because a constexpr function is checked where it’s defined and needs the constructor it calls to already exist. There’s a comment in the header explaining that, written for the next compiler.