Docs/Currency

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:

OrdTierLifetime threshold
1Tinder0
2Spark100
3Cinder500
4Hearth2,000
5Pyre10,000
6Bellows50,000
7Crucible250,000
8Inferno1,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 grants
  • refund — partial reversal of a purchase
  • spend — the future shop

balance and lifetime_in track separately:

  • balance — current spendable Ingots
  • lifetime_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:

text
api.katforge.com/data/<game>/award/<name>.yaml
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_granted is snapshotted into player_awards at grant time. Editing the YAML later doesn't rewrite history — earlier earners keep what they got.
  • repeatable: false uses a partial unique DB index. The second grant is a no-op, returned with granted: false so the client can distinguish first earn from idempotent retry.

API surface

Routes follow the existing <resource>/@me/<sub> convention:

text
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:

TypeScript
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:

Vue
<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

text
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.