Skip to main content

@robojs/xp

Standard chat XP system that exposes a powerful event-driven API that makes building custom features incredibly easy. No need to fork the plugin or write complex integrations—just listen to events and call imperative functions.

GitHub licensenpminstall sizeDiscord

📚 Documentation: Getting started

🚀 Community: Join our Discord server

Features

  • 💬 Standard XP mechanics - Awards 15-25 XP per message with 60-second cooldown
  • 🎯 Role rewards - Automatic role assignment with stack or replace modes
  • 🚀 Multipliers - Server, role, and user XP boosts (premium features)
  • 📊 Cached leaderboards - Optimized for 10k+ users with under 200ms refresh
  • 🛡️ No-XP roles/channels - Granular control over where XP is earned
  • 🔧 Admin commands - Complete control via /xp command suite
  • 📈 Event system - Real-time hooks for level changes and XP updates
  • 🌐 REST API - Optional HTTP endpoints (requires @robojs/server)
  • 💾 Flashcore persistence - Guild-scoped data storage with automatic caching
  • 🗄️ Multi-store architecture - Run parallel progression systems (leveling + currencies, multi-dimensional reputation)

Build Custom Features in Minutes

Here's a complete level-up announcement system in ~10 lines:

import { events } from '@robojs/xp'
import { client } from 'robo.js'

events.onLevelUp(async ({ guildId, userId, newLevel }) => {
const guild = await client.guilds.fetch(guildId)
const channel = guild.channels.cache.find((c) => c.name === 'level-ups')
if (!channel?.isTextBased()) return

await channel.send(`🎉 <@${userId}> just reached Level ${newLevel}!`)
})

Use the same pattern to award contest bonuses, apply moderation penalties, enable premium XP boosts, track analytics, or build any custom XP-based feature you can imagine.

Check out seed/events/_start/level-announcements.ts for a production-ready example with rich embeds and customization options, or explore the Integration Recipes section below for more patterns.

Getting Started

Terminal
npx robo add @robojs/xp

New to Robo.js? Start your project with this plugin pre-installed:

Terminal
npx create-robo <project-name> -p @robojs/xp

Optional: REST API

To enable the HTTP API endpoints, install @robojs/server:

Terminal
npx robo add @robojs/server

Slash Commands Reference

Admin Commands (Require Manage Guild Permission)

XP Manipulation

CommandDescriptionOptions
/xp give <user> <amount> [reason]Award XP to a useruser (required), amount (1-1,000,000), reason (optional)
/xp remove <user> <amount> [reason]Remove XP from a useruser (required), amount (1-1,000,000), reason (optional)
/xp set <user> <amount> [reason]Set exact XP valueuser (required), amount (0-1,000,000), reason (optional)
/xp recalc <user>Recalculate level and reconcile rolesuser (required)

Role Rewards

CommandDescriptionOptions
/xp configConfigure role rewards (admin only)Interactive UI with buttons and modals

Role Rewards Configuration (via /xp config → Role Rewards):

  • Add Reward: Assign a role to be awarded at a specific level (1-1000)
  • Remove Reward: Remove a role reward from a specific level
  • Set Mode: Choose between Stack (keep all rewards) or Replace (only highest reward)
  • Toggle Remove-on-Loss: Enable/disable removing rewards when users lose XP/levels
  • View All: See all configured rewards with pagination

Stack Mode: Users keep all role rewards from previous levels
Replace Mode: Users only get the highest level role reward

Configuration

CommandDescriptionOptions
/xp configConfigure XP system settingsInteractive UI with category navigation

Configuration Categories (via /xp config):

General Settings:

  • Cooldown: Set per-user message cooldown (0-3600 seconds)
  • XP Rate: Set XP rate multiplier (0.1-10.0x)
  • Leaderboard Visibility: Toggle public/private leaderboard access

No-XP Zones:

  • Add/Remove Roles: Manage roles that don't earn XP
  • Add/Remove Channels: Manage channels that don't award XP

Role Rewards: (see Role Rewards section above)

Note: General settings, No-XP zones, and role rewards are managed through an interactive component-based interface using Discord.js Components V2. Simply run /xp config and navigate through the categories using buttons and select menus. Multipliers are managed separately via dedicated slash commands (see Multipliers section below).

Multipliers

Multipliers are managed via dedicated slash commands and are not currently part of the interactive /xp config UI:

CommandDescriptionOptions
/xp multiplier server <multiplier>Set server-wide XP multipliermultiplier (0.1-10.0)
/xp multiplier role <role> <multiplier>Set role XP multiplierrole, multiplier (0.1-10.0)
/xp multiplier user <user> <multiplier>Set user XP multiplieruser, multiplier (0.1-10.0)
/xp multiplier remove-role <role>Remove role multiplierrole (required)
/xp multiplier remove-user <user>Remove user multiplieruser (required)

User Commands (No Permission Required)

CommandDescriptionOptions
/rank [user]View rank card with progressuser (optional, defaults to self)
/leaderboard [page]View server leaderboardpage (optional, default: 1)
/xp rewardsView all role rewardsNone (public, with pagination)

Note: /leaderboard requires admin permission when leaderboard.public is false. /xp rewards is always public and shows all configured role rewards with pagination support.

Configuration Guide

Guild Configuration Structure

interface GuildConfig {
// Basic settings
cooldownSeconds: number // Per-user message cooldown (default: 60)
xpRate: number // XP rate multiplier (default: 1.0)

// Exclusions
noXpRoleIds: string[] // Roles that don't earn XP
noXpChannelIds: string[] // Channels that don't award XP

// Role rewards
roleRewards: RoleReward[] // Level → Role mappings
rewardsMode: 'stack' | 'replace' // Stack (keep all) or replace (highest only)
removeRewardOnXpLoss: boolean // Remove roles when XP drops below level

// Multipliers (premium features)
multipliers: {
server?: number // Server-wide multiplier (set to 0 to disable automatic XP)
role?: Record<string, number> // Per-role multipliers (set to 0 to disable for role)
user?: Record<string, number> // Per-user multipliers (set to 0 to disable for user)
}

// Leaderboard
leaderboard: {
public: boolean // Allow non-admins to view
}

// Theme (future use)
theme: {
embedColor?: string // Hex color for embeds
backgroundUrl?: string // Custom rank card background
}

// Custom branding
labels?: {
xpDisplayName?: string // Custom terminology (default: 'XP', max 20 chars)
}
}
**Custom Branding:**

Customize XP terminology to match your server's theme. The `labels.xpDisplayName` field changes how XP is displayed in all user-facing commands (`/rank`, `/leaderboard`, `/xp give`, etc.) while keeping command names unchanged.

