PURPOSE

Full-screen React component that hosts a live Starship Survivors mission. Boots the engine via createMission from the bridge, owns the gameplay canvas plus a separate joystick overlay canvas, translates pointer / mouse / keyboard input into engine input calls, and orchestrates all in-mission React UI (pause overlay, weapon-mode toggles, run-summary, revive prompt, music widget, launch flash). It is the only valid React surface for an active run — all other screens are pre-mission (Hub) or post-mission (Reveal).

OWNS

  • canvasRef — the main gameplay <canvas> element passed to createMission.
  • overlayCanvasRef — a second <canvas> stacked above the game canvas; the joystick base/thumb is drawn here so the game renderer never clears it and there is no DPR transform race with the engine.
  • missionRef — the live MissionHandle returned by the bridge for the running mission.
  • rewardActiveRef / rewardActive state / rewardChoicesRef / rewardPressedCardRef — local reward-overlay state (active flag, current choice list, press-tracked card index for tap-then-release selection).
  • inputRef — touch joystick state: origin/current XY, angle, magnitude, linger seconds and previous finger XY for drift-to-finger.
  • mouseRef — desktop click-to-move state (held flag, viewport XY).
  • arrowsRef — WASD/arrow-key state: up, left, right, accumulated angle.
  • controlMode state + controlModeRef — current input mode ('touch' | 'mouse' | 'arrows'), persisted per-platform in localStorage keys ss_control_mode_mobile and ss_control_mode_desktop.
  • weaponModes state + weaponModesRef — array of 4 { fireMode: 'auto' | 'manual' } entries persisted under localStorage key ss_weapon_modes.
  • cogOpen, confirmEndRun, weaponModesOpen, debugOn, sfxVol, musicVol, skipAnims — pause overlay UI state.
  • revivePrompt state — payload { gemCost, useNumber } for the RevivePrompt modal.
  • runSummary state — { kills, time, score, survived, levelReached, weaponCount } shown briefly during the death-to-reveal transition.
  • launchPhase state — 'white' | 'fade' | 'done' for the white-flash intro overlay (0.3s hold, 0.6s fade).
  • The overlay requestAnimationFrame loop that drives joystick tween, crosshair, arrow-key input, mouse-input refresh, autoplay drift, and reward auto-pick.
  • The MusicPlayerWidget inner component (fixed black bar at screen bottom; polls MusicPlayer.getState() every 200 ms).
  • The ControlModeIcon inline SVG icon component for joystick / mouse / WASD.
  • isTouchDevice and hitTestRewardCard helper functions co-located in the file.

READS FROM

  • useSessionStorerunDef (current RunDefinition) and setMissionResult setter.
  • react-router-dom useNavigate — for redirects to hub (/) on missing runDef and to reveal (/games/starship-survivors/reveal) at end-of-run.
  • ../engine/bridgecreateMission and the BridgeCallbacks / MissionHandle types.
  • ../engine/core/configPERF_FLAGS (reads dprOverride, autoplay; writes devScenario when the ?dev= URL param is present).
  • ../engine/core/config/_accessibilityisSkipRewardAnimations, setSkipRewardAnimations.
  • ../engine/core/statesetDebugOverlay.
  • ../engine/core/device-capabilitiesinitDeviceCapabilities, isMobile.
  • ../engine/audio/audio-contextAudioBus.getSfxVolume / getMusicVolume / setSfxVolume / setMusicVolume / getCtx / resume.
  • ../engine/audio/music-playerMusicPlayer.getState / toggle / next / previous / heart / seek / stop.
  • ../engine/rendering/hudisRewardShowing, shouldAutoPick, setSelectedCard, hitTestWeaponSlot, setHudControlMode, hitTestCog, hitTestHelp, hitTestPauseBtn, toggleAutopickPause, triggerCogPressAnim, hitTestRerollBtn, hitTestBanishBtn, isBanishTargeting, isCardSlotDead.
  • ../engine/rendering/wheel-uihitTestWheelSpin, hitTestWheelExit.
  • ../engine/diagnostics/zoom-watcherstartZoomWatcher, toggleZoomOverlay, dumpZoomStateToSentry, resetAllZoomLayers.
  • ../engine/telemetrylogDiag.
  • ../services/runProgressionServicefinalizeRun.
  • ../components/RevivePromptRevivePrompt modal component.
  • @metagame/components/FeedbackFABsetActiveMission, clearActiveMission.
  • ../testing/perf-test-modeisPerfTestMode, startPerfTestSession, stopPerfTestSession, notifyPerfTestRunStart, buildPerfTestRunDef.
  • ../testing/dev-scenariosbuildDevScenario.
  • Browser globals: window.innerWidth/innerHeight, window.devicePixelRatio, window.matchMedia, window.location.search, window.history, window.visualViewport, screen.orientation, navigator.userAgent, document.hidden, document.querySelector('meta[name="viewport"]'), localStorage.

