NapNodes

← Discord bot guides

Discord bots · updated 24 October 2026

Discord Leveling Bot: XP for Messages, Leaderboard and Roles (Python and JavaScript)

A leveling bot gives XP to whoever chats on your Discord server, announces level-ups and hands roles to the most active members. Here it is in full, about 100 lines, in discord.py and in discord.js: spam-proof XP, /level with a progress bar, /leaderboard and roles per level, all stored in a SQLite file that doesn't need any database installed.

How it works

Each message gives a random 15 to 25 XP, but only one per minute per person counts: ten messages in a row give the same as one, so spamming levels nobody up. Bots don't earn XP and each Discord server keeps its own count.

Going from one level to the next takes 5·n² + 50·n + 100 points (100 for level 1, 155 more for 2, 220 for 3…), the curve MEE6 made popular. At one message per minute, level 5 comes after an hour of conversation, 10 after about 4 hours and 20 after about 20: the first levels come quickly and the high ones single out the regulars.

The bot doesn't read what anyone writes: it only needs to know someone wrote something. That's why you don't need to turn on the “Message Content Intent”, which is a special permission.

Rather not set it up yourself? Discord bot hosting from €1.50 a month, no contract and a full refund within 72 hours. Or create a free Discord bot, no card needed.

1. Create the bot on Discord

  1. Create the application and get the token

    At discord.com/developers/applications, “New Application”; in the “Bot” tab, “Reset Token” and copy the token. It's the bot's password: don't share it or push it to GitHub. If it's your first bot, the step-by-step process is in your first bot in Python.

  2. Invite it with just the permissions it needs

    In “OAuth2” → “URL Generator”, tick “bot” and “applications.commands”, and in the permissions “Send Messages”, “Embed Links” and, if it will hand out roles, “Manage Roles”. Open the link it gives you and pick your server.

  3. If it will hand out roles, move it up

    In “Server Settings” → “Roles”, drag the bot's role above the roles it will give. A bot can't give a role that's above its own, even with “Manage Roles”.

2. The code in Python (discord.py)

Three files in one folder. The token goes in .env, without quotes. The bot creates the database (levels.db) by itself the first time, in the same folder; sqlite3 comes with Python, there's nothing else to install. You need Python 3.10 or newer.

requirements.txt
discord.py
python-dotenv
.env
DISCORD_TOKEN=paste-your-token-here
app.py
import os
import random
import sqlite3
import time

import discord
from discord import app_commands
from dotenv import load_dotenv

load_dotenv()  # reads the token from the .env file

# Roles earned on reaching a level: {level: role id}. Empty, no roles.
LEVEL_ROLES = {
    # 5: 123456789012345678,
    # 10: 234567890123456789,
}
COOLDOWN = 60  # seconds: only one message per minute counts (against spam)

db = sqlite3.connect("levels.db")
db.execute(
    "CREATE TABLE IF NOT EXISTS xp (guild INTEGER, user INTEGER, name TEXT,"
    " xp INTEGER NOT NULL, last REAL NOT NULL, PRIMARY KEY (guild, user))"
)


def xp_to_level_up(level):
    """XP needed to go from this level to the next."""
    return 5 * level**2 + 50 * level + 100


def level_of(xp):
    """The level, the XP earned within it and the XP it takes to level up."""
    level = 0
    while xp >= xp_to_level_up(level):
        xp -= xp_to_level_up(level)
        level += 1
    return level, xp, xp_to_level_up(level)


intents = discord.Intents.default()  # receives messages, without reading their text
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)


