PURPOSE

Defines the shooting star — a rare collectible entity with a rainbow trail and golden ring that spawns during the second half of each level. If the player touches the ring, the star is caught: it triggers a multi-phase gold/white particle explosion, freezes the game, flies to screen center, and queues a shooting_star reward (pick a category to level up all items in that category). While uncaught, the star also acts as a movement buff source — flying through its rainbow trail grants the ship a temporary +30% speed boost.

OWNS

  • ShootingStar interface — runtime fields for a single star (position, velocity, lifetime, trail history, spin, speed-modulation state, collection animation state). The trail is a position-history ring buffer of up to 120 points sampled every 30ms (~3.6s of history).
  • spawnShootingStar(ship) — constructs a new star 600px from the player at a random angle, base speed ~154 px/s with ±20 jitter, aimed back toward the player.
  • updateShootingStars(stars, ship, world, game, dt) — per-frame update for all live stars. Drives the player-reactive speed system, trail sampling, ambient particles, trail-speed-buff write, off-screen despawn, gem/weaponBox bump physics, ring-collision pickup detection, multi-phase particle explosion, freeze trigger, and fly-to-center animation. Returns an array of collected star world positions.
  • ringPop — module-level world-space pop VFX state ({ active, x, y, timer }) and updateRingPop(dt) to advance its timer; auto-deactivates at 0.4s.
  • ShootingStarSpawner — module-level spawn controller with init() and tick(game, ship, world, dt). Holds private _spawnTimer and _active state.
  • _nextInterval(tierLevel) — log-normal interval sampler. Median scales as 120 / max(1, tierLevel) seconds; sigma is 0.5; clamped to [5s, 120s]. Targets ~1 star per tier during the 120s spawn window.
  • Tuning constants: STAR_RING_COLLISION_RADIUS = 100, STAR_FLY_DURATION = 0.6s, STAR_DESPAWN_DISTANCE = 3000 (~3 screens), TRAIL_SAMPLE_INTERVAL = 0.03s, TRAIL_MAX_LENGTH = 120, TRAIL_BUFF_RADIUS = 60, TRAIL_BUFF_REFRESH_SECONDS = 0.12.

READS FROM

  • ShipState from ../core/types — reads ship.x, ship.y, ship.outerRadius / ship.radius (collision radius for the ring touch; falls back to 12).
  • WorldState from ../core/types — reads world.gems and world.weaponBoxes for bump physics; writes spawned stars into world.comets (the legacy field name is reused for shooting stars).
  • GameState from ../core/types — reads game.uiTime (drives the fly-to-center animation during freeze), game._rawDt (frame delta during freeze; falls back to 0.016), game.missionTimerMax (default 240s), game.missionElapsed, and game._currentLevel (treated as the tier; default 1).
  • Camera.toS(x, y) from ../rendering/camera — converts world position to screen space at the moment of collection.
  • W, H from ../core/state — imported (screen extents); not directly referenced inside live logic in this module.

PUSHES TO

  • world.cometsShootingStarSpawner.tick pushes a new ShootingStar when the spawn timer expires inside the second-half spawn window.
  • ship._shootingStarBoostTimer — set to TRAIL_BUFF_REFRESH_SECONDS (0.12s) every frame the ship is within 60px of any point in the star’s rainbow trail. Decremented and read by engine/physics/movement.ts to apply a 1.30x speed/thrust multiplier.
  • game._weaponChestFreeze = true and game.timeDilation = 0 — on collection, the game freezes for the reward cinematic.
  • ringPop — on collection, ringPop.active, ringPop.x, ringPop.y are set and ringPop.timer is reset to 0. Read by bridge.ts and the renderer to draw the expanding golden ring.
  • Particles.add / Particles.burst from ../vfx/particles — sparse ambient smoke during flight, the 14-piece golden ring shatter on catch, and four phases of catch particles: 80 gold sparks (Phase 1), 50 bright-white sparks (Phase 2), 40 slow long-lived gold confetti (Phase 3), 30 upward gold geyser sparks (Phase 4), plus a 30-spark white-hot core burst. Phases 1–4 are centered on the player; the ring shatter is centered on the star.
  • Juice.fire('comet_catch') from ../vfx/juice — fires the catch juice cue on collection.
  • Bump impulses to nearby world.gems (within c.rad + 12 + g.rad) — adds outward velocity (80) plus 30% of the star’s velocity, and emits a 6-particle spark burst. Skips gems with collected or active _spawnTimer.
  • Bump VFX on nearby world.weaponBoxes (within c.rad + 12 + 18) — 10-particle spark burst only; no impulse. Skips _flying or spawn-timer-active boxes.
  • collected return array — caller (bridge.ts) uses this to push { type: 'shooting_star' } onto game.rewardQueue.

