voxolith

Mobile and PWA

Make a Voxolith game work on phones - detect one, pick a lighter profile, add touch controls, test over HTTPS and ship an installable offline app.

Phones run WebGPU, but with a fraction of a desktop GPU's memory and fill rate, a screen of three device pixels per CSS pixel, and a thumb instead of a mouse. This page covers how the examples adapt to that, how to add touch controls, and how Demolition Shot, the engine's showcase, ships as an installable app that plays offline. Which settings a phone gets is your game's decision; the values below are the valley example's (the nightwood example uses the same test and scales).

Knowing it is a phone

There is no single signal. The valley example treats a device as a phone when it has a touch-only pointer or the small storage bindings mobile GPUs offer, and lets ?full override:

const params = new URLSearchParams(location.search);
const touchOnly = matchMedia("(pointer: coarse)").matches && !matchMedia("(pointer: fine)").matches;
const smallGpu = gpu.limits.maxStorageBufferBindingSize < 512 * 1048576;
const phone = !params.has("full") && (touchOnly || smallGpu);

A tablet with a keyboard and trackpad reports a fine pointer and gets the desktop settings, which is usually right.

A lighter profile

On a desktop the valley runs at 50 voxels per metre (a quarter of the surface voxels of 100 to generate and draw). A phone gets 20 voxels per metre (5 cm voxels), which the entity generators support as a scale of their own (at 20 voxels per metre): about an eighth of the bricks of 50, and 19 MB of GPU memory for the valley against 126 MB. It also shrinks the rest: a 2 × 2 tile map, one model per species, a 16 m streamed radius instead of 24, the low preset with a shorter maxSteps, and a starting render scale of 0.6. Rats come only at 100 voxels per metre, where they are real size (20 of them on a phone, 60 on a desktop); see sizing for a world. Each setting can still be forced from the URL (?vpm=, ?scale=, ?variants=, ?rats=, ?radius=), and the HUD's scale button steps a phone between 20 and 50. The general levers, in the order they help:

  • Resolution: cap maxPixelRatio in initGpu, start gpu.renderScale low, and let makePerf climb from there.
  • Quality: renderer.setQuality("low") removes shadow rays, point-light shadows and ambient occlusion.
  • Voxel size: a coarser voxelsPerMetre cuts memory and ray steps together (nightwood takes 59 ms a frame at 20 against 144 ms at 50 at render scale 1, 1280 × 720).
  • Content: fewer model variants, a smaller streamed radius, fewer animated members.
  • Memory: keep an eye on renderer.stats().bytes against gpu.limits.maxStorageBufferBindingSize. Exceeding a binding makes rendering stop silently rather than fail loudly, so show GPU errors in the page: on a phone there is no console to find them in. The examples' boot.ts listens to the device's uncapturederror and lost for this.

Touch controls and gestures

The camera controllers already handle touch: one finger rotates, two pinch and pan. For game controls, bind touch: sources beside keys and pad, and put the controls on screen with makeTouchControls. They appear once a touch is seen and hide on mouse, keyboard or gamepad use:

const input = createInput(canvas);
makeTouchControls(input, {
  joysticks: [{ id: "move", side: "left" }],
  buttons: [{ id: "fire", label: "Fire" }],
});
// in the action map: analog: ["pad:LeftX", "touch:move.x"], fire: button("key:Space", "touch:fire")

Call prepareSurface(canvas) so the browser does not pan, zoom, select text or flash on taps over the canvas. Tap versus drag is decided on total travel with a 10 px slop for touch, so a finger that wanders a little still taps. Demolition Shot uses a single pull-back gesture over the whole screen for aiming, two fingers for the camera, and no on-screen buttons at all; that is a game decision too. See Input.

Testing on a phone

WebGPU needs a secure context, so a phone on your network cannot use a plain http:// dev server. Serve HTTPS with @vitejs/plugin-basic-ssl and server: { host: true } in vite.config.ts, open the LAN https:// address on the phone and accept the self-signed certificate once.

Installable and offline

Demolition Shot is an installable app that plays offline after one visit. It uses the engine's shared service worker: serviceWorker() from @voxolith/engine/vite in its vite.config.ts precaches the whole build and the public files its manifest and page use, and registerServiceWorker() from @voxolith/engine/pwa registers it under the deploy base. Each deploy gets a new worker and a fresh cache, so stale assets never outlive a release. The manifest uses "start_url": "." and "scope": "." so it works under any base path, and lists a maskable icon for launchers that crop to their own shape. How all of it works, and how to do the same in your app, is in Offline and installing.

Generated models are a separate cache: the generator worker's IndexedDB cache (see Workers and caching) survives between visits on its own.

Pitfalls

  • Mobile browsers pause a hidden tab. Render on demand, and expect a long frame gap on return; makePerf ignores gaps over maxSampleMs.
  • The viewport meta tag matters: Demolition Shot disables user scaling and uses viewport-fit=cover so the canvas owns the whole screen.
  • Test on a real device. Desktop device emulation does not reproduce the GPU's limits.

See also

On this page