Google Play rejected DimeTown, my top-down multiplayer browser game (a bit GTA2-style), on 28 September. The notice said “Broken Functionality” and listed one issue: “Unresponsive UI elements, such as buttons or icons”. It didn’t say which button. It gave me four screenshots and some help links.
The Android app is a Capacitor 8 shell that loads the live game, so every button was an ordinary web button. The real problem was that inside the Android WebView, the game decided it was running on a desktop. No joystick, no action buttons. You could open the phone and the map, but you couldn’t walk anywhere.
The fix: ask Capacitor before the media queries
My “is this a touch device?” check used only media queries. Now it asks Capacitor first:
import { Capacitor } from "@capacitor/core";
export const TOUCH =
Capacitor.isNativePlatform() ||
(matchMedia("(pointer: coarse)").matches &&
!matchMedia("(any-pointer: fine)").matches);
Before, it was only the second half. Capacitor.isNativePlatform() is true inside the iOS and Android shells and false in a normal browser, so browsers behave exactly as they did.
If a rejection won’t say what’s broken, log what your WebView reports for the pointer queries before you touch anything else. The rest of this post is how I found it and how sure I am.
What does Google Play’s “Broken Functionality: unresponsive UI” rejection mean?
Google’s policy lists “apps that load, but are not responsive” as a common violation. My notice falls in that category and stops there. It doesn’t say which control.
What I did have was the reviewer’s four screenshots. All four show the desktop layout: a “Say something…” chat bar along the bottom with Enter and / key hints, and no joystick. In two of them the phone and the city map are open, so some taps clearly worked. With no keyboard, nothing on that screen could move the character.
So on Google’s device the game had picked desktop controls. What I can’t prove is which query misfired there, or that this is the control Google meant. The fix doesn’t depend on the first.
Why did the Android WebView match both pointer: coarse and any-pointer: fine?
Here’s what I measured. I added a temporary log line to the game, ran a debug build of the Android app against my local dev server on an emulator (API 36), and read it back from logcat. The line looked roughly like this:
console.info("TOUCH_AUDIT", JSON.stringify({
native: Capacitor.isNativePlatform(),
platform: Capacitor.getPlatform(),
coarse: matchMedia("(pointer: coarse)").matches,
fine: matchMedia("(any-pointer: fine)").matches,
touchPoints: navigator.maxTouchPoints,
}));
adb logcat -d -s Capacitor/Console:I | grep TOUCH_AUDIT
It printed (trimmed):
{"native":true,"platform":"android","coarse":true,"fine":true,"touchPoints":5}
pointer asks about the primary device and any-pointer about any device, so a touchscreen with a fine pointer also available matches both. Why this WebView counted something as fine I haven’t tracked down; a virtual input device on the emulator is the obvious suspect. I only measured Android, on an emulator, not the reviewer’s device.
Coarse true plus fine true is also the exact combination my rule sends to the desktop layout, because a laptop with a touchscreen is meant to keep the mouse controls. To the only code that cared, the WebView looked like a laptop.
How do you detect a touch device in a Capacitor app?
Ask the shell first. Capacitor.getPlatform() returns "web", "ios" or "android", and the shell knows which it is. My web client imports @capacitor/core and runs in plain browsers too, where isNativePlatform() just returns false. The trade-off is that a native app now always gets touch controls, even with a mouse attached.
Don’t read any-pointer: fine as “this is a desktop”. It can be true next to a primary touchscreen. My rule needed it to be false before it showed touch controls, so any device that reported it lost the joystick. If you don’t care about touchscreen laptops, dropping the any-pointer: fine check would also have fixed my emulator, because pointer: coarse was true there. I kept it for browsers because I’d rather a laptop got the mouse layout.
Don’t swap in maxTouchPoints. The log says 5, which is what smartphones typically return. But touchscreen laptops report touch points too, so it can’t split the last two rows in the figure.
Keep an override. My game takes ?device=touch or ?device=desktop in the URL (or a localStorage key), and that beats everything. It’s how I check the touch layout from a laptop. A simplified version of the final check (the real one also reads localStorage):
const q = new URLSearchParams(location.search).get("device");
const forced = q === "touch" || q === "desktop" ? q : null;
export const TOUCH = forced
? forced === "touch"
: Capacitor.isNativePlatform() ||
(matchMedia("(pointer: coarse)").matches &&
!matchMedia("(any-pointer: fine)").matches);
Pin it with a test. Stub Capacitor and matchMedia, and add the case you got burned by. This is trimmed from my real test:
import { afterEach, expect, it, vi } from "vitest";
const platform = vi.hoisted(() => ({ native: false }));
vi.mock("@capacitor/core", () => ({
Capacitor: { isNativePlatform: () => platform.native },
}));
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
platform.native = false;
});
async function layout(native: boolean, coarse: boolean, fine: boolean) {
platform.native = native;
vi.stubGlobal("matchMedia", (q: string) => ({
matches: q === "(pointer: coarse)" ? coarse : fine,
}));
return import("../client/src/layout");
}
it(
"keeps native touch controls when an Android device also reports a mouse",
async () => {
expect((await layout(true, true, true)).TOUCH).toBe(true);
},
);
The fix came with seven layout tests. Run against the old code, 2 failed and 5 passed. Against the fix, all 7 pass.
None of my tests caught this: my Storybook tests pick the device explicitly, so nothing ever asked a real WebView. Same family as tests that pass while the code is broken.
Did that get the app through Google Play review?
It got published, with a caveat. Because the Android app is a shell around the live site, the fix went out as an ordinary web deploy on 3 October. Version 1.0.1 (version code 2) only bumped the version number so I had a new build to submit. If your shell loads a live URL, the fix reaches the old binary too, and the new build is just your way back into review.
I submitted it for production review that day, then added a Data safety correction, which restarted the review. Play Console shows the release as published on 4 October at 8:39 am, Brisbane time.
The caveat is that the reviewer saw more than the pointer fix. The same deploy fixed an action list whose tenth row never showed, a handbrake that stayed held after the app lost focus, and chat typing that kept the character walking. Three more game updates went out while the review ran. Google never named a control, so I can’t tell you which change did it. Rule the pointer check out first, but don’t count on it being the whole story.