Gamification: Building XP, Badges, and Leaderboards From Scratch
This blog has a gamification system: users earn XP for reading posts, unlock badges for milestones, maintain streaks for daily activity, and climb a global leaderboard. No third-party service — it's all custom-built in PostgreSQL and FastAPI.
The Event-Driven XP System
Every user action that should award XP emits an event:
class XPEvent(Enum):
POST_READ = ("post_read", 10)
POST_COMPLETED = ("post_completed", 25)
SERIES_COMPLETED = ("series_completed", 100)
CHALLENGE_SOLVED = ("challenge_solved", 30)
COMMENT_LEFT = ("comment_left", 5)
STREAK_MILESTONE = ("streak_milestone", 50)
BADGE_EARNED = ("badge_earned", 0) # No XP, badge is the reward
def __init__(self, event_type: str, base_xp: int):
self.event_type = event_type
self.base_xp = base_xpEvents are processed asynchronously through a Redis pub/sub channel:
async def award_xp(user_id: str, event: XPEvent, multiplier: float = 1.0):
xp = int(event.base_xp * multiplier)
await redis.publish("xp_events", json.dumps({
"user_id": user_id,
"xp": xp,
"event_type": event.event_type,
"timestamp": datetime.utcnow().isoformat(),
}))A background worker consumes the channel and writes to the database:
async def process_xp_events():
pubsub = redis.pubsub()
await pubsub.subscribe("xp_events")
async for message in pubsub.listen():
if message["type"] != "message":
continue
data = json.loads(message["data"])
async with db.begin():
user = await db.get(User, data["user_id"])
user.xp += data["xp"]
user.updated_at = func.now()
await check_level_up(user, db)
await check_badges(user, db, data["event_type"])
await update_leaderboard(user)The Leveling Curve
XP maps to levels using a quadratic formula:
def xp_for_level(level: int) -> int:
return int(100 * (level ** 1.5))Level 1: 100 XP. Level 10: 3162 XP. Level 20: 8944 XP. Level 50: 35355 XP.
The square-root relationship means early levels come quickly (good for engagement) and later levels take progressively more effort (good for retention).
def level_from_xp(total_xp: int) -> int:
return int((total_xp / 100) ** (2 / 3))When a user levels up, a notification is created:
async def check_level_up(user, db):
new_level = level_from_xp(user.xp)
if new_level > user.level:
old_level = user.level
user.level = new_level
db.add(Notification(
user_id=user.id,
type="level_up",
title=f"Level {new_level}!",
message=f"You advanced from level {old_level} to {new_level}.",
))The Badge Criteria Engine
Badges are defined in the database with a criteria JSONB field:
class Badge(Base):
__tablename__ = "badges"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str]
description: Mapped[str]
icon: Mapped[str]
criteria: Mapped[dict] = mapped_column(JSONB)The criteria field contains a JSON object that defines the condition:
{
"type": "posts_read",
"count": 10,
"scope": "total"
}
{
"type": "streak",
"count": 7,
"scope": "consecutive_days"
}
{
"type": "series_complete",
"count": 3,
"scope": "unique_series"
}
{
"type": "xp_total",
"count": 5000,
"scope": "lifetime"
}
{
"type": "challenge_streak",
"count": 5,
"scope": "consecutive_days"
}The badge checker runs after every XP event:
async def check_badges(user, db, event_type: str):
badges = await db.execute(select(Badge))
user_badge_ids = {ub.badge_id for ub in user.badges}
for badge in badges.scalars():
if badge.id in user_badge_ids:
continue
if await evaluate_criteria(user, badge.criteria, event_type, db):
user.badges.append(badge)
db.add(Notification(
user_id=user.id,
type="badge_earned",
title=f"Badge Unlocked: {badge.name}",
message=badge.description,
))
await award_xp(user.id, XPEvent.BADGE_EARNED)The evaluate_criteria function interprets the badge's criteria JSON:
async def evaluate_criteria(user, criteria, event_type, db) -> bool:
if criteria["type"] == "posts_read":
result = await db.execute(
select(func.count(UserProgress.id)).where(
UserProgress.user_id == user.id,
)
)
return result.scalar() >= criteria["count"]
if criteria["type"] == "streak":
return user.streak >= criteria["count"]
if criteria["type"] == "xp_total":
return user.xp >= criteria["count"]
return FalseStreak Tracking
The streak is computed daily from the user_progress table:
async def update_streak(user_id: str, db):
today = date.today()
last_activity = await db.execute(
select(func.max(UserProgress.completed_at)).where(
UserProgress.user_id == user_id,
)
)
last_date = last_activity.scalar()
if last_date and last_date.date() == today - timedelta(days=1):
user.streak += 1
if user.streak % 7 == 0:
await award_xp(user.id, XPEvent.STREAK_MILESTONE)
elif last_date and last_date.date() < today - timedelta(days=1):
user.streak = 0A midnight cron job checks for streaks that are about to expire and sends a push notification (if the user has enabled them): "Your 14-day streak expires tomorrow — read one post to keep it alive!"
Leaderboard
The leaderboard is stored as a Redis sorted set:
async def update_leaderboard(user):
await redis.zadd("leaderboard", {user.id: user.xp})Reading the top N:
async def get_leaderboard(limit: int = 100, offset: int = 0):
results = await redis.zrevrange("leaderboard", offset, offset + limit - 1, withscores=True)
return [
{"user_id": user_id.decode(), "xp": int(xp)}
for user_id, xp in results
]The frontend polls every 30 seconds:
const { data } = useQuery({
queryKey: ["leaderboard"],
queryFn: () => fetch("/api/v1/gamification/leaderboard").then((r) => r.json()),
refetchInterval: 30000,
});A materialized PostgreSQL view recomputes the leaderboard every 5 seconds as a fallback:
CREATE MATERIALIZED VIEW leaderboard_mv AS
SELECT id, username, xp, level,
RANK() OVER (ORDER BY xp DESC) AS rank
FROM users
WHERE xp > 0;The Redis sorted set is the primary read layer; the materialized view is the source of truth for consistency checks.
Frontend Components
BadgeGrid
Displays earned badges with a lock/unlock animation:
function BadgeGrid({ badges, earnedBadgeIds }: Props) {
return (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-4">
{badges.map((badge) => (
<div
key={badge.id}
className={`card p-4 text-center transition-all duration-300 ${
earnedBadgeIds.has(badge.id)
? "border-amber-500/30 bg-amber-500/5"
: "opacity-40 grayscale"
}`}
>
<span className="text-3xl">{badge.icon}</span>
<p className="text-[10px] font-bold mt-2 uppercase tracking-wider">
{badge.name}
</p>
</div>
))}
</div>
);
}UserStats
Shows XP, level, streak, and rank in a compact card:
function UserStats({ stats }: { stats: UserStats }) {
const xpProgress = (stats.xp % xpForLevel(stats.level)) / xpForLevel(stats.level + 1) * 100;
return (
<div className="card p-5 space-y-4">
<div className="flex justify-between items-center">
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">
Level {stats.level}
</span>
<span className="text-sm font-bold text-amber-500">
#{stats.rank}
</span>
</div>
<div className="w-full h-2 rounded-full bg-white/[0.06] overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-500 to-amber-400 transition-all duration-500"
style={{ width: `${xpProgress}%` }}
/>
</div>
<div className="flex justify-between text-xs text-white/50">
<span>{stats.xp.toLocaleString()} XP</span>
<span>{stats.streak} day streak</span>
</div>
</div>
);
}What's Next
The next post covers the Spaced Repetition System — how the SM-2 algorithm is implemented, how review schedules are computed, and how reading progress feeds into the retention model.
Built with FastAPI, PostgreSQL, Redis sorted sets, and zero third-party gamification services.