**Example:**
```typescript
await config.set(guildId, {
labels: { xpDisplayName: 'Reputation' }
})

// Now /rank shows "Reputation" instead of "XP":
// - Field: "Reputation: 1,500"
// - Field: "Reputation in Level: 450 / 750"
// - Field: "Next Level: 300 Reputation remaining"

Common alternatives:

  • 'Reputation' - Community reputation systems
  • 'Points' - Point-based reward systems
  • 'Karma' - Reddit-style karma systems
  • 'Credits' - Economy/currency systems
  • 'Stars' - Achievement/rating systems

Custom Branding (Reputation System)

Customize XP terminology to match your server's theme. Perfect for community reputation systems, point-based rewards, or any custom terminology.

import { config } from '@robojs/xp'

// Set custom branding for the guild
await config.set(guildId, {
labels: { xpDisplayName: 'Reputation' }
})

// All user-facing displays now use "Reputation" instead of "XP":
// - /rank shows "Reputation: 1,500" and "Reputation in Level: 450 / 750"
// - /leaderboard shows "1,500 Reputation" for each user
// - /xp give shows "Successfully gave 100 Reputation to @User"
// - /xp remove shows "Previous Reputation: 1,500, New Reputation: 1,450"

// Command names stay as /xp (only displays change)

Popular terminology options:

LabelUse CaseExample Display
'Reputation'Community reputation"1,500 Reputation"
'Points'Point-based rewards"1,500 Points"
'Karma'Reddit-style systems"1,500 Karma"
'Credits'Economy/currency"1,500 Credits"
'Stars'Achievement/rating"1,500 Stars"
'Exp'Gaming experience"1,500 Exp"

Note: Command names (/xp, /rank, /leaderboard) remain unchanged. Only the visual display of XP values and field names use custom terminology. Custom Branding (labels):

import { config } from '@robojs/xp'

// Set custom XP terminology
await config.set(guildId, {
labels: { xpDisplayName: 'Reputation' }
})

// Get config and extract label
const guildConfig = await config.get(guildId)
const label = guildConfig.labels?.xpDisplayName ?? 'XP'
console.log(label) // 'Reputation'

// Use in custom displays
import { formatXP, getXpLabel } from '@robojs/xp'
const formattedXp = formatXP(1500, getXpLabel(guildConfig))
console.log(formattedXp) // '1,500 Reputation'

Validation rules:

  • Must be a string (max 20 characters)
  • Cannot be empty string
  • Defaults to 'XP' if not configured
  • Applies to all user-facing displays (commands, embeds)

How to Customize XP Terminology

Goal: Change "XP" to custom terminology like "Reputation", "Points", or "Karma" throughout all displays.

Solution:

import { config } from '@robojs/xp'

// Set custom label for the guild
await config.set(guildId, {
labels: { xpDisplayName: 'Reputation' }
})

What changes:

  • /rank command: "Reputation: 1,500" instead of "XP: 1,500"
  • /leaderboard command: "1,500 Reputation" for each entry
  • /xp give/remove/set commands: "Previous Reputation", "New Reputation"
  • ✅ All embed field names: "Reputation in Level" instead of "XP in Level"

What stays the same:

  • ❌ Command names: /xp, /rank, /leaderboard (unchanged)
  • ❌ API function names: addXP(), getXP() (unchanged)
  • ❌ Internal logic: Only visual display changes

Validation:

  • Label must be 1-20 characters
  • Cannot be empty string
  • Can include spaces, emojis, Unicode

Examples:

// Reputation system
await config.set(guildId, { labels: { xpDisplayName: 'Reputation' } })

// Point-based rewards
await config.set(guildId, { labels: { xpDisplayName: 'Points' } })

// Gaming experience
await config.set(guildId, { labels: { xpDisplayName: 'Exp' } })

// Reset to default
await config.set(guildId, { labels: undefined })
// Or simply don't set labels field

Note: The custom label applies guild-wide. All users in the guild see the same terminology.


**Special value:** Set any multiplier to `0` to disable automatic XP earning at that level (server-wide, per-role, or per-user). This enables manual XP control where only admin commands (`/xp give`, `/xp set`) can grant XP. The `messages` counter will still increment to track activity, but `xpMessages` will remain 0.

**Example:**
```typescript
// Disable automatic XP earning for entire guild
await XP.config.set(guildId, {
multipliers: { server: 0 }
})

// Disable automatic XP for specific role (e.g., new members)
await XP.config.set(guildId, {
multipliers: {
role: { 'newMemberRoleId': 0 }
}
})

// Disable automatic XP for specific user
await XP.config.set(guildId, {
multipliers: {
user: { 'userId': 0 }
}
})

Multi-Store Architecture

The XP plugin supports multiple isolated data stores so you can run parallel progression systems side-by-side. Each store has independent user data, configuration, leaderboard cache, and event stream. The default store is used by built-in commands; custom stores are accessed imperatively via the API.

Basic Usage

import { XP, leaderboard, config } from '@robojs/xp'

// Default store (implicit)
await XP.addXP(guildId, userId, 100)
const defaultXP = await XP.getXP(guildId, userId)

// Custom reputation store
await XP.addXP(guildId, userId, 50, { storeId: 'reputation' })
const repXP = await XP.getXP(guildId, userId, { storeId: 'reputation' })

// Custom credits store
await XP.addXP(guildId, userId, 200, { storeId: 'credits' })
const creditsXP = await XP.getXP(guildId, userId, { storeId: 'credits' })

Use Cases

Use CaseDefault StoreCustom StoresExample
Leveling + CurrenciesXP/Levels with role rewards'coins', 'gems', 'tokens'Traditional leveling + economy
Multi-Dimensional ReputationOverall activity'helpfulness', 'creativity', 'trading'Community reputation systems
Seasonal SystemsPermanent progression'season1', 'season2', 'event_halloween'Battle passes, events

Store Isolation

  • Each store has its own configuration. Changing one store’s config doesn’t affect others.
  • Leaderboards are per store. Caches and invalidation are independent per store.
  • All emitted events include a storeId field so listeners can filter by store.

Different cooldowns per store:

// Default store
await config.set(guildId, { cooldownSeconds: 60 })

// Reputation store
await config.set(guildId, { cooldownSeconds: 120 }, { storeId: 'reputation' })

Built-in Commands vs Custom Stores

  • Built-in commands (/rank, /leaderboard, /xp) only use the default store.
  • Custom stores require building your own commands or integrations.
  • Role rewards only trigger for the default store to avoid conflicts where multiple stores would grant/remove the same roles.

Configuration Per Store

import { config } from '@robojs/xp'

// Configure default store
await config.set(guildId, {
cooldownSeconds: 60,
labels: { xpDisplayName: 'XP' }
})

// Configure reputation store with different settings
await config.set(
guildId,
{
cooldownSeconds: 120,
labels: { xpDisplayName: 'Reputation' }
},
{ storeId: 'reputation' }
)

Global Configuration Defaults

Global defaults apply to all guilds unless overridden. They're stored at ['xp', 'global', 'config'].

import { config } from '@robojs/xp'

// Set global defaults
await config.setGlobal({
cooldownSeconds: 45,
xpRate: 1.5,
leaderboard: { public: true }
})

// Guild-specific overrides
await config.set(guildId, {
cooldownSeconds: 30 // This guild gets 30s cooldown
})

Config Precedence: Guild config > Global config > System defaults

Default Behavior Notes

This plugin provides standard XP mechanics:

  • XP per message: 15-25 XP (random, configurable via xpRate)
  • Cooldown: 60 seconds (configurable)
  • Level curve: XP = 5 * level² + 50 * level + 100
  • Role rewards: Stack or replace modes
  • Multipliers: Server, role, and user multipliers (premium features)

Custom Level Curves

Robo XP supports fully customizable level progression curves. By default, the plugin uses a standard quadratic formula, but you can override the curve per guild and per store (multi-store aware). You can choose from presets (quadratic, linear, exponential, lookup) or supply code via the getCurve callback in your plugin config for dynamic logic.

Note: Unless you customize it, the default store uses the default quadratic curve defined as:

XP(level) = 5 × level² + 50 × level + 100

This preserves familiar progression for most servers. You can override this globally, per guild, and/or per store.

Preset Curve Types

You can set a preset curve for a guild (and optionally per store) using your configuration APIs or helper utilities. Presets are fully serializable and are persisted in Flashcore so they survive restarts.

All presets support an optional maxLevel cap to prevent progression beyond a certain level.

