Why we cap devicePixelRatio at 2
Pebble Merge draws to a single canvas. The board is a fixed 540 pixel box on desktop and whatever your phone decides to give it. I spent a week convinced the competent move was to render at the device's real pixel ratio, three on a modern phone, and I was wrong, and the reason is a sentence in a comment I wrote only after the fact.
The one line
The render setup does this: dpr = Math.min(window.devicePixelRatio || 1, 2). The
|| 1 is for browsers that report nothing, which still happens. The
Math.min against 2 is the decision. Everything downstream multiplies by it.
pxScale = (cssW / W) * dpr, and then the backing store is
W * pxScale wide and H * pxScale tall, and the context is scaled by the
same amount so the game still thinks in its own units. On a 2x phone the canvas fills four times
the pixels of a 1x screen. A 3x phone would fill nine. I stop at two.
Nine. That is the part that worried me. Nine times the fill work for a board the player holds in one hand. But fill work was not actually what changed my mind. Blur was.
What I expected
I expected capping to look worse. Retina is the whole pitch of a high density screen. The standard line. The comfortable line. Render fewer pixels than the glass can show and you get soft edges, that is the story I told myself, and it is the story most of us carry, the one we repeat because it is almost always true and only occasionally a trap. Native density equals native sharpness. It is a clean rule that fits on a poster, and it is also wrong here in a way that only shows up once you have measured the actual backing store against the actual CSS box, and the wrongness is specific rather than general, which is exactly what made it take me the better part of a week to see instead of an afternoon.
Wrong in this game. I will not pretend it is wrong everywhere. A photo, a map, text in a paragraph, those want every device pixel you can give them. A canvas game with a handful of thick shapes and a couple of very thin lines is a different animal, and I treated it like the first animal for most of a week.
What actually happens on the glass
The board has a CSS width. On desktop that is 540 pixels. On a phone it is the viewport, which is almost always smaller. The browser scales the canvas element to that box whether you like it or not. If the backing store is wider than the box, the browser downscales it to fit. Downscaling a crisp line is what blurs it. Not upscaling. Down. The thin arcs in this game, the landing guide and the tutorial ring, sit one or two device pixels thick at full draw. A backing store wider than its box gets squeezed on the way to the screen, and those lines survive the squeeze worse than anything else in the frame, because there is almost no area to average against.
So the blur does not come from drawing too few pixels. It comes from drawing too many and then letting the browser throw the extra ones away. Backwards. I had the direction completely backwards, and a comment written after the fact now hides that I ever had it backwards at all, because the comment sounds like the conclusion of a plan and it was actually the conclusion of a screenshot I did not want to believe. The comment in the code now says exactly that, in a tone that suggests the author knew it all along. I did not. I found it the slow way, with a screenshot.
We measured it on a 3x device profile, which is what a current flagship reports. Uncapped, the canvas came out 1.5 times wider and 1.5 times taller than the capped version, so 2.25 times the pixels to fill on every single frame, for a board the CSS box was going to shrink back down anyway. The thin arcs looked softer uncapped. Not sharper. Softer. I did not believe the first reading, so I rendered both and put them beside each other, and the capped one won on the lines that matter most to the feel of aiming.
The cost we avoided without naming it
Filling 2.25 times the pixels is not free even on a fast phone. On a phone it is the gap between a calm frame budget and one that starts to stutter exactly when the particle layer is also busy, which is to say when something exciting just happened. We had already capped the particles for the same underlying reason, and that is its own piece. The point I want to make here is that the two caps are one decision wearing two clothes: spend the device's effort on the pixels the player can actually see, and stop spending it the moment the box runs out of room for more.
I caught myself almost removing the cap during a later pass, because a reviewer asked why we were not native. The honest answer is that native was worse for this board, and I had the image to prove it, and still the question made me doubt the image. That is the trap. The social trap. A rule that sounds like a compromise is the easiest rule in the file to talk yourself out of, because compromise reads as a thing you did because you ran out of time, and a reviewer asking a single question is enough to make you believe your own measured conclusion was actually a corner you cut.
The thing I got wrong about myself
I wanted the uncapped version to be right because it felt like the competent choice. High DPI, render native, that is the thing you are supposed to do, the box you are supposed to tick. Saying out loud that we render at twice the density and then stop sounds like we gave up somewhere. We did not. It is the correct answer for a board this size, and I only accepted it after the screenshot, not before, which is the part I would rather not admit and am admitting anyway because the alternative is pretending the comment was written by someone smarter than the person who wrote it.
There is a second-order lesson that I use more often than the pixel math. When a performance number shares a file with a CSS box, measure the box, not the glass. The glass will always ask for more than it can show. The box is the truth. I have stopped trusting the glass since, in render code and in a couple of places that have nothing to do with rendering, and I am not sorry.
We keep the cap. Two. Not because phones cannot do three. Because three makes the lines we care about worse, and costs frames we cannot spare, to show pixels a 540 pixel box was never going to reveal. That is the whole reason, and it fits on the line that sets it.