Fixed-point maths in JavaScript

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.

10.5 (float) input / authoring × 65536 → 688128 Q16.16 in Int32Array integer simulation add · fpMul · fpDiv checksum per tick sin/cos → LUT sqrt → Newton ÷ 65536 → floats, render layer only (a * b) >> 16 wraps at int32 — use the split multiply
Convert at the edges. Everything inside the tick stays an integer.

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:

  1. Differential test against BigInt. Random-pair fpMul and fpDiv against a BigInt reference, tens of thousands of cases, negatives included. This is the test that catches the naive-shift bug in ten seconds.
  2. Golden replay. Record an input log, run it, hash the final state, commit the hash. Any refactor that changes it has to justify itself.
  3. Per-tick checksum. Hash the whole simulation state each tick (FNV-1a over the Int32Array is plenty) and compare across peers. When a desync happens you want the tick number, not a bug report saying "it went weird".
  4. 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.

Related
→ Lockstep vs rollback netcode → Seeded PRNGs in JavaScript → Client-side prediction in the browser → Portfolio & projects
Why is (a * b) >> 16 wrong for fixed-point multiply in JavaScript?

Because >> applies ToInt32 first. The product is a double far larger than 231, so its high bits are discarded before the shift ever happens. In Q16.16, 3.5 × 3.5 comes back as 0.25. Use a split multiply with Math.imul, or BigInt.

What range and precision does Q16.16 give you?

About −32768 to +32768, with a constant resolution of 1/65536 ≈ 0.0000152588. Precision never degrades with magnitude the way floats do, but the range is a hard cliff — and squared distances overflow past roughly 181 units. Choose your world units to fit.

Do I need fixed point, or are JavaScript doubles deterministic enough?

Usually doubles are enough. The spec pins + − × ÷ and Math.sqrt to exact IEEE 754 rounding; only the transcendentals (sin, cos, exp, pow) are implementation-approximated. Replace those with a lookup table and you're most of the way there. Go full fixed point when starting fresh, or when matching a non-JS server.

How do you implement sqrt and sin in fixed point?

sqrt via integer Newton iteration on the value shifted left by the fraction bits — pure integer division, floors to one ulp. sin/cos via a table indexed by a binary angle (1024 or 4096 steps per turn) generated at build time and committed, never filled at runtime with Math.sin.