XP economy and weekly spin
1. Foundational mental model
Section titled “1. Foundational mental model”XP is a single integer on the players row. Nothing adds to it directly. Every award first inserts a row into xpAwards, keyed by (userId, sourceType, sourceKey), and only the mutation that created that row patches players.xp. A second claim for the same key finds the row and returns without paying. Levels are never stored; getLevelFromXp derives them from the total at read time.
There are four sourceType values, and each has its own key:
sourceType | Earned by | sourceKey | Proof the server checks |
|---|---|---|---|
solo | Finishing a Solo game | From the signed game token payload | Server-signed completion token, single use via gameTokens.tokenHash |
multiplayer | Finishing a multiplayer game | From the signed game token payload | Same token check |
daily | Finishing the Daily | UTC date YYYY-MM-DD | Daily completion token (HMAC) |
weekly | The weekly bonus spin | Monday of the UTC week, YYYY-MM-DD | A dailyChallengeCompletions row this week |
Learn and Review award nothing. Signed-out players earn nothing that persists.
The weekly spin sits on top of the Daily. Once a signed-in player has finished at least one Daily in the current UTC week (Monday 00:00 UTC onward), the streak calendar modal offers one spin. The server picks the segment and pays; the wheel then animates to the segment the server chose.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”The ledger insert
Section titled “The ledger insert” const existingAwards = await context.db .query("xpAwards") .withIndex("by_user_and_source", (query) => query .eq("userId", award.userId) .eq("sourceType", award.sourceType) .eq("sourceKey", award.sourceKey) ) .take(INDEXED_DEDUPE_TAKE); const existingAward = await keepOldestIndexedDoc(context, existingAwards); if (existingAward) { return { awardId: existingAward._id, created: false }; }
const awardId = await context.db.insert("xpAwards", award); const claimedAwards = await context.db .query("xpAwards") .withIndex("by_user_and_source", (query) => query .eq("userId", award.userId) .eq("sourceType", award.sourceType) .eq("sourceKey", award.sourceKey) ) .take(INDEXED_DEDUPE_TAKE); const winner = await keepOldestIndexedDoc(context, claimedAwards); if (!winner || winner._id !== awardId) { return { awardId: winner?._id ?? awardId, created: false }; }keepOldestIndexedDoc sorts by _creationTime, deletes any extra rows, and returns the oldest. The same function then adds xpAmount to the matching monthlyXpRollups row for the award’s UTC month, split into solo, multiplayer, daily, and weekly columns.
Game XP
Section titled “Game XP”export function computeGameXpBreakdown(input: GameXpInput): GameXpBreakdown { const { isMultiplayer, isWinner, accuracyPercent, difficulty } = input;
const base = isMultiplayer ? XP_BASE_MULTIPLAYER : XP_BASE_SOLO; const winBonus = isMultiplayer && isWinner ? XP_BONUS_MULTIPLAYER_WIN : 0; const accuracyBonus = XP_ACCURACY_THRESHOLDS.find((threshold) => accuracyPercent >= threshold.minAccuracy) ?.bonus ?? 0; const lengthBonus = computeGameLengthBonus(difficulty, input.totalAnswers ?? 0); const difficultyMultiplier = XP_DIFFICULTY_MULTIPLIERS[difficulty] ?? 1.0; const total = Math.round( (base + winBonus + accuracyBonus + lengthBonus) * difficultyMultiplier );The constants: XP_BASE_SOLO = 50, XP_BASE_MULTIPLAYER = 65, XP_BONUS_MULTIPLAYER_WIN = 35; accuracy bonus 50 at 95%, 25 at 80%, 10 at 60%; multipliers easy 1.0, medium 1.5, hard 2.0. The length bonus applies on Hard only, from 50 answers, as Math.min(75, Math.round(totalAnswers / 4)). claimGameXp verifies the token, checks claimUserId and expiresAt, rejects a reused tokenHash, clamps the payload numbers, and only then computes XP.
Daily XP
Section titled “Daily XP”computeDailyChallengeXpBreakdown adds four parts and caps the sum:
| Part | Rule |
|---|---|
| Streak tier base | 100 from day 1 (Starter), 130 from day 7, 160 from day 30, 200 from day 100 |
| Effort bonus | +20 for solving every round, plus up to 40 for guess efficiency, capped at 60 |
| Milestone burst | On exact streak days 3, 7, 14, 21, 30, 60, 90, 180, 365: +25, 50, 75, 100, 150, 200, 300, 400, 500 |
| Surprise | +25 when an FNV-1a hash of ${userId}:${date}:daily-surprise falls under 0.05 |
The surprise roll is deterministic per user and day, so retrying a Daily cannot reroll it.
Weekly spin segments and draw
Section titled “Weekly spin segments and draw”export const WEEKLY_SPIN_SEGMENTS: WeeklySpinSegment[] = [ { id: "xp-25-a", xpBonus: 25, tier: "common", weight: 24 }, { id: "xp-25-b", xpBonus: 25, tier: "common", weight: 24 }, { id: "xp-25-c", xpBonus: 25, tier: "common", weight: 24 }, { id: "xp-50-a", xpBonus: 50, tier: "uncommon", weight: 14 }, { id: "xp-50-b", xpBonus: 50, tier: "uncommon", weight: 14 }, { id: "xp-75", xpBonus: 75, tier: "rare", weight: 6 }, { id: "xp-100", xpBonus: 100, tier: "rare", weight: 3 }, { id: "xp-150", xpBonus: 150, tier: "jackpot", weight: 1 },];
export const WEEKLY_SPIN_SEGMENT_COUNT = WEEKLY_SPIN_SEGMENTS.length;
export function pickWeeklySpinSegmentIndex( segments: WeeklySpinSegment[] = WEEKLY_SPIN_SEGMENTS, random: () => number = Math.random): number { const totalWeight = segments.reduce((sum, segment) => sum + segment.weight, 0); let roll = random() * totalWeight;
for (let index = 0; index < segments.length; index += 1) { roll -= segments[index].weight; if (roll < 0) { return index; } }
return segments.length - 1;}Every wedge has the same 45° on screen; only the weights differ. The wheel shows the jackpot as one eighth of the circle while it pays out 1 time in 110.
The claim mutation
Section titled “The claim mutation”claimWeeklySpin runs these checks in order and returns a reason for each failure:
getAuthUserIdornot_authenticated.weekKey = utcWeekKeyMonday(Date.now()). An existingweeklyaward for that key returnsalready_claimedwith the stored segment, so the client can still show it.hasCompletedDailyThisWeek: the firstdailyChallengeCompletionsrow onby_user_completed_atwithcompletedAt >= utcWeekStartMondayMs(now), ordaily_not_completed.- The
playersrow, orno_player. - Draw, insert the
xpAwardsrow withweeklySegmentIndex,weeklySegmentId, andweeklyTier, patchplayers.xp, emit aweekly_bonusactivity event.
If claimWeeklySpin returns segmentIndex: null, resolveSpinSegmentIndex in StreakCalendarHost.svelte tracks weekly_spin_claim_failed with the reason and returns null, and the wheel does not move.
Landing on the chosen wedge
Section titled “Landing on the chosen wedge”export function computeSpinRotationDeg( segmentIndex: number, segmentCount: number, currentRotationDeg: number, extraSpins = 5): number { const segmentArc = 360 / segmentCount; const targetMod = (360 - ((segmentIndex * segmentArc) % 360)) % 360; const currentMod = ((currentRotationDeg % 360) + 360) % 360; let delta = (targetMod - currentMod + 360) % 360; if (delta === 0) { delta = 360; } return currentRotationDeg + extraSpins * 360 + delta;}The controller sets rotationDeg to this value and lets CSS animate transform: rotate(...) with transition-timing-function: cubic-bezier(0.08, 0.78, 0.12, 1) over WEEKLY_SPIN_TRANSITION_MS = 4_000. With reduceMotion, it calls the same function with extraSpins = 0 and shows the result immediately.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Segment probabilities and expected value
Section titled “Segment probabilities and expected value”With weights and total , segment is drawn with probability .
| Segment | XP | Weight | Tier total | |
|---|---|---|---|---|
xp-25-a, xp-25-b, xp-25-c | 25 | 24 each | each | |
xp-50-a, xp-50-b | 50 | 14 each | each | |
xp-75 | 75 | 6 | rare, with xp-100: | |
xp-100 | 100 | 3 | ||
xp-150 | 150 | 1 |
The expected payout per spin is
with
Over a year of weekly spins that is about XP, and the chance of at least one jackpot is . A one-million-draw run of pickWeeklySpinSegmentIndex with Math.random matched every to within 0.3 percentage points.
For comparison, a Starter-tier Daily pays at least 100 XP, so the spin adds roughly a third of one Daily per week on average.
Game and Daily formulas
Section titled “Game and Daily formulas”with , (multiplayer only), , on Hard with answers, else 0, and . With after clamping, the maximum is for Solo and for a multiplayer win.
For the Daily, with rounds, guesses, and a perfect-solve indicator , the code uses and ideal , so
The cap only binds on milestone days late in a streak: day 90 would be and pays 400.
Solving the landing angle
Section titled “Solving the landing angle”Wedge is drawn with its centre at , where and is the top of the SVG, under the fixed pointer. Rotating the wheel clockwise by brings wedge to the pointer when . computeSpinRotationDeg picks the smallest positive step to that residue and adds full turns:
So a spin always turns more than and at most . From the end angles for are , and getSegmentIndexAtPointer maps each back to . The landing angle is closed form; no iteration is involved.
Inverting the easing curve with Newton’s method
Section titled “Inverting the easing curve with Newton’s method”Newton’s method appears one step later. The browser animates with CSS, but the ratchet sound needs to know when each rim peg passes the pointer, which means evaluating the same timing curve in JavaScript. A CSS cubic-bezier(x_1, y_1, x_2, y_2) is a parametric curve in :
Time is and rotation progress is . For linear time , weeklySpinEase solves with 8 Newton steps from , clamped to , and returns :
With and , , whose minimum on is about at . The derivative never approaches the 1e-7 guard, is strictly increasing, and Newton converges. Sampled output:
| Time | 0.05 | 0.1 | 0.25 | 0.5 | 0.75 | 0.9 |
|---|---|---|---|---|---|---|
| Rotation | 0.4021 | 0.6081 | 0.8422 | 0.9574 | 0.9924 | 0.9990 |
90% of the rotation is done after about 1351 ms of the 4000 ms transition; the rest is the slow crawl onto the wedge.
computeSpinTickTimesMs then needs the inverse of that: the time at which rotation reaches each peg. It bisects rotationAtSpinProgress for 32 iterations per peg. Pegs sit every (WEEKLY_SPIN_PEGS_PER_SEGMENT = 3), offset by half a wedge. A spin from 0 to crosses 123 pegs, and thinSpinTickTimesMs keeps 38 of them by dropping any tick less than 44 ms after the last kept one.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Forged spin results. The mutation takes no arguments. A client that skips the animation or edits
forceSegmentIndexchanges only what it displays; the award row andplayers.xpcome from the server draw. - Double claims. Two concurrent
claimWeeklySpincalls both read “not claimed”. Convex mutations are serializable, so one commits and the other retries against the new state and returnsalready_claimed. The insert-then-reread ininsertXpAwardAndIncrementRollupandkeepOldestIndexedDocalso clean up any duplicate rows that did get written, keeping the oldest. - Week boundary. Week keys come from
Date.now()on the server, Monday 00:00 UTC. A player in UTC−8 sees the reset on Sunday at 16:00 local time.formatWeeklySpinCountdown(msUntilNextUtcWeekMonday(...))shows the remaining time in the modal. - Eligibility mismatch. The modal decides
readyfrom the calendar’s completed UTC dates. The server checksdailyChallengeCompletions.completedAtagainst the week start. If the two disagree, the claim returnsdaily_not_completed, the failure is tracked, and the wheel stays still. - Lost
transitionend. A tab switch or an interrupted transition may never fire the event.scheduleSpinTimeoutfinishes the spin afterWEEKLY_SPIN_TRANSITION_MS + WEEKLY_SPIN_TIMEOUT_BUFFER_MS = 4300ms. XP is already paid by then; only the reveal depends on the timer. - Randomness source. Both the server and the guest draw use
Math.random. It is not a cryptographic generator, but the draw runs inside Convex where a player cannot observe or seed it, and one spin per week limits what a biased generator could be worth. - Guest spins. A signed-out spin runs
pickWeeklySpinSegmentIndexin the browser and saves nothing. Signing in afterwards does not carry the result over; the signed-in claim draws again. - Stale comment. The JSDoc above
WEEKLY_SPIN_PEGS_PER_SEGMENTsays 3 pegs are “one peg every 20° on the 6-wedge wheel”. The wheel has 8 wedges, so pegs are 15° apart. - Game XP inputs. Accuracy is clamped to , answers to , correct answers to at most the answer count, and an unknown difficulty becomes
easy. A valid token with extreme numbers still pays at most 300 (Solo) or 400 (multiplayer win).
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why this one |
|---|---|---|
| Ledger row per award, keyed by source | Increment players.xp directly | Idempotent retries, a record of every award for rollups and account export, and one place to enforce “once per game, day, or week”. |
| Server draws, client animates to the result | Client spins, then reports the segment | The client cannot choose its prize. The cost is a round trip before the wheel starts moving. |
| Equal wedges, unequal weights | Wedge size proportional to probability | Every prize label fits on a small wheel. The cost is that wedge size says nothing about the odds. |
| Daily completion as the spin gate | Spin for any sign-in | Ties the weekly bonus to the Daily streak loop. |
| Closed-form landing angle plus CSS transition | Frame-by-frame JavaScript animation | The compositor runs the animation. JavaScript evaluates the curve only to schedule sound, so Newton and bisection run once per spin, not per frame. |
Math.random for draws | Seeded or cryptographic RNG | Enough for one bounded prize per week; no seed to leak or replay. |
| Hard cap on Daily XP (400) | Uncapped milestone stacking | Keeps late-streak milestone days from dwarfing ordinary play. |
Non-goals: purchasable spins or currency, carrying guest spins into an account, XP for Learn, and a published odds page in the product UI (the weights live only in code and in this chapter).