← Back to Notes

Gamification System

2026-02-15

By @boooshir, @AI

Gamification system

Overview

One of my web app "Sifokus" uses gamification to motivate users through XP, levels, streaks, achievements, and leaderboards.


Tables

user_stats

Stores per-user gamification data. One row per user.

ColumnTypeDescription
userIdtext (FK → users)Unique, one stats row per user
xpintegerAll-time XP earned
levelintegerCurrent level (derived from XP)
streakCountintegerCurrent consecutive active days
longestStreakintegerBest streak ever achieved
lastActiveDatetimestampLast day user earned XP
totalTasksCompletedintegerAll-time tasks completed
totalPomodorosintegerAll-time pomodoro sessions
totalProjectsCompletedintegerAll-time projects completed
updatedAttimestamp_msAuto-updated on change

achievements

Badge definitions with tiers. Admin-seeded data.

ColumnTypeDescription
nametextDisplay name
descriptiontextHow to earn it
icontextLucide icon name
categoryenumpomodoro, task, streak, project, xp
tierenumbronze, silver, gold, platinum
thresholdintegerTarget number to unlock
xpRewardintegerXP granted on unlock

Example Achievements

NameCategoryTierThresholdXP Reward
First Steptaskbronze110
Getting Things Donetasksilver5050
Task Mastertaskgold500200
Task Legendtaskplatinum50001000
Focus Starterpomodorobronze110
Deep Workpomodorosilver5050
Pomodoro Masterpomodorogold500200
Focus Legendpomodoroplatinum50001000
7 Day Streakstreakbronze750
30 Day Streakstreaksilver30200
100 Day Streakstreakgold100500
365 Day Streakstreakplatinum3652000
Project Kickoffprojectbronze125
Project Slayerprojectsilver5100
Project Legendprojectgold20500
Rising Starxpbronze1000
Power Userxpsilver10000
Elitexpgold100000
Legendaryxpplatinum1000000

user_achievements

Tracks which badges a user has earned. Composite PK: .

ColumnTypeDescription
userIdtext (FK → users)Part of composite PK
achievementIdinteger (FK → achievements)Part of composite PK
countintegerTimes earned (supports repeatable)
firstEarnedAttimestampWhen first unlocked
lastEarnedAttimestampWhen last earned (for repeatable)

period_stats

Weekly and monthly leaderboard data. Composite PK: .

ColumnTypeDescription
userIdtext (FK → users)Part of composite PK
periodTypeenumweekly, monthly
periodStarttimestampMonday (weekly) or 1st of month (monthly)
xpEarnedintegerXP earned in this period
tasksCompletedintegerTasks completed in this period
pomodorosCompletedintegerPomodoros completed in this period

XP System

XP Sources

ActionXP
Complete a task+10
Complete a pomodoro (work session)+25
Complete a project+100
Daily streak bonus+15

Level Calculation

LevelXP Required
10
2500
31000
52000
104500
209500

Streaks

Rules

  • User must earn at least 1 XP in a day to maintain streak
  • Checked on first action of each day by comparing with today
  • If is yesterday → streak continues
  • If is today → no change
  • If is older than yesterday → streak resets to 1
  • Update if current streak exceeds it

Daily Streak Bonus

When streak continues: +15 XP bonus (on top of action XP)


Leaderboard

Visibility

  • All users appear on leaderboard (no opt-in required)
  • Free and paid users included

Categories

Top 10 users displayed for each period.

Weekly

1SELECT u.id, u.name, u.image, p.xpEarned, p.tasksCompleted, p.pomodorosCompleted 2FROM period_stats p 3JOIN users u ON u.id = p.userId 4WHERE p.periodType = 'weekly' 5 AND p.periodStart = <this_week_monday> 6ORDER BY p.xpEarned DESC 7LIMIT 10

Monthly

