Battles award XP and currency. XP unlocks creature upgrades: HP, traits, cards. Currency creates new creatures. This is the plan for building the server-side infrastructure to support it.
According to the GDD, creatures earn XP and currency (Bobium). XP unlocks HP, traits, adding and replacing cards. Bobium unlocks creature creation. These rewards are the core economy and the basis of monetization.
None of this has server-side infrastructure today. Our Cloudflare worker determines the winner of every battle, then discards that data. Battles, rewards, and upgrades are not recorded or tracked.
Without server authority over progression, the economy is unenforceable. Players could set any XP, level, HP, or currency value. We cannot sell progression content, run competitive ladders, enforce exhaustion cooldowns, or trust creature stats.
Every battle gets a row with participants, outcome, timestamp, and rewards granted. This is the provenance root for the entire economy.
Supercell engineering blog; Pokemon GO server postmortem (GDC 2017). "If it affects gameplay and you didn't log it, it didn't happen."
Every XP and currency award has a source_type and source_id. Without this you can't distinguish earned rewards from exploited ones, and you can't tune reward rates per battle type.
Pokemon GO (GDC 2017): when they found GPS spoofers, every XP award traced back to a specific event with a location and timestamp.
Each state change is a ledger entry, not an in-place UPDATE. You can reconstruct any creature's state at any point in time. Totals are cached sums. If they drift, recompute from the ledger.
Supercell engineering blog: "Every gem, every gold coin, every XP point has a ledger entry. The balance column is a cache. We recompute quarterly and always catch drift."
When a player levels up and picks from offered options, store what was offered, what was selected, and what the creature state was at the time. This enables balance analytics, dispute resolution, and future respec features.
Schell, Art of Game Design (Lens of the Progression).
We build this as a stack of PRs, each adding a layer. The first three can be built at the same time. The rest build on top of them in order.
When a battle finishes, the server writes a row to a new battles table: who played, which creatures, who won, how many rounds. The battle ID is also sent to the client so it can reference it later when claiming rewards.
Database tables for tracking creature upgrades. One table records each level-up choice (what was picked, creature state snapshot). Another stores the generated options that were offered. Also adds XP and max_hp columns to the creatures table.
An abstract service class that defines the API for claiming battle rewards and leveling up. A local implementation handles it on-device for development. The server implementation comes in PR 6. Typed data structures for reward claims, battle rewards, and level-up results.
An append-only table where every XP and currency award is a row, each referencing the battle it came from. The creature's total XP is a cached sum updated in the same transaction. If the cache ever drifts, it can be recomputed from the ledger.
When a battle finishes, the progression service is notified through the game lifecycle (not through the result screen UI). Rewards are claimed and persisted before the player sees anything. The result screen only displays what already happened.
Replace the local progression service with one that calls the server. The client sends a battle ID and creature ID. The server checks the battle happened, calculates the reward, writes it to the ledger, and returns the result. The client never writes progression data directly.
Remove the local progression service from production builds. Restrict XP, level, and HP columns so only the server can write them. Add scripts to verify the ledger matches the cached totals.
Each box on the left is something that exists in the codebase today. The arrow points to the PR that addresses it.
For every battle we need to know: who played, which creatures they used, what the outcome was, and when it happened. Every reward in the system traces back to a row in this table.
| Column | Type | Purpose |
|---|---|---|
| id | UUID PK | Game ID from Cloudflare DO. Reused, not generated. |
| status | TEXT | completed / abandoned / errored |
| outcome | TEXT | p1_win / p2_win / draw / forfeit |
| player1_id | UUID FK | First player |
| player2_id | UUID FK | Second player |
| player1_creature_id | UUID FK | Creature used by player 1 |
| player2_creature_id | UUID FK | Creature used by player 2 |
| winner_id | UUID FK | Nullable for draws |
| total_rounds | INT | Rounds played |
| duration_ms | INT | Battle duration |
| started_at | TIMESTAMPTZ | |
| completed_at | TIMESTAMPTZ |
RLS: SELECT own battles. INSERT/UPDATE: service_role only. Written by the server at GAME_COMPLETE.
Every time a creature earns XP or currency, it's a row here. Each row references the battle it came from. The creature's total XP on the creatures table is a cached sum of these rows, updated in the same transaction.
| Column | Type | Purpose |
|---|---|---|
| id | UUID PK | |
| creature_id | UUID FK | Which creature earned it |
| battle_id | UUID FK | Which battle it came from |
| amount | INT | XP awarded |
| reason | TEXT | battle_win / battle_loss / battle_draw / bonus |
| created_at | TIMESTAMPTZ |
RLS: SELECT own. INSERT: service_role only. Recomputable: if cache drifts, SELECT creature_id, SUM(amount) FROM xp_awards GROUP BY creature_id.
When a creature levels up, the player picks from several upgrade options. This table records which option was picked, along with a snapshot of the creature's deck and stats at that moment, so the history can be replayed or audited.
| Column | Type | Purpose |
|---|---|---|
| id | UUID PK | |
| creature_id | UUID FK | |
| level | INT | Level reached |
| xp_snapshot | INT | Cumulative XP at level-up time |
| hp_snapshot | INT | max_hp after this event |
| deck_snapshot | JSONB | Frozen deck state after this event |
| choice_type | TEXT | increase_hp / upgrade_card / add_card / replace_card |
| hp_delta | INT | HP increase (if increase_hp) |
| target_card_index | INT | Deck slot affected (if card change) |
| selected_option_index | INT | Which generated card was picked |
| created_at | TIMESTAMPTZ |
UNIQUE (creature_id, level). RLS: SELECT own. INSERT: service_role only.
The upgrade options generated for a level-up. Stored separately because they can be pre-generated before the player reaches that level. This is what was offered, per Principle 4.
| Column | Type | Purpose |
|---|---|---|
| id | UUID PK | |
| creature_id | UUID FK | |
| level | INT | Level these options are for |
| target_card_index | INT | Card slot being replaced (null for add) |
| card_options | JSONB | Array of generated card objects |
| generation_source | TEXT | on_demand / pre_generated |
| created_at | TIMESTAMPTZ |
UNIQUE (creature_id, level, COALESCE(target_card_index, -1)). RLS: SELECT own. INSERT: service_role only.