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 tocreateMission.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 liveMissionHandlereturned by the bridge for the running mission.rewardActiveRef/rewardActivestate /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, accumulatedangle.controlModestate +controlModeRef— current input mode ('touch' | 'mouse' | 'arrows'), persisted per-platform in localStorage keysss_control_mode_mobileandss_control_mode_desktop.weaponModesstate +weaponModesRef— array of 4{ fireMode: 'auto' | 'manual' }entries persisted under localStorage keyss_weapon_modes.cogOpen,confirmEndRun,weaponModesOpen,debugOn,sfxVol,musicVol,skipAnims— pause overlay UI state.revivePromptstate — payload{ gemCost, useNumber }for theRevivePromptmodal.runSummarystate —{ kills, time, score, survived, levelReached, weaponCount }shown briefly during the death-to-reveal transition.launchPhasestate —'white' | 'fade' | 'done'for the white-flash intro overlay (0.3s hold, 0.6s fade).- The overlay
requestAnimationFrameloop that drives joystick tween, crosshair, arrow-key input, mouse-input refresh, autoplay drift, and reward auto-pick. - The
MusicPlayerWidgetinner component (fixed black bar at screen bottom; pollsMusicPlayer.getState()every 200 ms). - The
ControlModeIconinline SVG icon component for joystick / mouse / WASD. isTouchDeviceandhitTestRewardCardhelper functions co-located in the file.
READS FROM
useSessionStore—runDef(currentRunDefinition) andsetMissionResultsetter.react-router-domuseNavigate— for redirects to hub (/) on missingrunDefand to reveal (/games/starship-survivors/reveal) at end-of-run.../engine/bridge—createMissionand theBridgeCallbacks/MissionHandletypes.../engine/core/config—PERF_FLAGS(readsdprOverride,autoplay; writesdevScenariowhen the?dev=URL param is present).../engine/core/config/_accessibility—isSkipRewardAnimations,setSkipRewardAnimations.../engine/core/state—setDebugOverlay.../engine/core/device-capabilities—initDeviceCapabilities,isMobile.../engine/audio/audio-context—AudioBus.getSfxVolume / getMusicVolume / setSfxVolume / setMusicVolume / getCtx / resume.../engine/audio/music-player—MusicPlayer.getState / toggle / next / previous / heart / seek / stop.../engine/rendering/hud—isRewardShowing,shouldAutoPick,setSelectedCard,hitTestWeaponSlot,setHudControlMode,hitTestCog,hitTestHelp,hitTestPauseBtn,toggleAutopickPause,triggerCogPressAnim,hitTestRerollBtn,hitTestBanishBtn,isBanishTargeting,isCardSlotDead.../engine/rendering/wheel-ui—hitTestWheelSpin,hitTestWheelExit.../engine/diagnostics/zoom-watcher—startZoomWatcher,toggleZoomOverlay,dumpZoomStateToSentry,resetAllZoomLayers.../engine/telemetry—logDiag.../services/runProgressionService—finalizeRun.../components/RevivePrompt—RevivePromptmodal component.@metagame/components/FeedbackFAB—setActiveMission,clearActiveMission.../testing/perf-test-mode—isPerfTestMode,startPerfTestSession,stopPerfTestSession,notifyPerfTestRunStart,buildPerfTestRunDef.../testing/dev-scenarios—buildDevScenario.- 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 handle —
mission.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(). - HUD —
setHudControlMode(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 config —
setDebugOverlay(on)toggle; writes(PERF_FLAGS as any).devScenariofrom the URL?dev=parameter so the bridge can pick up the dev scenario at mission boot. - Accessibility —
setSkipRewardAnimations(on). - Audio —
AudioBus.setSfxVolume(v),AudioBus.setMusicVolume(v),AudioBus.resume(); suspendsAudioBus.getCtx()onvisibilitychange(hidden) andMusicPlayer.stop()+ctx.close()onpagehide. - Session store —
setMissionResult(result)on game-over and on the early-exit “END RUN EARLY” confirm path. - Run progression service —
finalizeRun(result, currentChapter)on game-over, on early-exit, and onbeforeunload. - Telemetry —
logDiagcalls with event typesgame_screen_mount_audit,pwa_zoom_stuck, andrecover_from_stuck_pressed. - Zoom watcher —
startZoomWatcher()on mount;dumpZoomStateToSentry+resetAllZoomLayerson the RECOVER FROM STUCK button;toggleZoomOverlayfrom the TOGGLE ZOOM DEBUG OVERLAY button. - FeedbackFAB hooks —
setActiveMission(mission, canvas)aftercreateMission;clearActiveMission()on unmount; dispatches a'ss:open-feedback'window event when the help button is hit-tested. - Router —
navigate('/', { replace: true })ifrunDefis missing;navigate('/games/starship-survivors/reveal')3.5 s after a win, 0.5 s after death, 0.5 s after confirmed early exit. - localStorage —
ss_weapon_modes(per-slot fire-mode array),ss_control_mode_mobileandss_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 themeta[name="viewport"]contentattribute in the unlock-then-relock zoom recovery; pushes a sentinelhistory.pushState({ sspGameLock: true })entry on mount; re-pushes the sentinel on everypopstateto 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
MissionHandleinterface. - Does not own player-progression, account state, or run-history persistence. It hands the result to
finalizeRunand writes onlymissionResultinto 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.tsandengine/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
AudioBusandMusicPlayer. - Does not unlock or grant achievements directly; it computes a
runStatsshape from theBridgeResultfor 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, callsisRewardShowing()/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, callsfinalizeRun, branches onresult.outcome.survived(showsrunSummaryfor 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 usingbuildPerfTestRunDef().onRewardShow(choices)— setsrewardActiveRef,rewardActivestate, andrewardChoicesRef.onRevivePrompt(gemCost, useNumber)— sets therevivePromptstate to open the modal. Perftest mode auto-declines after 100 ms.onReviveDismissed()— clears therevivePromptmodal.
Window / document event listeners installed:
resize— recomputes canvas + overlay sizing and callsmission.onCanvasResize(w, h, dpr).keydown/keyup—1/2/3select reward cards when active, otherwise fire weapons 1-3;4always fires weapon 4;ArrowUp/w/Wthrust,ArrowLeft/a/Arotate left,ArrowRight/d/Drotate right (arrows mode only);preventDefaulton movement keys.popstate— re-pushes thesspGameLockhistory entry to consume the Android back gesture.beforeunload— fire-and-forgetfinalizeRunsnapshot tagged'browser_close'.visibilitychange— suspends the AudioContext and pauses the mission when hidden; resumes both on return.pagehide— stopsMusicPlayerand closes the AudioContext.visualViewport.resizeandvisualViewport.scroll— callsreportStuckZoom('event')+resetViewportMeta()when scale exceeds 1.01.
Canvas-attached event listeners:
pointerdown/pointermove/pointerup/pointercancel— the unified input pipeline.contextmenu—preventDefaultto 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. Thewebglprop 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 appliesctx.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 ownresizelistener — this comment is preserved in the source and is load-bearing. - DPR is read through
getCurrentDPR()which honoursPERF_FLAGS.dprOverrideso perftest can hold a fixed DPR. - Width cap.
MAX_W = 520clamps CSS width on desktop so the game does not stretch wider than a phone-sized strip. - Press-then-release reward selection.
pointerdownrecords the card index inrewardPressedCardRef;pointerupfires 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. IfisBanishTargeting()is on, the tap callsmission.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 asisRewardShowing()is true. - Autoplay drift. When
PERF_FLAGS.autoplayor perftest is on, the overlay loop synthesizes a slow circular input (autoplayT * (Math.PI * 2 / 8)per second, magnitude0.6, thrust on) every frame. - Drift-to-finger joystick. When the finger sits beyond
MAX_DIST = 80px and stays roughly still (movement under 2 px/frame) the origin creeps along the existing angle. Speed islingerSec * lingerSec * 20px/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 atmission.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.angleatMath.PI * 2rad/s (full 360° in ~0.5 s). When no key is held,arr.anglesnaps tomission.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()usesmatchMedia('(pointer: coarse)')as the primary signal, with'ontouchstart' in window && innerWidth < 1024as a fallback. Control mode preference is persisted under separate localStorage keys per platform. - Keyboard 1-4 dual-purpose. Keys
1/2/3first attempttrySelectReward(i)(which short-circuits if the reward state machine is not in theshowingphase); if that returns false, they fall through tomission.fireWeapon(i). Key4always fires weapon 4. - Cog opens pause. Cog hit-test triggers
setCogOpen; the cog-open effect callsmission.pause()on open andmission.resume()on close, and clearsconfirmEndRunon close. - End-Run-Early flow.
mission.getRunSnapshot('abandoned')builds aBridgeResultthat goes through the standardsetMissionResult+finalizeRunpath before routing to/games/starship-survivors/reveal. Equivalent path onbeforeunloadusesgetRunSnapshot('browser_close'). - Recover-From-Stuck. A user-visible safeguard button. Dumps current zoom state to Sentry via
dumpZoomStateToSentry, logsrecover_from_stuck_pressedto Supabase, callsmission.recoverFromStuck(), runsresetAllZoomLayers()(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_auditwith a full baseline snapshot (visualViewport scale/size, innerWidth/innerHeight, DPR, orientation, standalone, canvas buffer/CSS sizes, UA). Apwa_zoom_stuckwarning 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 everypopstateso 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.
visibilitychangeto hidden suspends the context and pauses the mission; the next visible event restores both viaAudioBus.resume()andmission.resume().pagehidestops the music player and closes the context outright. - Perftest mode forks behaviour.
isPerfTestMode()causes (1) the missing-runDefguard 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 fromwindow.location.searchon render (not fromPERF_FLAGSwhich captures only initial load), pushed back intoPERF_FLAGS.devScenarioso the bridge picks it up, and used to synthesize aRunDefinitionviabuildDevScenario(name). - Stat-rollup shape. The component flattens
result.combat,result.progression, andresult.performanceinto therunStatsshape that the cumulative-achievement tracker expects, even though the actualupdateCumulativeStatscall 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.
MusicPlayerWidgetis rendered only whencogOpenis true; it pollsMusicPlayer.getState()every 200 ms on its own interval (no canvas dependency) and supports drag-aware seeking.