Skip to content

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:

sourceTypeEarned bysourceKeyProof the server checks
soloFinishing a Solo gameFrom the signed game token payloadServer-signed completion token, single use via gameTokens.tokenHash
multiplayerFinishing a multiplayer gameFrom the signed game token payloadSame token check
dailyFinishing the DailyUTC date YYYY-MM-DDDaily completion token (HMAC)
weeklyThe weekly bonus spinMonday of the UTC week, YYYY-MM-DDA 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.

apps/web/src/convex/lib/xpAwards.ts
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.

packages/shared/src/constants/game.ts
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.

computeDailyChallengeXpBreakdown adds four parts and caps the sum:

PartRule
Streak tier base100 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 burstOn 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.

packages/shared/src/game-logic/weekly-spin.ts
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.

claimWeeklySpin runs these checks in order and returns a reason for each failure:

  1. getAuthUserId or not_authenticated.
  2. weekKey = utcWeekKeyMonday(Date.now()). An existing weekly award for that key returns already_claimed with the stored segment, so the client can still show it.
  3. hasCompletedDailyThisWeek: the first dailyChallengeCompletions row on by_user_completed_at with completedAt >= utcWeekStartMondayMs(now), or daily_not_completed.
  4. The players row, or no_player.
  5. Draw, insert the xpAwards row with weeklySegmentIndex, weeklySegmentId, and weeklyTier, patch players.xp, emit a weekly_bonus activity 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.

apps/web/src/lib/weekly-spin/weekly-spin-wheel-math.ts
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.

With weights wiw_i and total W=∑iwi=3⋅24+2⋅14+6+3+1=110W = \sum_i w_i = 3 \cdot 24 + 2 \cdot 14 + 6 + 3 + 1 = 110, segment ii is drawn with probability pi=wi/Wp_i = w_i / W.

SegmentXPWeightpip_iTier total
xp-25-a, xp-25-b, xp-25-c2524 each12/55≈21.82%12/55 \approx 21.82\% each36/55≈65.45%36/55 \approx 65.45\%
xp-50-a, xp-50-b5014 each7/55≈12.73%7/55 \approx 12.73\% each14/55≈25.45%14/55 \approx 25.45\%
xp-757563/55≈5.45%3/55 \approx 5.45\%rare, with xp-100: 9/110≈8.18%9/110 \approx 8.18\%
xp-10010033/110≈2.73%3/110 \approx 2.73\%
xp-15015011/110≈0.91%1/110 \approx 0.91\%1/1101/110

The expected payout per spin is

E[X]=72⋅25+28⋅50+6⋅75+3⋅100+1⋅150110=4100110=41011≈37.27 XP\mathbb{E}[X] = \frac{72 \cdot 25 + 28 \cdot 50 + 6 \cdot 75 + 3 \cdot 100 + 1 \cdot 150}{110} = \frac{4100}{110} = \frac{410}{11} \approx 37.27 \text{ XP}

with

E[X2]=201250110≈1829.55,σ=E[X2]−E[X]2≈440.29≈20.98 XP.\mathbb{E}[X^2] = \frac{201250}{110} \approx 1829.55, \qquad \sigma = \sqrt{\mathbb{E}[X^2] - \mathbb{E}[X]^2} \approx \sqrt{440.29} \approx 20.98 \text{ XP}.

Over a year of weekly spins that is about 52⋅410/11≈193852 \cdot 410/11 \approx 1938 XP, and the chance of at least one jackpot is 1−(109/110)52≈37.8%1 - (109/110)^{52} \approx 37.8\%. A one-million-draw run of pickWeeklySpinSegmentIndex with Math.random matched every pip_i 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.

gameXp=round⁡((b+win+acc+len)⋅m)\text{gameXp} = \operatorname{round}\big((b + \text{win} + \text{acc} + \text{len}) \cdot m\big)

with b∈{50,65}b \in \{50, 65\}, win∈{0,35}\text{win} \in \{0, 35\} (multiplayer only), acc∈{0,10,25,50}\text{acc} \in \{0, 10, 25, 50\}, len=min⁡(75,round⁡(n/4))\text{len} = \min(75, \operatorname{round}(n/4)) on Hard with n≥50n \ge 50 answers, else 0, and m∈{1.0,1.5,2.0}m \in \{1.0, 1.5, 2.0\}. With n≤200n \le 200 after clamping, the maximum is (50+50+50)⋅2=300(50 + 50 + 50) \cdot 2 = 300 for Solo and (65+35+50+50)⋅2=400(65 + 35 + 50 + 50) \cdot 2 = 400 for a multiplayer win.

For the Daily, with RR rounds, gg guesses, and a perfect-solve indicator 1perfect\mathbb{1}_{\text{perfect}}, the code uses maxGuesses=4R\text{maxGuesses} = 4R and ideal RR, so

effort=min⁡ ⁣(60, 20⋅1perfect+round⁡ ⁣(40⋅clamp⁡[0,1] ⁣(1−g−R3R)))\text{effort} = \min\!\Big(60,\ 20 \cdot \mathbb{1}_{\text{perfect}} + \operatorname{round}\!\Big(40 \cdot \operatorname{clamp}_{[0,1]}\!\Big(1 - \frac{g - R}{3R}\Big)\Big)\Big) dailyXp=min⁡(400, tier+effort+burst+surprise).\text{dailyXp} = \min\big(400,\ \text{tier} + \text{effort} + \text{burst} + \text{surprise}\big).

