← All posts

A puzzle game that kept the phone warm, and an engine that learned to stop drawing

Crown Quest is a logic puzzle I am building on Awaken: an n×n board cut into coloured realms, one crown per row, column and realm. The player spends most of their time looking at a board that is not moving while they think.

On an iPhone 16 it used too much battery. On an iPad 8th gen (A12) the sparkles stuttered and fast swipes missed squares. Nothing on the board was moving, so the phone had no reason to be busy. It was busy anyway.

Unless I say otherwise, the numbers below come from Chrome on an M2 Max, mostly headless with the CPU throttled 4x, because I can repeat that on every commit. They compare before with after. They are not phone measurements, and I come back to that at the end.

Twenty characters nobody could see

The game has a court: 20 characters from a Synty pack with idle and emote animations. They stand on the title screen and appear on the result card after a level. During a puzzle none of them is drawn.

All twenty were posed, skinned on the CPU and uploaded to the GPU every frame, on every screen, the puzzle board included. That came to about three quarters of main-thread time.

I thought I had dealt with this. A script called CourtDirector sits on each court member's root object and, when the member leaves the stage, does this:

api.setField("Animator", "enabled", false);

setField touches only the script's own entity. The Animator lives on the skinned mesh underneath it, not on the root. So the call found no Animator, did nothing and said nothing, and twenty parked characters kept animating off screen for as long as the game ran.

I fixed this in two places.

The first fix is in the game: find the Animator among the root's descendants once, then write to it directly. The engine now also warns, once per script and component, when setField or getField names a component the object does not have. The warning names the child that does have it and how to reach it with what a script already has:

api.world.get(api.find("SM_Chr_King_01")!, api.world.registry.get("Animator")).enabled = value

setField still does not reach into other objects on its own. With two matching children it would have to pick one, and that would be a worse surprise than a warning.

The second fix is the bigger one: the engine stopped relying on the game to switch characters off. Before posing a character, the animation system now asks whether the renderer will draw that mesh this frame. The object and its parents must be active, and its MeshRenderer must be on. The player adds a view test. A character counts as out of sight only when its bounding sphere is outside the camera and also out of reach of every shadow map. For a sun shadow, that means sweeping the sphere along the light as far as a cascade is deep, because something off screen can still cast a shadow on screen.

A character that fails the test still advances its clip clock, crossfade and root motion, so it is at the right point when it comes back into view. Posing, skinning and upload are skipped.

The result card after level 1 at iPad landscape size, with the developer readout on. The King is dancing, and the readout's last line counts him as the one skinned mesh drawn this frame, with the other 19 court members skipped.

This introduced one bug. The sphere was widened by how far each bone moved from its first keyframe, and a clip that holds a bone off its bind pose for its whole length (a seated pose) moves nothing from its first key. The sphere missed the mesh, and a character in plain view could be judged invisible and freeze. The reach is now measured from each bone's bind position.

Alongside that, a character holding a still pose is skinned once and then left alone, and the pose path stopped allocating several objects per bone per frame.

Drawing the same picture sixty times a second

With the court fixed, an idle board still drew 60 frames a second. Every frame looked exactly like the one before.

The renderer can now answer one question: would render() put anything on the canvas that is not already there? A tracker (drawDirty.ts) is marked by every call that changes the picture, from mesh uploads to light edits. It also compares the camera matrices and canvas size with the last frame drawn. Shader globals are now compared by value, so a script that sets a stopped clock every frame no longer counts as a change. Any material that reads globals.time keeps the frame dirty for as long as it is on screen.

api.setPosition and its siblings now mark an object moved only when the value changes, and api.requestDraw() covers a change the renderer cannot see.

The player skips render() when nothing changed, and the canvas keeps its last frame. Then a frame pacer slows the loop. After 30 quiet frames the player stops requesting animation frames and runs the game on a 100 ms timer instead. Scripts, timers and an on-screen clock keep running. Any input event, or a tick that changes something, brings it back to every display frame at once. The first frame after an idle gap can step up to 0.25 s, so game clocks keep real time. #awaken-always-draw in the URL turns all of this off.

On the idle board: before, 60 frames drawn and 60 animation-frame callbacks a second, with the main thread 9.8% busy. After, 0 frames drawn, 0 callbacks and 10 timer ticks a second, at 2.0–2.3% busy. A crown dropping onto the board brings it back to 16.7 ms frames, and it goes idle again afterwards.

Level 1 at the iPad 8's screen size, two crowns placed. The readout is on the idle tick: 0 frames drawn and 10 ticks a second, the main thread 1.7% busy, all 20 court members skipped, and a 1632×1224 canvas, which is the 2 MP cap described below. The rule card on the left slides to its next rule every 3.5 s, and the loop runs at full rate for that slide before it settles again.