1) Quadratic Curves (Custom Coefficients)

Formula: XP(level) = a × level² + b × level + c

  • Steeper progression example (large or highly active guilds):
// Example: steeper quadratic
await config.set(guildId, {
levels: {
type: 'quadratic',
params: { a: 10, b: 100, c: 200 },
maxLevel: 100,
},
})
  • Gentler progression example (small/early-stage guilds):
// Example: gentler quadratic
await config.set(guildId, {
levels: {
type: 'quadratic',
params: { a: 2, b: 20, c: 50 },
},
})

Notes:

  • Use higher a for faster late‑game growth; adjust b and c for early levels.
  • Add maxLevel to cap the system for seasons or events.

2) Linear Curves

Formula: XP(level) = level × xpPerLevel

  • Constant 100 XP per level:
await config.set(guildId, {
levels: { type: 'linear', params: { xpPerLevel: 100 } },
})
  • Slower 500 XP per level progression:
await config.set(guildId, {
levels: { type: 'linear', params: { xpPerLevel: 500 } },
})

Notes:

  • Simplest to reason about; great for reputation/credits or non‑competitive tracks.

3) Exponential Curves

Formula: XP(level) = multiplier × base^level

  • Doubles required XP every level:
await config.set(guildId, {
levels: {
type: 'exponential',
params: { base: 2, multiplier: 100 },
maxLevel: 50, // strongly recommended
},
})
  • 50% increase per level:
await config.set(guildId, {
levels: {
type: 'exponential',
params: { base: 1.5, multiplier: 100 },
maxLevel: 75,
},
})

Warnings:

  • Exponential growth becomes extremely steep; always set a maxLevel.

4) Lookup Table Curves

Define exact XP thresholds per level.

await config.set(guildId, {
levels: {
type: 'lookup',
params: { thresholds: [0, 100, 250, 500, 1000, 2000, 5000] },
// maxLevel defaults to thresholds.length - 1 when omitted
},
})

Notes:

  • Provides pixel‑perfect control over each level’s requirement.
  • Useful for seasonal passes and event‑specific tuning.

Advanced Customization with getCurve Callback

For dynamic logic that can’t be expressed with static presets, define levels.getCurve in your plugin config file config/plugins/robojs/xp.ts. This callback has the highest precedence:

getCurve callback → guild preset → default quadratic

The callback can be synchronous or asynchronous and is not stored in Flashcore.

Example: Different curves per store

// config/plugins/robojs/xp.ts
import type { PluginOptions } from '@robojs/xp'

export default {
levels: {
getCurve: (guildId, storeId) => {
if (storeId === 'reputation') {
// Linear: 500 XP per level
return {
xpForLevel: (level) => level * 500,
levelFromXp: (xp) => Math.floor(xp / 500),
}
}
return null // falls through to guild preset or default quadratic
},
},
} satisfies PluginOptions

Example: Special guild gets custom quadratic with cap

export default {
levels: {
getCurve: (guildId) => {
if (guildId === 'SPECIAL_GUILD_ID') {
return {
xpForLevel: (level) => 10 * level * level + 100 * level + 200,
levelFromXp: (xp) => Math.floor((-100 + Math.sqrt(10000 + 40 * (xp - 200))) / 20),
maxLevel: 100,
}
}
return null
},
},
} satisfies PluginOptions

Example: Dynamic curves based on guild size (async)

export default {
levels: {
getCurve: async (guildId) => {
const guild = await client.guilds.fetch(guildId)
if (guild.memberCount > 1000) {
return {
xpForLevel: (level) => 10 * level * level + 100 * level + 200,
levelFromXp: (xp) => Math.floor((-100 + Math.sqrt(10000 + 40 * (xp - 200))) / 20),
}
}
return null // small guilds use preset/default
},
},
} satisfies PluginOptions

Curves resolved via getCurve are cached per (guildId, storeId) to avoid repeated computation.

Migration Guide

Follow these steps to migrate from the default curve to custom curves.

  1. Choose your curve type
  • Simple, constant progression → Linear
  • Exact per‑level control → Lookup
  • Rapid growth (with caps) → Exponential
  • Familiar quadratic with tunable coefficients → Quadratic
  1. Configure a per‑guild preset (recommended)
await config.set(guildId, {
levels: { type: 'linear', params: { xpPerLevel: 100 } },
})
  • Presets persist in Flashcore and can be changed per guild and per store.
  1. Test your curve
  • Use /xp set to simulate levels and validate math.
  • Verify /rank and /leaderboard outputs.
  • Start with small XP ranges to confirm expected progression.
  1. Optional: Use the getCurve callback for advanced scenarios
  • Dynamic logic (guild size, time‑based seasons).
  • Different curves per store (e.g., reputation vs. coins).
  • Special partner/premium guilds.

Common use cases

  • Small servers (< 100 members): gentler curves for faster early progression.
  • Large servers (1000+ members): steeper curves to maintain challenge.
  • Seasonal systems: lookup tables with strict level caps.
  • Multi‑currency: different curves per store (linear for coins, exponential for gems).

Default Behavior

Default quadratic curve:

XP(level) = 5 × level² + 50 × level + 100

Example requirements:

  • Level 1: 100 XP
  • Level 5: 475 XP
  • Level 10: 1,100 XP
  • Level 20: 3,100 XP
  • Level 50: 15,100 XP
  • Level 100: 55,100 XP

Overriding defaults

  • Any custom preset or getCurve override replaces the default for that guild/store.
  • Built‑in commands like /rank and /leaderboard work with all curve types.

Custom stores

  • Custom stores (e.g., reputation, coins) also default to quadratic but may be configured independently.
  • Example: default store uses quadratic, reputation uses linear 500 XP/level via getCurve.

TypeScript API Reference

XP Manipulation

import { XP } from '@robojs/xp'

// Add XP to a user
const result = await XP.addXP(guildId, userId, 100, { reason: 'contest_winner' })
console.log(result.leveledUp) // true if user leveled up

// Remove XP from a user
const result = await XP.removeXP(guildId, userId, 50, { reason: 'moderation' })
console.log(result.leveledDown) // true if user leveled down

// Set exact XP value
const result = await XP.setXP(guildId, userId, 5000, { reason: 'admin_adjustment' })

// Recalculate level and reconcile roles (supports storeId option)
const result = await XP.recalc(guildId, userId)
console.log(result.reconciled) // true if level was corrected

// Query user data (supports storeId option)
const user = await XP.getUser(guildId, userId)
const xp = await XP.getXP(guildId, userId)
const level = await XP.getLevel(guildId, userId)

// User object includes both message counters
console.log(user.messages) // Total messages sent: 423
console.log(user.xpMessages) // Messages that awarded XP: 156

Supported signatures (multi-store):

  • addXP(guildId, userId, amount, { reason?, storeId? })
  • removeXP(guildId, userId, amount, { reason?, storeId? })
  • setXP(guildId, userId, totalXp, { reason?, storeId? })
  • recalc(guildId, userId, { storeId? })
  • getUser(guildId, userId, { storeId? })
  • getXP(guildId, userId, { storeId? })
  • getLevel(guildId, userId, { storeId? })

Multi-store query example:

// Query different stores
const defaultXP = await XP.getXP(guildId, userId) // Default store
const repXP = await XP.getXP(guildId, userId, { storeId: 'reputation' })
const coins = await XP.getXP(guildId, userId, { storeId: 'coins' })

Result Types:

interface XPChangeResult {
oldXp: number
newXp: number
oldLevel: number
newLevel: number
leveledUp: boolean
}

