PURPOSE

Detects when a newer build of the game has been deployed and surfaces a restart prompt to the player. Polls /version.txt on a fixed interval and exposes the result through a hook plus two banner components — a hub-bottom banner that overlaps the LAUNCH button and a safe-route fullscreen modal overlay. Never interrupts gameplay, post-game stats, reveal, or level select.

OWNS

  • Module-scoped polling state: _updateAvailable, _listeners, _started.
  • extractVersion(text) helper that parses the vX.Y.Z token out of version.txt.
  • _startPolling() boot routine that schedules the first check after 2 minutes and then every CHECK_INTERVAL (60s).
  • _notifyListeners() fan-out that pushes the latest _updateAvailable to every subscribed React setter.
  • useUpdateAvailable() hook that subscribes a component to the shared flag and returns it.
  • HubUpdateBanner component (slide-up bottom banner with med-banner-bottom styling, click anywhere reloads).
  • UpdateBanner component (fullscreen MedPanelModal overlay gated to SAFE_UPDATE_ROUTES).
  • SAFE_UPDATE_ROUTES allowlist: /, /shop, /collection, /profile, /settings, /admin, /prologue.

READS FROM

  • BUILD_VERSION from @starship-survivors/engine/core/config — used to derive CURRENT_SHORT (the local vX.Y.Z token).
  • MedPanel, MedPanelModal from ./MedPanel — the visual shell for the fullscreen overlay variant.
  • fetch('/version.txt?_vc=<ts>') with cache: 'no-store' — the deployed version manifest served from the site root.
  • window.location.pathname — to gate the fullscreen overlay to safe routes.
  • React useState, useEffect — local subscription wiring.

PUSHES TO

  • window.location.reload() on banner click and on the inline CLICK TO RESTART button (both banners). This is the only side effect that escapes the module.
  • Subscribed React setters in _listeners whenever _updateAvailable flips true.

DOES NOT

  • Does not write to localStorage, sessionStorage, Supabase, or any store.
  • Does not emit telemetry or analytics.
  • Does not flip _updateAvailable back to false once set — the flag latches until reload.
  • Does not poll on app boot — the first check is deferred 120s after the first hook mount.
  • Does not start polling if CURRENT_SHORT is empty (i.e. BUILD_VERSION does not match vX.Y.Z).
  • Does not show the fullscreen overlay on gameplay, reveal, stats, or level select routes.
  • Does not provide a dismiss affordance — both banners are click-to-reload only.
  • Does not retry on fetch error; network failures are swallowed and the next interval tick tries again.

Signals

  • Detection signal: remote vX.Y.Z from version.txt differs from CURRENT_SHORT and _updateAvailable is still false.
  • State flip: _updateAvailable = true then _notifyListeners() broadcast.
  • UI signal in hub: HubUpdateBanner returns its slide-up banner element instead of null.
  • UI signal on safe routes: UpdateBanner returns the MedPanelModal overlay instead of null.
  • Player action: click anywhere on either banner or the inline button triggers window.location.reload().
  • No-op signal: useUpdateAvailable() returns false; both components render null.

Entry points

  • useUpdateAvailable() — exported hook; first caller triggers _startPolling() via its useEffect.
  • HubUpdateBanner() — exported React component; mounted by the hub screen at the bottom of the LAUNCH column.
  • UpdateBanner() — exported React component; mounted at the app shell level to render on any safe route.

Pattern notes

  • Shared state lives at module scope, not in a Zustand store. _updateAvailable, _listeners, and _started are plain module variables so polling persists across mount/unmount of any single component and stays singleton across the app.
  • Polling is started lazily on the first useUpdateAvailable() mount, not at module import time, so server-side or pre-mount imports do not kick off network traffic. _started is the idempotency guard.
  • The first poll is intentionally delayed 120s to avoid hammering the deploy moments after launch; subsequent polls are every 60s (CHECK_INTERVAL).
  • The ?_vc=<Date.now()> cache buster plus cache: 'no-store' defeats any intermediate CDN or service-worker cache that would otherwise pin version.txt.
  • _updateAvailable is one-way latching. Once true it never resets, even if a later poll succeeds with the old version — this prevents a flapping banner.
  • Hub banner uses CSS classes from the medical-UI theme (med-banner, med-banner-bottom, med-banner-accent, med-banner-title, med-btn, med-btn-primary); fullscreen overlay reuses MedPanel with the doors variant.
  • Safe-route gating uses exact path match with optional trailing slash, not prefix match — gameplay subpaths under those roots will still be protected.
  • The fullscreen overlay has no onScrimClick handler, so it is non-dismissible by design; the only exit is reload.
  • Hub banner stops propagation on the inner button click so the outer div’s reload handler does not double-fire.