If you want two machines to run the same simulation and land on exactly the same state — for lockstep netcode, for a replay you can trust, for a server that re-checks a client's physics — the arithmetic has to be bit-identical. Fixed point is the strongest way to get there: no floats anywhere, just scaled integers. The idea takes one paragraph to explain. The JavaScript implementation has a trap in it that most tutorials copy straight from C, where it happens to work, into JS, where it silently returns the wrong number.
The one-paragraph version
A fixed-point number is an ordinary integer with an agreed-upon scale. In Q16.16
— the usual choice, because it fits a signed 32-bit word — the scale is 216 = 65536.
The value 10.5 is stored as the integer 688128. Addition and subtraction are just
integer addition and subtraction, because the scales line up. Multiplication doubles the scale,
so you shift back down by 16. Division halves it, so you shift up first. That's the whole model.
Know your budget before you write a line
Q16.16 in an Int32Array gives you a range of roughly −32768 to +32768
and a resolution of 1/65536 ≈ 0.0000152588. Two things follow, and they are the
opposite of how floats behave:
- The resolution is constant. A velocity of 0.001 keeps the same absolute precision as a coordinate of 30000 — which is great, because slow-moving objects don't quietly lose accuracy the way they do in float32.
- The range is a cliff. Floats degrade; fixed point wraps. Exceed ±32768 and your object teleports to the other side of the world. Intermediate values count too: a squared distance in Q16.16 overflows once the distance passes about 181 units.
So pick your units first. Decide that one fixed-point unit is one metre, or one tile, and size the
world to fit with headroom for squared terms. If you genuinely need more, Q32.32 on
BigInt is available and roughly an order of magnitude slower — use it for the few
operations that need it, not for the whole simulation.
The multiply that everyone gets wrong
Here's the line you'll find in almost every fixed-point explainer:
const mul = (a, b) => (a * b) >> 16; // WRONG in JavaScript
In C with 64-bit intermediates that's correct. In JavaScript it is not, and the reason is
specific: a * b is computed as a double (fine — exact up to 253), but
>> first applies ToInt32, which takes the value modulo
232 and reinterprets it as signed. Any product above 231 — which is almost
all of them, since both operands are already scaled by 65536 — gets its high bits thrown away
before the shift.
Concretely: 3.5 × 3.5 in Q16.16 with that naive multiply returns 0.25, not 12.25. I checked it against a BigInt reference over a million random 32-bit pairs — the naive version disagreed on 999,980 of them. It is wrong nearly always, and it is wrong quietly.
The fix is a split multiply: break each operand into a high and low 16-bit half so every partial
product stays inside 32 bits, and use Math.imul for the true 32-bit integer multiply.
const FRAC = 16;
function fpMul(a, b) {
const ah = a >> 16, al = a & 0xffff;
const bh = b >> 16, bl = b & 0xffff;
return (Math.imul(ah, bh) << 16)
+ Math.imul(ah, bl) + Math.imul(al, bh)
+ ((al * bl) >>> 16) | 0;
}
Note the asymmetry: ah and bh are signed (arithmetic
>>), the low halves are unsigned (& 0xffff), and the final
| 0 wraps the result back into int32 exactly as the format requires. That version
matched a BigInt arithmetic-shift reference on every one of the million pairs I threw
at it, negatives included. Test yours the same way — a fixed-point multiply that's right for
positive numbers and off by one for negatives is a desync waiting for a match to get interesting.
Divide, and the rounding decision nobody documents
Division is (a << 16) / b conceptually, but a << 16 overflows
int32 for the same reason. Do it in the double domain, where the numerator is exact well past
247, and truncate deliberately:
const fpDiv = (a, b) => Math.floor((a * 65536) / b) | 0;
Then write the rule down. Math.floor rounds toward negative infinity; C's integer
division truncates toward zero. They differ for negative results — -1.5 becomes
-2 versus -1. Either is fine; mixing them across a codebase,
or between your JS client and a Rust/Wasm server, is a desync generator. Pick floor, use it in
every rounding site (division, shifts, conversion from float), and say so in a comment.
sqrt, sin and cos
Math.sqrt is actually one of the safe ones — ECMAScript requires it to be correctly
rounded per IEEE 754, so it's bit-identical everywhere. But if the rule is "no floats in the
simulation", the integer Newton version is short and stays inside BigInt:
function isqrt(v) { // v: BigInt
if (v < 2n) return v;
let x0 = v, x1 = (x0 + v / x0) >> 1n;
while (x1 < x0) { x0 = x1; x1 = (x0 + v / x0) >> 1n; }
return x0;
}
const fpSqrt = x => Number(isqrt(BigInt(x) << 16n)) | 0;
It converges to the exact integer floor of the root, so fpSqrt of 2 gives
92681 — 1.41419983 against a true 1.41421356, one unit in the last place low. That
bias is deterministic, which is the point, but it accumulates: normalise vectors once per step,
not three times.
Trigonometry is where you have no choice. Math.sin and friends are the functions the
spec explicitly leaves implementation-approximated — engines are recommended to follow
fdlibm, not required to — so two browsers may legitimately differ in the last bit. Precompute a
table instead, indexed by a binary angle: 1024 or 4096 steps per full turn, so wrapping an angle
is a mask rather than a modulo. Generate the table at build time and commit the integers. Never
fill it at runtime by calling Math.sin — that just moves the nondeterminism into
startup where it's harder to spot.
Do you actually need this?
Honestly: often not. It's worth being precise about what JavaScript already guarantees, because
the internet is full of "floats are nondeterministic" hand-waving. ECMAScript pins
+ − × ÷ and Math.sqrt to IEEE 754 double semantics with rounding after
every individual step — no engine may fuse them, and every engine produces the same bits. The
nondeterminism is in the transcendentals, and in Math.random, and in iteration order
over hash maps.
So there's a cheaper tier: keep doubles, replace your dozen Math.sin/cos
call sites with a table, seed your randomness properly
(mulberry32 or sfc32, never Math.random), and
sort every entity iteration by a stable id. That gets a lot of teams to a stable lockstep build
without rewriting the physics. Full fixed point is what you choose when you're starting fresh,
when the simulation must match a non-JS server, or when you'd rather never audit an arithmetic
question again. The trade-off, and the netcode it serves, is laid out in
lockstep vs rollback netcode.
Prove it, or you don't have it
Determinism is a property you verify continuously, not one you declare. Four tests earn their keep:
- Differential test against BigInt. Random-pair
fpMulandfpDivagainst a BigInt reference, tens of thousands of cases, negatives included. This is the test that catches the naive-shift bug in ten seconds. - Golden replay. Record an input log, run it, hash the final state, commit the hash. Any refactor that changes it has to justify itself.
- Per-tick checksum. Hash the whole simulation state each tick (FNV-1a over the
Int32Arrayis plenty) and compare across peers. When a desync happens you want the tick number, not a bug report saying "it went weird". - Cross-engine CI. Run the golden replay in Chrome, Firefox, Safari and Node. Fixed point makes this pass trivially — which is exactly why it's worth checking that it does.
The pattern that holds all of it together is the one in the diagram: floats live at the edges — input, authoring, rendering — and never cross into the tick. Convert once on the way in, convert once on the way out, and the simulation in between is just integers doing arithmetic that every machine on earth agrees about.