interface XPRemoveResult {
oldXp: number
newXp: number
oldLevel: number
newLevel: number
leveledDown: boolean
}

interface XPSetResult {
oldXp: number
newXp: number
oldLevel: number
newLevel: number
}

interface RecalcResult {
oldLevel: number
newLevel: number
totalXp: number
reconciled: boolean
}

Configuration Management

import { config } from '@robojs/xp'

// Get guild configuration
const guildConfig = await config.get(guildId)

// Update guild configuration (partial updates supported)
await config.set(guildId, {
cooldownSeconds: 45,
noXpChannelIds: ['123456789012345678']
})

// Validate configuration
const isValid = config.validate(configObject)

// Global configuration
const globalConfig = config.getGlobal()
config.setGlobal({ xpRate: 1.5 })

Supported signatures (multi-store):

  • config.get(guildId, { storeId? })
  • config.set(guildId, partial, { storeId? })

Multi-store configuration example:

// Configure default store
await config.set(guildId, { cooldownSeconds: 60 })

// Configure custom store
await config.set(guildId, { cooldownSeconds: 120 }, { storeId: 'reputation' })

Multipliers (0 for manual control):

import { config } from '@robojs/xp'

// Disable automatic XP for entire guild
await config.set(guildId, {
multipliers: { server: 0 }
})

// Disable automatic XP for specific role
await config.set(guildId, {
multipliers: {
role: { roleId: 0 }
}
})

// Disable automatic XP for specific user
await config.set(guildId, {
multipliers: {
user: { userId: 0 }
}
})

// Admin commands still work regardless of multiplier settings
await XP.addXP(guildId, userId, 500, { reason: 'manual_grant' })

Leaderboard Queries

import { leaderboard } from '@robojs/xp'

// Get top 10 users (offset 0, limit 10)
const result = await leaderboard.get(guildId, 0, 10)
// Returns: { entries: Array<{ userId: string; xp: number; level: number; rank: number }>, total: number }

// Get next 10 users (offset 10, limit 10)
const page2 = await leaderboard.get(guildId, 10, 10)

// Get user's rank
const rankInfo = await leaderboard.getRank(guildId, userId)
// Returns: { rank: number; total: number } or null if not found

// Manually invalidate cache
await leaderboard.invalidateCache(guildId)

Supported signatures (multi-store):

  • leaderboard.get(guildId, offset, limit, { storeId? })
  • leaderboard.getRank(guildId, userId, { storeId? })
  • leaderboard.invalidateCache(guildId, { storeId? })

Multi-store example:

// Default store leaderboard
const defaultTop = await leaderboard.get(guildId, 0, 10)

// Custom store leaderboard
const repTop = await leaderboard.get(guildId, 0, 10, { storeId: 'reputation' })

Role Rewards

import { reconcileRewards } from '@robojs/xp'

// Manually reconcile role rewards for a user
await reconcileRewards(guildId, userId)
// Automatically called on level changes
// Note: this is also available as rewards.reconcile(guildId, userId)

Math Utilities

import { math } from '@robojs/xp'

// XP needed to reach a specific level
const xpNeeded = math.xpNeededForLevel(10) // 1100

// Total XP accumulated up to a level
const totalXp = math.totalXpForLevel(10) // 5675

// Compute level from total XP
const progress = math.computeLevelFromTotalXp(5500)
// { level: 9, inLevel: 925, toNext: 175 }

// Progress within current level
const { percentage } = math.progressInLevel(5500) // ~84.1%

// Validate level or XP
const isValidLevel = math.isValidLevel(50) // true
const isValidXp = math.isValidXp(10000) // true

Event System

import { events } from '@robojs/xp'

// Listen for level-up events
events.onLevelUp(async ({ guildId, userId, oldLevel, newLevel, totalXp }) => {
console.log(`User ${userId} leveled up from ${oldLevel} to ${newLevel}!`)
})

// Listen for level-down events
events.onLevelDown(async ({ guildId, userId, oldLevel, newLevel, totalXp }) => {
console.log(`User ${userId} lost a level: ${oldLevel}${newLevel}`)
})

// Listen for XP changes
events.onXPChange(async ({ guildId, userId, oldXp, newXp, delta, reason }) => {
console.log(`User ${userId} XP changed by ${delta} (reason: ${reason})`)
})

// One-time listeners (generic API)
events.once('levelUp', handler)
events.once('levelDown', handler)
events.once('xpChange', handler)

// Remove listeners (generic API)
events.off('levelUp', handler)
events.off('levelDown', handler)
events.off('xpChange', handler)

Event Payloads:

interface LevelUpEvent {
guildId: string
userId: string
storeId: string
oldLevel: number
newLevel: number
totalXp: number
}

interface LevelDownEvent {
guildId: string
userId: string
storeId: string
oldLevel: number
newLevel: number
totalXp: number
}

interface XPChangeEvent {
guildId: string
userId: string
storeId: string
oldXp: number
newXp: number
delta: number
reason?: string
}

Filter events by store:

import { events } from '@robojs/xp'

events.onLevelUp(({ guildId, userId, newLevel, storeId }) => {
if (storeId === 'reputation') {
console.log(`Reputation level up: ${newLevel}`)
} else if (storeId === 'default') {
console.log(`XP level up: ${newLevel}`)
}
})

Constants

import { constants } from '@robojs/xp'

// Default configuration values
constants.DEFAULT_COOLDOWN // 60
constants.DEFAULT_XP_RATE // 1.0

// Default curve coefficients
constants.DEFAULT_CURVE_A // 5
constants.DEFAULT_CURVE_B // 50
constants.DEFAULT_CURVE_C // 100

Additional APIs

config.getDefault()

Returns the default configuration before applying any global or guild overrides.

import { config } from '@robojs/xp'

const defaults = config.getDefault()
// Useful for building UIs or generating initial configs

math.xpDeltaForLevelRange()

Computes the XP difference needed to move between two levels (inclusive of starting point), useful for multi-level jumps and grants.

import { math } from '@robojs/xp'

// XP needed to go from level 5 to level 10
const delta = math.xpDeltaForLevelRange(5, 10)

rewards object

The rewards module is also available as an object if you prefer a namespaced import.

import { rewards } from '@robojs/xp'

// Reconcile a user's roles based on their current level
await rewards.reconcile(guildId, userId)

// Alias of reconcile for clarity
await rewards.reconcileRewards(guildId, userId)

Integration Recipes

The @robojs/xp plugin is designed to be extended. These recipes demonstrate common integration patterns using the event system and imperative API.

For a complete, production-ready example, see seed/events/_start/level-announcements.ts which demonstrates standard level-up announcements with rich embeds, progress bars, and extensive customization options.

Below are additional recipes for common use cases:

Contest Plugin: Award Bonus XP

// src/events/contest-winner.ts
import { XP } from '@robojs/xp'

export default async (interaction) => {
const winnerId = interaction.user.id
const guildId = interaction.guildId

// Award 500 bonus XP
const result = await XP.addXP(guildId, winnerId, 500, { reason: 'contest_winner' })

if (result.leveledUp) {
await interaction.reply({
content: `🎉 You won the contest and leveled up to ${result.newLevel}!`,
ephemeral: true
})
} else {
await interaction.reply({
content: '🎉 You won the contest and earned 500 XP!',
ephemeral: true
})
}
}

Moderation Plugin: Remove XP for Violations

// src/events/warn-issued.ts
import { XP } from '@robojs/xp'

