DevKeys

PURPOSE

Global keyboard shortcut handler for dev/automation navigation. Mounts a keydown listener on window that intercepts Ctrl+Shift+<key> chords and either navigates the React Router to a metagame screen, toggles the debug overlay, or programmatically clicks shop/collection buttons. Pure side-effect component — renders null. Must be mounted inside BrowserRouter because it consumes useNavigate and useLocation.

The Ctrl+Shift prefix is chosen specifically to avoid collisions with in-game input (which uses bare keys / WASD / arrows). Enables Claude and any other automation to drive the entire game UI from the browser console or DevTools snippets without clicking.

OWNS

  • The keyboard event listener (window.addEventListener('keydown', ...)) registered in useEffect and torn down on unmount.
  • The dispatch switch statement mapping uppercased e.key to navigation / debug / button-click actions.
  • A module-local helper _clickButton(selector, textMatch, nthMatch) that performs DOM lookup-and-click for context-sensitive shop pulls. Tries the CSS selector first, falls back to scanning all <button> elements for matching text content (case-insensitive includes), and clicks the nth match (0-indexed).

READS FROM

  • react-router-domuseNavigate(), useLocation() for current path (location.pathname).
  • @starship-survivors/services/assembleRunServiceassembleRunDef({ shipId }) to build a RunDef for the quick-launch shortcut.
  • @starship-survivors/stores/sessionStoreuseSessionStore.getState() reads selectedShipId and writes setRunDef(runDef) during quick-launch.
  • @starship-survivors/engine/core/state — reads debugOverlay flag, writes via setDebugOverlay(!debugOverlay).
  • DOM — document.querySelectorAll(selector) and document.querySelectorAll('button') for the _clickButton fallback.
  • Live KeyboardEvent fields: e.ctrlKey, e.shiftKey, e.key.

PUSHES TO

  • React Router history via navigate('/<route>') — pushes to /, /shop, /collection, /collection?tab=ships, /profile, /admin, /games/starship-survivors/levels, /games/starship-survivors/play, /games/starship-survivors/reveal, /ship-playground.
  • sessionStoresetRunDef(runDef) on the L (quick-launch) shortcut.
  • Engine debug state — setDebugOverlay(boolean) toggles the overlay flag on D.
  • DOM — synthetic .click() calls on shop/collection buttons via _clickButton.
  • Calls e.preventDefault() on every matched Ctrl+Shift chord so the browser does not consume the chord for its own bindings.

DOES NOT

  • Render any UI — returns null.
  • Listen for plain key presses, single-modifier chords (Ctrl-only or Shift-only), or key-up events. The very first guard if (!e.ctrlKey || !e.shiftKey) return; short-circuits everything else.
  • Stop propagation (stopPropagation is never called) — other listeners on the page still see the event after preventDefault.
  • Handle Ctrl+Shift+E — explicitly noted as removed because the settings screen was deleted (settings now live in the in-game cog menu).
  • Handle Ctrl+Shift+7 — referenced in the file header comment (Collection: ACHIEVEMENTS tab) but no case '7' exists in the switch.
  • Guard the quick-launch shortcut against missing selectedShipId — passes whatever value the store currently holds to assembleRunDef.
  • Persist any state of its own; the shortcuts are stateless dispatch on each keystroke.

Signals

  • Trigger: any keydown event on window where both ctrlKey and shiftKey are true.
  • Key normalization: e.key.toUpperCase() — so letter cases match, and the number-row keys are matched both as digits ('1''6') and as their shifted symbol forms ('!', '@', '#', '$', '%', '^') since Shift+1 produces ! on US layouts.
  • Path-gated branches: number-key shortcuts only fire when location.pathname matches:
    • '1'/'2'/'3'/'4' only act when path === '/shop'.
    • '5'/'6' only act when path.startsWith('/collection').

Entry points

  • Default export: none. Named export DevKeys (React function component).
  • Module-internal: _clickButton(selector: string, textMatch: string, nthMatch: number): void.
  • Listener lifecycle is keyed on [navigate, location.pathname] — the effect re-registers on every route change, so the path captured in the closure stays current.

