Revive System (Death Defiance)

In-run resurrection mechanic. On player death the bridge plays a “YOU DIED” cinematic, then surfaces a modal asking the player to spend gems for a second chance. Up to three revives per run, with an escalating gem cost. Failure to accept ends the run and routes to mission results. Constants live in src/starship-survivors/data/economy.ts under the DEATH_DEFIANCE_* prefix; the UI is src/starship-survivors/components/RevivePrompt.tsx; flow control lives in src/starship-survivors/engine/bridge.ts.

Pricing and limits

ConstantValueMeaning
DEATH_DEFIANCE_BASE_COST20Base gem cost (first use of the run)
DEATH_DEFIANCE_COST_ESCALATION1Cost is BASE_COST × useNumber, so 1st = 20, 2nd = 40, 3rd = 60
DEATH_DEFIANCE_MAX_USES3Hard cap per run; after this the prompt no longer appears
DEATH_DEFIANCE_FREE_DAILY1Free daily revive token (separate from gem-purchased uses)
DEATH_DEFIANCE_HP_RESTORE0.5Fraction of hpMax restored on revive
DEATH_DEFIANCE_INVULN_SECONDS3.0Post-revive iframe window

deathDefianceCost(useNumber) is the canonical pricing function — UI and bridge both call it instead of computing the multiplication inline.

Lifecycle

The bridge state machine drives the entire flow. Phase transitions from playingdead → either playing (revive accepted) or results (declined / timed out).

  1. Death detectedship.alive flips false in the per-frame check. The bridge fires Sampler.event('player_death', ...), stops music, calls onBossEncounterEnd if a boss arena was active, and sets game.phase = 'dead'.
  2. Cinematic gate — if _deathDefianceUses < DEATH_DEFIANCE_MAX_USES, the bridge enters _deathCinematicPlaying = true with _deathCinematicTimer = DEATH_DEFIANCE_CINEMATIC_DURATION (3.0 s). The canvas draws the “YOU DIED” vignette + text during this window. If max uses are already exhausted, the cinematic is skipped — game.deathTimer is set to 1.0 and the run hard-cuts to results.
  3. Prompt show — when the cinematic timer hits zero, the bridge sets _revivePromptActive = true, _revivePromptTimer = DEATH_DEFIANCE_TOTAL_REAL_DURATION (~7.75 s), and game.revivePrompt = true. It fires callbacks.onRevivePrompt(deathDefianceCost(useNum), useNum) so the React layer mounts RevivePrompt.
  4. Player decision — three exits:
    • Acceptbridge.revive() is called (after useWalletStore.spendGems(cost) succeeds in the component). Restores ship, fires phoenix VFX, clears enemies, resumes play.
    • Declinebridge.declineRevive() is called. Sets game.deathTimer = 0.01 for an instant hard cut to results.
    • Timeout_revivePromptTimer reaches zero with no input. Bridge clears the prompt flags and calls onReviveDismissed; the RevivePrompt component handles the explosion/fade transition then calls its onDecline prop.
  5. Results phase — on decline / timeout / final death, game.phase advances to results once game.deathTimer ≤ 0. Telemetry fires, MissionResult is populated, and callbacks.onGameOver(missionResult) ships the run summary to the screen layer.

While the prompt is up, gameplay is frozen — _revivePromptActive short-circuits the normal death-timer countdown so enemies don’t keep moving and the death cinematic doesn’t keep elapsing in any meaningful way.

Accept flow — what bridge.revive() does

The revive does more than top up HP. It re-asserts a safe playable state so the player isn’t immediately re-killed by whatever just killed them.

