The renderer is pixel-bound on a retina screen: cap the internal resolution and batch the wall slices #15
shipped production is serving it deploy b42aa0d
Pushed; bun test green (248 pass) and the page serves in ~3ms.
The frame, first. Counted with a recording context over ~250 open positions on the real map × 4 facings, for the 1512pt retina case in the issue (a 3024px canvas):
| rays/frame | wall fills | map tiles/frame | |
|---|---|---|---|
| before | 1512 | 1513 | 570 (minimap, every frame) |
| after | 480 | ~300 (307 with 9 bodies and 3 marks) | 0 — one blit |
≈2080 canvas fills a frame down to ≈307 plus two blits, and 91k ray marches a second at 60fps down to 29k. The JS half (ray march + batching, timed in bun with a no-op context) went 0.12ms → 0.07ms a frame; the canvas rasterisation is the part I cannot time headlessly, which is why the counts above are the honest number and why the readout exists. Press F3 or ` `` in the preview for the real one: fps, mean ms/frame, snapshot Hz, interpolation delay, internal width vs canvas.
Taking the fidelity permission. The world is drawn 960 columns wide on every window (not 1280 on wide ones), one ray every other column, flat-shaded runs batched into one fillRect each, upscaled onto the canvas; HUD, crosshair and minimap stay sharp at device resolution. The old step = W > 1100 ? 2 : 1 rule made the ladder lie — the 960 rung cast more rays than the 1280 one — so the ray step is now a property of the buffer and every rung down is genuinely cheaper. I did not turn bodies into two lines and a head: measured, all bodies and marks together are ~40 of 336 calls a frame, so it would buy ~10% and cost the silhouette rules (filled shape, hard edge, chevron for enemies, tag for mates) that make people readable across a dim room. The much bigger win in that direction was the minimap redrawing 570 tiles every frame — more than the entire 3D view now costs — so the tile layer is drawn once a size and blitted.
Enemies materialising. Fixed in the drawing only; nothing extra is sent and the vision rule is untouched (static/presence.js, pure, with its own tests). A body in its first 120ms is faded and grows from its feet; if it was seen within ~320 units before, its arrival starts a stride back along the line it came in on, so it slides into view instead of appearing at its destination; where somebody was last seen alive leaves an outline in the 3D view and a hollow ring on the map, dimming over a second, drawn at the position you saw them — so a wall between you and that spot hides it like anything else, a corpse leaves no mark, and a round change forgets everybody.
First thirty seconds of the preview: click the view to capture the mouse, press ` `` and read the width (960 on any window), then walk to a doorway and watch a bot cross it — they should ease in over about a tenth of a second and leave a dimming outline when they step back out. Tab holds the big map open; the rings show there too.
Two things to know. The client prediction test (src/client.test.ts, "the bundled client connects…") is flaky, and it is flaky on main too: I ran the untouched default branch in a worktree and it failed 1 in 6, my branch about the same. Both failure modes are in the prediction section I did not touch — the human seat sometimes gets shot by bots before the assertions run (alive: false, no arrow drawn), and the lead < 72.5 tolerance is marginal against wall-clock timing. I left it alone rather than edit a test that isn't this pull's business; it's worth its own issue. And there is still no browser in this sandbox: the blit, the tile cache, the dpr case, the fade and the marks are all exercised through the real served bundle in client.test.ts and through scene.test.ts's recording canvas, but nobody has looked at the upscaled picture or a bot walking through a door on a real screen yet. That is the review.
🤖 Generated with Claude Code
Three milestones landed, all pushed,
bun testgreen (230 pass).What changed
static/slices.js— onefillRecta run, not a column. The ray loop now draws nothing: it writes a shade level, a wall face, the slice geometry and the haze into column arrays, andwallRunscollapses the stretches where all four agree.scene.jsdraws one rectangle a run. Rasterising both renderers at the same width and diffing the pictures, the batched frame differs from the old one by at most 0.011% of pixels (a few half-pixel edge rows, from the half-pixel merge tolerance).static/resolution.js— a fixed internal width, blitted up. The world is drawn into an offscreen canvas 1280 columns wide on a window ≥1200 CSS px and 960 otherwise, never wider than the canvas actually has pixels, thendrawImaged onto the visible canvas scaled. The crosshair, the HUD bars and the minimap stay on the visible canvas at full device resolution.static/budget.js— the governor. Measures what the frame costs to draw (the work, not the gap between frames — a machine at its refresh-rate cap would never earn a rung back otherwise), one decision a second: coarser over 20ms mean, finer again only after two consecutive windows under 13ms, never finer than the rung the window started on.`printsfps · ms/frame · Hz in · ms behind · internal×internal of canvas×canvas`.The numbers, counted over ~250 eye positions across the real map, 4 facings each: the 1512pt retina case was 1512 rays and 1513 fills a frame (≈91k of each a second at 60fps); it is now 640 rays and 394 fills — 2.4× the rays, 3.8× the fills. Batching alone at the old 3024px width takes 1513 fills to 925. The JS half of the frame (ray marching plus batching, measured in bun with a counting context) roughly halves, 0.12ms → 0.07ms; the canvas fills are the part I cannot time headlessly, which is why the call counts are the number above.
To see it: open the preview, click the view to capture the mouse, press `
`— the readout appears bottom-left and says the world is drawn at 1280 or 960 columns while the canvas is whatever your display makes of it; walk down a corridor and watch fps and ms/frame. Then resize the window narrow and wide and press`` again to see the width change rung.Deliberately not done: no change to what is sent, predicted or simulated — this is presentation only, and
interp.js,predict.jsand the server are untouched. The governor turns one knob (the internal width) rather than both it andstep; two knobs for one signal is how oscillation gets in. There is no on-screen button for the readout, only the key — it is a debugging print, not a control. The terminal client has no renderer, so nothing there changed.Two tests I changed rather than added to:
scene.test.tsasserted one fill per screen column andclient.test.tsassertedfillRect > 800for a first-person frame. Both described the old implementation, not the picture; they are replaced by an assertion that the wall runs tile the whole width end to end with no gaps, and by one that the frame's cost does not grow when the device pixel ratio doubles. Everything else is new coverage:src/slices.test.ts,src/resolution.test.ts,src/budget.test.ts, and the readout end-to-end inclient.test.ts.What I could not do here: this sandbox has no browser, so the "open it and play" pass was done through
client.test.ts, which loads the real served bundle against a recording canvas and a live socket — it covers the blit, the dpr case and the readout, but nobody has looked at the upscaled picture on a real retina display yet. That is the first thing worth doing in review.🤖 Generated with Claude Code
Closes #14