The cap only binds on milestone days late in a streak: day 90 would be 160+60+300=520160 + 60 + 300 = 520 and pays 400.

Wedge ii is drawn with its centre at −90°+i⋅a-90° + i \cdot a, where a=360°/na = 360°/n and −90°-90° is the top of the SVG, under the fixed pointer. Rotating the wheel clockwise by θ\theta brings wedge ii to the pointer when θ≡−ia(mod360)\theta \equiv -i a \pmod{360}. computeSpinRotationDeg picks the smallest positive step to that residue and adds kk full turns:

Δ=((360−(ia mod 360)) mod 360−(θ0 mod 360)+360) mod 360,Δ←360 if Δ=0\Delta = \big((360 - (i a \bmod 360)) \bmod 360 - (\theta_0 \bmod 360) + 360\big) \bmod 360, \qquad \Delta \leftarrow 360 \text{ if } \Delta = 0 θend=θ0+360k+Δ,k=5 (0 with reduced motion).\theta_{\text{end}} = \theta_0 + 360k + \Delta, \qquad k = 5 \ (0 \text{ with reduced motion}).

So a spin always turns more than 1800°1800° and at most 2160°2160°. From θ0=0\theta_0 = 0 the end angles for i=0…7i = 0 \ldots 7 are 2160,2115,2070,2025,1980,1935,1890,18452160, 2115, 2070, 2025, 1980, 1935, 1890, 1845, and getSegmentIndexAtPointer maps each back to ii. 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 s∈[0,1]s \in [0, 1]:

x(s)=3(1−s)2s x1+3(1−s)s2x2+s3,y(s)=3(1−s)2s y1+3(1−s)s2y2+s3.x(s) = 3(1-s)^2 s\, x_1 + 3(1-s) s^2 x_2 + s^3, \qquad y(s) = 3(1-s)^2 s\, y_1 + 3(1-s) s^2 y_2 + s^3.

Time is xx and rotation progress is yy. For linear time pp, weeklySpinEase solves x(s)=px(s) = p with 8 Newton steps from s0=ps_0 = p, clamped to [0,1][0, 1], and returns y(s)y(s):

sj+1=clamp⁡[0,1] ⁣(sj−x(sj)−px′(sj)),x′(s)=3(1−s)2x1+6(1−s)s (x2−x1)+3s2(1−x2).s_{j+1} = \operatorname{clamp}_{[0,1]}\!\left(s_j - \frac{x(s_j) - p}{x'(s_j)}\right), \qquad x'(s) = 3(1-s)^2 x_1 + 6(1-s)s\,(x_2 - x_1) + 3s^2 (1 - x_2).

With x1=0.08x_1 = 0.08 and x2=0.12x_2 = 0.12, x′(s)=0.24−0.24s+2.64s2x'(s) = 0.24 - 0.24 s + 2.64 s^2, whose minimum on [0,1][0,1] is about 0.23450.2345 at s≈0.045s \approx 0.045. The derivative never approaches the 1e-7 guard, xx is strictly increasing, and Newton converges. Sampled output:

Time pp0.050.10.250.50.750.9
Rotation yy0.40210.60810.84220.95740.99240.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 a/3=15°a / 3 = 15° (WEEKLY_SPIN_PEGS_PER_SEGMENT = 3), offset by half a wedge. A spin from 0 to 1845°1845° 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 forceSegmentIndex changes only what it displays; the award row and players.xp come from the server draw.
  • Double claims. Two concurrent claimWeeklySpin calls both read “not claimed”. Convex mutations are serializable, so one commits and the other retries against the new state and returns already_claimed. The insert-then-reread in insertXpAwardAndIncrementRollup and keepOldestIndexedDoc also 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 ready from the calendar’s completed UTC dates. The server checks dailyChallengeCompletions.completedAt against the week start. If the two disagree, the claim returns daily_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. scheduleSpinTimeout finishes the spin after WEEKLY_SPIN_TRANSITION_MS + WEEKLY_SPIN_TIMEOUT_BUFFER_MS = 4300 ms. 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 pickWeeklySpinSegmentIndex in 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_SEGMENT says 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 [0,100][0, 100], answers to [0,200][0, 200], 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).
ChoiceAlternativeWhy this one
Ledger row per award, keyed by sourceIncrement players.xp directlyIdempotent 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 resultClient spins, then reports the segmentThe client cannot choose its prize. The cost is a round trip before the wheel starts moving.
Equal wedges, unequal weightsWedge size proportional to probabilityEvery prize label fits on a small wheel. The cost is that wedge size says nothing about the odds.
Daily completion as the spin gateSpin for any sign-inTies the weekly bonus to the Daily streak loop.
Closed-form landing angle plus CSS transitionFrame-by-frame JavaScript animationThe 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 drawsSeeded or cryptographic RNGEnough for one bounded prize per week; no seed to leak or replay.
Hard cap on Daily XP (400)Uncapped milestone stackingKeeps 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).