Music system
In-run music playlist driving Starship Survivors’ gameplay soundtrack. 60 MP3 tracks hosted in Supabase Storage (music bucket), pulled through a single MusicPlayer singleton that exposes start/stop and per-track skip/heart controls. Music plays only during a mission — there is no menu music — and the HUD surfaces the current track name, progress, and player buttons so the player can curate their station while running.
Signal chain and crossfade
Each track is loaded into an HTMLAudioElement (preloaded with preload = 'auto'), permanently bound to a MediaElementAudioSourceNode, and routed through a per-track fadeGain GainNode into AudioBus.getMusicBus(). From the music bus the signal flows through a 6 kHz lowpass (50 % wet), a compressor, master gain, and out to destination.
The fade envelope is exponential and lasts 0.5 s (FADE_DURATION). On track start, fadeGain ramps from 0.0001 to 1.0; on track end (skip, manual next/previous, or natural end), the same gain ramps back to 0.0001 and the audio element is paused 500 ms later. The cleanup uses a captured currentRef so that if a newer track has already taken over during the fade window, the old fade gain disconnects without nulling the live _current. This crossfade is what gives skips and natural track transitions a smooth handoff instead of an audible cut.
Weighted-shuffle playlist
_buildQueue() builds a fresh play queue every time the existing queue is depleted or a new mission starts:
- Walk all 60
TRACKS. - Skip any track whose ID is in
_skips— skipped tracks are removed entirely from rotation. - Push each remaining track once into the pool. Tracks in
_heartsare pushed 3× to triple their pick weight. - Fisher–Yates shuffle the pool.
- Deduplicate consecutive same-track runs so hearted tracks can’t cluster back-to-back.
Hearted weighting and skip removal are persisted in localStorage under ss_music_hearts and ss_music_skips. The queue is built fresh on every start() and rebuilt mid-mission whenever it drains below 3 tracks.
History is tracked separately (most recent 10 entries) so previous() can step backwards without penalising the current track.
Skip and heart actions
The HUD music buttons map to MusicPlayer.next() / MusicPlayer.previous() / MusicPlayer.heart(). Each action has explicit semantics:
next()— always counts as a skip. Adds the current track ID to_skips, removes it from_hearts, saves both sets tolocalStorage, emitsmusic_skiptelemetry, then crossfades to the next queue entry. Once skipped, the track is gone from future rotations until the player explicitly hearts it again.previous()— does not count as a skip. Pops the most recent history entry, pushes the current track back to the front of the queue, and crossfades to the previous track. No telemetry, no list mutation.heart()— toggle on the current track. If already hearted, the heart is removed andmusic_unheartfires. Otherwise the track is added to_heartsand removed from_skips(heart undoes a skip), thenmusic_heartfires.
Heart-then-skip pattern (thumbs-up). The intended UI gesture for “I like this and want to skip ahead but keep it in rotation” is heart first, then skip: hearting un-skips the track and weights it 3×, and the subsequent next() still records a skip event for telemetry but the heart re-adds the track to _hearts the moment the player tapped it. Effectively the heart action overrides the skip’s _skips insertion because hearts and skips are mutually exclusive — a track in _hearts is never selected from _skips because _advanceToNext() deletes from _hearts before saving. The player is expected to heart before skipping when they want to keep the track in rotation.
Telemetry events
All music interactions are sent through trackEvent (Mixpanel via @metagame/services/analytics):
| Event | When | Properties |
|---|---|---|
music_skip | _advanceToNext(true) (i.e. next() button) | { track } (track ID) |
music_heart | heart() toggles a track into _hearts | { track } |
music_unheart | heart() toggles a track out of _hearts | { track } |
music_prefs_dump | Once per device on first start() after the flag was unset | { hearts: string[], skips: string[] } |
music_prefs_dump is a one-shot migration event: on first start() the player’s existing localStorage hearts and skips are dumped to telemetry so the analytics pipeline can backfill historical preferences, then the ss_music_prefs_dumped key is set so the dump never repeats on the same device.
Reward-overlay ducking
When reward overlays (level-up, boss chest, run-end summary) open, the music bus is ducked so the overlay’s chimes and VO sit on top of the soundtrack. The ducking is owned by the audio bus (AudioBus.getMusicBus()), not by MusicPlayer — the music player itself doesn’t pause, and the track continues to advance underneath. When the overlay closes, the music bus unducks and the track is back at full level. This means a track started 30 s before a boss chest reward overlay still has its full duration available when the overlay closes.
Per-track metadata
Each track has a minimal Track record:
interface Track {
id: string; // filename lowercased — used as the key for hearts/skips
name: string; // filename in original casing — shown in the HUD
file: string; // full CDN URL including speed variant subfolder
}The 60 track names are listed inline in TRACKS in music-player.ts. File names are URL-safe: no apostrophes, ampersands replaced with and. The CDN URL is constructed as ${MUSIC_CDN}/${MUSIC_SPEED_VARIANT}/${encodeURIComponent(name + '.mp3')} where MUSIC_SPEED_VARIANT is currently lo16 (a low-bitrate processed variant; swap to slow20 / slow30 / slow40 / slow50 for tempo A/B tests or to '' for the originals).
Preload window and cache
The cache (_cache) is a Map<trackId, CachedAudio> keyed by track ID. Each entry retains both the HTMLAudioElement and its permanently-bound MediaElementAudioSourceNode so reused tracks don’t have to recreate the Web Audio plumbing.
_preloadAhead() runs after every track start and enforces a keep-window:
- The currently playing track.
- The last 3 history entries (for
previous()). - The next 3 queue entries (preloaded with
preload = 'auto'andload()).
Everything outside the keep set is evicted: audio.pause(), audio.src = '', source.disconnect(), then dropped from the map. This caps memory while still giving instant skip/previous response.
Warp interaction
MusicPlayer.setWarpIntensity(t) is called each frame from bridge.ts with ship.warpT and interpolates two parameters as the ship warps:
- The music bus low-pass cutoff exponentially in log-frequency space, from 1000 Hz at
t = 0to 400 Hz att = 1(≈3.6 octave drop, “muffled through a wall”). - The current track’s
playbackRatelinearly from1.0to0.8(20 % slowdown at full warp).
Filter sweeps use setTargetAtTime with a 0.05 s time constant to avoid clicks against the upstream 0.25 s warpT tween.
Volume and seek
volumeUp() / volumeDown() step the music bus volume in 0.1 increments (10 taps from 0 → 1), clamped to [0, 1]. seek(time) jumps the current track’s currentTime to a specific second, clamped to [0, audio.duration]. Volume is read back through AudioBus.getMusicVolume() and surfaced in getState() for the HUD slider.
HUD contract — getState()
The HUD calls MusicPlayer.getState() each frame and renders:
{
trackName: string; // empty when inactive
progress: number; // seconds played
duration: number; // total seconds (0 until loaded)
isPlaying: boolean;
isHearted: boolean;
volume: number; // 0..1
active: boolean; // false outside missions
}When active === false (between missions or before first start) the HUD hides the player UI entirely; MusicPlayer.start() is what flips this flag on mission start.
Source
src/starship-survivors/engine/audio/music-player.ts— full implementation (queue, cache, fades, telemetry, warp coupling)src/starship-survivors/engine/audio/audio-context.ts—AudioBus(music bus, lowpass filter, compressor, master gain, ducking)src/metagame/services/analytics.ts—trackEvent(Mixpanel)