20 October 2026 · 7 min read
A canvas game is drawn in pixels, and pixels are not the same size on every phone. A layout that looks perfect on a 1080p device can look cramped on a small one and postage-stamp tiny on a tablet. Getting this right is not difficult, but it is easy to get wrong in ways that only appear on hardware you do not own.
Candy Mass was checked against twenty different viewport sizes. These are the four problems that came out of it.
The first build drew at CSS pixel resolution. On a screen with a device pixel ratio of 3 that means the canvas is upscaled by the browser and every edge is soft.
The fix is to size the canvas backing store by the ratio and scale the drawing context to match:
const ratio = Math.min(window.devicePixelRatio || 1, 3);
canvas.width = cssWidth * ratio;
canvas.height = cssHeight * ratio;
ctx.scale(ratio, ratio);
The cap at 3 matters. Some devices report a ratio of 4, which means four times the pixels in each direction — sixteen times the fill cost — for a visual difference nobody can see. Capping at 3 keeps the sharpness and drops the cost.
The nastiest bug in this area is a transform that is never reset. If you scale the context by 3 every frame without restoring it first, then on frame one the drawing is 3x, on frame two it is 9x, frame three 27x, and within a dozen frames everything is drawn a billion pixels off screen. The symptom is a completely blank canvas, which looks like a rendering failure rather than a maths bug.
The rule is that every save() must be matched by a restore(), and the save must happen before the scale, not after. If the save comes after, the restore resets to the scaled state and the problem persists.
ctx.save();
ctx.scale(ratio, ratio);
// ... draw everything ...
ctx.restore();
That single pattern removed an entire class of bug.
Modern phones have notches, rounded corners and a gesture bar at the bottom. A full-bleed layout puts buttons underneath them.
CSS has the answer: env(safe-area-inset-*) values report how much space the system needs on each edge. Applying them as padding to the outer container pushes content into the safe zone without hard-coding any device dimensions.
#gameWrap {
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
These values are only reported when the page opts into full-bleed layout with viewport-fit=cover. Without that meta tag the values are always zero and the padding does nothing.
Folding phones and small tablets in landscape have unusually short viewports. On those, a fixed aspect ratio leaves the play area too short for candy to fall any useful distance, and rounds become impossible for reasons that have nothing to do with difficulty.
The fix is a minimum play height. If the available height falls below the minimum, the layout switches to a scrollable form rather than squashing the play area. A slightly awkward layout is better than an unplayable one.
Testing on real hardware is the only way to be sure, but it is slow and you cannot do it after every change. So the sizes are also simulated: a script builds the game in a mocked browser environment at twenty viewport sizes, from 320 by 568 up to a tablet at 1024 by 1366, and asserts that the play area, the basket and the HUD all stay within bounds at each one.
That does not catch visual problems, but it does catch the structural ones: overflow, zero-height areas, elements pushed off screen. Those are the failures that make a game unplayable rather than merely ugly.
Three things a simulated environment cannot verify: how the game feels at various refresh rates, whether haptics fire correctly, and whether the audio actually sounds right. Those need a real device, ideally more than one.
The rule of thumb that came out of this: automate what can be measured, and be honest about what cannot. A passing test suite is not proof that the game feels good. It is proof that it runs at all.