When I was working on Raccoon Bot, I wanted it to do more than send text messages. Levels, XP, rankings, and welcomes for new members look much better as cards.
Judging by members' reactions, those details matter. They enjoy seeing their progress on a card instead of receiving another XP number in a message.
Drawing a single card with Canvas is fairly straightforward. Maintaining several types of cards means handling avatars, fonts, long names, backgrounds, and user settings. It gets more involved when those images need to be generated quickly in response to bot commands.
I wanted a tool that could handle this without making me write the drawing logic from scratch each time. The idea was to pass in a name, an avatar, a level, and a few colors, then get an image back.
That led me to work on discord-card-canvas, a library I use and continue to develop for generating cards.
I'll walk through three examples: a rank card, a welcome, and a level-up notification. We'll generate PNGs locally, then use the same renderer in a Discord command.
What the bot passes to a card
The /rank command is a good example. A member runs it and receives their avatar, name, level, ranking position, and a progress bar.
In Raccoon Bot, XP calculations and design selection happen before rendering. The bot determines the level, ranking position, and personal or server settings, then passes the prepared values to the library. Changing the progress bar's color therefore doesn't require changing the XP calculation.
Now I can change card designs and check the results separately from the bot. That makes small changes noticeably easier.
The first PNG, without connecting to Discord
The examples below were tested with Node.js 22.19.0 and discord-card-canvas 2.3.4. To install with npm 10:
mkdir card-demo cd card-demo npm init -y npm install discord-card-canvas@2.3.4 The node-canvas dependency includes a native component. If installation fails or you get a canvas.node error, check the system requirements and permission to run installation scripts in the Installation section. It includes instructions for other npm versions, pnpm, and Yarn.
Place avatar.png alongside the scripts you'll create, plus a fonts directory containing Nunito-SemiBold.ttf and Nunito-Bold.ttf. These files aren't included in the npm package; keep the font license when distributing them. These are subsets covering Latin, Cyrillic, and common punctuation.
Put the renderer in cards.mjs. Both the local script and the command handler will use createRankCard:
import { fileURLToPath } from 'node:url'; import { loadFonts, RankCardBuilder, WelcomeBuilder, LevelUpBuilder, } from 'discord-card-canvas'; loadFonts( [ { path: 'Nunito-SemiBold.ttf', family: 'Nunito', weight: '600' }, { path: 'Nunito-Bold.ttf', family: 'Nunito', weight: '700' }, ], fileURLToPath(new URL('./fonts/', import.meta.url)), ); export function createRankCard({ nickname, avatarURL, level, rank, xp, requiredXP }) { return new RankCardBuilder({ nicknameText: { content: nickname, font: 'Nunito', weight: '700' }, avatarImgURL: avatarURL, currentLvl: level, currentRank: rank, currentXP: xp, requiredXP, userStatus: 'offline', userStatusEnable: false, backgroundColor: { background: '#070D19', bubbles: '#0CA7FF' }, colorTextDefault: '#E8EDF5', }).build(); } level, rank, xp, and requiredXP come from the calling code. The library doesn't award XP or determine a member's ranking position. userStatusEnable: false hides the status indicator, which this demo doesn't need.
In a separate render-cards.mjs, pass in demo data and save the result:
import { readFileSync, writeFileSync } from 'node:fs'; import { createRankCard } from './cards.mjs'; const avatarURL = `data:image/png;base64,${readFileSync(new URL('./avatar.png', import.meta.url)).toString('base64')}`; const rank = await createRankCard({ nickname: 'Raccoon Bot', avatarURL, level: 12, rank: 3, xp: 4200, requiredXP: 6000, }); writeFileSync(new URL('./rank.png', import.meta.url), rank.toBuffer('image/png')); node render-cards.mjs This produces a 1000 × 250 rank.png. Resource paths are relative to the script files, so the current working directory doesn't affect avatar or font loading. The avatar is passed as a data URL: ./avatar.png by itself isn't a supported URL for the default loader.
Names and numbers in the examples are demo values; the Raccoon Bot logo serves as the avatar.
Welcomes and level-up notifications
A welcome needs the member's name and the community's name. The library provides WelcomeBuilder for this, alongside LeaveBuilder for farewell images. Raccoon Bot uses the configurable base builder for welcomes; the short, purpose-built wrapper is convenient for this example.
Append this function to cards.mjs:
export function createWelcomeCard(nickname, avatarURL) { return new WelcomeBuilder({ nicknameText: { content: nickname, weight: '700' }, avatarImgURL: avatarURL, mainText: { content: 'WELCOME', weight: '700' }, secondText: { content: 'Raccoon Bot Discord', weight: '600' }, fontDefault: 'Nunito', backgroundColor: { background: '#FFFFFF', waves: '#0CA7FF' }, }).build(); } mainText sets the heading, nicknameText the name, and secondText the bottom line. Background and wave colors are set separately. The font has already been registered at the top of the module.
A level-up notification takes different data: the previous and new levels. Add another function to the same file:
export function createLevelUpCard(nickname, avatarURL, previousLvl, newLvl) { return new LevelUpBuilder({ nicknameText: { content: nickname, weight: '700' }, avatarImgURL: avatarURL, previousLvl, newLvl, userStatusEnable: false, fontDefault: 'Nunito', levelUpText: { content: 'LEVEL UP', weight: '700' }, backgroundColor: { background: '#F2F8FF', pattern: 'stars', patternColor: '#0CA7FF' }, colorTextDefault: '#008FDB', }).build(); } This example uses a light background with stars. The pattern field also accepts bubbles; setting patternEnable: false removes the pattern. The bot decides when a member has leveled up and a notification should be sent.
Finally, append both calls to render-cards.mjs and run node render-cards.mjs again:
import { createWelcomeCard, createLevelUpCard } from './cards.mjs'; const welcome = await createWelcomeCard('New member', avatarURL); writeFileSync(new URL('./welcome.png', import.meta.url), welcome.toBuffer('image/png')); const levelUp = await createLevelUpCard('Raccoon Bot', avatarURL, 12, 13); writeFileSync(new URL('./level-up.png', import.meta.url), levelUp.toBuffer('image/png')); You'll now have three PNGs: rank.png, welcome.png, and level-up.png. The welcome image is 800 × 350; the level-up image is 1000 × 250.
These examples share an avatar and fonts, while each card has its own design parameters. In Raccoon Bot, users choose their design in the dashboard; the bot service turns the saved settings into builder parameters.
Using the same rank renderer in a slash command
You can now call createRankCard from an existing bot. Below is rank-command.mjs for an already registered /rank command and a command dispatcher that calls its execute function. The example uses discord.js 14.26.2. Bot authentication and command registration remain part of the existing application.
import { AttachmentBuilder } from 'discord.js'; import { createRankCard } from './cards.mjs'; export async function execute(interaction) { await interaction.deferReply(); const demoStats = { level: 12, rank: 3, xp: 4200, requiredXP: 6000 }; const card = await createRankCard({ nickname: interaction.user.displayName, avatarURL: interaction.user.displayAvatarURL({ extension: 'png', size: 256 }), ...demoStats, }); const png = await new Promise((resolve, reject) => { card.toBuffer((error, buffer) => error ? reject(error) : resolve(buffer), 'image/png'); }); await interaction.editReply({ files: [new AttachmentBuilder(png, { name: 'rank.png' })], }); } deferReply() acknowledges the command before avatar loading and rendering. Then editReply() sends the attachment; this sequence is described in the discord.js API. The avatar request explicitly selects PNG.
The local script uses synchronous toBuffer, while the handler wraps node-canvas's callback version in a Promise. Both produce a PNG Buffer. This changes how PNG encoding runs; it doesn't move the entire card renderer into a separate worker.
Replace demoStats with data from your leveling system: every call currently gets the same numbers. The code shows the successful path; errors need to reach your bot's command error handler. Raccoon Bot handles missing member data separately from rendering errors. If rendering fails, /rank responds with an error embed. Level-up notifications have a separate embed fallback.
What I check before connecting it to the bot
A short name is enough for the first render. Next, try a long name, characters used in your community, and XP values just before and after a level change. Setting font: 'Nunito' doesn't load a font: its files and weights must be registered in the Node environment running the bot.
While preparing the illustration, I found a clipped red status badge even though userStatusEnable was already off. The badge was part of the source avatar PNG. Replacing the image with a clean logo export removed the artifact. Now, when I see artifacts like this, I check the source PNG first, before looking for a bug in the renderer.
The playground lets you adjust colors, backgrounds, and fonts, preview the result, download a PNG, and copy the code. Browser support is still experimental, so check your chosen design again in Node. Local images uploaded to the editor and the fonts used there need to be supplied separately to the bot.
You can explore the bot's settings in the Raccoon Bot demo. To try the library in your own project, you can now generate three local PNGs and connect the working renderer to your command handler.