@client.event
async def on_message(message):
    if message.author.bot or message.guild is None:
        return
    now = time.time()
    row = db.execute(
        "SELECT xp, last FROM xp WHERE guild = ? AND user = ?",
        (message.guild.id, message.author.id),
    ).fetchone()
    xp, last = row or (0, 0)
    if now - last < COOLDOWN:
        return
    before = level_of(xp)[0]
    xp += random.randint(15, 25)
    db.execute(
        "INSERT OR REPLACE INTO xp VALUES (?, ?, ?, ?, ?)",
        (message.guild.id, message.author.id, message.author.display_name, xp, now),
    )
    db.commit()
    level = level_of(xp)[0]
    if level == before:
        return
    try:
        await message.channel.send(f"🎉 {message.author.mention} reached level {level}")
        role = message.guild.get_role(LEVEL_ROLES.get(level, 0))
        if role:
            await message.author.add_roles(role, reason=f"Level {level}")
    except discord.HTTPException as error:
        print(f"Couldn't announce or give the role: {error}")


@tree.command(name="level", description="Your level (or someone else's)")
@app_commands.guild_only()
async def show_level(interaction: discord.Interaction, user: discord.Member | None = None):
    user = user or interaction.user
    row = db.execute(
        "SELECT xp FROM xp WHERE guild = ? AND user = ?",
        (interaction.guild_id, user.id),
    ).fetchone()
    xp = row[0] if row else 0
    rank = db.execute(
        "SELECT COUNT(*) + 1 FROM xp WHERE guild = ? AND xp > ?",
        (interaction.guild_id, xp),
    ).fetchone()[0]
    level, inside, needed = level_of(xp)
    filled = inside * 10 // needed
    bar = "▰" * filled + "▱" * (10 - filled)
    card = discord.Embed(title=f"Level {level}", color=0x5B4AE8)
    card.set_author(name=user.display_name, icon_url=user.display_avatar.url)
    card.description = f"{bar} {inside}/{needed} XP\nRank {rank} · {xp} XP in total"
    await interaction.response.send_message(embed=card)


@tree.command(name="leaderboard", description="The 10 highest levels on the server")
@app_commands.guild_only()
async def leaderboard(interaction: discord.Interaction):
    rows = db.execute(
        "SELECT name, xp FROM xp WHERE guild = ? ORDER BY xp DESC LIMIT 10",
        (interaction.guild_id,),
    ).fetchall()
    if not rows:
        await interaction.response.send_message("Nobody has XP yet: start chatting!")
        return
    lines = [f"**{i}.** {name} · level {level_of(xp)[0]} ({xp} XP)" for i, (name, xp) in enumerate(rows, 1)]
    card = discord.Embed(title="🏆 Leaderboard", description="\n".join(lines), color=0x5B4AE8)
    await interaction.response.send_message(embed=card)


@client.event
async def on_ready():
    await tree.sync()  # publishes /level and /leaderboard
    print(f"Logged in as {client.user}")


client.run(os.environ["DISCORD_TOKEN"])

3. The same bot in JavaScript (discord.js)

It uses node:sqlite, the database that Node.js includes since version 22.13: nothing to compile or install. On startup, Node.js prints “ExperimentalWarning: SQLite is an experimental feature”; it's only a warning that this part may still change, and the bot works the same.

package.json
{
  "name": "leveling-bot",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "discord.js": "^14.16.0",
    "dotenv": "^16.4.0"
  }
}
.env
DISCORD_TOKEN=paste-your-token-here
index.js
require("dotenv").config(); // reads the token from the .env file
const { DatabaseSync } = require("node:sqlite"); // comes with Node.js 22.13 or newer
const { Client, EmbedBuilder, Events, GatewayIntentBits, InteractionContextType, SlashCommandBuilder } = require("discord.js");

// Roles earned on reaching a level: { level: "role id" }. Empty, no roles.
const LEVEL_ROLES = {
  // 5: "123456789012345678",
  // 10: "234567890123456789",
};
const COOLDOWN = 60_000; // milliseconds: only one message per minute counts (against spam)