DOES NOT

  • Does not despawn stars by lifetime — l and _maxL are retained on the struct for type compatibility but set to 9999 and never decremented. Stars only despawn when distance from the player exceeds STAR_DESPAWN_DISTANCE (3000px) or after the post-catch fly-to-center animation completes. Comment in source notes Nate observed a star vanish mid-flight, which the distance-only rule prevents.
  • Does not draw the star or its trail. Rendering lives in engine/rendering/draw-shooting-star.ts, invoked from bridge.ts.
  • Does not pick or apply the reward. It only queues a shooting_star reward token (via the caller in bridge.ts); choice generation is generateShootingStarChoices in engine/world/leveling.ts, and category effects (weapons, ship_upgrades, artifacts, lowest_weapon, player_buff, grant_reroll, grant_banish, grant_refuel) are applied by leveling/reward code.
  • Does not apply the +30% trail speed buff itself — it only refreshes ship._shootingStarBoostTimer. The buff multiplier (1.30) is applied inside engine/physics/movement.ts.
  • Does not spawn outside the second half of the level. ShootingStarSpawner.tick early-returns and clears _active when missionElapsed < halfTime or >= timerSeconds. The very first star inside the window is rolled at 0.5× the normal interval so the first one arrives sooner.
  • Does not bump weapon boxes physically — only emits a spark burst. Gems get both impulse and spark burst.
  • Does not handle the ring-pop expansion math — it only flips ringPop.active and resets its timer; updateRingPop only advances the timer and auto-deactivates past 0.4s. The actual ring rendering is owned by bridge.ts / the renderer.

Signals

  • Return value of updateShootingStarsArray<{ x, y }> of world positions for stars whose fly-to-center animation finished this frame. Caller pushes one { type: 'shooting_star' } reward per entry into game.rewardQueue.
  • ship._shootingStarBoostTimer (write) — refreshed to 0.12s whenever the ship is in the trail; consumed by movement physics for the +30% boost.
  • ringPop module export — public mutable VFX state (active, x, y, timer) read by bridge.ts.
  • game._weaponChestFreeze + game.timeDilation = 0 — freeze handshake with the global reward / time-dilation system; the same freeze flag is reused across weapon-chest, artifact-box, and shooting-star catches.

Entry points

  • spawnShootingStar(ship) — factory; called by ShootingStarSpawner.tick to produce a star and push it into world.comets.
  • updateShootingStars(stars, ship, world, game, dt) — per-frame update; called from bridge.ts after ShootingStarSpawner.tick.
  • ShootingStarSpawner.init() — called at level start from bridge.ts (also called at level reset paths).
  • ShootingStarSpawner.tick(game, ship, world, dt) — called every frame from bridge.ts inside the simulation step.
  • updateRingPop(dt) — called from bridge.ts with raw delta so the pop animates during the post-catch freeze.
  • ringPop (export) — read by bridge.ts for ring-pop rendering.
  • ShootingStar type (export) — used by bridge.ts for typed access to world.comets.

Pattern notes

  • Iterates backwards with swapRemove. The loop runs for (i = stars.length - 1; i >= 0; i--) so removals via swapRemove(stars, i) are safe within the same pass.
  • Reuses world.comets. The shooting star is a rename/repurposing of the archive’s comet entity. The world array, the ShootingStar struct fields (e.g. _baseVx, _prevDist, _approachT, _exiting), and the comet_catch juice cue all keep the comet vocabulary for archive compatibility.
  • Player-reactive speed system. Per-frame distance and approach-vs-recede detection (_prevDist) drives a speed multiplier that shapes a small chase mini-game: approach → decelerate to 0.5× over 4.5s, then exit; recede while nearby → drop to 0.8× then exit; far away → drift to 0.9× then exit. Once _exiting is set, the multiplier ramps back to 1.0×.
  • Trail is both visual and gameplay surface. The 120-point/3.6s rainbow trail is what the renderer draws AND the active buff zone. Trail-buff detection uses a cheap broad-phase check against the trail head (radius STAR_RING_COLLISION_RADIUS + TRAIL_BUFF_RADIUS + 400) before walking the trail array, and breaks at the first hit.
  • Crash-on-bad-data avoided at boundary. Defensive fallbacks exist only at the runtime boundary: game._rawDt ?? 0.016, ship.outerRadius || ship.radius || 12, game.missionTimerMax || 240, game.missionElapsed ?? 0, game._currentLevel ?? 1. Internal state is initialized at spawn and not re-defaulted.
  • Cast through any for cross-module fields. (ship as any)._shootingStarBoostTimer and (game as any)._currentLevel are written/read through any rather than adding cross-cutting type bridges; the field exists on the canonical ShipState/GameState type definitions in engine/core/types.ts.
  • Log-normal spawn interval. _nextInterval uses Box-Muller for a standard normal then exp(mu + sigma*z) with sigma = 0.5 (tighter than the archive’s 1.15). Median scales inversely with tier so deeper tiers see more frequent stars; clamped to [5s, 120s].
  • Freeze-aware animation. The fly-to-center animation reads game.uiTime and game._rawDt so it advances even though dt (the sim delta) is 0 during the post-catch freeze. The reward collected signal is intentionally deferred until the animation finishes, not fired at the moment of touch.
  • Particle catch sequence is fixed-count, not data-driven. The four phases (80 gold + 50 white + 40 confetti + 30 geyser) plus the 14-piece ring shatter and 30-spark core burst are all inlined as numeric loops; values live as locals in the catch block rather than in a data table.