PURPOSE

v4 dev workbench screen. Hosts the live mission canvas plus left and right tab panels for tuning hull/star, mods, weapons, enemies, level, vfx, post-fx, physics, boss, gauntlet, and planets. Used for iterating on game balance, content, and feel outside the run flow. Forces sandbox mode with a 999999-second timer, low-quantity enemy spawns, weapon/artifact boxes disabled, and events disabled.

OWNS

  • Canvas element (canvasRef) that the mission renders into via createMission.
  • MissionHandle lifecycle in missionRef — created in startMission, destroyed on unmount and on restart.
  • Local React state: hullClass, leftTab, rightTab, leftVisible, rightVisible, godMode, frozen, collisionDebug, buildMode, selectedShape, vfxState, levelConfig, gameSpeed, deathCountdown.
  • Refs for input plumbing: arrowsRef (keyboard angle/thrust state), mouseRef (pointer drag steering), activeInputRef ('arrows' | 'mouse'), buildModeRef, selectedShapeRef, prevSpeedRef, deathTimerRef.
  • The overlay rAF loop that translates raw input into mission.setInput(angle, magnitude, thrust) each frame.
  • localStorage entry ss_playground_state_v4 — persists hull, leftTab, rightTab.
  • HUD bounds calculation — calls setHudBounds(left, right) to constrain the HUD to a portrait-width band (HUD_PORTRAIT_W = 520) between the visible panels.
  • Death countdown UI (3-second respawn countdown for non-boss deaths).

READS FROM

  • ../engine/bridgecreateMission, BridgeCallbacks, MissionHandle.
  • ../services/assembleRunServiceassembleRunDef to build the sandbox runDef.
  • ../data/shipsHULL_CLASSES (initial hull selection).
  • ../engine/core/configPERF_FLAGS.dprOverride.
  • ../engine/core/device-capabilitiesinitDeviceCapabilities.
  • ../engine/rendering/hudsetHudBounds.
  • ../engine/audio/music-playerMusicPlayer static API (getState, start, toggle, next, previous).
  • ../data/level-configDEFAULT_LEVEL_CONFIG, LevelConfig.
  • ./playground/PlaygroundSharedSTROKE_SHADOW, BTN_STYLE, PANEL_BG, PANEL_BORDER, PANEL_RADIUS, DEFAULT_VFX_STATE, VfxState.
  • Sub-tab modules under ./playground/: ShipsTab, ModsTab, WeaponsTab, EnemiesTab, UpgradesTab, ArtifactsTab, LevelTab, VfxTab, FxTab, PhysicsTab, BossTab, BossGauntletTab, TestRunnerTab, PlanetsTab, PlaygroundUpdateGuard.
  • react-router-domuseNavigate for back navigation to /dev.
  • window.innerWidth, window.innerHeight, window.devicePixelRatio for canvas sizing.

PUSHES TO

  • MissionHandle methods on missionRef.current:
    • start, destroy, setSpawnerEnabled(true).
    • setInput(angle, magnitude, thrust) each overlay frame.
    • setGodMode(bool), freezeEnemies(bool), clearEnemies().
    • sandboxGetBossState(), sandboxRespawn(), sandboxScreenToWorld(x, y), sandboxPlaceTerrain(shape, x, y, size, rot), sandboxSetDebugOverlay(bool).
    • getShipAngle(), getShipScreenPos().
  • MusicPlayer.start(), MusicPlayer.toggle(), MusicPlayer.next(), MusicPlayer.previous().
  • setHudBounds(left, right) — called on layout changes and reset to (0, 0) on unmount.
  • (window as any).__dev?.speed(mult) — bridges the speed buttons to the engine’s dev speed hook.
  • window.location.search = '?dev=<scenario>' — scenario buttons trigger a full URL reload to enter named dev scenarios and per-artifact tests.
  • navigate('/dev') on Back button and Escape key.
  • localStorage.setItem('ss_playground_state_v4', ...) on hull/tab changes.
  • React portal into document.body — the whole screen mounts outside the React root tree.