Shortcut table

ChordActionPath gate
Ctrl+Shift+Hnavigate('/') (hub)any
Ctrl+Shift+LQuick-launch — assembleRunDef({ shipId: selectedShipId }), setRunDef, navigate('/games/starship-survivors/play')any
Ctrl+Shift+Snavigate('/shop')any
Ctrl+Shift+Cnavigate('/collection')any
Ctrl+Shift+Pnavigate('/profile')any
Ctrl+Shift+Anavigate('/admin')any
Ctrl+Shift+Vnavigate('/games/starship-survivors/levels')any
Ctrl+Shift+Tnavigate('/ship-playground')any
Ctrl+Shift+Rnavigate('/games/starship-survivors/reveal')any
Ctrl+Shift+DsetDebugOverlay(!debugOverlay)any
Ctrl+Shift+1Solar Legion x1 pull — click [data-testid="pull-1-solar"] (or button-text "x1" match 0)/shop
Ctrl+Shift+2Solar Legion x10 pull — [data-testid="pull-10-solar"] (text "x10" match 0)/shop
Ctrl+Shift+3Freebooters x1 pull — [data-testid="pull-1-free"] (text "x1" match 1)/shop
Ctrl+Shift+4Freebooters x10 pull — [data-testid="pull-10-free"] (text "x10" match 1)/shop
Ctrl+Shift+5navigate('/collection?tab=ships')/collection*
Ctrl+Shift+6navigate('/collection')/collection*

Pattern notes

  • Side-effect-only component returning null. Same shape as the React idiom for global listeners (e.g., scroll restoration, analytics). Mount once at the app shell, never inside a route.
  • Effect re-runs on location.pathname change. This means path in the closure is always fresh and the listener is rebound on every navigation — cheap because the handler body is small, but it does mean a removed-and-re-added listener on every route hop.
  • Selector-first, text-fallback button finder. _clickButton is deliberately tolerant: shop UI authors don’t have to add data-testid for the dev shortcut to keep working — the text-content fallback catches buttons labeled "x1" / "x10". nthMatch (0 for Solar Legion row, 1 for Freebooters row) assumes a stable visual order of shop banners.
  • Shifted-symbol cases: case '1': case '!': etc. The e.key.toUpperCase() does not transform '!' into '1', so both forms are listed explicitly. This is what makes the shortcuts work regardless of whether Shift normalizes the digit on the user’s keyboard layout.
  • No collision avoidance with browser chords. Ctrl+Shift+T (reopen closed tab), Ctrl+Shift+P (private window / command palette), Ctrl+Shift+R (hard reload), Ctrl+Shift+D (bookmark all tabs), Ctrl+Shift+H (history) are all browser-reserved. e.preventDefault() blocks the browser’s default action when the chord matches a case in the switch — but only after the React handler has fired, so DevTools / extensions that intercept at a higher level still win.
  • No feature flag. Shortcuts are live in production; this is intentional — Claude needs them on the deployed Vercel URL, not just locally.

EXTRACT-CANDIDATE

  • _clickButton(selector, textMatch, nthMatch) is a generic DOM-click utility (CSS selector first, button-text fallback). If a second component grows similar “drive the UI by clicking a button” needs (e.g., a test harness or another automation surface), lift it to src/metagame/utils/clickButton.ts. Not worth extracting yet — single caller.
  • The Ctrl+Shift+<key> chord-dispatch pattern (modifier guard → uppercased key → switch with preventDefault) could become a generic useKeyboardShortcuts({ 'Ctrl+Shift+H': () => navigate('/'), ... }) hook if a second shortcut surface appears (in-game hotkeys, admin keybinds). Today it’s the only consumer.
  • The shortcut → route mapping is currently inline in the switch. If the metagame screen list ever moves to a data table (screens.ts with { path, key, label }), this file should consume that table instead of duplicating the route strings.