The engine can only rest if the game does

The title screen stayed at 60, because three things on it never stopped moving:

  • The logo's wave and the breathing "Level N" button played forever. Now they play for 4 s after the logo lands and ease to rest.
  • The backdrop is a full-screen shader driven by a clock. A clock that changes every frame means a frame that has to be drawn. The backdrop now has its own clock, which slows to a stop with the logo. With the court switched off to measure the backdrop alone, the title went from 60 draws a second to 0, and the main thread from about 5.5% busy to 1.7%.
  • The court's idle animation cost 4–5 ms of main thread per frame at 4x throttle.

For the court I added Animator.poseRate: pose a character at most this many times a second. The clip clock and crossfades still run every frame, so a dance keeps its speed and only the pose is sampled less often. Between poses nothing changes, so a draw-on-change player has nothing to draw. At 30 Hz the title at rest went from 60 to 30 draws a second, and the frame function from 14.8% to 9.3% busy. Forty-five seconds after the title's motion ends, the court rests too, and the title drops to 0.

The title screen a few seconds after the logo lands, with the King posed at 30 Hz. The readout shows 30 frames drawn and 30 skipped a second. One court member is on screen, so 1 skinned mesh is drawn and 19 are skipped.

The engine can detect that nothing changed, but only the game can decide that something should stop moving. A logo that bobs forever costs battery for the whole session. The chest glow and the last-heart pulse got the same treatment: a few beats, then still.

GPU work that drew nothing

Even a frame that needed drawing did GPU work nobody would see. The rules now live in passSkip.ts, and each keeps the frame bit-identical.

  • A shadow cascade with no casters in it is cleared once, then skipped. When every cascade is empty, the shader skips its 18 shadow taps and returns the value those taps would have read.
  • At bloom intensity 0, the bright pass and the blur are skipped.
  • The overlay pass runs only when there are gizmos or lines to draw. The main pass keeps its depth buffer only for that overlay, and the multisample colour is never stored.
  • A sky that is one flat colour draws with a one-line fragment shader. I wanted to use the pass's clear colour instead, but on Apple GPUs a clear rounds to the 16-bit float target to nearest while a fragment output truncates. The two differed by one step on 1,210 of 3,148 tested channels, so it stays a draw.

GPU time per frame (median of about 900 readings) went from 1.01 to 0.80 ms on the title and from 0.80 to 0.49 ms on the board at iPhone size. At iPad size it went from 2.26 to 1.93 ms on the title and from 1.89 to 1.23 ms on the board.

Stripping the dead lighting code out of unlit shaders measured as no change beyond noise. I mention it because it sounds like it should have helped.

The iPad 8 was the real problem: 36.5 ms of GPU time per frame. The player caps devicePixelRatio at 2, but the iPad 8 is already a DPR 2 screen, so the cap did nothing and the game drew all 3.5 million pixels of a 2160×1620 display. The game now sets maxRenderPixels to 2,000,000, which scales the iPad's 3D render to about 0.76. The HUD is DOM, so text and buttons stay sharp.

The sparkles were a different problem. A tint on a UI image is an SVG filter, and iPad WebKit repaints a filtered element in software every frame it changes. The game now tints those sprites once at build time.

Startup, input and sound

A few smaller fixes from the same pass:

  • Swipes read one pointer position per frame, so a fast finger skipped cells. api.input.pointerSamples now keeps every position in the frame, coalesced moves included.
  • The audio context started on the first tap whether or not the game played a sound: a 170 ms hitch, and a context that then ran all session. It now starts on the first sound and suspends after five seconds of silence.
  • On first launch the player compiled about 55 GPU pipelines synchronously, each one to Metal, and the loading screen froze. Pipelines now compile asynchronously, and only the ones the scene uses: Crown Quest needs 7.
  • Texture compression ran a JavaScript BC encoder at load. On an A14 iPad or an iPhone 16 that kept the game on its loading screen for minutes, to save 38 MB. It now compresses only when the uncompressed textures with mipmaps would pass 256 MB.

What I do not know yet

I do not have a battery number. Nearly everything above was measured on a desktop, and an A12 is not an M2 Max with a throttle setting. The device tests are still on the release list: 20 minutes of play in Low Power Mode against Xcode's Energy Impact, and frame rate on a 3 GB device. At the last check the iPad 8 ran the title and result screens at 40–48 fps, so that work is not finished.

What changed is that I can now see it. #awaken-perf (or the Developer readout setting in App Settings, for a phone build) puts a small box over the game. It shows frames drawn and skipped, main-thread time, GPU time split into shadow, main and post, the backing size, and how many skinned meshes were drawn, skipped or held.

The costliest bug in all of this was one line that did nothing and reported nothing. Its only symptom was a warm phone, which is why the engine now warns about it.

← All posts