Generating Images
Leaderboards and stat cards are rendered as PNG images with @napi-rs/canvas (prebuilt binaries, nothing to install). A ready-made stat card, and a table to draw in it, cover most needs; lower-level helpers are there for anything else.
| Module | Provides |
|---|---|
#lib/table | drawTable, playerColumn, drawCellText: ranked table with medals, avatars, names and your own columns, drawn in a stat card |
#lib/stat-card | renderStatCard: card with a portrait, a title, an optional rank, a grid of stat boxes and a side panel |
#lib/imaging | Shared palette and font, default accent (DEFAULT_ACCENT), avatar download, circular images and ringed portraits (drawPortrait), highlighting (drawHighlight), fitted text, assets |
The Outfit font and the gold, silver and bronze medals ship in src/lib/assets/ and are loaded for you. Outfit has no emojis: font() adds the system's emoji font when there is one (Noto Color Emoji, installed by the Docker image), so that names containing emojis render. Always go through font() rather than setting ctx.font by hand.
Each module declares its accent color in rendering/theme.ts (export const ACCENT: Rgb = [141, 196, 255];) and passes it as accent to every renderer, so all its images look alike. Without accent, DEFAULT_ACCENT applies.
A Leaderboard
A ranked table is drawn by drawTable from #lib/table, in the footer of a stat card (see below): the card supplies the header (server icon, title, subtitle) and the table fills its width. With rows: [], the table starts right below the header.
Rows extend RankedPlayer (rank, name, avatar); playerColumn draws the rank (a medal for the podium), the avatar and the name, under two headers, and takes the width the names may use before they shrink. Describe the other columns:
import { fetchAvatar, fetchImage, loadMedals } from "#lib/imaging.js";
import { PANEL_PADDING, renderStatCard } from "#lib/stat-card.js";
import {
drawCellText,
drawTable,
playerColumn,
tableHeight,
type RankedPlayer,
} from "#lib/table.js";
interface Row extends RankedPlayer {
points: number;
}
const rows: Row[] = await Promise.all(
players.map(async (player, index) => ({
rank: index + 1,
name: player.user.username,
avatar: await fetchAvatar(player.user),
points: player.points,
}))
);
const medals = await loadMedals();
const png = await renderStatCard({
accent: ACCENT,
width: 800,
image: await fetchImage(guild.iconURL({ extension: "png" })),
title: t("leaderboard.title"),
rows: [],
footer: {
height: tableHeight(rows.length),
draw(ctx, x, y, width) {
drawTable<Row>(ctx, x, y, width, {
rows,
columns: [
...playerColumn(
{ rank: t("header.rank"), player: t("header.player") },
420,
medals
),
{
header: t("header.points"),
x: width - PANEL_PADDING,
align: "right",
draw: (c, row, cx, cy) =>
drawCellText(c, String(row.points), cx, cy, {
align: "right",
color: ACCENT,
}),
},
],
});
},
},
});Each column has a header and an x, its left edge, or its right edge with align: "right"; its draw(ctx, row, x, centerY) draws the cell, drawCellText for text (align, color, size, and fitWidth to shrink a long text). A column without draw only labels what another column draws below it. tableHeight(rows, extra, titled) gives the height to reserve. The table can also take a title, drawn inside the panel above a separator, an extra row (the viewer's own, when they are not on the page) under a separator, tinted with extraAccent, and a highlight(row, index) that tints a row. The RNGdle leaderboards and history (src/modules/rngdle/rendering/) and the 4h game leaderboards (src/modules/four-hour-game/rendering/leaderboard-image.ts) are built this way.
A Stat Card
import { renderStatCard } from "#lib/stat-card.js";
const png = await renderStatCard({
image: await fetchAvatar(user),
title: user.username,
subtitle: t("stats.memberSince", { date }),
rank: { position: 3, total: 42 },
rows: [
[
{ title: t("stats.messages"), value: "1 204" },
{ title: t("stats.streak"), value: "12 days", color: [94, 233, 181] },
],
[
{
title: t("stats.joined"),
value: "2024-03-01",
subtext: t("stats.hint"),
},
],
],
});The card is 1000 px wide by default (width widens it), landscape, so Discord displays it large enough to read. Its accent, the module's color, colors the line under the header, the portrait ring and the rank.
- The header shows the round
image, thetitle, an optionalsubtitleand an optionalrank: a medal for the podium,#N / totalotherwise, hidden whenpositionis 0. - Rows hold one to three boxes, which share the row's width.
- A box has a
titleand avalue, and optionally acolor(value, light tint and an edge on the left;highlight: falsecolors the value without highlighting the box), asubtext, atrend({ text, direction: "up" | "down" }, a green or red arrow and text before the subtext) and a small corneravatar. Text that is too long shrinks to fit the box. - An aside (
{ width, draw(ctx, x, y, width, height) }) adds a custom panel on the right of the boxes, as tall as them.drawPanel,drawPanelTitle,drawText,drawFittedTextandmeasureTextfrom#lib/stat-cardkeep it consistent with the rest of the card; the RNGdle tier breakdown is built this way (src/modules/rngdle/rendering/cards.ts). - A footer (
{ height, draw(ctx, x, y, width) }) adds a custom section below the boxes, across the whole content width, and the card grows by its height. Leaderboards draw their tables there (see above).
Previewing an Image
A renderer can only be judged by looking at it. Next to it, a <renderer>.preview.ts file exports sample renders, typed Previews (#lib/preview), each returning a PNG from data written in the file (no network: avatars and icons stay null):
import type { Previews } from "#lib/preview.js";
import { renderReportCard } from "./report-card.js";
export default {
report: () => renderReportCard(data, labels),
"without-members": () =>
renderReportCard({ ...data, topMembers: null }, labels),
} satisfies Previews;pnpm render-preview <file.preview.ts> --out <dir> draws them as <name>-after.png, from the working tree. With --before <ref> (for a PR: the hash git merge-base HEAD origin/master prints), it also draws <name>-before.png from that commit, in a temporary worktree into which the current preview file is copied: both sides draw the same data, so they differ only by the change. --only <name> draws one entry. A renderer that takes a t gets one from await previewT("<module id>", fr) (#lib/preview), with the module's i18n/fr.json, as in src/modules/four-hour-game/rendering/profile-card.preview.ts. The preview files are type-checked with the rest of src/, so a change of data shape shows up at build time. src/modules/server-stats/rendering/report-card.preview.ts is an example.
Emojis are drawn with the system's emoji font, so they look different from one system to another; text and layout do not.
Getting a pair worth showing:
- The data. Copy verbatim the characters the change is about (a channel name with
︱, say: a look-alike typed by hand is another code point), give names at Discord's 32-character limit, five rows, accents. The file is public: invent the member names and say so in a comment. - Which side. Nothing changed yet: no
--before, an identical pair proves nothing. A fix already merged: the working tree holds everything merged since, so draw both sides as "before",--before <fix>~1 --out <dir>/oldthen--before <fix> --out <dir>/new(~1, as cmd.exe swallows^), and compareold/<name>-before.pngwithnew/<name>-before.png. - The output.
identical: <name>means the pair is byte-equal: nothing changed there, or the wrong commit was drawn. An entry the old code cannot draw, or draws as "undefined"/"NaN" (a field the data lacks), fails: show it without a "before". Look at every image before showing it, and give--outa new, empty directory. - A stopped run leaves the worktree it printed on stderr (
worktree: <path>);pnpm render-preview --clean <path>removes it, and refuses anything else.
In a pull request, and only when one is asked for: gh pr create … --attach "./x-before.png#Before" --attach "./x-after.png#After" (gh 2.99 or later) uploads the images and rewrites the  references of the body in place, so lay the pair out as a two-column table with the local paths. Run it from the images' directory, with -R <owner>/<repo> --head <branch> outside the repository; for an existing pull request, gh pr edit <number> --body-file body.md --attach …, or the images go to the end of the body. Say which data is made up, and that emojis may look different in production, without naming the system that drew the images.
Good Practices
- Translate every text you pass in (headers, titles, units): renderers draw what they receive.
- Defer the reply (
interaction.deferReply()) before rendering: downloading avatars and drawing takes longer than Discord's 3-second limit allows. - Cache the PNG when the same image is requested often, with
RevisionCachefrom#lib/revision-cachekeyed on the data that drives it. - Module-specific assets (an extra font, icons) go in the module's own
assets/folder; the Dockerfile copies everyassets/folder next to the compiled code.