My browser game Void Parry went to a portal's moderation with dozens of automated browser tests passing. The answer came back a few days later:
The WASD keys aren't working in the game.
That was the whole review. On my machine the keys worked. In every test the keys worked. On the portal's page they did nothing. It took one real key press on the real page to see why, and the cause was a line I wrote on day one.
The line
canvas.addEventListener("pointerdown", (e) => {
e.preventDefault(); // no text selection, no touch scrolling, no long-press menu
handlePress(e);
});
Calling preventDefault() on pointerdown is common in canvas games. It also cancels the default action of the press - and one of those defaults is moving keyboard focus to what you clicked.
On my own site that never mattered: the game is the top-level page, the document already has focus, keys arrive.
A portal does not load your game as the top-level page. It puts it in an <iframe> (one portal I checked nests it two frames deep). An iframe only receives key events while it has focus, and it normally gets focus when the player clicks inside it. My handler cancelled exactly that. Pointer events kept arriving - mouse aim and buttons worked, which hid the problem - but keydown went to the portal's page forever.
You can see it in two lines from the devtools console of the game frame:
document.hasFocus() // false, even right after clicking the game
The first fix was not enough
The obvious repair is to take focus yourself:
canvas.addEventListener("pointerdown", (e) => {
e.preventDefault();
window.focus();
handlePress(e);
});
I verified it on the live page - click, press W A S D, all four arrive - and sent the build back.
Hours later I walked through what the reviewer had actually done, and it was not what I had tested. A new player in my game lands straight in the first boss fight. The mouse only aims; you never have to click. So the reviewer opened the game, pressed W, A, S, D, saw nothing move, and wrote the review. No click ever happened. My fix repaired a path the reporter never took.
A frame is allowed to focus itself without a click, so the real fix has three parts:
window.focus(); // when the game loads
function startFight() { window.focus(); /* ... */ }
canvas.addEventListener("pointermove", () => { // the player is over the game
if (!document.hasFocus()) window.focus();
});
Plus window.focus() after an ad closes, because an ad overlay takes the keyboard and does not hand it back.
The test I should have had
All my tests loaded index.html directly. The fix for that is small: a host page with the game in an iframe, and a test that never clicks.
fs.writeFileSync("host.html", '<iframe src="index.html" style="width:1280px;height:720px"></iframe>');
fs.writeFileSync("host2.html", '<iframe src="host.html" style="width:1280px;height:720px"></iframe>');
for (const host of ["host.html", "host2.html"]) { // one and two frames deep
await page.goto(host);
const game = page.frames().find((f) => /index\.html/.test(f.url()));
await game.evaluate(() => { window.keys = []; addEventListener("keydown", (e) => keys.push(e.code)); });
for (const k of ["KeyW", "KeyA", "KeyS", "KeyD"]) await page.keyboard.press(k); // no click first
assert.equal(await game.evaluate(() => keys.join()), "KeyW,KeyA,KeyS,KeyD");
}
It has three cases now: no click at all, the portal page has focus and the player only moves the mouse, and the portal page has focus and the player clicks. The old build fails the first two. Then I ran the same check against the live portal page, because a test page I wrote can share my blind spots.
What it cost
I went back to a second portal that had rejected the game twice, the last time for "overall quality", opened the build they had reviewed in their own preview tool, clicked in the game and pressed the keys. Nothing arrived there either. Two reviews of a keyboard game whose keyboard did not work on their site. I do not know how much of "overall quality" that was. I do know the reviewers could not move.
What I took from it
- Test the way the player meets the product. If it ships inside someone else's frame, test it inside a frame. If a new player never clicks, the test must not click.
-
preventDefault()on pointer events cancels focus too. Keep it if you need it, and take focus yourself. - "Fixed" means the reporter's exact path passes, not a path next to it. Write that path down before you touch the code.
- Green tests describe your test page. One manual key press on the real page found in a minute what dozens of tests could not see.
The game and its tests were written with an AI coding assistant; the bug, the review text and the fix are from the real build.
Top comments (1)
The no-click fixture catches a path that a click-first test hides. I'd add the inverse check alongside it: focus a search field on the host page, move the pointer across the game on the way to another control, then type WASD. Does the pointermove handler take focus away from that field? That would test whether restoring game input also interferes with portal input. A blur while W is held is another useful fixture: on refocus, movement should stop until a fresh keydown, rather than depend on a keyup the frame may never receive.