scale by whole pixels, and make the stage small enough to matter

Two halves of the same problem: the picture was sharp everywhere and wrong
overall.

── whole-number scaling ──

The canvas is now sized to the largest WHOLE number of device pixels per
source pixel that fits the panel, measured with a ResizeObserver and the
device pixel ratio. Below 1x it takes a unit fraction instead, so the rows
that survive are evenly spaced rather than dropped in clumps.

This is a 16px-per-cell bitmap from 1995. At a fractional scale one cell gets
three screen pixels and its neighbour gets two, the grid goes uneven and the
digits wobble — which reads as "not quite right" rather than as blur, and
`image-rendering: pixelated` does not help because unevenness is not blur.
A fractional devicePixelRatio produces exactly this at what looks like 1:1.

It is also allowed to scale UP past the framebuffer's own size, which was the
other half of it: `max-w-full` only ever shrinks, so on a wide panel the
canvas sat at its intrinsic size using a fraction of the pixels available.

── a smaller stage ──

800x600 down to 560x400. The stage is what gets scaled to fill the panel, so
every pixel of empty surround is panel the game does not get, and the whole-
number steps are far apart: in an 1866-device-pixel panel, 800 wide lands on
2x and 560 wide lands on 3x. Same panel, half again as much game.

560x400 is expert (504x342, measured on this machine) plus a margin. A custom
board bigger than the stage is now clipped at the right and bottom rather than
shrunk; `centre` clamps at zero so the menu bar stays reachable. Widen the two
constants if that turns out to be the wrong trade.