export default async ({ userId, guildId, severity }) => {
const penalties = {
minor: 50,
moderate: 200,
severe: 500
}

const amount = penalties[severity] || 100

await XP.removeXP(guildId, userId, amount, { reason: `moderation_${severity}` })
}

Premium Plugin: Enable +50% XP Boost

// src/events/premium-activated.ts
import { config } from '@robojs/xp'

export default async ({ userId, guildId }) => {
// Set 1.5x multiplier for premium user
const guildConfig = await config.get(guildId)

await config.set(guildId, {
multipliers: {
...guildConfig.multipliers,
user: {
...(guildConfig.multipliers?.user || {}),
[userId]: 1.5
}
}
})
}

Analytics Plugin: Track XP Changes

// src/listeners/xp-analytics.ts
import { events, XP } from '@robojs/xp'
import { logger } from 'robo.js'

// Track all XP changes
events.onXPChange(async ({ guildId, userId, delta, reason }) => {
logger.info(`XP Analytics: ${userId} ${delta > 0 ? 'gained' : 'lost'} ${Math.abs(delta)} XP`, {
guildId,
userId,
delta,
reason
})

// Send to analytics service
await analytics.track('xp_change', {
guild: guildId,
user: userId,
amount: delta,
reason
})
})

// Also track message activity
events.onXPChange(async ({ guildId, userId }) => {
const user = await XP.getUser(guildId, userId)

// Calculate XP efficiency (what % of messages award XP)
const efficiency = user.messages > 0 ? ((user.xpMessages / user.messages) * 100).toFixed(1) : 0

logger.info(`User ${userId} XP efficiency: ${efficiency}%`, {
totalMessages: user.messages,
xpMessages: user.xpMessages
})
})

Note: Track message efficiency to identify users affected by cooldowns or No-XP restrictions.

Announcement Plugin: Send Level-Up Messages

// src/listeners/level-announcements.ts
import { events } from '@robojs/xp'
import { client } from 'robo.js'
import { EmbedBuilder } from 'discord.js'

events.onLevelUp(async ({ guildId, userId, newLevel, totalXp }) => {
const guild = await client.guilds.fetch(guildId)
const user = await guild.members.fetch(userId)

// Find announcement channel
const channel = guild.channels.cache.find((c) => c.name === 'level-ups')
if (!channel?.isTextBased()) return

const embed = new EmbedBuilder()
.setTitle('🎉 Level Up!')
.setDescription(`${user} reached **Level ${newLevel}**!`)
.addFields({ name: 'Total XP', value: totalXp.toString(), inline: true })
.setColor('#00ff00')
.setThumbnail(user.displayAvatarURL())

await channel.send({ embeds: [embed] })
})

Manual XP Control (Disable Automatic Earning)

Disable automatic message-based XP earning while retaining full control via admin commands. Perfect for contest-only XP systems, event-based rewards, or manual moderation.

import { XP } from '@robojs/xp'

// Disable automatic XP earning for entire guild
await XP.config.set(guildId, {
multipliers: { server: 0 }
})

// Now only admin commands can grant XP
await XP.addXP(guildId, contestWinnerId, 1000, { reason: 'contest_winner' })
await XP.addXP(guildId, eventParticipantId, 500, { reason: 'event_participation' })

// Users can still send messages (messages counter increments)
// But xpMessages stays 0 (no automatic XP)

Use cases:

  • Contest-only XP: Award XP only to contest winners, not for chatting
  • Event-based rewards: Grant XP for specific achievements (reactions, voice time, etc.)
  • Manual moderation: Admins review and approve all XP grants
  • Temporary freeze: Disable XP during maintenance or special events
  • Hybrid systems: Disable for new members (role multiplier 0), enable after verification

Note: Admin commands (/xp give, /xp set, /xp remove) bypass the message handler and work regardless of multiplier settings.

Custom Configuration UI Integration

If you need to programmatically configure XP settings (e.g., from a web dashboard or bot setup wizard), use the same public API that the /xp config command uses:

import { config } from '@robojs/xp'

// Example: Setup wizard that configures XP for a new guild
export default async function setupGuild(guildId: string) {
// Configure basic settings
await config.set(guildId, {
cooldownSeconds: 45,
xpRate: 1.2,
leaderboard: { public: true }
})

// Add no-XP zones
await config.set(guildId, {
noXpRoleIds: ['123456789012345678'], // Muted role
noXpChannelIds: ['987654321098765432'] // Bot commands channel
})

// Configure role rewards
await config.set(guildId, {
roleRewards: [
{ level: 5, roleId: '111111111111111111' },
{ level: 10, roleId: '222222222222222222' },
{ level: 20, roleId: '333333333333333333' }
],
rewardsMode: 'stack',
removeRewardOnXpLoss: false
})

// Set multipliers
await config.set(guildId, {
multipliers: {
server: 1.5, // 50% boost for entire server
role: {
'444444444444444444': 2.0 // Booster role gets 2x XP
}
}
})
}

Use cases:

  • Web dashboard for guild admins
  • Automated setup wizards
  • Bulk configuration across multiple guilds
  • Integration with other management systems

Note: The /xp config command provides a user-friendly interface for manual configuration, while the API enables programmatic control for advanced integrations.

Custom Rewards: Build Custom Logic

// src/listeners/custom-rewards.ts
import { events } from '@robojs/xp'
import { client } from 'robo.js'

events.onLevelUp(async ({ guildId, userId, newLevel }) => {
const guild = await client.guilds.fetch(guildId)
const member = await guild.members.fetch(userId)

// Custom reward logic
switch (newLevel) {
case 10:
// Award custom badge
await giveCustomBadge(member, 'veteran')
break
case 25:
// Unlock special channel
await unlockChannel(member, 'vip-lounge')
break
case 50:
// Grant special permissions
await grantPermission(member, 'create_events')
break
}
})

Multi-Currency Economy System

// Award XP to multiple stores simultaneously
import { XP } from '@robojs/xp'

export default async (message) => {
const guildId = message.guildId
const userId = message.author.id

// Award to default store (leveling with role rewards)
await XP.addXP(guildId, userId, 20, { reason: 'message' })

// Award coins (server currency)
await XP.addXP(guildId, userId, 10, { reason: 'message', storeId: 'coins' })

// Award tokens (premium currency) if user has premium role
if (message.member.roles.cache.has(premiumRoleId)) {
await XP.addXP(guildId, userId, 5, { reason: 'premium_message', storeId: 'tokens' })
}
}

Reputation System with Multiple Dimensions

// Track different types of reputation
import { XP, events } from '@robojs/xp'

// Award helpfulness reputation
export async function awardHelpfulness(guildId: string, userId: string) {
await XP.addXP(guildId, userId, 25, {
reason: 'helped_member',
storeId: 'helpfulness'
})
}

// Award creativity reputation
export async function awardCreativity(guildId: string, userId: string) {
await XP.addXP(guildId, userId, 50, {
reason: 'created_content',
storeId: 'creativity'
})
}

// Listen for reputation level-ups
events.onLevelUp(({ userId, newLevel, storeId }) => {
if (storeId === 'helpfulness') {
console.log(`${userId} reached helpfulness level ${newLevel}!`)
}
})

Seasonal Battle Pass System

// Seasonal progression with store isolation
import { XP, config } from '@robojs/xp'

const CURRENT_SEASON = 'season3'

// Award seasonal XP
export async function awardSeasonalXP(guildId: string, userId: string, amount: number) {
await XP.addXP(guildId, userId, amount, {
reason: 'seasonal_activity',
storeId: CURRENT_SEASON
})
}

