The Same Ship on Every Screen

A daily leaderboard is only fair if flying the same stretch of cave is equally hard on a small old phone, a 144 Hz tablet and a desktop browser. Here is what that took, and what I could not make equal.

Devlog · 20 September 2026 · all posts

The daily cave post is about making the world identical for every player. This one is about the other half of fairness: making the ship behave identically. There are two separate ways a device can change how the game plays, and they need two separate fixes. One is how often the screen refreshes. The other is how big the screen is.

Problem one: the frame rate

The ship has a position and a velocity. Each frame, gravity or thrust changes the velocity, and the velocity changes the position. The obvious way to write that is:

vy += accel * dt;     // update velocity
py += vy * dt;        // then move by the NEW velocity

It looks harmless, and it is subtly wrong. The second line pretends the ship spent the entire frame already travelling at its end-of-frame speed. If the ship is accelerating, that overshoots by half of accel × dt² every single frame. The catch is that dt, the frame length, is different on every device, so the overshoot is different too, and a ship flew a slightly different path on every refresh rate.

I measured it with a held thrust over half a second. The exact answer is 225 pixels. The old integrator gave 228 at 144 Hz, 232 at 60 Hz and 240 at 30 Hz. Three devices, three different distances, from the same input.

The fix is the trapezoid rule, which uses the average of the velocity before and after the frame:

const vyPrev = vy;
vy += accel * dt;
py += (vyPrev + vy) * 0.5 * dt;   // average speed over the frame

For constant acceleration that is exact, whatever the frame length. Replaying an identical input schedule now gives a spread of 0.000 pixels across 12 to 144 Hz. It was 4.1 pixels before. A test asserts both halves: that the new integrator agrees across all refresh rates and that the old one genuinely diverged, so if anyone reverts it, the test fails rather than the leaderboard quietly drifting.

Two honest caveats

It did not close the score gap between refresh rates. I expected it to. A simulated pilot still scored about a third higher at 144 Hz than at 60 Hz afterwards. That gap is not physics. It is input resolution: a thumb can only change from holding to not holding on a frame boundary, so a faster screen genuinely gives finer control. No integrator can fix that, and a bot that flips its input every frame exaggerates it compared with a human. The physics half is now exact, and the rest is a property of hardware.

It is very slightly a nerf at 60 Hz. A half-second climb now covers 225 pixels instead of 232. I left that uncompensated. If real-device testing ever found the ship sluggish, the lever is the thrust constant, about 3% more would restore the old half-second manoeuvre. Which is a good moment to say that thrust has been changed twice before on player feedback, and that story is why I am cautious about touching it.

Problem two: the screen height

Gravity in pixels per second squared means very different things on a 375-point-tall phone and a 520-point-tall tablet. If the constants were the same, the same climb would cross a much smaller fraction of the screen on the tablet, and the game would feel heavier.

So the constants are defined against a reference height of 440 points, the landscape height of the largest iPhone, where the game's feel was tuned and playtested. On any other screen, gravity, thrust and terminal speed are all multiplied by H / 440. Because the corridor's half-gap scales with height too, every trajectory is geometrically similar to the reference one: the same fraction of the corridor crossed per second, the same time to get from one wall to the other, the same felt snap. A bigger screen gets a steeper acceleration in pixels, and a smaller one gets a gentler one, by design.

That comes with a rule for all future code: anything that reasons about vertical motion must be ratio-based. A fraction of terminal speed, a fraction of the height. Never a raw pixels-per-second constant compared against the ship's velocity, because that number is only right on one device. If a literal is unavoidable it gets multiplied by the same scale.

The web version follows the same rule. The browser build clamps its canvas to 956 by 440, the same footprint as that reference phone, so the game in a browser is a pixel-and-physics copy of it. An earlier web build had its own separate feel multiplier, which existed only to fight floatiness caused by a different clamp, and it was deleted once the clamp matched.

Problem three: the screen width

Height is the vertical axis. Width is the horizontal one, and it is subtler. Obstacles are spaced at fixed distances in the world. The cave scrolls at a speed that includes a factor for the screen's width. So on a wider screen the same obstacles pass by faster in screen terms, and the time you have to react to each one goes down in proportion to the width.

This was a real leak. A large Android tablet at about 1,280 points wide faced the shared cave roughly 1.75 times faster than a small phone. So the width is capped at 956 points on every platform, the width of the widest iPhone in landscape. A device wider than that gets black bars left and right. It is a no-op on iPhones and clamps only large Android devices and desktop browsers.

The cap has a residual I have not fixed. The player's collision radius scales with width while the corridor scales with height, so on a squarer tablet the ship is a little smaller relative to the gap than on a phone, roughly 20% more forgiving. Fixing it means changing the hitbox, which is a feel change that needs its own playtest, and it errs in the forgiving direction, so it can wait.

Testing fairness instead of asserting it

None of this is visible by playing. You cannot feel a few pixels of drift, and nobody plays the same cave on six devices at once. So each claim has a test that replays it: the physics test integrates a fixed input at frame rates from 12 to 144 Hz, and a cave test replays the real spawners at six device sizes and diffs the result byte for byte. A fairness property you do not test has, in my experience, already quietly broken.

The short version is that TUNL is fair across screens in three separate ways (frame rate, height and width), each with a different cause, a different fix and a different residual. You can see the result for yourself, on any device you have, in today's cave.

Play today's cave