The first version of our session schema had 11 fields and a metadata blob. Each game stuffed its own data into metadata— Cabo put the winner’s UID, Trek put the grid size and difficulty, Wordgrid put the puzzle ID. It seemed reasonable. It was a disaster.
The metadata blob was 300+ bytes per session. At scale, that’s 30MB per 100K sessions — just for the blob. Worse, the Node backend had to parse it differently for each game, and the Firestore writes were more expensive because the blob counted against the document size limit.
We trimmed it to 9 fields. No metadata. No per-game data. Each game owns its own detailed data. The session is just a receipt.
The 9 fields
interface SessionEntry {
sessionId: string; // UUID v4 (solo) or "{caboSessionId}-{uid}" (Cabo)
gameId: string; // "cabo" | "trek" | "recall" | "wordgrid" | "crunch"
startedAt: string; // ISO 8601
durationMs: number; // milliseconds from game start to end/exit
outcome: string; // "win" | "loss" | "tie" | "abandoned"
xpEarned: number; // post-clamp XP value
source: string; // "client" (solo) | "server" (Cabo)
validated: boolean; // true if server clamped XP, false otherwise
processedAt: string; // server timestamp — used for rate limiting
}That’s it. Nine fields. ~120 bytes per session. Every game writes the same shape. The PQ pipeline doesn’t need to know whether it’s processing a Cabo session or a Trek session — it just reads gameId, xpEarned, outcome, and durationMs.
Why the receipt shouldn’t contain the recipe
The session entry tells you what resulted— the game, the outcome, the XP. It doesn’t tell you what happened— the specific moves, the puzzle configuration, the card sequence. That detail lives in each game’s own data store:
- Cabo: The full game state is in MongoDB (
live_sessions, thenarchive_sessions). - Trek: The puzzle, path, and score are client-side only. The server doesn’t need them.
- Wordgrid: The crossword puzzle definition is in MongoDB. The session just records that a game happened.
This separation means the PQ pipeline is game-agnostic. When Crunch arrives, it writes the same 9-field SessionEntry and the pipeline processes it without any code changes. The game-specific detail stays in the game-specific store.
What we removed
| Field | Why removed |
|---|---|
metadata (JSON blob) | Per-game data belongs in the game’s own store |
difficulty | Trek/Wordgrid specific; PQ doesn’t need it |
puzzleId | Wordgrid specific; the gameStats subcollection has per-game aggregates |
winnerUid | PQ needs per-player XP, not the match winner. See CaboSessionModel.xpSummaries |
The dedup problem
Without a unique ID, retry logic can create duplicate sessions. With sessionIdas a UUID v4, the dedup is simple: check if the sessionId already exists in today’s sessions document. If it exists, the endpoint returns 200 with a duplicate flag — the XP is not double-counted.
// 3. Dedup: if sessionId already in today's array → 200 duplicate
const todayDoc = await db.collection("users").doc(uid)
.collection("sessions").doc(todayIST).get();
const existing = todayDoc.data()?.sessions ?? [];
if (existing.some(s => s.sessionId === entry.sessionId)) {
return res.status(200).json({ duplicate: true });
}The session ID is unique per (match, player) for Cabo (format: caboSessionId-uid) and a random UUID for solo games. UUID v4 collision probability is ~0%, so this is safe at any scale we’ll ever see.
Per-game caps, not rejection
The validated field is important. If the server clamps XP (e.g., Trek submits 300 but the cap is 250), validated is set to false and xpEarned is set to 250. The session is still recorded — the player played, they just exceeded the cap.
We could have rejected the session entirely (“your XP is too high, try again”). But that would punish the player for a client-side calculation error. Clamping is gentler — the session is recorded, the XP is bounded, and the analytics show it happened.
The cost savings
At 275 DAU averaging 3 sessions per day, we write ~825 sessions per day. At 120 bytes each, that’s ~100KB per day — ~36MB per year. The old schema with metadata would have been 300+ bytes per session, or ~90MB per year. Firestore charges by the document read and write, so 5x the size isn’t just storage — it’s read amplification every time the PQ pipeline processes these sessions.
At 1,000 DAU, the savings scale to ~360MB/year avoided. At 10,000 DAU, it’s 3.6GB. For an indie app on AdMob revenue, that’s actual money.
What I’d Tell My Past Self
- The receipt shouldn’t contain the recipe. Per-game detail belongs in the game’s own store. The session entry is a receipt — what, when, how much.
- 9 fields is enough. If you’re adding per-game metadata to the session, you’re in the wrong place. Add it to a game-specific subcollection.
- Clamp, don’t reject. A session with clamped XP is still a valid session. The player played. Record it.
- UUID v4 is your dedup key. No separate audit collection. No composite keys. One UUID per session, checked against today’s array.
- Source matters.
source: "server"for Cabo (authoritative).source: "client"for solo games (clamped). One field tells you whether to trust the XP value.