Verified: beginner centres at 204,93 on the new stage, which is exact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
2026-08-23 21:26:10 +00:00
co-authored by Claude Opus 5
parent ad2aa632a5
commit 26c8dd3e1a
3 changed files with 76 additions and 15 deletions
+9 -1
View File
@@ -60,11 +60,19 @@ 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 listener that exists because a flag is missing, and running the same arguments by hand did not reproduce
it either. it either.
**The game is centred on a fixed stage, and the whole stage is what you see.** The framebuffer is 800x600 **The game is centred on a fixed stage, and the whole stage is what you see.** The framebuffer is 560x400
whatever the difficulty, `sidecar/session.ts` keeps the window in the middle of it, and the panel draws the 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 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. resizing the panel, and the surround is teal (`-solid`) rather than the root's black.
**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.
It used to park the window at the origin and have the client crop to the window's size, polled every four 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 seconds. That resized the panel under the player, and it was off by the 3x29 wine puts between the root
origin and the window — a black strip along the top and a row of cut-off cells along the bottom, worst on origin and the window — a black strip along the top and a row of cut-off cells along the bottom, worst on
+14 -4
View File
@@ -47,11 +47,21 @@ export type SessionStatus = {
* player mid-game, and it was wrong by the 3x29 wine puts between the root origin and the window, which * player mid-game, and it was wrong by the 3x29 wine puts between the root origin and the window, which
* showed as a black strip on top and a row of cut-off cells at the bottom. * showed as a black strip on top and a row of cut-off cells at the bottom.
* *
* 800x600 is generous on beginner and that is deliberate: it holds the largest board Minesweeper X can * ── Why it is this small ──
* make (30x24 custom, 504x470) with room to spare, so no difficulty ever reaches an edge. *
* The stage is what gets scaled to fill the panel, so every pixel of empty surround is panel the game
* does not get. The client scales by whole numbers only — fractional scaling makes a 16px grid uneven —
* 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.
*/ */
const SCREEN_W = 800; const SCREEN_W = 560;
const SCREEN_H = 600; const SCREEN_H = 400;
/** /**
* How often to check whether the board changed size. * How often to check whether the board changed size.
+53 -10
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react'; import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import type { RfbError } from './rfb'; import type { RfbError } from './rfb';
@@ -20,7 +20,7 @@ type SessionStatus = {
// //
// Only a placeholder until `onReady`, which sizes the canvas from the real framebuffer. Drift with the // 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. // sidecar's stage costs one frame of the wrong aspect ratio and nothing else.
const PLACEHOLDER = { width: 800, height: 600 }; const PLACEHOLDER = { width: 560, height: 400 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 }; const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
@@ -58,11 +58,52 @@ export const MinesweeperPanel = () => {
const rfbRef = useRef<RfbClient | null>(null); const rfbRef = useRef<RfbClient | null>(null);
const buttonsRef = useRef(0); const buttonsRef = useRef(0);
const boxRef = useRef<HTMLDivElement | null>(null);
const [frame, setFrame] = useState<{ width: number; height: number } | null>(null);
const [box, setBox] = useState<{ width: number; height: number; dpr: number } | null>(null);
const [status, setStatus] = useState<SessionStatus | null>(null); const [status, setStatus] = useState<SessionStatus | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Measured rather than assumed, because the scale below has to be exact and a panel is resizable.
// Re-attached when the session starts: until then this branch of the tree is not rendered and the ref
// is null, so a mount-only observer would watch nothing for the rest of the session.
useEffect(() => {
const node = boxRef.current;
if (!node) return;
const observer = new ResizeObserver(([entry]) => {
const rect = entry?.contentRect;
if (rect) setBox({ width: rect.width, height: rect.height, dpr: window.devicePixelRatio || 1 });
});
observer.observe(node);
return () => observer.disconnect();
}, [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.
*
* 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.
*
* 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.
*/
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);
return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr };
}, [frame, box]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
setStatus(await clientRef.current.get<SessionStatus>('/minesweeper/status')); setStatus(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
@@ -107,6 +148,7 @@ export const MinesweeperPanel = () => {
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
} }
setFrame({ width, height });
setConnected(true); setConnected(true);
setError(null); setError(null);
}, },
@@ -127,6 +169,7 @@ export const MinesweeperPanel = () => {
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
} }
setFrame({ width, height });
rfb.requestUpdate(false); rfb.requestUpdate(false);
}, },
onError: (rfbError) => setError(describeRfbError(rfbError)), onError: (rfbError) => setError(describeRfbError(rfbError)),
@@ -197,7 +240,7 @@ export const MinesweeperPanel = () => {
return ( return (
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4"> <div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
{/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */} {/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */}
<div className="flex min-h-0 w-full flex-1 items-center justify-center"> <div ref={boxRef} className="flex min-h-0 w-full flex-1 items-center justify-center">
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={PLACEHOLDER.width} width={PLACEHOLDER.width}
@@ -206,14 +249,14 @@ export const MinesweeperPanel = () => {
onMouseUp={(ev) => onPointer(ev, 'up')} onMouseUp={(ev) => onPointer(ev, 'up')}
onMouseMove={(ev) => onPointer(ev, 'move')} onMouseMove={(ev) => onPointer(ev, 'move')}
onContextMenu={(ev) => ev.preventDefault()} onContextMenu={(ev) => ev.preventDefault()}
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a // Nearest-neighbour: smoothing a 1995 bitmap looks like a photograph of a screen rather than
// photograph of a screen rather than the screen. // the screen. It is half of what keeps this sharp; `stage` above is the other half.
// //
// max-w and max-h together, and no explicit width: a canvas is a replaced element, so CSS // The max-* pair is only the fallback for the frame before `stage` is known — once it is, the
// shrinks it to fit while holding its ratio. That is the whole "centred in the panel" — the // explicit size governs and cannot exceed the box, so capping one axis would only squash the
// flex parent does the centring and this does the scaling. // other.
className="max-h-full max-w-full rounded-sm shadow-lg [image-rendering:pixelated]" className={`rounded-sm shadow-lg [image-rendering:pixelated] ${stage ? '' : 'max-h-full max-w-full'}`}
style={{ cursor: connected ? 'default' : 'progress' }} style={{ ...(stage ?? {}), cursor: connected ? 'default' : 'progress' }}
/> />
</div> </div>
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-xs text-muted-foreground">