// Get user's seasonal progress
export async function getSeasonalProgress(guildId: string, userId: string) {
const xp = await XP.getXP(guildId, userId, { storeId: CURRENT_SEASON })
const level = await XP.getLevel(guildId, userId, { storeId: CURRENT_SEASON })
return { xp, level, season: CURRENT_SEASON }
}

// Reset season (start new season without affecting default store)
export async function startNewSeason(guildId: string) {
// Old season data remains in 'season3' store
// New season starts fresh in 'season4' store
// Default store (permanent progression) is unaffected
}

REST API Documentation

Prerequisite: Install @robojs/server to enable HTTP endpoints

Response Format

All endpoints return JSON with the following structure:

Success:

{
"success": true,
"data": { ... }
}

Error:

{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable error message"
}
}

Error Codes

CodeHTTP StatusDescription
MISSING_GUILD_ID400Guild ID parameter missing
GUILD_NOT_FOUND404Guild not found or bot not member
MISSING_USER_ID400User ID parameter missing
USER_NOT_FOUND404User has no XP record
METHOD_NOT_ALLOWED405HTTP method not allowed
INVALID_REQUEST400Invalid request body or parameters
INVALID_AMOUNT400Invalid XP amount
INVALID_CONFIG400Invalid configuration
INVALID_LEVEL400Invalid level value
INVALID_ROLE_ID400Invalid Discord role ID
INVALID_MULTIPLIER400Invalid multiplier value
DUPLICATE_REWARD400Role reward already exists at level
REWARD_NOT_FOUND404Role reward not found
INTERNAL_ERROR500Unexpected server error

Endpoints

User XP Data

GET /api/xp/users/:guildId/:userId Get user XP data and level progress.

Terminal
curl http://localhost:3000/api/xp/users/123456789012345678/987654321098765432

Response:

{
"success": true,
"data": {
"user": {
"xp": 5500,
"level": 10,
"messages": 423,
"xpMessages": 156,
"lastAwardedAt": 1234567890000
},
"progress": {
"level": 10,
"inLevel": 495,
"toNext": 1155
},
"percentage": 42.86
}
}

POST /api/xp/users/:guildId/:userId Add XP to user.

Terminal
curl -X POST http://localhost:3000/api/xp/users/123.../987...   -H "Content-Type: application/json"   -d '{"amount": 100, "reason": "api_award"}'

PUT /api/xp/users/:guildId/:userId Set user XP to specific value.

Terminal
curl -X PUT http://localhost:3000/api/xp/users/123.../987...   -H "Content-Type: application/json"   -d '{"xp": 5000, "reason": "api_set"}'

DELETE /api/xp/users/:guildId/:userId Remove XP from user.

Terminal
curl -X DELETE http://localhost:3000/api/xp/users/123.../987...   -H "Content-Type: application/json"   -d '{"amount": 50, "reason": "api_penalty"}'

Recalculate Level

POST /api/xp/users/:guildId/:userId/recalc Recalculate user's level from total XP.

Terminal
curl -X POST http://localhost:3000/api/xp/users/123.../987.../recalc

Guild Configuration

GET /api/xp/config/:guildId Get current guild configuration.

PUT /api/xp/config/:guildId Update guild configuration (partial updates supported).

Terminal
curl -X PUT http://localhost:3000/api/xp/config/123...   -H "Content-Type: application/json"   -d '{"cooldownSeconds": 45, "xpRate": 1.5}'

Role Rewards

GET /api/xp/config/:guildId/rewards List all role rewards.

POST /api/xp/config/:guildId/rewards Add new role reward.

Terminal
curl -X POST http://localhost:3000/api/xp/config/123.../rewards   -H "Content-Type: application/json"   -d '{"level": 10, "roleId": "456789012345678901"}'

DELETE /api/xp/config/:guildId/rewards Remove role reward by level.

Terminal
curl -X DELETE http://localhost:3000/api/xp/config/123.../rewards   -H "Content-Type: application/json"   -d '{"level": 10}'

Multipliers

GET /api/xp/config/:guildId/multipliers Get all multipliers (server, role, user).

PUT /api/xp/config/:guildId/multipliers Set/update multipliers.

Terminal
curl -X PUT http://localhost:3000/api/xp/config/123.../multipliers   -H "Content-Type: application/json"   -d '{"server": 2.0, "role": {"456...": 1.5}}'

DELETE /api/xp/config/:guildId/multipliers Remove specific multipliers.

Terminal
curl -X DELETE http://localhost:3000/api/xp/config/123.../multipliers   -H "Content-Type: application/json"   -d '{"role": ["456..."], "user": ["789..."]}'

Global Configuration

GET /api/xp/config/global Get global configuration defaults.

PUT /api/xp/config/global Update global defaults (affects all guilds).

Terminal
curl -X PUT http://localhost:3000/api/xp/config/global   -H "Content-Type: application/json"   -d '{"cooldownSeconds": 45, "xpRate": 1.2}'

Performance & Caching

Caching Strategy

  • Leaderboard cache: Top 100 users per guild per store, 60-second TTL
  • Config cache: In-memory with event-driven invalidation
  • Complexity: O(1) cached reads, O(n log n) refresh for n users

Multi-Store Performance

  • Each store maintains an independent cache (e.g., default store cache, reputation store cache).
  • XP changes in one store do not invalidate caches for other stores.
  • Approximate memory: ~10KB per store per guild for 100 cached entries.
  • Example: A guild with 3 stores (default, reputation, coins) ≈ ~30KB cache.

Scalability

  • Memory per guild: ~10KB × number of stores for 100 cached leaderboard entries per store
  • Recommended limits: 100k users per guild max
  • Cache eviction: TTL-based, auto-refreshes on query after expiry

See PERFORMANCE.md for detailed benchmarks and optimization guide.

Data Model

Flashcore keys live under ['xp', storeId, guildId]:

KeyContentsExample
['xp', 'default', guildId]Guild config merged with global defaults{ cooldownSeconds: 60, xpRate: 1.0, ... }
['xp', 'default', guildId, 'users', userId]UserXP record (default store){ xp: 5500, level: 10, messages: 423, xpMessages: 156, lastAwardedAt: 1234567890000 }
['xp', 'reputation', guildId, 'users', userId]UserXP record (custom store example){ xp: 250, level: 2 }
['xp', 'default', guildId, 'members']Set of tracked member IDs['user1', 'user2', ...]
['xp', 'default', guildId, 'lb:{perPage}:{page}']Leaderboard cache (per store)[{ userId, xp, level, rank }, ...]
['xp', 'default', guildId, 'schema']Schema version for future migrations1

Global defaults live at ['xp', 'global', 'config'] and apply to all stores as base defaults.

Multi-Store Namespace Structure

Each store has independent data under its own namespace:

  • Default store: ['xp', 'default', guildId, ...]
  • Reputation store: ['xp', 'reputation', guildId, ...]
  • Coins store: ['xp', 'coins', guildId, ...]

This ensures complete isolation between stores:

  • Independent user data
  • Independent configuration
  • Independent members tracking
  • Independent schema versions

UserXP Structure

interface UserXP {
xp: number // Total XP accumulated
level: number // Current level (computed from xp)
messages: number // Total messages sent in guild text channels
xpMessages: number // Messages that awarded XP (subset of messages)
lastAwardedAt: number // Timestamp of last XP award (Unix ms)
}

Message Counter Distinction:

  • messages tracks all messages sent in guild text channels (increments after basic validation: bot check, guild check, text channel check)
  • xpMessages tracks only messages that actually awarded XP (increments after all checks pass: No-XP channels, No-XP roles, cooldown)