StepEffect
_deathDefianceUses++Increments the per-run counter. Drives the next prompt’s cost via deathDefianceCost(useNum + 1).
_revivePromptActive = false / _deathCinematicPlaying = falseCloses the modal state.
game.revivePrompt = false / game.reviveAvailable = falseClears the public flags the React layer reads.
game.tracking.deathDefianceUsed = trueRun-end telemetry marker.
ship.alive = trueRe-enters the live entity set.
ship.hp = ceil(hpMax × DEATH_DEFIANCE_HP_RESTORE)50% HP back.
ship.shield = ship.shieldMaxFull shield restore.
ship.invulnerable = true, ship.invulnTimer = DEATH_DEFIANCE_INVULN_SECONDS3 s of iframes.
game.phase = 'playing' + Sig.fire('phase_change', ..., 'playing')Resumes the loop.
Phoenix VFXGold spark burst, 20 upward-drifting yellow sparks, white center flash.
Sonar shockwaveTwo expanding rings (orange #ff6600 + lighter #ff9944) — kills every non-boss enemy in the world and clears world.enemyBullets.
Juice.fire('revive')Hit-stop / shake feedback.

The shockwave is intentional: the player just paid gems to come back, and the screen state that killed them is still hostile. Wiping the field gives a clean lane to recover. Boss bodies and shared-health minions are deliberately spared — the boss damage pipeline owns their teardown and a 999999-damage broadcast would corrupt it.

Decline flow — what bridge.declineRevive() does

Decline is a hard cut. No fade, no death-timer delay.

  • _revivePromptActive = false, _deathCinematicPlaying = false
  • _postDeclineDeath = true — suppresses the “YOU DIED” text re-showing during the final result transition (the RevivePrompt component is already playing its own fade-to-black, so the canvas vignette would double-draw).
  • game.revivePrompt = false, game.reviveAvailable = false
  • game.deathTimer = 0.01 — next frame the dead-phase tick fires game.phase = 'results'.
  • Juice.fire('player_death') + callbacks.onReviveDismissed().

The component-side transition (white flash → fade to black) is what the player actually sees during this hard cut; the bridge just gets out of the way fast.

UI side — RevivePrompt.tsx

The React component receives gemCost, useNumber, onAccept, and onDecline props. It owns three things the bridge does not:

  • Cheater timer — non-linear bar drain. See death-defiance-timing for the full math. The bridge’s _revivePromptTimer matches DEATH_DEFIANCE_TOTAL_REAL_DURATION so the auto-dismiss fires exactly when the visual bar ends.
  • Affordability gate — reads useWalletStore.gems. If gems < gemCost, the RESPAWN button disables and a Not enough gems (X/Y) line shows below. Decline still works.
  • Death transition — on decline or timeout, plays a 0.4 s white flash + 0.8 s fade to black before calling onDecline(). Bridge’s declineRevive runs immediately when the user taps decline; the visual transition is a parallel animation the bridge doesn’t gate on.

Audio is hand-rolled with AudioContext: an 80 Hz sine gong on timeout / decline, a stacked A4/E5/A5/E6 chime on respawn.

State surface

game (engine core state) exposes three flags the React layer subscribes to:

FieldMeaning
game.revivePromptBoolean — RevivePrompt mounts when true.
game.reviveTimerReserved counter for the prompt timer (mirror of _revivePromptTimer).
game.reviveAvailableSet during cinematic; true means the player has revives remaining.

game.tracking.deathDefianceUsed is a per-run telemetry flag set true the first time revive() runs.

The bridge keeps the source-of-truth counter in module-scope: _deathDefianceUses, _revivePromptActive, _revivePromptTimer, _deathCinematicPlaying, _deathCinematicTimer, _postDeclineDeath. The game.* mirrors exist purely so the React layer doesn’t reach into bridge internals.

  • death-defiance-timing — the cheater-timer + linger math behind the prompt’s countdown
  • data/economy.ts — all DEATH_DEFIANCE_* constants and deathDefianceCost()
  • components/RevivePrompt.tsx — modal UI, audio, transition
  • engine/bridge.tsrevive(), declineRevive(), dead-phase tick, callback wiring
  • engine/core/state.tsgame.revivePrompt, game.reviveTimer, game.reviveAvailable