We put the water on the ceiling, and it looked completely fine
Water Tracker has one party trick. The main card is full of water, and when you tilt your phone, the water tilts too. It stays level with the world, like the drink in your hand does, and it moves like something thick and calm rather than something nervous.
We built it, reviewed it, admired it, and put it on a real phone.
The water was on the ceiling.
Not broken. Not frozen. Not glitching. A neat, confident slab of water hanging from the top of the card like a bat, and — this is the important part — sloshing beautifully. Tilt left, it sloshed. Tilt right, it sloshed. It eased and settled with exactly the viscous grace we'd tuned for. It just did it all upside down — and, if you watched closely, backwards.
Here it is. This is the real maths and the real physics constants from the app, running in your browser. Drag the phone round, then flip the formula:
Drag the phone, or use the slider. The amber arrow is real gravity. It never changes — only our opinion of it does.
Yes, there is a duck. The duck is load-bearing: a flat slab of blue is ambiguous, but nobody has ever looked at an upside-down duck and thought "design choice".
We didn't have a duck. That was the problem.
One minus sign, and how it got there
The entire bug, as a diff:
- const target = Math.atan2(-p.gx, p.gy);
+ const target = Math.atan2(-p.gx, -p.gy);
One character. It is, by some distance, the smallest thing we have ever shipped that turned a room upside down.
The sensor API — DeviceMotion from expo-sensors — hands you accelerationIncludingGravity, a vector. The question that matters is absurdly simple: when the phone is standing upright and still, does that vector point up or down?
Our code assumed up. It said so, in a comment, with reasoning:
accelerationIncludingGravity measures the reaction force (points "up" at
rest), so gravity/"down" is its negation.
That is a respectable belief. An accelerometer physically cannot feel gravity; what it feels is the table pushing back. It is also exactly what the web standard says: the W3C spec gives a phone lying flat, screen up, an accelerationIncludingGravity of z: +9.8. Up. The reaction force.
Expo on our iPhone reports the opposite. Upright and at rest, y comes back negative — gravity itself, pointing down. The docs describe the axes and the units and are silent on the sign. We can't prove where our wrong belief came from, but "it's what the web does" is the prime suspect, and it made it into our own standards document as a rule. Then the code obeyed the document.
The documentation was the bug. The code was a faithful implementation of a false sentence. Every review checked the code against the note, found that they agreed, and moved on — which is what reviews do.
The general lesson, which has now cost this project real time more than once: a sign convention is not a thing you reason about. It is a thing you print. One console.log of the raw vector with the phone standing on a desk would have settled it in ten seconds. We wrote three sentences of physics instead.
Two wrongs make a right, but only in the simulator
Here is the part that turns this from a typo into a trap. Press Simulator in the lab above with the old formula selected.
Level water. Perfect.
The iOS simulator has no accelerometer. DeviceMotion never fires, so the code runs forever on its starting value — and our starting value was written by the same person with the same wrong belief:
const phys = useRef({ angle: 0, vel: 0, smoothTarget: 0, gx: 0, gy: 1 }).current;
// gravity points up? ──┘
default gy | formula | simulator | real phone, upright | |
|---|---|---|---|---|
| as built | +1 | atan2(-gx, gy) | 0°, looks right | 180°, ceiling |
| fixed | -1 | atan2(-gx, -gy) | 0° | 0° |
A wrong default fed into a wrong formula gives atan2(0, 1), which is zero, which is level. The two mistakes cancel — but only in the one environment where nothing ever moves. Everywhere we looked during development, the feature was flawless. The only place it was broken was the only place it was real.
If you take one practical thing from this post: anything that reads a sensor has not been tested until it has been tested on a device that has the sensor. That sounds too obvious to say. We have the commit history to show it isn't.
A short, friendly word about atan2
While we're here, because atan2 is doing all the work and deserves better than being blamed.
Three coordinate systems walk into a render loop:
Every tilt effect is a translation between the middle one and the right-hand one, and there is one flip of y between them. That flip is where the minus signs breed. The derivation that finally went into our code, in full:
- Gravity as reported:
(gx, gy), device axes, y up. - The same vector on screen, y down:
(gx, -gy). - The water's own "down", once you rotate it by θ:
(-sin θ, cos θ). - Set them equal, solve:
θ = atan2(-gx, -gy).
Four lines. It would have fit in the comment that held the wrong physics.
Four things about atan2 worth knowing, all of which you can poke at in the lab:
It takes (y, x), not (x, y). Every language, every time, and it has never once felt natural. If your angle is off by exactly 90°, this is why.
It doesn't care about magnitude. Expo reports metres per second squared; some APIs report multiples of g. atan2(-9.8·a, -9.8·b) and atan2(-a, -b) are the same angle, which is why this code never needed to know the units — and why a units mistake would never have shown up here either.
Negate one argument and you get a mirror, not a turn. atan2(y, -x) is π − atan2(y, x). That is why our bug is exactly 180° out only when the phone is upright. Tilt the phone 30° in the lab with the old formula and look at the waterline: it isn't level any more. It leans with the phone, twice over. A true 180° rotation would have needed both signs wrong — we managed precisely half of that.
Its output has a seam. Rotate through 180° and the answer jumps from +π to −π. If you ease toward the target naively, your water sees a target that suddenly moved "all the way over there" and travels 359° the long way to reach a spot 1° away. Untick wrap angles in the lab and spin it. The fix is a four-line helper that every rotation animation on Earth eventually grows:
function wrap(a) {
while (a > Math.PI) a -= 2 * Math.PI;
while (a < -Math.PI) a += 2 * Math.PI;
return a;
}
const diff = wrap(target - current); // always the short way round
Why ours moves like honey
The Feel buttons in the lab are the other half of the feature, and the half we're actually proud of.
Raw pipes the sensor straight to the water. Accurate, instant, and unpleasant: an accelerometer is a jittery little instrument, and water that twitches with your pulse reads as a spirit level, not a drink.
Jelly is what you get from the obvious fix — a spring. It overshoots and wobbles. It's fun for about four seconds, and it says "toy".
Honey is two stages. First a low-pass filter drags a smoothed target slowly toward the sensor reading. Then a soft, heavily damped spring chases the smoothed target:
p.smoothTarget += wrap(target - p.smoothTarget) * 0.05; // low-pass: ignore the jitter
p.vel += wrap(p.smoothTarget - p.angle) * 0.012; // soft spring: lean toward it
p.vel *= 0.9; // heavy damping: never overshoot
p.angle += p.vel;
The filter removes the nerves; the damped spring adds the weight. The result drifts to level a beat after you stop moving, which is slower than real water and about as slow as calm water feels. And all three constants were correct the entire time the water was on the ceiling. Upside down, it was still the best-feeling bug we've ever had.
What we do differently now
- Print the raw sensor vector first, on a real device, in a known pose.
Upright on a desk. Write down what it says. Derive from that, not from physics class.
- Defaults are part of the formula. A starting value encodes the same
assumption as the maths; if one is wrong they are usually both wrong, and they will cancel in precisely the environment where you do your looking.
- Put a duck in it. Any orientation-dependent visual should contain
something that is unmistakably wrong upside down. Text, a face, a duck. A symmetric blob will let a 180° error sail through every review.
- When a note and the device disagree, the device wins, and the note gets
rewritten the same day — including a line saying the old note was wrong, so the next reader doesn't "fix" it back.
Our standards file now carries that correction, with the most useful sentence we've written all year attached: the earlier note here claimed the opposite, and was the cause.
The short version
- Expo's
accelerationIncludingGravitypointed down at rest on our device;
the web spec's version points up. Never assume. Print it.
θ = atan2(-gx, -gy)for a y-up sensor drawn on a y-down screen.- A wrong default plus a wrong formula cancel out in the iOS simulator, which
has no accelerometer. Sensor features are untested until they're on hardware.
wrap()your angle differences or enjoy the barrel roll.- Low-pass, then a damped spring. That's honey.
- A 180° bug in a symmetric shape looks like a creative decision. **Add a
duck.**
From the same workshop, right way up
RoundtripWe removed gravity from this one on purpose — its suitcase packs better without it On the App Store → Tiny HabitsNo sensors, no sign conventions. Just one small habit On the App Store →Comments
No account needed — pick any name and say your piece.
Nothing here yet. Be the first.