DOES NOT

  • Does not assemble or grant inventory progression; assembleRunDef resolves star level from inventory XP and the playground default lets that stand (the docstring notes a star=1 force, but no forceStar flag is applied in buildPlaygroundRunDef).
  • Does not award rewards, write to run history, or push to Supabase.
  • Does not run the game loop directly — only the input/overlay rAF loop. Simulation runs inside the mission created via the bridge.
  • Does not mutate runDef after createMission — the def is built fresh on every startMission call.
  • Does not render any HUD itself — it only sets HUD bounds for the engine-side HUD.
  • Does not handle touch as a distinct path; pointer events cover both mouse and touch (touchAction: 'none' on the canvas).
  • Does not manage which sub-tab renders inside the panel beyond switching the active component; tab content lives in ./playground/*Tab modules.

Signals

  • BridgeCallbacks.onPhaseChange('dead'):
    • If sandboxGetBossState().alive — immediately calls sandboxRespawn() (matches bossGauntlet behavior).
    • Otherwise starts a 3-second countdown via setInterval(..., 1000), decrementing deathCountdown, and calls sandboxRespawn() at zero.
  • BridgeCallbacks.onGameOver is a no-op.
  • Window resize — resizes canvas to innerWidth/innerHeight times dpr and recomputes HUD bounds.
  • Window keydown:
    • Ignored when target is INPUT or SELECT.
    • Escapenavigate('/dev').
    • ArrowUp / W → thrust; ArrowLeft / A → rotate left; ArrowRight / D → rotate right (sets activeInputRef to 'arrows').
    • Space → toggles pause via changeSpeed(0 ↔ prevSpeedRef).
    • + / = → step up gameSpeed along [0.5, 1, 2, 4].
    • - → step down along [0.5, 1, 2, 4].
    • Tab → toggle left panel visibility.
    • ` (backtick) → toggle right panel visibility.
    • B → force left panel open and jump to gauntlet tab.
  • Window keyup — clears the matching arrowsRef flags.
  • Canvas pointerdown:
    • If buildMode is on — places terrain at world coords via sandboxScreenToWorld + sandboxPlaceTerrain(shape, x, y, 50, 0).
    • Otherwise marks mouse as held and switches activeInputRef to 'mouse'.
  • Window pointermove — updates mouseRef.x/y while held.
  • Window pointerup — releases mouse, sends setInput(0, 0, false).

Entry points

  • React component ShipPlaygroundScreen (default export). Imported and mounted by the dev route inside src/starship-survivors/.
  • Renders via createPortal into document.body at zIndex: 9000, full-viewport fixed positioning.
  • Mount lifecycle (first useEffect):
    1. initDeviceCapabilities().
    2. Set canvas pixel dimensions using PERF_FLAGS.dprOverride ?? devicePixelRatio.
    3. updateHudBounds().
    4. startMission(hullClass).
    5. Start the overlay rAF loop.
    6. Attach resize, keydown, keyup, pointerdown (canvas), pointermove, pointerup listeners.
  • Unmount cleanup: cancels rAF, removes all listeners, clears the death countdown interval, destroys the mission, and zeroes HUD bounds.

Pattern notes

  • v4 contract: shipId === hull_class. There is no rarity suffix; restartMission(hull, _rar) ignores its second arg.
  • buildPlaygroundRunDef mutates the result of assembleRunDef directly: sets sandbox = true, timerSeconds = 999999, heat = 1, both spawn curves flat at 0.3 / 0, worldKnobs.enemyHpMult = 0.5, worldKnobs.enemyDamageMult = 0.5, weaponBoxCount = 0, artifactBoxCount = 0, empty events pool, eventsUnlocked = false.
  • HUD constraint: HUD is centered in the visible canvas band between the two panels and clamped to a HUD_PORTRAIT_W = 520 portrait width.
  • Input priority: the overlay rAF picks arrows vs mouse based on activeInputRef. When neither is active, it snaps arr.angle to mission.getShipAngle() so resuming keyboard input doesn’t jump.
  • Speed control: gameSpeed === 0 represents pause; prevSpeedRef remembers the last non-zero speed so Space and stepping logic can resume. The window-level __dev.speed hook is also called so the engine bridge picks up the change.
  • Tab state is split: the left panel acts as the live “instance” editor (passes side="instance" to tabs), the right panel acts as the “base stats” editor (passes side="base"). Both surface the same tabProps bundle (missionRef, shipId, build-mode getters/setters, vfxState/setVfxState, levelConfig/setLevelConfig).
  • PlaygroundUpdateGuard is mounted alongside the panels — its responsibility is owned by its own module.
  • Scenario buttons set window.location.search, which triggers a full reload into a named dev scenario; artifact tests use the art-test-<id> query pattern.
  • All state persistence is best-effort: both loadState and saveState swallow exceptions (localStorage may be unavailable).
  • The component depends on gameSpeed inside onKeyDown but the mount useEffect has an empty deps array with an exhaustive-deps disable — keypress speed stepping reads the stale closure value, which is acceptable here because changeSpeed reads the current via setGameSpeed’s functional form.
  • Cleanup is centralized in the mount effect’s return; no separate teardown path.