PURPOSE
React overlay component shown after the “YOU DIED” cinematic. Offers the player a one-tap revive in exchange for a gem cost, gated by a stretched countdown timer with escalating UI panic. On accept, spends gems via the wallet store and calls onAccept; on decline / timeout, plays a white-flash → fade-to-black transition before calling onDecline. Mounted inside the game HUD layer; visibility driven by game.revivePrompt state from the bridge.
OWNS
- Component-local React state:
timeLeft— display-time seconds remaining (drives bar + button label).lingering— bar has hit 0% and is holding at ~1% for tension.transitioning— explosion/fade overlay is playing.transitionElapsed— seconds elapsed inside the transition.sparkSeed— re-randomization tick counter for spark particle positions.
- Refs:
startTime— wall-clock anchor (Date.now()) for the countdown.declineFiredRef— guard against double-firingonDecline.lingerStartRef— wall-clock anchor for the linger phase.gongFiredRef— guard so the timeout/decline gong only plays once.
- Two private intervals:
- 50ms countdown tick (bar/shake/sparks/linger detection).
- 30ms transition tick (explosion → fade interpolation).
- Two private Web Audio one-shots:
playGong(decline/timeout) andplayRespawnChime(accept). - Pure helpers
cheaterTimeRemaining(realElapsed)(real→display time map), plus module-level constantsBAR_REAL_DURATION,TOTAL_REAL_DURATION,EXPLOSION_DURATION,FADE_TO_BLACK_DURATION,TRANSITION_DURATION,SPARK_COUNT,LINGER_DURATION.
READS FROM
useWalletStore—gems(current balance) andspendGems(debit function).@starship-survivors/data/economy—DEATH_DEFIANCE_PROMPT_DURATION,DEATH_DEFIANCE_CHEAT_THRESHOLD,DEATH_DEFIANCE_CHEAT_STRETCH,DEATH_DEFIANCE_LINGER_DURATION. Cost / threshold / stretch numbers are not duplicated here.- Props:
gemCost,useNumber(1..3),onAccept,onDecline. Date.now()for timer math;Math.random()for shake jitter;window.AudioContext(withwebkitAudioContextfallback) for SFX.
PUSHES TO
walletStore.spendGems(gemCost)on accept — wallet store handles the debit and validates affordability.- Parent-provided
onAccept()— caller wires this tobridge.revive(). - Parent-provided
onDecline()— caller wires this tobridge.declineRevive(). - No direct bridge / engine calls and no telemetry from this component.
DOES NOT
- Does not read or write
game.revivePromptdirectly — visibility is the parent’s responsibility. - Does not decide cost, use-cap, or affordability rules — those live in the caller and the economy table.
- Does not call
bridge.revive/bridge.declineReviveitself — only the parent-supplied callbacks. - Does not pause the game loop, dim audio, or freeze the engine — pure overlay.
- Does not persist anything across mounts; unmount discards timer state.
- Does not respect
prefers-reduced-motion— shake and sparks run unconditionally. - Does not handle keyboard or gamepad input — pointer-only via two
<button>elements. - Does not block touch outside the card; full-screen
touchAction: 'none'overlay swallows other gestures while mounted. - Does not localize the strings
LAST STAND,RESPAWN — {n} gems,No thanks ({n}s),Use {n} of 3 — Continue the fight?,Not enough gems.
Signals
- Cheater timer.
cheaterTimeRemainingmaps real elapsed seconds to a non-linear display countdown: 1:1 mapping for the early window, then the finalDEATH_DEFIANCE_CHEAT_THRESHOLDfraction is stretched byDEATH_DEFIANCE_CHEAT_STRETCHso the last seconds feel longer than they read. - Linger phase. When display time falls to ≤ 0.15s the bar pins at 1% and the timer label sticks at
0.1; afterLINGER_DURATIONseconds the auto-decline transition fires.BAR_REAL_DURATION+DEATH_DEFIANCE_LINGER_DURATIONis the absolute wall-clock cap. - Urgency factor (0..1). Derived from
1 - timeLeft / DEATH_DEFIANCE_PROMPT_DURATION, clamped to 1 during linger. Drives:- Card border / glow color (orange → red past 0.7).
- Header color and text-shadow intensity.
- Box-shadow blur radius.
- Bar fill gradient (orange → orange-red at >0.5, red at >0.7).
- Spark visibility (active when urgency > 0.7).
- Screen shake: quadratic ramp on
(urgency - 0.5) * 2, peaking at 6px translate jitter on the card.
- Spark particles.
SPARK_COUNTdeterministic-ish offsets seeded bysparkSeed * 7 + i * 13; re-randomized every 50ms tick. Two colors (#ffaa33every third spark, else#ff3344). - Timeout/decline gong.
playGong— 80 Hz sine, gain 0.18 → 0.001 exponential decay over 2s. Played once viagongFiredRef. - Accept chime.
playRespawnChime— four staggered sine partials (A4/E5/A5/E6), 60ms stagger, ~0.6s envelope each. - Transition overlay. While
transitioning, the card is unmounted and replaced by a fixed full-screen<div>: ramps to opaque white overEXPLOSION_DURATION * 0.3of the explosion window, holds white for the remainder, then linearly fades white→black overFADE_TO_BLACK_DURATIONbefore invokingonDecline.
Entry points
RevivePrompt({ gemCost, useNumber, onAccept, onDecline })— default named export.- Internal handlers:
handleRevive— guards on!canAfford || transitioning, attemptsspendGems, plays chime, callsonAccept.handleDecline— guards ontransitioning, delegates tostartTransition.startTransition— plays gong once, flipstransitioning, resetstransitionElapsed.
Pattern notes
- Wall-clock timers (
Date.now()) not engine ticks — overlay is decoupled from any paused game loop. Single source of truth isstartTime.current;timeLeftstate is derived per tick. - Two effects, gated by
transitioning: countdown effect early-returns once transition starts, transition effect early-returns until it does. The cleanupclearIntervalruns on every re-render of the effect; deps[transitioning, lingering]cause the countdown effect to re-mount when linger flips — the new interval re-readsstartTime.current, so timing is preserved. - Double-fire protection via two separate refs (
declineFiredRef,gongFiredRef) — both are required becausehandleDeclineand the natural timeout both route throughstartTransition. - All visuals are inline styles; no CSS module, no Tailwind class, no styled-components. Fonts:
Cal Sansfor headings/CTA,Space Groteskfor body. V32 palette:#ff6600(orange),#ff3344(red),#0a0a14(card bg), slate greys for disabled / muted. - Spark animation is render-driven, not CSS-keyframe-driven —
sparkSeedbump on each 50ms tick causes React to remount sparks with newkeys and new transform offsets. Thetransition: transform 0.15s ease-outsmooths the per-tick jump. - Audio uses a fresh
AudioContextper call, closed viasetTimeoutafter the envelope finishes. Wrapped intry/catch— silent failure on browsers without Web Audio. No interaction with the engine’s audio system. - No
onUnmountcancel path for in-flightAudioContexts — if the prompt unmounts during a gong, the tone continues until its ownsetTimeoutcloses the context.
EXTRACT-CANDIDATE
playGong/playRespawnChime— bespoke Web Audio one-shots that bypass the engine audio system. If more overlays need ad-hoc SFX, hoist into a sharedcomponents/_audio.ts(or fold into the engine audio service) so envelope shapes and the AudioContext lifecycle live in one place.cheaterTimeRemaining+ theBAR_REAL_DURATION/TOTAL_REAL_DURATIONderivation — a generic “stretched countdown” helper. If any other UI (boss timer, shop offer, missile lock) wants the same non-linear pacing, extract tolib/cheaterTimer.tskeyed on(total, threshold, stretch).- Urgency-driven shake/glow/color-ramp block — same primitive likely wanted by any “timer-of-doom” overlay. Candidate for a
useUrgencyVisuals(progress)hook returning{ shakeX, shakeY, glowColor, borderColor, showSparks }. - Inline overlay style (
position: fixed; inset: 0; zIndex: 2000; touchAction: 'none') is duplicated between the live prompt and the transition overlay — fine here, but the same z-index / touch-blocking pattern recurs across HUD overlays and could move to a shared<FullScreenOverlay>wrapper.