The daily spin wheel looks simple: tap to spin, watch the animation, get a prize. Under the hood, it’s a Firestore transaction, a weighted random selector, a TTL auto-expiring document, and a write-once claim model — all designed to prevent cheating at every step.
The daily wheel
Every day at midnight IST, a cron job generates a new daily_spin document in Firestore. It contains 6 slots, each with a type (coins, gems, XP boost, avatar, rare collectible), a value, and a weight:
// 7 theme rotations — one per day of the week
const themes = [
{ label: "Monday Rush", slots: mondaySlots },
{ label: "Treasure Tuesday", slots: tuesdaySlots },
// ... 5 more
];
export function buildDailySpinDoc(dateStr) {
const dayIndex = new Date(dateStr).getDay();
const theme = themes[dayIndex % themes.length];
return {
date: dateStr,
slots: theme.slots, // [{slotId, type, value, weight, rareLabel}]
ttlAt: new Date(Date.now() + 3 * 86400000), // auto-delete after 3 days
};
}The weights are not equal. Common prizes (coins, small gems) have high weights (30-40). Rare items (gem packs, exclusive avatars) have low weights (1-5). The total adds up to 100:
export function pickWinningSlot(slots) {
const total = slots.reduce((sum, s) => sum + s.weight, 0);
let r = Math.random() * total;
for (let i = 0; i < slots.length; i++) {
r -= slots[i].weight;
if (r < 0) return i;
}
return slots.length - 1;
}The winning slot is determined on the server — the client sends a POST, and the server picks the prize. The mobile app just animates the wheel to land on whatever index the server tells it to.
The claim transaction
When the player taps “Spin”, the mobile app calls POST /dailySpinOps/spin. The server does three things atomically:
- Read the player’s spin document for today. Does it exist? Has spinCount reached the max?
- Pick the winning slot using the weighted random algorithm.
- Write the result atomically. Increment spinCount, append to spinHistory.
The key: the server picks the prize afterchecking eligibility. The client never sends “I want slot 3.” The client can’t predict the outcome because the slot weights change daily, and the server’s random number generator is not visible to the client.
Write-once claims
A spin result is written once and never updated. The spinHistory array uses FieldValue.arrayUnion to append the result. If the client retries the same spin (network timeout), the same document is updated with an incremented spin count and an appended history entry.
For claiming the prize (gems, coins, or an avatar), a separate POST /dailySpinOps/claimendpoint handles the reward grant in a Firestore transaction. The claim is idempotent — if the same spin result is claimed twice, the second claim is a no-op. The player can’t double-claim because the claim status is checked inside the transaction.
TTL auto-expiry
Every daily_spin document has a ttlAt field set to 3 days from creation. Firestore TTL policies automatically delete expired documents:
gcloud firestore fields ttls update ttlAt \
--collection-group=daily_spin \
--project=kabo-prodThis means yesterday’s and the day-before’s spin documents are automatically cleaned up. No cron job, no manual deletion, no accumulating stale data. The same pattern is used for spin week progress (21-day TTL) and gear rentals (rental duration TTL).
Can you predict tomorrow’s wheel?
The daily spin document is generated by a cron job at midnight IST. The client doesn’t have access to the generation algorithm — it only sees the 6-slot array after fetching the document. Even if the client could read tomorrow’s document (they can’t — it doesn’t exist yet), they still can’t predict the random index selected by the server during the spin.
The weights are visible in the response, but weighted random selection is not predictable from the weights. A slot with weight 5 has a 5% chance, but which specific spin lands on it is determined by the server’s Math.random(), which the client cannot influence.
What I’d Tell My Past Self
- The server picks the prize, not the client. Any other design is vulnerable to tampering. The mobile app only animates the result.
- Use Firestore transactions for claims. Read-then-write without a transaction is a race condition. Two rapid taps could double-claim.
- TTL is cheaper than crons. Firestore TTL policies delete expired documents automatically. No monitoring, no failure modes, no stale data.
- Daily rotation prevents prediction. Different weights each day mean even if someone reverse-engineers today’s probabilities, tomorrow is different.
- Max spins per day is a server-side check. The client can show spinning animations all day, but the server caps at 3 actual spins per day.
