Originally written as internal engineering notes while building the thing it describes (October 2025). Republished here with light editing.
Starlight Breaker is a brick-breaker game built on the HTML5 Canvas API with no dependencies — plain HTML, CSS and JavaScript (source). It worked fine on a desktop, and was close to unplayable on a phone.
That gap is normal, and it isn't one problem. A Canvas game built at a fixed desktop resolution fails on mobile in several independent ways at once: the canvas is the wrong size, the touch coordinates are wrong in a way that looks like input lag, the particle system eats the frame budget, and the controls sit where a thumb can't reach. Below is what each of those actually required.
1. A canvas that picks its own resolution#
A fixed 800x600 canvas on a 375px-wide phone gets scaled down by CSS to roughly 40% — which means every pixel the game draws is being resampled, and the GPU is doing work on a resolution nobody can see.
Instead, the canvas backing-store resolution is chosen from the viewport at startup, in three tiers, all at the game's native 4:3 ratio:
| Screen width | Canvas resolution |
|---|---|
| > 768px (desktop) | 800 × 600 |
| 481–768px | 480 × 360 |
| ≤ 480px | 320 × 240 |
Holding 4:3 across all three tiers is what makes this cheap to retrofit: every coordinate in the game logic is expressed relative to the canvas dimensions, so dropping the resolution scales the whole game rather than cropping it. Nothing in the collision or physics code had to change.
The same sizing routine is re-run on resize, debounced — without the debounce, dragging a desktop window or rotating a phone fires the handler continuously and reallocates the canvas on every frame of the gesture.
2. Touch coordinates, corrected for scaling#
This is the bug that reads as "the controls feel wrong" and is actually arithmetic.
touch.clientX is in CSS pixels. The canvas draws in backing-store pixels. The moment the canvas is displayed at any size other than its intrinsic resolution — which, after step 1, is always — those two units diverge, and a paddle driven directly from clientX drifts further from your finger the further you move from the canvas origin.
The correction is one ratio:
function getTouchPosition(touch) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const relativeX = (touch.clientX - rect.left) * scaleX;
return relativeX;
}rect.width is the displayed width, canvas.width is the drawing width, and their ratio converts between the two. Subtracting rect.left first makes the coordinate relative to the canvas rather than the viewport. Getting this wrong is subtle enough that it is easy to blame on touch latency.
The rest of the touch work was unglamorous: clamp the paddle so it can't be dragged past the play area, and only respond to touch events while a round is actually in progress, so stray taps on the menu don't move things.
3. Haptics, cheaply#
The Vibration API gives touch controls physical feedback that a mouse never needed, in about six lines:
function vibrateDevice(duration = 10) {
if (isMobile && 'vibrate' in navigator) {
navigator.vibrate(duration);
}
}Three call sites, with durations chosen so the events are distinguishable by feel: 10ms when the paddle is touched, 15ms on a brick hit, 20ms on a paddle bounce. The feature-detect means it degrades to nothing on browsers without the API — including desktop Safari and iOS Safari — with no branching at the call site.
4. A particle budget that scales with the device#
Particle systems are where a Canvas game's frame budget goes, and a phone GPU is not a laptop GPU. Rather than disabling effects on mobile, the ceilings are parameterised by screen size:
const performanceConfig = {
maxParticles: isSmallScreen() ? 50 : 100,
particleGenerationRate: isSmallScreen() ? 0.5 : 1,
explosionParticleCount: isVerySmallScreen() ? 8 : (isSmallScreen() ? 10 : 15)
};Backed by the detection the rest of the responsive logic also uses:
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const isSmallScreen = () => window.innerWidth < 768;
const isVerySmallScreen = () => window.innerWidth < 480;Two notes on this. First, the user-agent test is used only for the vibration check — everything visual keys off innerWidth, because a narrow desktop window has the same rendering constraints as a phone and UA sniffing gets that wrong in both directions. Second, halving the generation rate as well as capping the total matters: capping alone leaves the system generating particles it will immediately discard, which costs allocation without producing anything visible.
5. Controls where a thumb can reach#
On desktop the control buttons sit centred on the right of the screen. On mobile that is the worst possible place — it is either behind your hand or out of reach.
The mobile layout moves them to the bottom centre in a horizontal row, with a minimum width of 90px (75px on the smallest tier) so they remain comfortably tappable, and the game container gains bottom padding so the buttons never overlap the canvas. In-game HUD text steps down with the same tiers (18px / 16px / 14px), with its position computed from the canvas dimensions rather than hardcoded, so it stays inside the play area at every resolution.
6. Two small pieces of onboarding#
Neither is technically interesting; both changed how the game feels on first contact.
A rotation prompt appears when a mobile device is held in portrait, driven by an orientation-change listener so it shows and hides itself. A 4:3 game in portrait is a postage stamp; asking for landscape is better than shipping the postage stamp.
A touch guide explains the controls on first play only, tracked in localStorage and dismissed with a button. Touch controls that seem obvious to the person who built them are rarely obvious on first contact, and once is the right number of times to say so.
What degrades, and how#
- Vibration API — feature-detected, silently absent where unsupported.
- Screen Orientation API — older browsers may not fire the change event, so the rotation prompt simply never appears. Nothing depends on it.
- Canvas scaling — universally supported; no fallback needed.
Tested on Chrome/Edge, Firefox, Safari (iOS and macOS) and Samsung Internet, on both real devices and device emulation. Emulation is good enough for layout and touch geometry; it will not tell you anything useful about haptics or sustained frame rate, which need a real phone.
What it added up to#
Roughly 150 lines of JavaScript and 200 lines of responsive CSS, plus a little markup for the prompt and guide. The core game logic — collision detection, physics, the particle system's internals, audio, high scores — was not touched. That is the honest summary of the exercise: none of this was game programming. It was making the game's existing logic independent of the assumption that it was being drawn at 800×600 with a mouse.
The zero-dependency constraint held throughout. Everything above is HTML5, CSS3 and vanilla JavaScript.
What I'd do next#
Fullscreen API support would remove the browser chrome that costs the most screen area on mobile; a service worker would make it installable and playable offline. Both are additive, and neither is needed for the game to be playable — which it now is.