React Native Skia
The rounded border that pushed every Sudoku digit off-center
A tester looked at the board and said the numbers felt off. We agreed, blamed the font, and were wrong. The fix came from measuring pixels in a screenshot, not from trusting our eyes.
We gave the Sudoku board in Daily Sudoku: Offline Puzzle a rounded frame. A real border, warm ink color, soft corners. It looked better in code review and better on device. Then the person testing it said the digits looked slightly off, a little pushed to one side, and honestly a bit ugly.
That sentence started a debugging session that taught the same lesson twice: when a screen looks slightly wrong, your eyes are the least reliable instrument in the room.
First mistake: trusting the glyph
The obvious suspect was the numeral 1. It has a flag on top and a thin stem, so it reads as if it leans right. My first explanation was confident and comfortable: the 1 is just shaped that way, every font does it, nothing to fix.
I could have shipped that explanation. Instead I pulled a screenshot off the Redmi Note 7 over adb and measured it. Not “looked at it closely.” Measured it. A tiny script found the board frame, split it into a 9×9 grid, and computed, for each printed digit, the horizontal center of its dark pixels relative to the cell.
The 1 was not special. Every digit sat at about 55% across its cell instead of 50%. Fives, eights, sevens, all of them, uniformly ~5% to the right. A single glyph cannot cause a uniform offset across every glyph. So my first explanation was not just incomplete, it was pointing at the wrong thing entirely.
This is the part worth internalizing. “The 1 leans” felt true because I already believed it before I looked. The measurement did not care what I believed.
Second mistake: fixing the centering math
The board renders on a Skia <Canvas>. Each digit is drawn with SkiaText, and the code centered it using the text bounds:
const bounds = font.measureText(text);
x = cellLeft + (cellSize - bounds.width) / 2;
There is a known gotcha here: measureText returns a rect with an x offset, the glyph’s left side bearing, and the code above ignored it. So I did the textbook fix and centered on the true ink box:
x = cellLeft + cellSize / 2 - (bounds.x + bounds.width / 2);
Rebuilt, reinstalled, measured again. It moved from ~55% to ~55%. Basically nothing.
Fine. Maybe measureText’s bounds are unreliable for the system font. I switched to centering by the glyph advance from getGlyphWidths, a completely different code path. Rebuilt, measured: ~54.6%. Still off, still by the same amount, whichever method I used.
Two independent centering strategies producing the identical error is not a centering bug. It is a signal that the thing I was centering inside was not where I thought it was. The digit was correctly centered in its cell. The cell was in the wrong place.
The real cause: a border that resized the content box
Here is the frame I had added:
<View className="overflow-hidden border-2 ..." style={{ width: boardSize, height: boardSize }}>
<SudokuGrid boardSize={boardSize} />
</View>
Read it slowly. The container is boardSize wide and has a 2dp border. In React Native, a border lives inside the box, so the content area is boardSize - 4, not boardSize. But the Skia canvas inside still received the full boardSize. With overflow-hidden, the canvas was laid out starting at the content-box origin, which sits one border-width in from the outer edge, and its full width overflowed the content box on the right, where it got clipped.
The net effect: the entire grid was shifted inward by the border width, roughly 2dp, and the last column and last row were quietly clipped by that same amount. Every cell moved right and down by the border. The digits were dead-center in their real cells; my screenshot measurement compared them against the outer frame, so the whole grid’s shift showed up as a uniform per-digit offset.
The border did not just draw a frame. It changed the size of the space the board was supposed to fill, and nobody told the board.
The fix is small once you see it. Size the canvas to the content box, and keep the border width in one named constant so the two can never drift:
const BOARD_FRAME_BORDER = 2;
<View
className="overflow-hidden"
style={{ width: boardSize, height: boardSize, borderWidth: BOARD_FRAME_BORDER }}
>
<SudokuGrid boardSize={boardSize - BOARD_FRAME_BORDER * 2} />
</View>;
Now the canvas is exactly the content box. No overflow, no clip, no shift. With the offset gone, the ordinary bounds-based centering finally does what it always claimed to. The screenshot measurement went to 50.1% horizontal and 48.9% vertical. Dead center, including the corner cells that had been clipped before.
Why this is easy to miss
A fixed-size child inside an overflow-hidden bordered box fails silently. Nothing throws. Layout does not warn you. The corners still look rounded because the overflow is hidden exactly as intended. The clipped strip on the far edge is only a couple of density-independent pixels, invisible unless you go looking. And the offset is uniform, so it never reads as “this one cell is broken,” which is the kind of thing a human eye actually catches. It reads as a vague “the numbers feel a little off,” which is the kind of thing a human eye rationalizes away.
Skia makes it easier to hit because a <Canvas> does not flex to its parent’s content box the way a plain View with flex: 1 would. You hand it an explicit pixel size, and if that number came from the border-box, the border silently eats into it. The native React Native renderer of the same board did not have the bug, because its cells are flex children that redistribute inside whatever content box they get. Same layout, two renderers, only the fixed-size one was wrong.
The other thing we had already gotten wrong that day
The border was not the first time eyeballing misled us in this exact screen. The redesign had bumped the given digits to font weight 600 to make them sit heavier than the player’s guesses. On the test device they looked chunky, almost bold, and the tester flagged it.
The reason is a platform detail that is easy to forget: Android’s system sans-serif is Roboto, and Roboto ships weights 400, 500, and 700. There is no 600. Ask for 600 and the platform rounds toward the nearest real face, which tends to land on Bold. So a value that looks like a gentle step up in a design tool becomes a hard jump on the device. We dropped back to 400 and let color alone separate givens from guesses, which is what the design already did anyway. Weight was redundant emphasis that only showed up as noise.
Both bugs share a shape. A value that seems reasonable in the abstract, boardSize for the canvas, 600 for the weight, behaves differently once a concrete platform rule touches it: borders shrink content boxes, and Android snaps font weights. Neither is exotic. Both are invisible until you either know the rule or measure the result.
The habit worth keeping
None of this required clever tooling. The measurement script was a few lines of Python over a PNG. adb was already connected. The whole loop was: change one thing, build a release-style APK, install it, take a screenshot, measure, compare to the previous number.
What made it work was refusing to let “it looks about right” or “it looks a bit off” be the end of the sentence. Both are opinions. 54.6% is a fact, and 50.1% is a better fact. Once you have numbers, a fix that does not move them is telling you something, and a fix that overshoots is telling you something else.
If you take one thing from this: when UI looks slightly wrong and the obvious explanation is about the content itself, check whether something in the layout quietly changed the space the content lives in. A border on an overflow-hidden container is a very good place to start looking, because it changes a size without changing a single visible pixel until the thing inside overflows.
The board is centered now, and this time I can prove it rather than argue about it.