const db = new DatabaseSync("levels.db");
db.exec(
  "CREATE TABLE IF NOT EXISTS xp (guild TEXT, user TEXT, name TEXT," +
    " xp INTEGER NOT NULL, last INTEGER NOT NULL, PRIMARY KEY (guild, user))",
);
const read = db.prepare("SELECT xp, last FROM xp WHERE guild = ? AND user = ?");
const save = db.prepare("INSERT OR REPLACE INTO xp VALUES (?, ?, ?, ?, ?)");
const rankOf = db.prepare("SELECT COUNT(*) + 1 AS rank FROM xp WHERE guild = ? AND xp > ?");
const top = db.prepare("SELECT name, xp FROM xp WHERE guild = ? ORDER BY xp DESC LIMIT 10");

// XP needed to go from this level to the next.
const xpToLevelUp = (level) => 5 * level ** 2 + 50 * level + 100;

// The level, the XP earned within it and the XP it takes to level up.
function levelOf(xp) {
  let level = 0;
  while (xp >= xpToLevelUp(level)) xp -= xpToLevelUp(level++);
  return { level, inside: xp, needed: xpToLevelUp(level) };
}

// Messages arrive without their text: the “Message Content Intent” isn't needed.
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] });

client.on(Events.MessageCreate, async (message) => {
  if (message.author.bot || !message.inGuild()) return;
  const now = Date.now();
  const row = read.get(message.guildId, message.author.id) ?? { xp: 0, last: 0 };
  if (now - row.last < COOLDOWN) return;
  const before = levelOf(row.xp).level;
  const xp = row.xp + 15 + Math.floor(Math.random() * 11);
  save.run(message.guildId, message.author.id, message.member?.displayName ?? message.author.username, xp, now);
  const { level } = levelOf(xp);
  if (level === before) return;
  try {
    await message.channel.send(`🎉 ${message.author} reached level ${level}`);
    const role = LEVEL_ROLES[level];
    if (role && message.member) await message.member.roles.add(role, `Level ${level}`);
  } catch (error) {
    console.log(`Couldn't announce or give the role: ${error.message}`);
  }
});

const commands = [
  new SlashCommandBuilder()
    .setName("level")
    .setDescription("Your level (or someone else's)")
    .setContexts(InteractionContextType.Guild)
    .addUserOption((o) => o.setName("user").setDescription("Whose")),
  new SlashCommandBuilder().setName("leaderboard").setDescription("The 10 highest levels on the server").setContexts(InteractionContextType.Guild),
];

client.on(Events.InteractionCreate, async (i) => {
  if (!i.isChatInputCommand() || !i.inGuild()) return;
  if (i.commandName === "level") {
    const user = i.options.getMember("user") ?? i.member;
    const xp = read.get(i.guildId, user.id)?.xp ?? 0;
    const { rank } = rankOf.get(i.guildId, xp);
    const { level, inside, needed } = levelOf(xp);
    const filled = Math.floor((inside * 10) / needed);
    const card = new EmbedBuilder()
      .setColor(0x5b4ae8)
      .setTitle(`Level ${level}`)
      .setAuthor({ name: user.displayName, iconURL: user.displayAvatarURL() })
      .setDescription(`${"▰".repeat(filled)}${"▱".repeat(10 - filled)} ${inside}/${needed} XP\nRank ${rank} · ${xp} XP in total`);
    await i.reply({ embeds: [card] });
  }
  if (i.commandName === "leaderboard") {
    const rows = top.all(i.guildId);
    if (!rows.length) return i.reply("Nobody has XP yet: start chatting!");
    const lines = rows.map((r, n) => `**${n + 1}.** ${r.name} · level ${levelOf(r.xp).level} (${r.xp} XP)`);
    await i.reply({ embeds: [new EmbedBuilder().setColor(0x5b4ae8).setTitle("🏆 Leaderboard").setDescription(lines.join("\n"))] });
  }
});

client.once(Events.ClientReady, async (c) => {
  await c.application.commands.set(commands.map((x) => x.toJSON())); // publishes /level and /leaderboard
  console.log(`Logged in as ${c.user.tag}`);
});

client.login(process.env.DISCORD_TOKEN);

4. Roles per level