These counters will differ when:

  • User sends messages within cooldown period (default 60s)
  • User has a No-XP role
  • User sends messages in No-XP channels
  • User is a bot (neither counter increments)

Example: A user sends 100 messages in 5 minutes. With a 60s cooldown, only ~5 messages award XP. Result: messages: 100, xpMessages: 5.

GuildConfig Structure

See Configuration Guide above for complete structure.

Default Configuration

This plugin provides standard XP system features:

Standard Features

  • XP per message: 15-25 XP (configurable)
  • Cooldown: 60 seconds (configurable)
  • Level curve: Standard quadratic formula
  • Role rewards: Stack or replace modes
  • Multipliers: Server, role, user (premium features)
  • No-XP roles/channels
  • Leaderboard pagination
  • Admin commands

Configuration for Standard Behavior

import { config } from '@robojs/xp'

await config.set(guildId, {
cooldownSeconds: 60,
xpRate: 1.0,
rewardsMode: 'stack',
removeRewardOnXpLoss: false,
leaderboard: { public: true }
})

Troubleshooting

Users Not Earning XP from Messages

Symptom: Users send messages but their XP doesn't increase. The messages counter increments but xpMessages stays at 0.

Possible causes:

  1. Multiplier set to 0 (manual control mode):

    const config = await XP.config.get(guildId)
    console.log('Server multiplier:', config.multipliers?.server ?? 1.0)
    console.log('Role multipliers:', config.multipliers?.role)
    console.log('User multipliers:', config.multipliers?.user)
    • If any multiplier in the chain is 0, no XP is awarded
    • Check: server: 0 disables for everyone
    • Check: role: { 'roleId': 0 } disables for users with that role
    • Check: user: { 'userId': 0 } disables for specific user
    • Solution: Set multipliers to non-zero values to re-enable automatic XP
  2. No-XP channels:

    const config = await XP.config.get(guildId)
    console.log('No-XP channels:', config.noXpChannelIds)
    • Messages in these channels don't award XP
    • Solution: Remove channel from noXpChannelIds list
  3. No-XP roles:

    const config = await XP.config.get(guildId)
    console.log('No-XP roles:', config.noXpRoleIds)
    • Users with these roles don't earn XP
    • Solution: Remove role from noXpRoleIds list
  4. Cooldown blocking rapid messages:

    • Default 60s cooldown means only 1 message per minute awards XP
    • Check: messages much higher than xpMessages indicates cooldown blocking
    • Solution: This is expected behavior; reduce cooldownSeconds if needed

Difference between 0 multiplier and No-XP zones:

Feature0 MultiplierNo-XP Channels/Roles
ScopeServer/role/user levelChannel or role level
messages counter✅ Increments✅ Increments
xpMessages counter❌ Stays 0❌ Stays 0
Admin commands work✅ Yes✅ Yes
Use caseManual XP controlExclude specific areas
FlexibilityCan combine (server × role × user)All-or-nothing per channel/role

Debug checklist:

const user = await XP.getUser(guildId, userId)
const config = await XP.config.get(guildId)

console.log('User XP:', user.xp)
console.log('Total messages:', user.messages)
console.log('XP messages:', user.xpMessages)
console.log('Ratio:', ((user.xpMessages / user.messages) * 100).toFixed(1) + '%')
console.log('\nConfig:')
console.log('- Cooldown:', config.cooldownSeconds + 's')
console.log('- XP Rate:', config.xpRate)
console.log('- Server multiplier:', config.multipliers?.server ?? 1.0)
console.log('- No-XP channels:', config.noXpChannelIds)
console.log('- No-XP roles:', config.noXpRoleIds)

XP Not Being Awarded

Possible causes:

  1. Channel type: Only text channels award XP
  2. No-XP roles: User has a role in noXpRoleIds
  3. No-XP channels: Channel is in noXpChannelIds
  4. Cooldown: User sent message within cooldown period (default 60s)
  5. Bot messages: Bots don't earn XP

Debug:

import { config } from '@robojs/xp'
const guildConfig = await config.get(guildId)
console.log('No-XP roles:', guildConfig.noXpRoleIds)
console.log('No-XP channels:', guildConfig.noXpChannelIds)
console.log('Cooldown:', guildConfig.cooldownSeconds)

Message Counter Discrepancy

Issue: The messages count is much higher than xpMessages count.

This is expected behavior. The two counters track different metrics:

  • messages: Total messages sent in guild text channels (after basic validation)
  • xpMessages: Messages that actually awarded XP (after all checks)

Common reasons for discrepancy:

  1. Cooldown blocking XP: With default 60s cooldown, rapid messages don't award XP

    • Example: 10 messages in 1 minute → messages: 10, xpMessages: 1
  2. No-XP channels: Messages in excluded channels increment messages but not xpMessages

    • Check: config.noXpChannelIds
  3. No-XP roles: Users with excluded roles increment messages but not xpMessages

    • Check: config.noXpRoleIds
  4. Temporary restrictions: User had No-XP role or was in No-XP channel for a period

Debug:

import { XP, config } from '@robojs/xp'
const user = await XP.getUser(guildId, userId)
const guildConfig = await config.get(guildId)

console.log('Total messages:', user.messages)
console.log('XP messages:', user.xpMessages)
console.log('Ratio:', ((user.xpMessages / user.messages) * 100).toFixed(1) + '%')
console.log('Cooldown:', guildConfig.cooldownSeconds + 's')
console.log('No-XP channels:', guildConfig.noXpChannelIds)
console.log('No-XP roles:', guildConfig.noXpRoleIds)

Expected ratios:

  • Active chatters with 60s cooldown: 10-30% (depends on chat frequency)
  • Users in No-XP channels frequently: 5-15%
  • Users with temporary No-XP role: varies widely

This is not a bug - it's intentional design to track both total activity and XP-eligible activity.

Roles Not Being Granted

Possible causes:

  1. Bot permissions: Bot lacks Manage Roles permission
  2. Role hierarchy: Reward role is higher than bot's highest role
  3. Managed roles: Cannot assign managed roles (e.g., Nitro Boost)
  4. Missing role: Role was deleted but still in roleRewards

Fix:

import { reconcileRewards, config } from '@robojs/xp'

// Recalculate roles for a user
await reconcileRewards(guildId, userId)

// Remove deleted roles from config
const guildConfig = await config.get(guildId)
const validRewards = guildConfig.roleRewards.filter((r) => guild.roles.cache.has(r.roleId))
await config.set(guildId, { roleRewards: validRewards })

Leaderboard Showing Stale Data

Cause: Cache TTL (60 seconds)

Solution:

import { leaderboard } from '@robojs/xp'
// Manually invalidate cache
await leaderboard.invalidateCache(guildId)

// Or wait for TTL to expire (auto-refreshes on next query)

Performance Issues

Large guilds (10k+ users):

  • First leaderboard query after cache expiry may take 200-500ms
  • Subsequent queries are under 10ms (cached)
  • Consider warming cache during off-peak hours

Cache warming:

import { leaderboard } from '@robojs/xp'
// Warm cache for all guilds (top 100 users)
for (const guildId of guildIds) {
await leaderboard.get(guildId, 0, 100)
}

Multi-Store Issues

Custom Store Not Working

  • Symptom: Custom store operations fail or return null
  • Cause: Forgetting to pass storeId in options
  • Solution: Always include { storeId: 'storeName' } for custom stores
// ❌ Wrong - uses default store
await XP.getXP(guildId, userId)

// ✅ Correct - uses reputation store
await XP.getXP(guildId, userId, { storeId: 'reputation' })

