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 viacreateMission. MissionHandlelifecycle inmissionRef— created instartMission, 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— persistshull,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/bridge—createMission,BridgeCallbacks,MissionHandle.../services/assembleRunService—assembleRunDefto build the sandboxrunDef.../data/ships—HULL_CLASSES(initial hull selection).../engine/core/config—PERF_FLAGS.dprOverride.../engine/core/device-capabilities—initDeviceCapabilities.../engine/rendering/hud—setHudBounds.../engine/audio/music-player—MusicPlayerstatic API (getState,start,toggle,next,previous).../data/level-config—DEFAULT_LEVEL_CONFIG,LevelConfig../playground/PlaygroundShared—STROKE_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-dom—useNavigatefor back navigation to/dev.window.innerWidth,window.innerHeight,window.devicePixelRatiofor canvas sizing.
PUSHES TO
MissionHandlemethods onmissionRef.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;
assembleRunDefresolves star level from inventory XP and the playground default lets that stand (the docstring notes a star=1 force, but noforceStarflag is applied inbuildPlaygroundRunDef). - 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
runDefaftercreateMission— the def is built fresh on everystartMissioncall. - 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/*Tabmodules.
Signals
BridgeCallbacks.onPhaseChange('dead'):- If
sandboxGetBossState().alive— immediately callssandboxRespawn()(matchesbossGauntletbehavior). - Otherwise starts a 3-second countdown via
setInterval(..., 1000), decrementingdeathCountdown, and callssandboxRespawn()at zero.
- If
BridgeCallbacks.onGameOveris a no-op.- Window
resize— resizes canvas toinnerWidth/innerHeighttimesdprand recomputes HUD bounds. - Window
keydown:- Ignored when target is
INPUTorSELECT. Escape→navigate('/dev').ArrowUp/W→ thrust;ArrowLeft/A→ rotate left;ArrowRight/D→ rotate right (setsactiveInputRefto'arrows').Space→ toggles pause viachangeSpeed(0 ↔ prevSpeedRef).+/=→ step upgameSpeedalong[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 togauntlettab.
- Ignored when target is
- Window
keyup— clears the matchingarrowsRefflags. - Canvas
pointerdown:- If
buildModeis on — places terrain at world coords viasandboxScreenToWorld+sandboxPlaceTerrain(shape, x, y, 50, 0). - Otherwise marks mouse as held and switches
activeInputRefto'mouse'.
- If
- Window
pointermove— updatesmouseRef.x/ywhile held. - Window
pointerup— releases mouse, sendssetInput(0, 0, false).
Entry points
- React component
ShipPlaygroundScreen(default export). Imported and mounted by the dev route insidesrc/starship-survivors/. - Renders via
createPortalintodocument.bodyatzIndex: 9000, full-viewport fixed positioning. - Mount lifecycle (first
useEffect):initDeviceCapabilities().- Set canvas pixel dimensions using
PERF_FLAGS.dprOverride ?? devicePixelRatio. updateHudBounds().startMission(hullClass).- Start the overlay rAF loop.
- Attach
resize,keydown,keyup,pointerdown(canvas),pointermove,pointeruplisteners.
- 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. buildPlaygroundRunDefmutates the result ofassembleRunDefdirectly: setssandbox = true,timerSeconds = 999999,heat = 1, both spawn curves flat at0.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 = 520portrait width. - Input priority: the overlay rAF picks arrows vs mouse based on
activeInputRef. When neither is active, it snapsarr.angletomission.getShipAngle()so resuming keyboard input doesn’t jump. - Speed control:
gameSpeed === 0represents pause;prevSpeedRefremembers the last non-zero speed so Space and stepping logic can resume. The window-level__dev.speedhook 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 (passesside="base"). Both surface the sametabPropsbundle (missionRef,shipId, build-mode getters/setters,vfxState/setVfxState,levelConfig/setLevelConfig). PlaygroundUpdateGuardis 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 theart-test-<id>query pattern. - All state persistence is best-effort: both
loadStateandsaveStateswallow exceptions (localStoragemay be unavailable). - The component depends on
gameSpeedinsideonKeyDownbut the mountuseEffecthas an empty deps array with anexhaustive-depsdisable — keypress speed stepping reads the stale closure value, which is acceptable here becausechangeSpeedreads the current viasetGameSpeed’s functional form. - Cleanup is centralized in the mount effect’s return; no separate teardown path.