Currency
Every player has a single in-platform balance shared across every
KATFORGE game. Two currencies live inside it, deliberately kept apart so achievements stay prestigious and payments stay transparent:
- Embers — earned. Granted by awards. Drives the level ladder. Pure prestige; never purchasable, never refundable.
- Ingots — paid. Stripe / PayPal top-ups land here. Purely transactional; the future shop spends them.
The split exists because mixing them devalues both: a paid balance that flows through the same ledger as an earned achievement makes "I reached Crucible tier" mean "I bought Crucible tier" for some players. Same reason Riot keeps Blue Essence and Riot Points apart.
Embers
A player earns Embers by hitting awards — game-defined events with a fixed ember_value. Examples: scoring your first word in Lextris, dinging level 60 in Gear Goblins, completing a perfect run in Stumper.gg.
Each grant is server-authoritative — only the API decides what an award is worth. It resolves the award definition through the codex, reads the canonical ember_value, and writes both the award row and the wallet credit in one transaction. Clients submit the award id; they don't get to suggest a value.
Every earned Ember credits two counters:
balance— the current spendable amount (drops when something costs Embers in the future shop)lifetime_in— the total ever earned (monotonic; drives levels)
Levels
Levels are a lookup table keyed off lifetime_in. The current ladder is eight tiers:
| Ord | Tier | Lifetime threshold |
|---|---|---|
| 1 | Tinder | 0 |
| 2 | Spark | 100 |
| 3 | Cinder | 500 |
| 4 | Hearth | 2,000 |
| 5 | Pyre | 10,000 |
| 6 | Bellows | 50,000 |
| 7 | Crucible | 250,000 |
| 8 | Inferno | 1,000,000 |
Tier names are seed values, intended to be tunable in a follow-up migration once the curve settles.
progress is the fraction of the way from the current tier's threshold to the next, clamped to [0, 1]. Maxed-out players see progress = 1.0 and next_level = null.
Ingots
Ingots only move via:
purchase— real-money top-up via the future Stripe / PayPal webhook (this PR doesn't ship that yet — only the wallet rails)gift— admin / promo grantsrefund— partial reversal of a purchasespend— the future shop
balance and lifetime_in track separately:
balance— current spendable Ingotslifetime_in— total ever credited via purchase/gift/admin (never drops on spend; reserved for a future VIP tier)
There is no client purchase endpoint yet. The IngotService exists so the eventual purchase controller is a thin wrapper. For now, only gift is reachable from a console command.
Awards (the source of truth)
Award definitions live in YAML inside the API repo, alongside other codex data:
api.katforge.com/data/<game>/award/<name>.yaml
id: id.award.first_word
name: First Word
description: Score your first word in Lextris.
category: id.award.category.beginner
icon: heroicons:sparkles
ember_value: 25
repeatable: false
Three things to know:
- IDs are dot-separated and globally unique within a game. Two games can both have an award named
id.award.first_word; the(award_id, game)tuple disambiguates. ember_value_grantedis snapshotted intoplayer_awardsat grant time. Editing the YAML later doesn't rewrite history — earlier earners keep what they got.repeatable: falseuses a partial unique DB index. The second grant is a no-op, returned withgranted: falseso the client can distinguish first earn from idempotent retry.
API surface
Routes follow the existing <resource>/@me/<sub> convention:
GET /v1/wallet/@me # both currencies + level info
GET /v1/embers/@me # ember balance + level
GET /v1/embers/@me/awards # earned awards
POST /v1/embers/@me/awards # grant award by codex id
GET /v1/embers/@me/ledger # paginated ledger
GET /v1/ingots/@me # ingot balance + purchased
GET /v1/ingots/@me/ledger # paginated ledger
Full request/response shapes live in the Embers reference.
SDK + UI integration
The SDK exposes three modules:
katforge.embers.balance (); // EmberBalance
katforge.embers.awards.list (); // PlayerAwardEntry []
katforge.embers.awards.grant ({ ... }); // emits 'embers:change'
katforge.ingots.balance (); // IngotBalance
katforge.wallet.me (); // both, single fetch
For Vue, Spark provides reactive composables and ready-made components:
<script setup>
import { SparkWallet, useWallet } from '@katforge/spark';
const { state: wallet } = useWallet ();
</script>
<template>
<SparkWallet v-if="wallet" :snapshot="wallet" variant="full" :show-progress="true" />
</template>
The login surface (SparkUserMenu) embeds SparkWallet in its dropdown, so any game that adopts it gets the wallet display for free. See Spark components.
Database shape
player_wallet (player_id, currency, balance, lifetime_in) UNIQUE (player_id, currency)
wallet_ledger append-only audit log; balances derive from sum (amount)
player_awards snapshots ember_value_granted + repeatable; partial UNIQUE only when not repeatable
levels lookup, seeded with 8 tiers
balance and lifetime_in on player_wallet are denormalized fast-path counters — wallet_ledger is the source of truth, and the counters can be rebuilt from it if they ever drift.