This page builds a real starter bot, not a toy example.
By the end you will have:
a full index.js (client + manager + loaders) event files (ready, messageCreate) command loading from commands/ Moonlink debug + node/playback event listeners the correct voice packet wiring (send + raw) We keep debug and node behavior config-driven (see the previous page). The code should read config and obey .
Your main file has a single job: wire everything together .
Create the Discord client. Create the Moonlink manager. Load commands. Load events. Login. Create index.js:
// index.js
const { Client , GatewayIntentBits , Collection } = require ( "discord.js" );
const { Manager } = require ( "moonlink.js" );
const fs = require ( "fs" );
const config = require ( "./config.json" );
// 1) Discord client
const client = new Client ({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
// 2) Shared state
client.commands = new Collection ();
client.config = config;
// 3) Moonlink Manager
client.manager = new Manager ({
nodes: config.lavalink.nodes,
// Keep node selection / failover / defaults in config.json
options: config.moonlink?.options,
// Required when you are NOT using a Connector
send : ( guildId , payload ) => {
const guild = client.guilds.cache. get (guildId);
if (guild) guild.shard. send (payload);
},
});
// 4) Load commands
for ( const file of fs. readdirSync ( "./commands" ). filter (( f ) => f. endsWith ( ".js" ))) {
const command = require ( `./commands/${ file }` );
// Register command name
client.commands. set (command.name, command);
// Register aliases (optional)
if (Array. isArray (command.aliases)) {
for ( const alias of command.aliases) {
client.commands. set (alias, command);
}
}
}
// 5) Load events
for ( const file of fs. readdirSync ( "./events" ). filter (( f ) => f. endsWith ( ".js" ))) {
const event = require ( `./events/${ file }` );
if (event.once) {
client. once (event.name, ( ... args ) => event. execute ( ... args, client));
} else {
client. on (event.name, ( ... args ) => event. execute ( ... args, client));
}
}
// 6) Moonlink debug (config-driven)
if (client.config?.debug) {
client.manager. on ( "debug" , ( msg ) => console. log ( `[Moonlink] ${ msg }` ));
}
// 7) Node lifecycle logs
client.manager. on ( "nodeConnect" , ( node ) => {
console. log ( `Node ${ node . identifier } connected` );
});
client.manager. on ( "nodeDisconnect" , ( node ) => {
console. log ( `Node ${ node . identifier } disconnected` );
});
client.manager. on ( "nodeError" , ( node , error ) => {
console. error ( `Node ${ node . identifier } error:` , error);
});
// 8) Playback logs
client.manager. on ( "trackStart" , ( player , track ) => {
const channel = client.channels.cache. get (player.textChannelId);
if (channel) channel. send ( `Now playing: **${ track . title }**` );
});
client.manager. on ( "queueEnd" , ( player ) => {
const channel = client.channels.cache. get (player.textChannelId);
if (channel) channel. send ( "Queue ended. Disconnecting soon if idle." );
// Basic idle cleanup
setTimeout (() => {
if ( ! player.playing && player.queue.size === 0 ) {
player. destroy ();
if (channel) channel. send ( "Disconnected due to inactivity." );
}
}, 30000 );
});
// 9) Raw voice packets (required without a Connector)
client. on ( "raw" , ( packet ) => client.manager. packetUpdate (packet));
// 10) Login
client. login (config.token);
Moonlink must be able to send voice state payloads to the Discord gateway.
That’s what send(guildId, payload) is for: it tells Moonlink how to reach Discord’s gateway layer through your library.
Discord voice uses low-level gateway events (VOICE_STATE_UPDATE, VOICE_SERVER_UPDATE).
Moonlink needs those packets to complete the handshake. If you don’t pass them to manager.packetUpdate(...), the bot may join a channel but never actually stream.
If you choose to use a Connector (Discord.js connector), it can automate init() + raw handling. This guide shows the manual wiring so you understand what’s happening.
Create events/ready.js:
// events/ready.js
module . exports = {
name: "clientReady" ,
once: true ,
async execute ( client ) {
console. log ( `Logged in as ${ client . user . tag }` );
// Connect nodes, prepare sessions, start internal services
await client.manager. init (client.user.id);
console. log ( "Moonlink Manager initialized" );
},
};
Create events/messageCreate.js:
// events/messageCreate.js
module . exports = {
name: "messageCreate" ,
async execute ( message , client ) {
const prefix = client.config?.prefix || "!" ;
if (message.author.bot) return ;
if ( ! message.guild) return ;
if ( ! message.content. startsWith (prefix)) return ;
const args = message.content. slice (prefix. length ). trim (). split ( / + / g );
const commandName = args. shift ()?. toLowerCase ();
if ( ! commandName) return ;
const command = client.commands. get (commandName);
if ( ! command) return ;
try {
await command. execute (message, args, client);
} catch (error) {
console. error (error);
await message. reply ( "There was an error executing that command." );
}
},
};
At this point, your project should look like this:
my-moonlink-bot/
├─ commands/
│ └─ (we will add these next)
├─ events/
│ ├─ ready.js
│ └─ messageCreate.js
├─ config.json
├─ index.js
└─ package.json
Now your bot boots, connects to nodes, and is ready to execute commands.
Next, we’ll implement the full music command set (play/queue/filters/etc.) with consistent validations.