PUSHES TO

  • Mission handlemission.setInput(angle, magnitude, thrust), mission.setWeaponModes(weaponModes), mission.fireWeapon(slotIdx), mission.banishOption(idx), mission.selectReward(idx), mission.rerollRewards(), mission.toggleBanishTargeting(), mission.wheelSpin(), mission.wheelExit(), mission.pause() / resume(), mission.revive() / declineRevive(), mission.onCanvasResize(w, h, dpr), mission.recoverFromStuck(), mission.destroy(), mission.getRunSnapshot(reason), mission.getShipScreenPos(), mission.getShipAngle().
  • HUDsetHudControlMode(controlMode) whenever the control mode changes; setSelectedCard(idx) on reward pick; triggerCogPressAnim() when the cog button is hit-tested; toggleAutopickPause() when the autopick pause button is tapped.
  • Engine configsetDebugOverlay(on) toggle; writes (PERF_FLAGS as any).devScenario from the URL ?dev= parameter so the bridge can pick up the dev scenario at mission boot.
  • AccessibilitysetSkipRewardAnimations(on).
  • AudioAudioBus.setSfxVolume(v), AudioBus.setMusicVolume(v), AudioBus.resume(); suspends AudioBus.getCtx() on visibilitychange (hidden) and MusicPlayer.stop() + ctx.close() on pagehide.
  • Session storesetMissionResult(result) on game-over and on the early-exit “END RUN EARLY” confirm path.
  • Run progression servicefinalizeRun(result, currentChapter) on game-over, on early-exit, and on beforeunload.
  • TelemetrylogDiag calls with event types game_screen_mount_audit, pwa_zoom_stuck, and recover_from_stuck_pressed.
  • Zoom watcherstartZoomWatcher() on mount; dumpZoomStateToSentry + resetAllZoomLayers on the RECOVER FROM STUCK button; toggleZoomOverlay from the TOGGLE ZOOM DEBUG OVERLAY button.
  • FeedbackFAB hookssetActiveMission(mission, canvas) after createMission; clearActiveMission() on unmount; dispatches a 'ss:open-feedback' window event when the help button is hit-tested.
  • Routernavigate('/', { replace: true }) if runDef is missing; navigate('/games/starship-survivors/reveal') 3.5 s after a win, 0.5 s after death, 0.5 s after confirmed early exit.
  • localStoragess_weapon_modes (per-slot fire-mode array), ss_control_mode_mobile and ss_control_mode_desktop (control mode preference).
  • DOM — sets canvas.width / height / style.width / style.height (DPR-scaled buffer, CSS-pixel display) and the same on the overlay canvas; rewrites the meta[name="viewport"] content attribute in the unlock-then-relock zoom recovery; pushes a sentinel history.pushState({ sspGameLock: true }) entry on mount; re-pushes the sentinel on every popstate to swallow Android back-gesture navigation.

DOES NOT

  • Does not render any gameplay graphics itself. The engine renderer draws everything on the main canvas; this component only paints the joystick overlay canvas and the React-layer UI (pause panel, revive modal, run summary, launch flash, music widget).
  • Does not run the simulation loop or RAF for the engine. The engine’s RAF lives inside the mission handle returned by createMission. The RAF this component owns is a separate overlay loop for joystick / crosshair / arrow input / autoplay.
  • Does not directly manipulate game-world entities, weapons, enemies, projectiles, or any engine internal — every command goes through the MissionHandle interface.
  • Does not own player-progression, account state, or run-history persistence. It hands the result to finalizeRun and writes only missionResult into the session store.
  • Does not draw the cog, help, reward cards, autopick pause/reroll/banish buttons, weapon slots, wheel UI, or any HUD widget — those live on the engine canvas via engine/rendering/hud.ts and engine/rendering/wheel-ui.ts. This component only hit-tests them.
  • Does not handle audio synthesis or music selection logic. It only forwards volume and play / pause / next / previous to AudioBus and MusicPlayer.
  • Does not unlock or grant achievements directly; it computes a runStats shape from the BridgeResult for downstream consumption but does not call the achievement tracker here.
  • Does not own the reward state machine (timing, autopick countdown, banish targeting state). It mirrors flags into rewardActiveRef, calls isRewardShowing() / shouldAutoPick() / isBanishTargeting() to read engine state, and forwards selections.

