Music bot architecture: queues, playlists, and filters

The first /play was the easy part. This is the architecture of everything after it — the per-guild queue, loop and shuffle, playlists, audio filters, and the lifecycle that keeps your bot from misbehaving — explained library-agnostically so your library's docs slot right in.

The Lavalink music bot guide gets you to a bot that plays one track. This guide is the map of everything past that — the queue, loop modes, playlists, filters, and the lifecycle that stops your bot leaking state between servers or squatting in an empty voice channel. It's deliberately library-agnostic: every Lavalink client names its methods differently, so here we cover the concepts and the shape of the state, and you fill in the exact calls from your library's docs.

At a glance
You need a working Lavalink setup — the Lavalink music bot guide up and playing one track
You'll learn how to structure a real music bot, not one method's syntax
Plan free or premium — remember a music bot is two servers, each with its own timer on free
Time about thirty minutes of reading

The one idea to hold onto

Your bot holds the state — what's playing, what's next, whether it's looping. Lavalink holds the audio — it fetches and streams. Almost every feature below is a change to state your bot manages, then a single instruction to Lavalink through your client library. Keep that split clear and the rest falls into place.

And one rule underlies all of it: state is per-guild. Two servers can play different songs at once, so anything you store — the queue, the loop mode, the volume — lives keyed by guild ID, never in a single global variable. The classic first bug is a global queue array; the moment two servers use the bot, their songs collide.

The queue

A queue is just an ordered list of tracks per guild, plus a pointer to what's playing now. In plain JavaScript, before any library is involved:

// A per-guild store. The values are YOUR state; the audio lives in Lavalink.
const players = new Map(); // guildId -> { queue: [], current: null, loop: 'off' }

function stateFor(guildId) {
  if (!players.has(guildId)) {
    players.set(guildId, { queue: [], current: null, loop: 'off' });
  }
  return players.get(guildId);
}

The lifecycle:

  1. /play — your library resolves the search or URL into one or more track objects. Push them onto queue. If nothing is playing, start the next track.
  2. Track ends — your library fires a "track ended" event. That is your cue to advance: take the next track off queue and tell Lavalink to play it.
  3. Queue empties — nothing left to advance to. Now you decide the lifecycle (idle timeout, below).

🎯 Good to know: Skipping is just "end the current track early." Most libraries expose a stop/skip call that triggers the same track-ended event your advance logic already listens to — so skip, natural end, and /stop all flow through one code path. Build that path once.

Loop and shuffle

Loop is a small state machine with three modes. Read it when a track ends to decide what plays next:

Loop mode On track end
off advance to the next track in the queue
track replay the same track
queue play the next track, and push the finished one back onto the end

Shuffle is a one-time reorder of the pending queue (a Fisher–Yates shuffle of the array), not a mode — it rearranges what's already queued and leaves the current track alone. Store the loop mode in your per-guild state so /loop just cycles off → track → queue → off.

Playlists

A single /play can add many tracks. When a user gives a playlist URL, your library's track resolver returns a list of tracks instead of one — enqueue them all and report how many you added. The distinction that trips people up:

  • A URL to a playlist → resolves to many tracks → push all of them.
  • A search query ("lofi beats") → resolves to a list of candidates → you pick the first (or show a chooser) and push one.

Your library tells you which case you got (playlist vs search vs single track) in the resolve result. Handle the playlist case explicitly, or a 200-song playlist becomes one song.

Audio filters

Filters — bass boost, nightcore, karaoke, and friends — are a Lavalink feature, applied server-side to the audio while it streams. Your bot doesn't process audio; it sends Lavalink a filter configuration and Lavalink does the work. The families Lavalink offers:

Filter What it does
Equalizer boost or cut frequency bands — the basis of "bass boost"
Timescale change speed and pitch — the basis of "nightcore" and "slowed + reverb"
Karaoke attenuate the vocal band
Tremolo / Vibrato oscillate volume / pitch
Rotation the "8D audio" pan effect
Distortion, ChannelMix, LowPass tone-shaping and channel effects

Your client library exposes these as a filters object on the player. The exact method names and value ranges are your library's — check its filter docs. Two honest cautions: filters stack (setting one usually replaces the whole filter config, so apply them together), and heavy filtering is more work for the Lavalink node, which matters on a RAM-limited free server.

⚠️ Heads up: Don't invent filter syntax from this table. It lists what exists in Lavalink; your library decides how you call it. Every method in this guide is a concept — the source of truth is your library's current documentation, exactly as in the Lavalink guide.

Lifecycle: the part tutorials skip

A music bot that only knows how to start is a bad citizen. Handle the whole life of a session:

  • Idle timeout. When the queue empties, start a timer (say 5 minutes). If nothing new plays, leave the voice channel and clear that guild's state. This is the single most-requested "why won't my bot leave" fix.
  • Everyone left. Listen for voice-state changes; if the bot is alone in the channel, pause or leave. Nobody wants a bot playing to an empty room.
  • Disconnected. If the voice connection drops, your per-guild state is now stale — clear it so the next /play starts clean rather than trying to resume a dead connection.
  • Permissions. The bot needs Connect and Speak on the voice channel. Check before joining and give a clear reply if it can't.

Does the queue survive a restart?

By default, no — the Map above lives in memory and resets when the server stops (a free-plan timer expiry, a redeploy, a crash). For most music bots that's completely fine; people expect the queue to clear when the bot restarts. If you want it to survive, persist the pending queue to storage on change and reload it on start — see Storing data for your bot. Don't reach for that until you actually need it.

Troubleshooting

  • Songs from one server play in another — you stored state globally instead of per-guild. Key everything by guild ID.
  • Skip does nothing / the queue never advances — you're not handling the track-ended event, so nothing tells the queue to move on. That event is the engine of the whole thing.
  • A playlist only adds one song — you treated the resolve result as a search. Check the result type and loop over the playlist's tracks.
  • Filters don't apply — a version or library mismatch (Lavalink v4 and v3 differ), or you're setting one filter and wiping the rest. Confirm your client targets your Lavalink version and apply filters as one config.
  • Bot sits in an empty channel forever — you have no idle timeout or alone-check. Add both.

Next steps

Was this guide helpful?