Cabo is a multiplayer card game. Trek, Recall, and Wordgrid are solo games. All four feed into the same PQ (Play Quotient) system. But the way they report XP is fundamentally different — and it’s not a bug. It’s the right architecture for each game type.
Two paths to XP
Multiplayer games have opponents. Opponents care about fairness. If the client computes XP and the server just trusts it, a motivated player can tamper with the score, inflate their ranking, and ruin the competition.
Solo games have no opponents. If you inflate your own Wordgrid XP from 200 to 400, you’re only cheating yourself. The leaderboard is personal. The XP cap per game (500 for Wordgrid, 250 for Trek, 200 for Recall) limits the damage even if someone tries.
So we built two paths:
- Cabo (multiplayer): Server-authoritative. The Spring backend computes XP from game events. The client never POSTs a session. The server writes XP directly to Firestore.
- Trek/Recall/Wordgrid (solo): Client-computed with server clamping. The mobile app computes XP locally and POSTs it. The server validates the structure and clamps to per-game caps.
Cabo: server-authoritative XP
Every Cabo game runs on a Spring WebSocket server. The server sees every action — draws, swaps, burns, Cabo calls — and tracks XP in real time. When the game ends, it writes the final XP to Firestore via the FirestorePqWriter:
// XP values per Cabo event
POWER_USE = +5 // view/swap power card
CORRECT_OWN_BURN = +10 // burn your own card correctly
CORRECT_OPP_BURN = +15 // burn opponent's card
CABO_CALL = +30 // call Cabo (flat, no validation)
SOLE_WIN = +50 // only lowest hand
SLOW_FIRST_ACTION = -5 // >10s think time on first action
FAKE_BURN = -10 // incorrect burn attempt
MISS_TURN = -5 // server auto-skip on timeout
LOSS = -5 // game ended, didn't win
ABANDON = -50 // quit mid-session
// Per-match clamp: [-100, +400]The three-layer architecture is key:
CaboXpRulebook— Cabo-specific XP values and timing rules. Knows what each action is worth.PlayerXpTracker— Game-agnostic. Accumulates XP, clamps at submit time. If Crunch is added later,CrunchXpRulebooktalks to the same tracker.FirestorePqWriter— Writes to Firestore via Admin SDK. On failure, queues to MongoDB for retry.
The mobile client does nothing for Cabo XP. It doesn’t POST, it doesn’t compute, it doesn’t validate. It just emits a Mixpanel event for analytics: cabo_session_finalized. The server is the only source of truth.
Solo games: client-computed with server clamping
Trek, Recall, and Wordgrid compute XP on the client. When the game ends, the mobile app builds a PlaySession object and POSTs it to the Node backend:
final session = PlaySessionModel(
sessionId: Uuid.v4(),
gameId: 'trek',
startedAt: gameStartedAt,
durationMs: elapsed.inMilliseconds,
outcome: 'win',
xpEarned: calculatedXp,
source: 'client',
validated: false,
);
await PlayTelemetryService.instance.recordSession(session);The Node backend validates the structure (required fields, valid gameId, duration in range, outcome in the allowed set), deduplicates by sessionId, rate-limits to 30 sessions per minute, and clamps the XP to per-game caps:
| Game | Max XP per session |
|---|---|
| Trek | 250 |
| Recall | 200 |
| Wordgrid | 500 |
| Cabo | N/A (server-authoritative) |
If a submitted XP value exceeds the cap, it’s clamped and the session is marked validated: false. The player still gets credit for playing — just not more than the cap.
Why not validate solo XP on the server?
Validating Trek XP on the server would require the server to know the puzzle state, the player’s path, and the difficulty. That means the server needs to be the game engine — which it already is for Cabo, but adding a puzzle generator and path validator for Trek, Recall, and Wordgrid would triple the backend scope for no user-facing benefit.
The caps limit the blast radius. Even if someone inflates their Trek XP from 200 to 250 (the cap), the effect on their PQ and weekly XP is minimal. They don’t affect other players. The leaderboard is personal. And the dedup + rate limit prevents bulk abuse.
The dedup trap
Sessions are deduplicated by sessionId— a UUID v4 generated on the client. If the same session is submitted twice (e.g., network retry), the second submission is silently accepted with duplicate: true and the XP is not double-counted.
The dedup checks today’s daily document: users/{uid}/sessions/{YYYY-MM-DD}. Each day’s document has a sessionsarray. If the sessionId already exists in the array, it’s a duplicate. This is efficient — we only scan one document, not the entire collection.
The edge case: a retry that crosses midnight IST. The second attempt writes to a different daily document. Since UUID v4 collisions are effectively zero, the harm is bounded to one extra entry. Acceptable.
Designing for extensibility
When we add Crunch (the math duel game), the XP pipeline requires zero new infrastructure. A new CrunchXpRulebook implements the same lifecycle hooks as CaboXpRulebook. The PlayerXpTracker and FirestorePqWriterare already game-agnostic. The only new code is Crunch’s XP rules.
For solo games, adding a new game is even simpler: the mobile app already knows how to POST a PlaySession. Add a new gameId, set a cap in the Node config, and you’re done.
What I’d Tell My Past Self
- Server-authoritative for multiplayer, client-computed for solo. Opponents need trust; solo players impact only themselves.
- Cap, don’t reject. If a solo session exceeds the XP cap, clamp it and mark it as unvalidated. Don’t reject it — the player still played.
- The three-layer rulebook architecture pays off. When the next game comes, you only write one rulebook and plug it into the existing tracker + writer.
- UUID v4 dedup is enough. Don’t build a separate audit collection. The per-day session array handles dedup and rate limiting in one structure.
- Cabo XP is clamped per-match, not per-action. The running tally has no bounds. The final
submitXp()applies [-100, +400]. This lets in-game events play out naturally while preventing extreme outcomes.