Signals

Receives via BridgeCallbacks passed into createMission:

  • onPhaseChange(phase) — no-op stub.
  • onGameOver(result) — sets the session-store mission result, calls finalizeRun, branches on result.outcome.survived (shows runSummary for 3.5 s on win, 0.5 s blank-out on death), then navigates to the reveal screen. Perftest mode skips finalization and auto-restarts a fresh mission after 2 s using buildPerfTestRunDef().
  • onRewardShow(choices) — sets rewardActiveRef, rewardActive state, and rewardChoicesRef.
  • onRevivePrompt(gemCost, useNumber) — sets the revivePrompt state to open the modal. Perftest mode auto-declines after 100 ms.
  • onReviveDismissed() — clears the revivePrompt modal.

Window / document event listeners installed:

  • resize — recomputes canvas + overlay sizing and calls mission.onCanvasResize(w, h, dpr).
  • keydown / keyup1 / 2 / 3 select reward cards when active, otherwise fire weapons 1-3; 4 always fires weapon 4; ArrowUp / w / W thrust, ArrowLeft / a / A rotate left, ArrowRight / d / D rotate right (arrows mode only); preventDefault on movement keys.
  • popstate — re-pushes the sspGameLock history entry to consume the Android back gesture.
  • beforeunload — fire-and-forget finalizeRun snapshot tagged 'browser_close'.
  • visibilitychange — suspends the AudioContext and pauses the mission when hidden; resumes both on return.
  • pagehide — stops MusicPlayer and closes the AudioContext.
  • visualViewport.resize and visualViewport.scroll — calls reportStuckZoom('event') + resetViewportMeta() when scale exceeds 1.01.

Canvas-attached event listeners:

  • pointerdown / pointermove / pointerup / pointercancel — the unified input pipeline.
  • contextmenupreventDefault to suppress right-click menus.

Window events dispatched:

  • 'ss:open-feedback' — emitted when the on-canvas help button is tapped.

Entry points

  • GameScreen({ webgl? }) — the default React component export. The webgl prop is accepted but the body does not currently branch on it (Canvas 2D is the live path).
  • MusicPlayerWidget — internal-only component, mounted under the pause overlay (cogOpen).
  • ControlModeIcon — internal-only inline SVG component used inside the pause panel’s control-mode toggle.

The component is mounted by the app router at the game routes that lead to a live mission. The only valid live-mission entry is the Hub LAUNCH flow, which sets runDef in the session store before navigating; if runDef is missing (and the URL is not in perftest or ?dev= mode), the guard effect redirects back to /. Perftest and dev-scenario modes synthesize their own RunDefinition via buildPerfTestRunDef() or buildDevScenario(_devScenario) and bypass the guard.