Role Rewards Not Working for Custom Store

  • Symptom: Leveling up in custom store doesn't grant roles
  • This is expected: Role rewards only trigger for default store
  • Rationale: Prevents conflicts where multiple stores would grant/remove the same roles
  • Solution: Use default store for leveling with role rewards; use custom stores for parallel progression without roles

Leaderboard Showing Wrong Data

  • Symptom: Leaderboard shows data from different store
  • Cause: Not passing storeId to leaderboard query
  • Solution:
// Default store leaderboard
const defaultTop = await leaderboard.get(guildId, 0, 10)

// Reputation store leaderboard
const repTop = await leaderboard.get(guildId, 0, 10, { storeId: 'reputation' })

Events Firing for All Stores

  • Symptom: Event listener triggers for all stores, not just one
  • Solution: Filter events by storeId field
events.onLevelUp((event) => {
if (event.storeId !== 'reputation') return // Skip non-reputation events
console.log('Reputation level up!')
})

Development

Run tests or build from the package root:

Terminal
# Run all tests
pnpm test

# Build plugin
pnpm build plugin

Robo XP supports fully customizable level progression curves. By default, the plugin uses a standard quadratic formula, but you can override the curve per guild and per store (multi-store aware). You can choose from presets (quadratic, linear, exponential, lookup) or supply code via the getCurve callback in your plugin config for dynamic logic.

Note: Unless you customize it, the default store uses the default quadratic curve defined as:

XP(level) = 5 × level² + 50 × level + 100

This preserves familiar progression for most servers. You can override this globally, per guild, and/or per store.

Preset Curve Types

You can set a preset curve for a guild (and optionally per store) using your configuration APIs or helper utilities. Presets are fully serializable and are persisted in Flashcore so they survive restarts.

All presets support an optional maxLevel cap to prevent progression beyond a certain level.

1) Quadratic Curves (Custom Coefficients)

Formula: XP(level) = a × level² + b × level + c

  • Steeper progression example (large or highly active guilds):
// Example: steeper quadratic
await config.set(guildId, {
levels: {
type: 'quadratic',
params: { a: 10, b: 100, c: 200 },
maxLevel: 100,
},
})
  • Gentler progression example (small/early-stage guilds):
// Example: gentler quadratic
await config.set(guildId, {
levels: {
type: 'quadratic',
params: { a: 2, b: 20, c: 50 },
},
})

Notes:

  • Use higher a for faster late‑game growth; adjust b and c for early levels.
  • Add maxLevel to cap the system for seasons or events.

2) Linear Curves

Formula: XP(level) = level × xpPerLevel

  • Constant 100 XP per level:
await config.set(guildId, {
levels: { type: 'linear', params: { xpPerLevel: 100 } },
})
  • Slower 500 XP per level progression:
await config.set(guildId, {
levels: { type: 'linear', params: { xpPerLevel: 500 } },
})

Notes:

  • Simplest to reason about; great for reputation/credits or non‑competitive tracks.

3) Exponential Curves

Formula: XP(level) = multiplier × base^level

  • Doubles required XP every level:
await config.set(guildId, {
levels: {
type: 'exponential',
params: { base: 2, multiplier: 100 },
maxLevel: 50, // strongly recommended
},
})
  • 50% increase per level:
await config.set(guildId, {
levels: {
type: 'exponential',
params: { base: 1.5, multiplier: 100 },
maxLevel: 75,
},
})

Warnings:

  • Exponential growth becomes extremely steep; always set a maxLevel.

4) Lookup Table Curves

Define exact XP thresholds per level.

await config.set(guildId, {
levels: {
type: 'lookup',
params: { thresholds: [0, 100, 250, 500, 1000, 2000, 5000] },
// maxLevel defaults to thresholds.length - 1 when omitted
},
})

Notes:

  • Provides pixel‑perfect control over each level’s requirement.
  • Useful for seasonal passes and event‑specific tuning.

Advanced Customization with getCurve Callback

For dynamic logic that can’t be expressed with static presets, define levels.getCurve in your plugin config file config/plugins/robojs/xp.ts. This callback has the highest precedence:

getCurve callback → guild preset → default quadratic

The callback can be synchronous or asynchronous and is not stored in Flashcore.

Example: Different curves per store

// config/plugins/robojs/xp.ts
import type { PluginOptions } from '@robojs/xp'

export default {
levels: {
getCurve: (guildId, storeId) => {
if (storeId === 'reputation') {
// Linear: 500 XP per level
return {
xpForLevel: (level) => level * 500,
levelFromXp: (xp) => Math.floor(xp / 500),
}
}
return null // falls through to guild preset or default quadratic
},
},
} satisfies PluginOptions

Example: Special guild gets custom quadratic with cap

export default {
levels: {
getCurve: (guildId) => {
if (guildId === 'SPECIAL_GUILD_ID') {
return {
xpForLevel: (level) => 10 * level * level + 100 * level + 200,
levelFromXp: (xp) => Math.floor((-100 + Math.sqrt(10000 + 40 * (xp - 200))) / 20),
maxLevel: 100,
}
}
return null
},
},
} satisfies PluginOptions

Example: Dynamic curves based on guild size (async)

export default {
levels: {
getCurve: async (guildId) => {
const guild = await client.guilds.fetch(guildId)
if (guild.memberCount > 1000) {
return {
xpForLevel: (level) => 10 * level * level + 100 * level + 200,
levelFromXp: (xp) => Math.floor((-100 + Math.sqrt(10000 + 40 * (xp - 200))) / 20),
}
}
return null // small guilds use preset/default
},
},
} satisfies PluginOptions

Curves resolved via getCurve are cached per (guildId, storeId) to avoid repeated computation.

Migration Guide

Follow these steps to migrate from the default curve to custom curves.

  1. Choose your curve type
  • Simple, constant progression → Linear
  • Exact per‑level control → Lookup
  • Rapid growth (with caps) → Exponential
  • Familiar quadratic with tunable coefficients → Quadratic
  1. Configure a per‑guild preset (recommended)
await config.set(guildId, {
levels: { type: 'linear', params: { xpPerLevel: 100 } },
})
  • Presets persist in Flashcore and can be changed per guild and per store.
  1. Test your curve
  • Use /xp set to simulate levels and validate math.
  • Verify /rank and /leaderboard outputs.
  • Start with small XP ranges to confirm expected progression.
  1. Optional: Use the getCurve callback for advanced scenarios
  • Dynamic logic (guild size, time‑based seasons).
  • Different curves per store (e.g., reputation vs. coins).
  • Special partner/premium guilds.

Common use cases

  • Small servers (< 100 members): gentler curves for faster early progression.
  • Large servers (1000+ members): steeper curves to maintain challenge.
  • Seasonal systems: lookup tables with strict level caps.
  • Multi‑currency: different curves per store (linear for coins, exponential for gems).

Default Behavior

Default quadratic curve:

XP(level) = 5 × level² + 50 × level + 100

Example requirements:

  • Level 1: 100 XP
  • Level 5: 475 XP
  • Level 10: 1,100 XP
  • Level 20: 3,100 XP
  • Level 50: 15,100 XP
  • Level 100: 55,100 XP

Overriding defaults

  • Any custom preset or getCurve override replaces the default for that guild/store.
  • Built‑in commands like /rank and /leaderboard work with all curve types.

Custom stores

  • Custom stores (e.g., reputation, coins) also default to quadratic but may be configured independently.
  • Example: default store uses quadratic, reputation uses linear 500 XP/level via getCurve.

More on GitHub