At the top of the code is LEVEL_ROLES, empty. To give someone a role when they reach level 5, remove the hash (or the two slashes) from that line and put in the role's id. You copy the id with Discord's developer mode on (“User Settings” → “Advanced” → “Developer Mode”): right-click the role in “Server Settings” → “Roles” → “Copy Role ID”. In Python the id goes without quotes; in JavaScript, in quotes.

If the bot doesn't give the role, look at its console: “Missing Permissions” (error 50013) means it lacks “Manage Roles” or its role is below the one it's trying to give. The level-up message still goes out; only the role fails.

5. Try it and tweak it

On your computer: “pip install -r requirements.txt” and “python app.py” (or “npm install” and “node index.js”). When it says “Logged in as…”, chat on your server: the first level comes after 5 or 6 minutes of chatting. /level shows your card (or someone else's, if you pick them) and /leaderboard the top ten. Slash commands can take a few minutes to show up the first time.

To level up faster, lower COOLDOWN (the seconds between messages that count) or raise the points per message; to make it harder, the other way round. If you change the curve when people already have XP, levels recalculate themselves from each person's points: nobody loses what they've earned.

Everything is in levels.db. If you delete it, everyone goes back to zero. If you deploy the bot from GitHub, add levels.db and .env to .gitignore so you don't push the data or the token.

6. Keep it online all the time (for free)

A leveling bot has a catch other bots don't: it only counts XP from the messages it sees. What's written while it's off doesn't count, so if it lives on your computer, every night you switch it off is a night your server doesn't count, and the leaderboard ends up rewarding whoever chats at the same hours as you.

On NapNodes: create the free bot (it starts on Node.js; if yours is the Python one, in “Settings” → “Language”, “Switch to Python”), upload the three files in “Files” and click “Start”. The libraries in requirements.txt or package.json install themselves at startup, and app.py or index.js is the startup file that comes set. With Node.js, keep version 24 or 22 in “Advanced settings” → “Version & startup” (node:sqlite isn't in 20). levels.db stays in the bot's folder between restarts, and you can download it from “Files” whenever you like to keep your own copy.

The free bot has 256 MB, plenty for a leveling bot, and you have to renew it with one click every week: if you forget, it goes offline and stops counting until you start it. To avoid that gap, the Mini plan (512 MB) stays online with nothing to renew (Discord bot pricing). The whole setup is in the Discord bot hosting guide.

Ideas to extend it

XP for time in a voice channel: with the voice state update event you note when each person joins and leaves and give points per minute (it needs the voice states intent, which isn't a special one). A /give-xp only for whoever manages the server, to reward events. A monthly leaderboard, also storing each month's XP and resetting it on day 1. Or channels that don't count (the commands one, the memes one), by checking the channel id before adding.

Frequently asked questions

How do I make a leveling bot for Discord?

With an event that fires on every message: you add XP to whoever wrote it (with a wait between messages so spam doesn't count), store it in a database like SQLite and announce when the level changes. The full code, in Python and JavaScript, is in this guide.

Does a leveling bot need the Message Content Intent?

No. The bot is notified that someone wrote something even if it can't read the text, and it doesn't need to read it to give XP.

Why doesn't my leveling bot give roles?

It's almost always the hierarchy: the bot's role has to be above the role it gives, and the bot needs the “Manage Roles” permission. The console says so with “Missing Permissions” (50013).

How much XP does each level take?

With this guide's curve, 5·n² + 50·n + 100 to go from level n to the next: 100 for level 1, 155 more for 2, 220 more for 3. Level 10 takes 4675 points in total.

Does XP count while the bot is off?

No: it only counts the messages it sees while it's online. That's why a leveling bot is best hosted on a server and not on your computer.

Shall we set it up for you?

Discord bot hosting from €1.50 a month, never oversold: your RAM is yours alone. And if you'd rather start without paying, there's a free Discord bot, no card needed.

Keep reading

All Discord bot guides, sorted →