Pattern notes

  • Two-canvas layering. The joystick overlay lives on its own canvas with identity transform, sized in CSS pixels. The game canvas is DPR-scaled (canvas.width = cssW * dpr) and the bridge applies ctx.scale(dpr, dpr). Drawing the joystick on the game canvas previously had a timing race with the engine’s per-frame clear and an inherited DPR transform; splitting them removed both.
  • Single source of truth for canvas sizing. Resize handling is owned here and pushed to the mission via mission.onCanvasResize(w, h, dpr). The engine no longer registers its own resize listener — this comment is preserved in the source and is load-bearing.
  • DPR is read through getCurrentDPR() which honours PERF_FLAGS.dprOverride so perftest can hold a fixed DPR.
  • Width cap. MAX_W = 520 clamps CSS width on desktop so the game does not stretch wider than a phone-sized strip.
  • Press-then-release reward selection. pointerdown records the card index in rewardPressedCardRef; pointerup fires the select only if the same card is still under the pointer. This avoids accidental taps and the historical “stuck reward” bug from spam-clicking. If isBanishTargeting() is on, the tap calls mission.banishOption(idx) instead and the reward stays open.
  • Auto-pick driven from the overlay loop. Each frame the overlay loop checks shouldAutoPick() and, when it returns true, picks a random live (non-dead) card index. In autoplay/perftest, the pick fires immediately as soon as isRewardShowing() is true.
  • Autoplay drift. When PERF_FLAGS.autoplay or perftest is on, the overlay loop synthesizes a slow circular input (autoplayT * (Math.PI * 2 / 8) per second, magnitude 0.6, thrust on) every frame.
  • Drift-to-finger joystick. When the finger sits beyond MAX_DIST = 80 px and stays roughly still (movement under 2 px/frame) the origin creeps along the existing angle. Speed is lingerSec * lingerSec * 20 px/s, capped at 160 px/s. Any movement above the 2 px threshold zeroes the linger accumulator. Direction is preserved exactly.
  • Hysteresis on the joystick angle. The applied angle only updates when the raw-to-current angle delta exceeds (3 + (1 - magnitude) * 8) degrees, so small-magnitude jitter does not swing the ship.
  • Mouse click-to-move uses canvas-local coordinates. Cursor viewport XY is converted to canvas-local space via getBoundingClientRect() and aimed at mission.getShipScreenPos(). The mouse input is refreshed every overlay frame so it keeps working even while the cursor is held still.
  • Arrow / WASD mode. Holding L/R rotates the accumulated arr.angle at Math.PI * 2 rad/s (full 360° in ~0.5 s). When no key is held, arr.angle snaps to mission.getShipAngle() so the next L/R press starts from the ship’s actual facing rather than a stale absolute angle.
  • Touch vs desktop detection. isTouchDevice() uses matchMedia('(pointer: coarse)') as the primary signal, with 'ontouchstart' in window && innerWidth < 1024 as a fallback. Control mode preference is persisted under separate localStorage keys per platform.
  • Keyboard 1-4 dual-purpose. Keys 1 / 2 / 3 first attempt trySelectReward(i) (which short-circuits if the reward state machine is not in the showing phase); if that returns false, they fall through to mission.fireWeapon(i). Key 4 always fires weapon 4.
  • Cog opens pause. Cog hit-test triggers setCogOpen; the cog-open effect calls mission.pause() on open and mission.resume() on close, and clears confirmEndRun on close.
  • End-Run-Early flow. mission.getRunSnapshot('abandoned') builds a BridgeResult that goes through the standard setMissionResult + finalizeRun path before routing to /games/starship-survivors/reveal. Equivalent path on beforeunload uses getRunSnapshot('browser_close').
  • Recover-From-Stuck. A user-visible safeguard button. Dumps current zoom state to Sentry via dumpZoomStateToSentry, logs recover_from_stuck_pressed to Supabase, calls mission.recoverFromStuck(), runs resetAllZoomLayers() (viewport meta unlock-relock, scrollTo, root + canvas transform reset, camera zoom, synthetic resize), and closes the cog menu.
  • PWA zoom telemetry. Every mount fires game_screen_mount_audit with a full baseline snapshot (visualViewport scale/size, innerWidth/innerHeight, DPR, orientation, standalone, canvas buffer/CSS sizes, UA). A pwa_zoom_stuck warning fires once per run when the viewport scale exceeds 1.01 (either on mount or via a vv resize/scroll event), and an unlock-then-relock of the viewport meta runs in the same path.
  • Android back-gesture trap. A sentinel history.pushState({ sspGameLock: true }) entry is pushed on mount and re-pushed on every popstate so the back gesture is consumed without ejecting the user out of an in-progress run. The entry is implicitly popped at unmount, leaving normal navigation elsewhere unaffected.
  • AudioContext lifecycle. visibilitychange to hidden suspends the context and pauses the mission; the next visible event restores both via AudioBus.resume() and mission.resume(). pagehide stops the music player and closes the context outright.
  • Perftest mode forks behaviour. isPerfTestMode() causes (1) the missing-runDef guard to be bypassed, (2) buildPerfTestRunDef() to be used as the effective run def, (3) startPerfTestSession() + notifyPerfTestRunStart() on mount, (4) auto-restart of a fresh mission after the 2 s grace period on game-over, (5) auto-decline of the revive prompt, and (6) stopPerfTestSession() on unmount.
  • Dev-scenario URL parameter. ?dev=<name> is read from window.location.search on render (not from PERF_FLAGS which captures only initial load), pushed back into PERF_FLAGS.devScenario so the bridge picks it up, and used to synthesize a RunDefinition via buildDevScenario(name).
  • Stat-rollup shape. The component flattens result.combat, result.progression, and result.performance into the runStats shape that the cumulative-achievement tracker expects, even though the actual updateCumulativeStats call is left to the downstream pipeline — the shape is built here so the contract stays adjacent to the only producer.
  • Music widget mounts only while paused. MusicPlayerWidget is rendered only when cogOpen is true; it polls MusicPlayer.getState() every 200 ms on its own interval (no canvas dependency) and supports drag-aware seeking.