Braineos now has badges: eight on Braineos, each with up to four tiers from Bronze to Platinum, a trophy case on every profile and a share card for each one. The same code runs CertBrain and Finance Brain, so the design had to work for three sites with different ideas of what is worth rewarding. Three decisions shaped it.
Definitions live in code, chosen per site
The catalog is one TypeScript file. Each badge has an id, its tiers, the events that can change it, and a progress() function that returns a number:
{
id: "streak",
kind: "tiered",
tiers: [7, 30, 100, 365],
events: ["attempt_recorded"],
progress: (ctx) => ctx.currentStreak(),
}
Each site’s profile lists the badges it uses. Braineos has Tower Defense, deck creation and “studied by others”. CertBrain’s list, not switched on yet, swaps those for course and practice-readiness badges. A badges feature flag turns the whole thing on per site; so far it’s on for Braineos only.
We considered an admin editor and decided against it for now. A badge rule is code that reads the database, and keeping it in code means every rule is reviewed and tested before it runs.
No stored progress
There is one new table, UserBadge, with a unique key on user, badge and tier. Nothing stores progress. A rule reads what already exists: the streak, the review count, mastered decks, challenge results, referrals. That keeps the numbers from drifting out of sync, and it makes “how close am I?” free. The same progress() that awards a tier also drives the Next up bars on the Progress page.
Awarding happens at the moment it’s earned. When a study session, a review or a challenge result is saved, the server checks only the badges listening for that event, inserts any new tiers with skipDuplicates, and returns them so the celebration card can appear straight away. The unique key means two requests racing can’t award a tier twice. A rule that throws is logged and skipped, so badges can never break a review.
The backfill met the connection pool
Learners who had studied for months shouldn’t start with an empty trophy case, so a backfill script runs every rule for every user. It awards silently, so nobody got a pile of pop-ups on launch day.
The first production run failed with EMAXCONNSESSION. Our database pooler allows 15 clients in session mode, and that pool is shared with the live sites. A script running many users at once took enough of them to matter. The fix was to make the script a polite guest: it caps its own connections at 3, works on a few users at a time, retries a failing rule with a fresh context, and reports any skipped users so a second run fills them in. It also prints progress with a time estimate and can resume from where it stopped.
Sharing without personal data
A shared badge link carries only the badge and its tier, as in /share/badge-streak-2. No user id, name or score. Links from the earlier milestone cards still work and now show the matching badge. Both earning and sharing send an event to our analytics, so we’ll know whether badges get shared, not just earned.
See the badges on Braineos.
