Skip to main content

Command Palette

Search for a command to run...

How To Implement A Level And XP System With Convex

Published
6 min read
How To Implement A Level And XP System With Convex

The goal

A good level and XP system should do three things well. It should be easy to reason about. It should be hard to exploit. It should be flexible enough to support more than one reward source. That means the math should be pure, and the write path should be centralized.

What to store

Keep the long lived progression state on the profile.

profiles: defineTable({
  xp: v.number(),
  level: v.number(),
  streakCount: v.number(),
})

xp is the real source of truth for progression. level is a cached convenience field. streakCount matters if your XP system includes a streak multiplier.

Keep level math pure

Do not calculate level progression ad hoc inside mutations. Keep the math in one pure module. This is the pattern.

function getXpForNextLevel({ currentLevel }: { currentLevel: number }): number {
  return Math.floor(100 * Math.pow(1.3, currentLevel - 1))
}

function getLevelFromXp({ totalXp }: { totalXp: number }): number {
  let level = 1
  let cumulativeXp = 0

  while (true) {
    const xpForNextLevel = getXpForNextLevel({ currentLevel: level })
    if (cumulativeXp + xpForNextLevel > totalXp) {
      return level
    }

    cumulativeXp += xpForNextLevel
    level += 1
  }
}

function getCurrentLevelProgress({ totalXp }: { totalXp: number }) {
  const currentLevel = getLevelFromXp({ totalXp })
  let cumulativeXpToCurrentLevel = 0

  for (let level = 1; level < currentLevel; level += 1) {
    cumulativeXpToCurrentLevel += getXpForNextLevel({ currentLevel: level })
  }

  const xpIntoLevel = totalXp - cumulativeXpToCurrentLevel
  const xpForNextLevel = getXpForNextLevel({ currentLevel })

  return {
    currentLevel,
    xpIntoLevel,
    xpForNextLevel,
    percentComplete: xpForNextLevel === 0 ? 0 : xpIntoLevel / xpForNextLevel,
  }
}

This gives you one clear level curve and one clear progress calculation.

Decide your reward sources explicitly

Do not just sprinkle XP everywhere. Name the reward sources. That makes analytics, balancing, and future refactors much easier. This is the pattern.

type XpAwardSource = 'word' | 'world' | 'chapter'

type XpAward = {
  source: XpAwardSource
  baseXp: number
}

const LEVEL_COMPLETION_XP = 10
const WORLD_COMPLETION_BONUS_XP = 50
const CHAPTER_COMPLETION_BONUS_XP = 150

Now your system is not a pile of anonymous numbers. It is a list of named rewards.

Add the streak multiplier as a pure function

If you want streaks to matter, keep that logic pure too.

function getStreakMultiplier({ streakDays }: { streakDays: number }): number {
  if (streakDays <= 2) return 1
  if (streakDays <= 6) return 1.2
  if (streakDays <= 29) return 1.5
  if (streakDays <= 99) return 2
  if (streakDays <= 364) return 2.5

  const yearsAfterFirst = Math.floor((streakDays - 365) / 365)
  return 3 + yearsAfterFirst * 0.5
}

function calculateXpEarned({
  baseXp,
  streakMultiplier,
}: {
  baseXp: number
  streakMultiplier: number
}) {
  return Math.floor(baseXp * streakMultiplier)
}

This makes the multiplier easy to test and easy to rebalance later.

Support multiple XP awards in one completion

This is the part many systems get wrong. A single completion can trigger more than one reward. For example. A level completion reward. A world completion bonus. A chapter completion bonus. If you model XP as one number only, the logic becomes messy fast. Instead, accept an array of award items.

function getXpBatchAwardOutcome({
  awards,
  currentXp,
  streakMultiplier,
}: {
  awards: ReadonlyArray<XpAward>
  currentXp: number
  streakMultiplier: number
}) {
  const awardsBreakdown = awards.map((award) => ({
    ...award,
    xpEarned: calculateXpEarned({
      baseXp: award.baseXp,
      streakMultiplier,
    }),
  }))

  const totalXpEarned = awardsBreakdown.reduce(
    (total, award) => total + award.xpEarned,
    0
  )

  const newXp = currentXp + totalXpEarned
  const previousLevel = getLevelFromXp({ totalXp: currentXp })
  const newLevel = getLevelFromXp({ totalXp: newXp })

  return {
    awardsBreakdown,
    previousXp: currentXp,
    newXp,
    previousLevel,
    newLevel,
    didLevelUp: newLevel > previousLevel,
    levelsGained: newLevel - previousLevel,
    totalXpEarned,
  }
}

This is much easier to understand. It also gives the frontend a clean payload for XP animations.

Centralize the write path in one internal mutation

This is the most important implementation detail. Do not let every gameplay mutation patch XP by itself. Create one internal XP mutation that applies awards. This is the pattern.

export const awardXp = internalMutation({
  args: {
    profileId: v.id('profiles'),
    awards: v.array(
      v.object({
        baseXp: v.number(),
        source: v.union(
          v.literal('word'),
          v.literal('world'),
          v.literal('chapter')
        ),
      })
    ),
  },
  handler: async (ctx, args) => {
    const profile = await ctx.db.get(args.profileId)
    if (!profile || profile.deletedAt) {
      throw appError({
        code: 'NOT_FOUND',
        message: 'Profile not found',
      })
    }

    const streakMultiplier = getStreakMultiplier({
      streakDays: profile.streakCount,
    })

    const outcome = getXpBatchAwardOutcome({
      awards: args.awards,
      currentXp: profile.xp,
      streakMultiplier,
    })

    await ctx.db.patch(profile._id, {
      xp: outcome.newXp,
      level: outcome.newLevel,
    })

    return outcome
  },
})

That keeps the write path consistent. It also makes it very hard to award XP twice by accident in different places.

Why this shape is good

This shape gives you a few strong properties. The math is pure. The mutation is simple. Every reward is explicit. Every reward source is trackable. The frontend gets a clean response with didLevelUp, levelsGained, and totalXpEarned. That is exactly what a dynamic UI needs.

How to think about the algorithm

The algorithm is not just math. It is product design. Here are the practical questions that matter. How fast should level one feel. How long should level growth take later. How much should streaks matter. Should world and chapter bonuses feel rare and meaningful. Should replay give zero XP. The code should make those decisions easy to change. That is another reason pure helpers are so valuable.

A practical reward model

A simple model that works well is this. 10 XP for a normal level. 50 bonus XP for finishing a world. 150 bonus XP for finishing a chapter. Then apply the streak multiplier once to each award item. That gives you good momentum without making the math hard to follow.

Replay should not touch progression

This rule should be strict. Replay is for practice. Not for farming XP. So in replay mode. No progress writes. No XP writes. No unlock changes. That keeps the system fair and keeps the mental model clean.

What to test

The best part of this system is that most of the hard parts are pure. Test these first. getXpForNextLevel getLevelFromXp getCurrentLevelProgress getStreakMultiplier calculateXpEarned getXpBatchAwardOutcome Those tests give you confidence before you ever touch a real mutation.

The main lesson

A good XP system is not one mutation. It is a small progression engine. If you keep the engine pure and keep the write path centralized, the rest of the app gets much easier to build.