1SELECT u.id, u.name, u.image, p.xpEarned, p.tasksCompleted, p.pomodorosCompleted 2FROM period_stats p 3JOIN users u ON u.id = p.userId 4WHERE p.periodType = 'monthly' 5 AND p.periodStart = <this_month_1st> 6ORDER BY p.xpEarned DESC 7LIMIT 10

Period Start Calculation

  • Weekly: (Monday of current week)
  • Monthly: (1st of current month)

Query Patterns

Increment XP + Check Level

1// After action, atomically increment XP 2db.update(userStats) 3 .set({ 4 xp: sql`${userStats.xp} + ${xpAmount}`, 5 totalTasksCompleted: sql`${userStats.totalTasksCompleted} + 1`, 6 }) 7 .where(eq(userStats.userId, userId)) 8 9// Then check level 10const newLevel = Math.floor((currentXp + xpAmount) / 500) + 1 11if (newLevel > currentLevel) { 12 db.update(userStats).set({ level: newLevel }).where(...) 13}

Update Streak

1const today = startOfDay(new Date()) 2const lastActive = userStats.lastActiveDate 3 4if (isSameDay(lastActive, today)) { 5 // Already active today, no change 6} else if (isYesterday(lastActive)) { 7 // Streak continues 8 db.update(userStats).set({ 9 streakCount: sql`${userStats.streakCount} + 1`, 10 longestStreak: sql`MAX(${userStats.longestStreak}, ${userStats.streakCount} + 1)`, 11 lastActiveDate: today, 12 xp: sql`${userStats.xp} + 15`, // streak bonus 13 }) 14} else { 15 // Streak broken 16 db.update(userStats).set({ 17 streakCount: 1, 18 lastActiveDate: today, 19 }) 20}

Check Achievement Unlock

1// 1. Single query: find all newly unlocked achievements not yet earned 2const newAchievements = await db 3 .select({ id: achievements.id, xpReward: achievements.xpReward }) 4 .from(achievements) 5 .leftJoin( 6 userAchievements, 7 and( 8 eq(userAchievements.userId, userId), 9 eq(userAchievements.achievementId, achievements.id), 10 ), 11 ) 12 .where( 13 and( 14 eq(achievements.category, 'task'), 15 lte(achievements.threshold, userStats.totalTasksCompleted), 16 isNull(userAchievements.userId), // not yet earned 17 ), 18 ) 19 20// 2. Bulk insert unlocked achievements 21if (newAchievements.length > 0) { 22 await db.insert(userAchievements).values( 23 newAchievements.map((a) => ({ 24 userId, 25 achievementId: a.id, 26 })), 27 ) 28 29 // 3. Grant total XP reward in one update 30 const totalReward = newAchievements.reduce((sum, a) => sum + a.xpReward, 0) 31 await db 32 .update(userStats) 33 .set({ xp: sql`${userStats.xp} + ${totalReward}` }) 34 .where(eq(userStats.userId, userId)) 35}

Update Period Stats

1const weekStart = getMonday(new Date()) 2const monthStart = getFirstOfMonth(new Date()) 3 4// Upsert weekly 5await db 6 .insert(periodStats) 7 .values({ 8 userId, 9 periodType: 'weekly', 10 periodStart: weekStart, 11 xpEarned: xpAmount, 12 tasksCompleted: 1, 13 }) 14 .onConflictDoUpdate({ 15 target: [ 16 periodStats.userId, 17 periodStats.periodType, 18 periodStats.periodStart, 19 ], 20 set: { 21 xpEarned: sql`${periodStats.xpEarned} + ${xpAmount}`, 22 tasksCompleted: sql`${periodStats.tasksCompleted} + 1`, 23 }, 24 }) 25 26// Same for monthly with periodType: 'monthly'

User Stats Initialization

When a new user signs up, create their stats row:

1await db.insert(userStats).values({ 2 userId: newUser.id, 3 xp: 0, 4 level: 1, 5 streakCount: 0, 6 longestStreak: 0, 7 totalTasksCompleted: 0, 8 totalPomodoros: 0, 9 totalProjectsCompleted: 0, 10})