diff --git a/changes/reclaim-focus-removed.md b/changes/reclaim-focus-removed.md
new file mode 100644
index 000000000..90f4c6b71
--- /dev/null
+++ b/changes/reclaim-focus-removed.md
@@ -0,0 +1,3 @@
+### Fixed
+
+- After a click on a control that then unmounts (a start screen's "Play", a menu's last button), `@jgengine/shell` returns focus to the play surface instead of leaving it on `
`, so gameplay keys work without an extra click on the canvas.
diff --git a/packages/shell/src/GamePlayerShell.tsx b/packages/shell/src/GamePlayerShell.tsx
index 5053d17e5..b881b488b 100644
--- a/packages/shell/src/GamePlayerShell.tsx
+++ b/packages/shell/src/GamePlayerShell.tsx
@@ -67,6 +67,7 @@ import { observableShellTracker, attachShellInputPublication } from "./shellInpu
import { attachShellControlSuspension } from "./shellControlSuspension";
import { LazyShell3dPresentation } from "./lazyShell3dPresentation";
import { createPresentationDiagnosticOwnership, createPlaySurfaceFocusOwnership, PresentationRecovery, type PresentationRecoveryEvent } from "./presentationRecovery";
+import { installPlayFocusReclaim } from "./playSurfaceFocus";
const DEV_USER_ID = "dev-player";
@@ -135,6 +136,8 @@ export function GamePlayerShell({
const presentationDiagnostics = useRef(createPresentationDiagnosticOwnership());
const [remotePlayers, setRemotePlayers] = useState([]);
const wrapperRef = useRef(null);
+ // A start/menu button that unmounts on click drops focus to , where gameplay keys never arrive.
+ useEffect(() => (poster ? undefined : installPlayFocusReclaim(document, () => wrapperRef.current)), [poster]);
const recoveredFocus = useRef(createPlaySurfaceFocusOwnership());
const yawRef = useRef(0);
const pitchRef = useRef(0);
diff --git a/packages/shell/src/playSurfaceFocus.test.ts b/packages/shell/src/playSurfaceFocus.test.ts
new file mode 100644
index 000000000..a1478c71d
--- /dev/null
+++ b/packages/shell/src/playSurfaceFocus.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from "bun:test";
+import { shouldReclaimPlayFocus } from "./playSurfaceFocus";
+
+const el = (isConnected: boolean) => ({ isConnected }) as Element;
+const body = el(true);
+
+describe("shouldReclaimPlayFocus", () => {
+ test("reclaims when the focused button unmounted and focus fell to the body", () => {
+ expect(shouldReclaimPlayFocus(el(false), body, body)).toBe(true);
+ expect(shouldReclaimPlayFocus(el(false), null, body)).toBe(true);
+ });
+
+ test("leaves focus alone when the old element still exists (a deliberate blur)", () => {
+ expect(shouldReclaimPlayFocus(el(true), body, body)).toBe(false);
+ });
+
+ test("leaves focus alone when something else already took it", () => {
+ expect(shouldReclaimPlayFocus(el(false), el(true), body)).toBe(false);
+ });
+
+ test("ignores focusout events without an element target", () => {
+ expect(shouldReclaimPlayFocus(null, body, body)).toBe(false);
+ });
+});
diff --git a/packages/shell/src/playSurfaceFocus.ts b/packages/shell/src/playSurfaceFocus.ts
new file mode 100644
index 000000000..400873b55
--- /dev/null
+++ b/packages/shell/src/playSurfaceFocus.ts
@@ -0,0 +1,39 @@
+/**
+ * Focus recovery for the play surface. Gameplay keys are read on the shell's focusable wrapper, so
+ * when a focused menu button unmounts (a start screen's "Play", a closed panel's last control) the
+ * browser drops focus to `` and WASD silently does nothing until the player clicks the canvas.
+ * The one decision: reclaim only when the clicked element is gone from the document and
+ * nothing else took focus — a deliberate click elsewhere on the page keeps its focus.
+ * @internal
+ */
+export function shouldReclaimPlayFocus(lost: Element | null, active: Element | null, body: Element | null): boolean {
+ if (lost === null || lost.isConnected) return false;
+ return active === null || active === body;
+}
+
+/**
+ * Watches clicks (pointer, Enter or Space on a button) and, right after the click's update commits, returns focus to `surface()` if the clicked control unmounted and focus fell to the body.
+ * Chromium moves focus to `` on removal without a `focusout`, so the click is the hook.
+ * Returns the cleanup.
+ * @internal
+ */
+export function installPlayFocusReclaim(doc: Document, surface: () => HTMLElement | null): () => void {
+ let timer: ReturnType | undefined;
+ const onClick = (event: MouseEvent) => {
+ const clicked = event.target instanceof Element ? event.target : null;
+ if (clicked === null) return;
+ clearTimeout(timer);
+ // React commits a click's update synchronously at the end of the event; a macrotask is enough and,
+ // unlike a frame callback, does not stall when the page renders slowly.
+ timer = setTimeout(() => {
+ const target = surface();
+ if (target === null || !target.isConnected) return;
+ if (shouldReclaimPlayFocus(clicked, doc.activeElement, doc.body)) target.focus({ preventScroll: true });
+ });
+ };
+ doc.addEventListener("click", onClick, true);
+ return () => {
+ clearTimeout(timer);
+ doc.removeEventListener("click", onClick, true);
+ };
+}
diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json
index 919c4995b..28992ea28 100644
--- a/scripts/export-manifest.json
+++ b/scripts/export-manifest.json
@@ -862,6 +862,7 @@
"./map/terrainMap",
"./materialOverride",
"./multiplayer",
+ "./playSurfaceFocus",
"./pointer/PointerOverlays",
"./pointer/PointerProbe",
"./pointer/pointerService",