The battery pass: paint less, wake less

- opaque canvas: the compositor stops alpha-blending 2.5Mpx at 60Hz
- settled modals and the death summary idle at 20-24 fps; play untouched
- TUNE.lowPower caps RES at 3 (-44% pixels/frame), flippable live

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-08 21:16:15 +02:00
1 parent 911035af00
commit 3b6a82c97d
6 files changed
+43 -4

No files matched your search

+16
View File
@@ -442,6 +442,22 @@ the countdown panel ('CHANGEOVER · RESUMING IN n'). He kept the burst-arm weapo
praise words. **A bottom/above-rack message slot was proposed and REJECTED**: that is thumb praise words. **A bottom/above-rack message slot was proposed and REJECTED**: that is thumb
territory — covered by the hand mid-pull and outside where the eyes are. territory — covered by the hand mid-pull and outside where the eyes are.
**Fifth batch, same day — THE BATTERY PASS.** "Drains battery like there is no tomorrow." Measured
first: update() 0.01 ms, render() 0.15 ms per frame — the JS is nothing. The bill is fill-rate ×
frame-rate (a 3x iPhone pushes a ~1080×2400 canvas through the compositor 60×/s) plus 60 Hz wakeups
on static screens. Three levers shipped:
- **The canvas is OPAQUE now** (`dom.js`: `getContext('2d', {alpha:false, desynchronized:true})`)
— render() clears the whole frame to a solid colour anyway, so the compositor no longer alpha-
blends 2.5 Mpx sixty times a second. Free.
- **Calm states run slow** (`calmFps()` in main.js): a SETTLED modal (world frozen, only the blink
moves) runs at 20 fps, the death summary once faded at 24. Live play stays at TUNE.fps — the 60
was a feel decision and the governor deliberately never touches anything that moves.
- **`TUNE.lowPower`** (>= 0.5, T-panel-flippable live): caps RES at 3 — −44% of pixels painted per
frame, THE big lever. `RES` is `export let` now, re-derived in fitViewport; main.js re-fits when
the knob flips. Sprites are 224px sources so RES 3 draws them at-or-below 1:1: softer, never
mushy. OFF by default — whether the sharpness trade is worth it is Oliver's phone-call, and if he
wants it player-facing it should become a settings toggle, not a TUNE knob.
**Fourth batch, same day — the card went HORIZONTAL, and nothing draws under a modal.** The **Fourth batch, same day — the card went HORIZONTAL, and nothing draws under a modal.** The
stacked icon card ran 102 px tall and its lower third hung over live court; the icon sits LEFT of stacked icon card ran 102 px tall and its lower third hung over live court; the icon sits LEFT of
the text now (boxed to 58×52 so a wide tin cannot reach the type — and it draws BIGGER than the the text now (boxed to 58×52 so a wide tin cannot reach the type — and it draws BIGGER than the
+3
View File
@@ -312,6 +312,9 @@ export const TUNE = {
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
barrelH: 1.15, // drawn height (a body is 1.8) barrelH: 1.15, // drawn height (a body is 1.8)
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped) fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
lowPower: 0, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery-saver
// switch, flippable live from the T panel. Play speed and fps are untouched:
// the trade is sharpness only, and 224px sprites keep it "softer", never mushy.
runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble
runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable
shamblerHp: 2, // splat-quality hits a classic shambler ('a'/'b') soaks before it dies — only the runner ('f') is still one-shot glass shamblerHp: 2, // splat-quality hits a classic shambler ('a'/'b') soaks before it dies — only the runner ('f') is still one-shot glass
+4 -1
View File
@@ -1,3 +1,6 @@
// Canvas handles — imported by everyone, imports nothing (keeps module cycles harmless). // Canvas handles — imported by everyone, imports nothing (keeps module cycles harmless).
export const cv = document.getElementById('c'); export const cv = document.getElementById('c');
export const ctx = cv.getContext('2d'); // alpha:false — render() clears the whole frame to an opaque colour anyway, so declaring the canvas
// opaque lets the compositor skip blending a 2.5-megapixel layer 60 times a second (battery, not
// looks). desynchronized is a further no-op-where-unsupported compositor shortcut (Chrome).
export const ctx = cv.getContext('2d', { alpha: false, desynchronized: true });
+1 -1
View File
@@ -359,6 +359,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=132"></script> <script type="module" src="main.js?v=133"></script>
</body> </body>
</html> </html>
+11 -1
View File
@@ -18,9 +18,19 @@ let last = performance.now();
// next frame without the scene moving on underneath you. Halting rAF instead (the old test recipe) // next frame without the scene moving on underneath you. Halting rAF instead (the old test recipe)
// freezes the picture too, which is useless for looking at a shot. // freezes the picture too, which is useless for looking at a shot.
let paused = false; let paused = false;
// CALM STATES RUN SLOW. The battery bill is fill-rate × frame-rate, and two screens can sit on a
// phone for minutes while fully static: a SETTLED modal (the world is frozen behind it — only the
// blink still moves) and the death summary once its fade and gore have played out. 20-odd fps
// there is invisible and thirds the drain; live play stays at TUNE.fps, untouched — the 60 was a
// feel decision and this deliberately never applies to anything that moves.
const calmFps = () => G.modal && G.modal.in >= 1 ? 20
: G.mode === 'eaten' && G.eatenT > EATEN_RESTART + 1 ? 24 : 0;
let lowPowerApplied = false; // the last lowPower state fitViewport was run under (T-panel flips it live)
function frame(now) { function frame(now) {
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good) requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat if ((TUNE.lowPower >= 0.5) !== lowPowerApplied) { lowPowerApplied = TUNE.lowPower >= 0.5; fitViewport(); }
const cap = calmFps() || TUNE.fps;
if (cap && now - last < 1000 / cap - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
// THE MENU IS OPAQUE (#intro: fixed, inset 0, solid background). Painting the whole court at RES // THE MENU IS OPAQUE (#intro: fixed, inset 0, solid background). Painting the whole court at RES
// density behind it — for the minutes a phone idles on the splash and stats screens — was pure // density behind it — for the minutes a phone idles on the splash and stats screens — was pure
// battery burn nobody could see. The world holds still too; nothing on it predates the menu. // battery burn nobody could see. The world holds still too; nothing on it predates the menu.
+8 -1
View File
@@ -58,7 +58,10 @@ const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMed
// nearly double the pixels it can show, on exactly the hardware least able to afford it. The same // nearly double the pixels it can show, on exactly the hardware least able to afford it. The same
// near-1:1 logic per tier: dpr 3 -> 4, dpr 2 -> 3, dpr 1 -> 2. // near-1:1 logic per tier: dpr 3 -> 4, dpr 2 -> 3, dpr 1 -> 2.
// THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget. // THIS IS THE FIRST KNOB TO TURN DOWN if the phone ever stutters: it is the whole fill-rate budget.
export const RES = Math.max(2, Math.min(4, Math.ceil((devicePixelRatio || 3) * 1.35))); // `let`, not const: TUNE.lowPower caps it at 3 live (fitViewport re-reads on every fit) — importers
// see the change through the module binding.
const RES_AUTO = Math.max(2, Math.min(4, Math.ceil((devicePixelRatio || 3) * 1.35)));
export let RES = RES_AUTO;
export let W = 480, H = 270; export let W = 480, H = 270;
// Split responsibility, and the split is deliberate: // Split responsibility, and the split is deliberate:
@@ -111,6 +114,10 @@ function fitBox() {
export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is export function fitViewport() { // landscape 480x270; portrait 270 wide and as TALL as the phone is
fitBox(); fitBox();
// battery saver: RES 4 -> 3 is −44% of pixels painted per frame, by far the biggest lever there
// is. Sprites are 224px sources, so at 3 they still draw at-or-below 1:1 — softer than 4, never
// mushy. main.js re-calls fitViewport when the panel flips the knob.
RES = TUNE.lowPower >= 0.5 ? Math.min(3, RES_AUTO) : RES_AUTO;
const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || innerWidth); const portrait = (cv.clientHeight || innerHeight) > (cv.clientWidth || innerWidth);
// Portrait height ADAPTS to the device. A fixed 270x480 is 9:16, but a modern iPhone is taller // Portrait height ADAPTS to the device. A fixed 270x480 is 9:16, but a modern iPhone is taller
// (~390x844 = 0.46), so width bound the fit and ~150 CSS px of screen went unused at the bottom. // (~390x844 = 0.46), so width bound the fit and ~150 CSS px of screen went unused at the bottom.