smaller stage, half steps above 2x — bigger, still even

Trimming the stage alone does nothing, and that is worth writing down: the
scale is snapped, so at 3x the game is 1512 device pixels wide whether the
stage is 560 or 512. Shrinking the surround only pays when it crosses a step.
So both halves land together.

── the stage ──

560x400 down to 512x352, which is expert (504x342, measured) plus four pixels
a side. There is nothing left to give — intermediate is 342 tall too, so
anything shorter clips a board people actually play.

── half steps from 2x ──

Not a retreat from whole-number scaling. The unit that has to stay regular is
the CELL, not the pixel: this is a 16px grid, so at 3.5x a cell is exactly 56
screen pixels — as even as 3x or 4x — and only sub-cell pixels alternate
between three and four. Below 2x that alternation is a third of a pixel rather
than a seventh, so whole numbers still apply there. The case that started all
this, a fractional devicePixelRatio at nominal 1:1, put the CELL boundaries at
26.4px, which is a different thing entirely.

Together, on a 1800x1400 device-pixel panel: 3.0x becomes 3.5x, and the game
goes from 1512 to 1764 device pixels wide.

Known cost, and it is in the README: the Extras > Auto Options dialog is 328
wide and opens 25px right of the game, so it needs a 554-wide stage to fit
whole from beginner. It cannot be closed from the panel anyway — no window
manager, no title bar, no OK button — so that is the thing to fix before this
number is revisited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
2026-08-23 22:24:57 +00:00
co-authored by Claude Opus 5
parent 105068170c
commit e4247cd760
3 changed files with 42 additions and 24 deletions
+11 -5
View File
@@ -77,7 +77,7 @@ The check that matters is `ss -lntp` against the **running** process. Reading th
listener that exists because a flag is missing, and running the same arguments by hand did not reproduce
it either.
**The game is centred on a fixed stage, and the whole stage is what you see.** The framebuffer is 560x400
**The game is centred on a fixed stage, and the whole stage is what you see.** The framebuffer is 512x352
whatever the difficulty, `sidecar/session.ts` keeps the window in the middle of it, and the panel draws the
framebuffer entire — scaled to fit, never cropped. So changing difficulty moves the board rather than
resizing the panel, and the surround is teal (`-solid`) rather than the root's black.
@@ -85,10 +85,16 @@ resizing the panel, and the surround is teal (`-solid`) rather than the root's b
**The stage is small because the panel scales it by whole numbers.** Pixel art at a fractional scale gets
uneven cells — some three screen pixels wide, their neighbours two — which reads as "not quite right"
rather than as blur, and `image-rendering: pixelated` does not help because unevenness is not blur. So the
panel picks the largest whole number of DEVICE pixels per source pixel and every pixel of empty stage is
panel the game does not get: in an 1866-device-pixel panel, 800 wide lands on 2x and 560 wide lands on 3x.
560x400 is expert (504x342) plus a margin. A custom board bigger than that is clipped at the right and
bottom rather than shrunk, with the menu bar kept reachable.
panel snaps the scale, in DEVICE pixels, to whole numbers below 2x and half steps above it. Halves are safe
because the unit that has to stay regular is the CELL: at 3.5x a cell is exactly 56 screen pixels, as even
as at 3x, and only sub-cell pixels alternate.
That makes the size QUANTISED, which is the thing to understand before changing the stage: at 3x the game
is the same width whether the stage is 512 or 560. Trimming the surround is worth nothing on its own — it
only helps when it crosses a step. 512x352 is expert (504x342) plus four pixels a side, and there is no
room left: intermediate is 342 tall too. A custom board bigger than that is clipped at the right and bottom
rather than shrunk, with the menu bar kept reachable, and the Auto Options dialog needs a 554-wide stage to
fit whole from beginner.
It used to park the window at the origin and have the client crop to the window's size, polled every four
seconds. That resized the panel under the player, and it was off by the 3x29 wine puts between the root
+11 -7
View File
@@ -54,14 +54,18 @@ export type SessionStatus = {
* so the size here decides which step it lands on, and the steps are far apart. In a 1866-device-pixel
* panel: 800 wide gives 2x, 560 wide gives 3x. Same panel, half again as much game.
*
* 560x400 is expert (504x342) plus a margin, which is what the three standard difficulties need. A
* CUSTOM board larger than this is clipped at the right and bottom edges rather than shrunk — `centre`
* clamps at zero so the menu bar stays reachable. That is the trade: the sizes people actually play are
* as large as they can be, and the one that is not is still playable. Widen these two numbers if that
* turns out to be the wrong way round.
* 512x352 is expert (504x342, measured) plus four pixels a side. There is no room left to give: expert
* and intermediate are both 342 tall, so anything shorter clips a board somebody actually plays.
*
* Two things get clipped at this size, and both are deliberate. A CUSTOM board larger than the stage
* loses its right and bottom edges — `centre` clamps at zero, so the menu bar stays reachable. And the
* Extras → Auto Options dialog is 328 wide and opens 25px right of the game window, which needs a stage
* 554 wide to fit whole when the board is beginner. That dialog cannot be closed from the panel anyway
* (no window manager, so no title bar, and it has no OK button) — see the README. Fix that first, then
* revisit this number.
*/
const SCREEN_W = 560;
const SCREEN_H = 400;
const SCREEN_W = 512;
const SCREEN_H = 352;
/**
* How often to check whether the board changed size.
+20 -12
View File
@@ -20,7 +20,7 @@ type SessionStatus = {
//
// Only a placeholder until `onReady`, which sizes the canvas from the real framebuffer. Drift with the
// sidecar's stage costs one frame of the wrong aspect ratio and nothing else.
const PLACEHOLDER = { width: 560, height: 400 };
const PLACEHOLDER = { width: 512, height: 352 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
@@ -82,25 +82,33 @@ export const MinesweeperPanel = () => {
}, [status?.running]);
/**
* How big to draw the stage — and the answer is always a WHOLE number of screen pixels per source
* pixel, or a whole number of source pixels per screen pixel.
* How big to draw the stage. Computed in DEVICE pixels, so a 2x display gets twice the detail rather
* than the same picture twice the size — and allowed to scale UP past the framebuffer's own size,
* without which a wide panel leaves the canvas sitting at a fraction of the pixels available to it.
*
* This is a 16px-per-cell bitmap from 1995. At a fractional scale some cells get three screen pixels
* and their neighbours get two, so the grid goes uneven and the digits wobble: sharp everywhere and
* wrong overall, which is what "not quite right" looks like on pixel art. `image-rendering: pixelated`
* does not save you — it removes the blur, not the unevenness.
* ── Why the scale is snapped, and to what ──
*
* Computed in DEVICE pixels, so a 2x display gets 2x the detail rather than the same picture twice as
* large. And it is allowed to scale UP past the framebuffer's own size, which is the part that was
* missing: on a 1x display the canvas sat at 800 CSS px in a panel twice that wide, using a quarter of
* the pixels available to it.
* This is a 16px-per-cell bitmap from 1995. At an arbitrary scale a cell is 26.4 screen pixels, so the
* grid lines themselves land unevenly and the digits wobble: sharp everywhere and wrong overall, which
* is what "not quite right" looks like on pixel art. `image-rendering: pixelated` does not save you —
* it removes the blur, not the unevenness. A fractional devicePixelRatio produces this at what looks
* like 1:1, which is the case that is easiest to miss.
*
* HALF steps are allowed from 2x up, and that is not a compromise on the above. The unit that has to
* stay regular is the CELL, not the pixel: at 3.5x a cell is exactly 56 screen pixels, so the grid is
* as even as it is at 3x or 4x, and only sub-cell pixels alternate between three and four. Below 2x
* that alternation is a third of the pixel rather than a seventh, so it stays whole-number there.
*
* The steps are what make the size quantised — at 3x the game is the same width whether the stage is
* 512 or 560 wide. Shrinking the stage is only worth anything when it crosses a step, which is why
* these two changes landed together.
*/
const stage = useMemo(() => {
if (!frame || !box || box.width <= 0 || box.height <= 0) return null;
const fit = Math.min((box.width * box.dpr) / frame.width, (box.height * box.dpr) / frame.height);
// Below 1 there is nothing to floor to, so take a unit fraction: the rows that survive are then
// evenly spaced instead of dropped in clumps.
const scale = fit >= 1 ? Math.floor(fit) : 1 / Math.ceil(1 / fit);
const scale = fit < 1 ? 1 / Math.ceil(1 / fit) : fit < 2 ? 1 : Math.floor(fit * 2) / 2;
return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr };
}, [frame, box]);