boilerplate/economy-simulator.ts
// ============================================================
// Economy Simulator — Sink/Faucet Balance Tracking
// ============================================================
// ============================================================
// Types
// ============================================================
export interface CurrencyFlowEntry {
timestamp: number;
currencyId: string;
amount: number;
/** Positive = faucet (currency entering), negative = sink (currency leaving) */
direction: "faucet" | "sink";
source: string;
playerId?: string;
}
export interface FlowSummary {
currencyId: string;
totalFaucet: number;
totalSink: number;
netFlow: number;
/** Net flow / total faucet — closer to 0 = balanced */
balanceRatio: number;
entryCount: number;
}
export interface InflationAlert {
currencyId: string;
severity: "low" | "medium" | "high" | "critical";
inflationRate: number;
windowMs: number;
message: string;
}
export interface InflationThresholds {
low: number;
medium: number;
high: number;
critical: number;
}
// ============================================================
// Flow Tracker
// ============================================================
const flows: CurrencyFlowEntry[] = [];
export const trackFlow = (
entry: Omit<CurrencyFlowEntry, "timestamp">,
): CurrencyFlowEntry => {
const record: CurrencyFlowEntry = {
...entry,
timestamp: Date.now(),
};
flows.push(record);
return record;
};
export const trackFaucet = (
currencyId: string,
amount: number,
source: string,
playerId?: string,
): CurrencyFlowEntry => {
return trackFlow({
currencyId,
amount: Math.abs(amount),
direction: "faucet",
source,
playerId,
});
};
export const trackSink = (
currencyId: string,
amount: number,
source: string,
playerId?: string,
): CurrencyFlowEntry => {
return trackFlow({
currencyId,
amount: Math.abs(amount),
direction: "sink",
source,
playerId,
});
};
export const getFlows = (filter?: {
currencyId?: string;
since?: number;
playerId?: string;
}): CurrencyFlowEntry[] => {
let result = flows;
if (filter?.currencyId) {
result = result.filter((f) => f.currencyId === filter.currencyId);
}
if (filter?.since) {
result = result.filter((f) => f.timestamp >= filter.since!);
}
if (filter?.playerId) {
result = result.filter((f) => f.playerId === filter.playerId);
}
return result;
};
export const clearFlows = (): void => {
flows.length = 0;
};
// ============================================================
// Balance Calculator
// ============================================================
export const calculateBalance = (
currencyId: string,
windowMs?: number,
): FlowSummary => {
const since = windowMs ? Date.now() - windowMs : 0;
const relevant = flows.filter(
(f) => f.currencyId === currencyId && f.timestamp >= since,
);
let totalFaucet = 0;
let totalSink = 0;
for (const entry of relevant) {
if (entry.direction === "faucet") {
totalFaucet += entry.amount;
} else {
totalSink += entry.amount;
}
}
const netFlow = totalFaucet - totalSink;
const balanceRatio = totalFaucet === 0 ? 0 : netFlow / totalFaucet;
return {
currencyId,
totalFaucet,
totalSink,
netFlow,
balanceRatio,
entryCount: relevant.length,
};
};
export const calculateAllBalances = (windowMs?: number): FlowSummary[] => {
const currencyIds = new Set(flows.map((f) => f.currencyId));
return Array.from(currencyIds).map((id) => calculateBalance(id, windowMs));
};
// ============================================================
// Inflation Monitor
// ============================================================
const DEFAULT_THRESHOLDS: InflationThresholds = {
low: 0.05,
medium: 0.15,
high: 0.3,
critical: 0.5,
};
export const detectInflation = (
currencyId: string,
windowMs: number,
thresholds: InflationThresholds = DEFAULT_THRESHOLDS,
): InflationAlert | null => {
const balance = calculateBalance(currencyId, windowMs);
if (balance.entryCount === 0 || balance.balanceRatio <= 0) {
return null;
}
const rate = balance.balanceRatio;
let severity: InflationAlert["severity"];
if (rate >= thresholds.critical) {
severity = "critical";
} else if (rate >= thresholds.high) {
severity = "high";
} else if (rate >= thresholds.medium) {
severity = "medium";
} else if (rate >= thresholds.low) {
severity = "low";
} else {
return null;
}
return {
currencyId,
severity,
inflationRate: rate,
windowMs,
message: `${currencyId}: ${(rate * 100).toFixed(1)}% net inflow over ${windowMs / 1000}s window (${severity})`,
};
};
export const detectAllInflation = (
windowMs: number,
thresholds?: InflationThresholds,
): InflationAlert[] => {
const currencyIds = new Set(flows.map((f) => f.currencyId));
const alerts: InflationAlert[] = [];
for (const id of currencyIds) {
const alert = detectInflation(id, windowMs, thresholds);
if (alert) {
alerts.push(alert);
}
}
return alerts;
};
// ============================================================
// Per-Player Balance
// ============================================================
export const calculatePlayerBalance = (
playerId: string,
currencyId: string,
windowMs?: number,
): FlowSummary => {
const since = windowMs ? Date.now() - windowMs : 0;
const relevant = flows.filter(
(f) =>
f.playerId === playerId &&
f.currencyId === currencyId &&
f.timestamp >= since,
);
let totalFaucet = 0;
let totalSink = 0;
for (const entry of relevant) {
if (entry.direction === "faucet") {
totalFaucet += entry.amount;
} else {
totalSink += entry.amount;
}
}
const netFlow = totalFaucet - totalSink;
const balanceRatio = totalFaucet === 0 ? 0 : netFlow / totalFaucet;
return {
currencyId,
totalFaucet,
totalSink,
netFlow,
balanceRatio,
entryCount: relevant.length,
};
};
// ============================================================
// Top Sources — identify biggest faucets/sinks
// ============================================================
export const getTopSources = (
currencyId: string,
direction: "faucet" | "sink",
limit = 10,
windowMs?: number,
): Array<{ source: string; total: number; count: number }> => {
const since = windowMs ? Date.now() - windowMs : 0;
const relevant = flows.filter(
(f) =>
f.currencyId === currencyId &&
f.direction === direction &&
f.timestamp >= since,
);
const bySource = new Map<string, { total: number; count: number }>();
for (const entry of relevant) {
const existing = bySource.get(entry.source) ?? { total: 0, count: 0 };
existing.total += entry.amount;
existing.count++;
bySource.set(entry.source, existing);
}
return Array.from(bySource.entries())
.map(([source, data]) => ({ source, ...data }))
.sort((a, b) => b.total - a.total)
.slice(0, limit);
};
SKILL.md
---
name: design-game-economy-design
description: >-
Use when designing virtual economies, currencies, sink/faucet balance, loot tables, crafting systems, shops, or inflation control. Triggers: economy, currency, sinks, loot, inflation, crafting, shop.
---
# Game Economy Design
## Purpose
Virtual currencies, sinks/faucets, inflation control, reward balancing, and monetization integration. Genre-agnostic — applies to any game with resources, currencies, or tradeable items.
## When to Use
Trigger: game economy, virtual currency, sinks and faucets, inflation, reward balance, loot tables, crafting economy, marketplace, monetization, pricing, premium currency, gold sinks, resource balance
## Prerequisites
- `game-design-fundamentals` — core loop understanding
- `postgres-game-schema` — currency and item data models
## Core Principles
> Sid Meier: "A game is a series of interesting decisions" — economy makes decisions meaningful.
> Richard Garfield: "Elegant systems create depth from simple rules."
> Raph Koster: "Players will optimize the fun out of your game" — design economies that stay fun when optimized.
> Will Wright: "Simulation systems generate emergent behavior" — economies should emerge, not be scripted.
1. **Every faucet needs a sink** — if players earn currency, they must spend it; otherwise inflation destroys the economy
2. **Two-currency model** — soft currency (earned in-game, plentiful) + hard currency (premium, scarce, optional) keeps free players engaged while monetizing
3. **Exponential costs, linear income** — the natural idle game pattern; cost scaling must outpace earning to maintain engagement
4. **Price anchoring** — establish what things "should" cost early; players will judge all future prices against the first items they buy
5. **Never sell power directly** — sell time, cosmetics, convenience; pay-to-win destroys retention (Sid Meier principle)
6. **Closed-loop economy** — track total currency in circulation; if it grows unbounded, you have a leak
7. **Test with spreadsheets first** — simulate 100 hours of play before coding; if the spreadsheet breaks, the game will too
## Step-by-Step Instructions
### 1. Define Currencies
List every currency: name, how earned, how spent, persistence on reset.
### 2. Map Faucets (Income Sources)
Every way players earn currency: combat rewards, quest rewards, daily login, achievements, trading.
### 3. Map Sinks (Spending Destinations)
Every way players spend: upgrades, crafting, shop, repairs, fees, taxes, cosmetics.
### 4. Balance the Spreadsheet
Create a spreadsheet simulating player income vs. spending over time. Target: net currency growth should be slow or zero at every stage.
### 5. Define Reward Tables
Use weighted random for drops, with pity system for rare items. Cross-reference `quest-mission-design` for quest rewards.
### 6. Integrate Monetization
Map premium currency to Stripe products via `stripe-game-payments`. Ensure premium currency can't break game balance.
## Economy Health Metrics
| Metric | Healthy Range | Warning Sign |
|--------|-------------|-------------|
| Daily currency earned / spent ratio | 0.8-1.2 | > 1.5 (inflation) or < 0.5 (deflation) |
| Median player currency held | 2-5x average purchase cost | > 20x (nothing to buy) |
| Premium conversion rate | 2-5% of active players | < 1% (too expensive) or > 15% (too aggressive) |
| Time to first meaningful purchase | 5-15 minutes | > 30 min (slow start) |
## Cross-References
- `postgres-game-schema` — currency and item table schemas
- `stripe-game-payments` — premium currency and IAP integration
- `quest-mission-design` — quest reward balancing
- `game-design-fundamentals` — core loop reward schedules
- `redis-game-patterns` — leaderboard caching for economy rankings
## Pitfalls & Anti-Patterns
- **"Hyperinflation"** — unlimited faucets with no sinks; currency becomes worthless
- **"Pay-to-win"** — selling direct gameplay advantages; kills free player retention
- **"Currency confusion"** — too many currency types; players can't understand what to spend
- **"Dead economy"** — nothing interesting to buy; currency accumulates uselessly
- **"Exploitable loops"** — crafting or trading loops that generate infinite value
- **"Early abundance"** — giving too much currency early; players expect it forever
## Designer Philosophy
**Sid Meier**: The economy exists to make decisions interesting. If every choice has an obvious best option, the economy has failed. Players should regularly face "do I buy upgrade A or save for upgrade B?" dilemmas.
**Richard Garfield** (Magic: The Gathering): The best economies emerge from simple, elegant rules. A few well-designed resource types with clear trade-offs create more depth than a dozen currencies that nobody understands.
**Raph Koster**: Players will find the optimal farming strategy and grind it. Design your economy so that even the optimal path is fun, and the sub-optimal paths are still viable.
## Sources
- "Designing Virtual Economies" — GDC 2017
- "Free-to-Play Game Economies" — GDC 2019
- "Theory of Fun" — Raph Koster
- "The Art of Game Design" — Jesse Schell, Chapter on Game Mechanics
- Stripe gaming best practices documentation
templates/economy-config.md
# Economy Configuration: [Game Name]
## Currencies
| Currency | Type | Earn Source | Spend Sink | Persistence | Cap |
|----------|------|-----------|-----------|-------------|-----|
| [Name] | Soft (earned) | [Sources] | [Sinks] | [Resets on prestige? Persists?] | [Max holdable] |
| [Name] | Hard (premium) | [Purchase / rare drops] | [Cosmetics / QoL] | Permanent | [None] |
## Faucets (Income)
| Source | Currency | Amount | Frequency | Notes |
|--------|----------|--------|-----------|-------|
| [Combat victory] | [Soft] | [10-50 scaled by level] | Per encounter | Main income source |
| [Quest completion] | [Soft] | [100-500] | Per quest | Burst income |
| [Daily login] | [Soft + Hard] | [50 soft + 5 hard] | Daily | Retention hook |
| [Achievement] | [Hard] | [10-50] | One-time | Milestone reward |
## Sinks (Spending)
| Sink | Currency | Cost Range | Purpose |
|------|----------|-----------|---------|
| [Upgrades] | [Soft] | [Exponential scaling] | Core progression |
| [Crafting fees] | [Soft] | [% of material value] | Currency drain |
| [Cosmetics] | [Hard] | [50-500] | Premium monetization |
| [Respec/reset] | [Soft] | [Escalating] | Prevent free experimentation |
## Balance Targets
| Stage | Playtime | Expected Soft Currency/hr | Expected Hard Currency/hr |
|-------|----------|--------------------------|--------------------------|
| Early | 0-2h | [200] | [10] |
| Mid | 2-10h | [500] | [5] |
| Late | 10-50h | [1000] | [3] |
| Endgame | 50h+ | [2000] | [2] |
## Reward Tables
### Common Drop Table
| Item | Weight | Min Level | Notes |
|------|--------|-----------|-------|
| [Common item] | 60 | 1 | Filler |
| [Uncommon item] | 25 | 1 | Useful |
| [Rare item] | 10 | 5 | Exciting |
| [Epic item] | 4 | 10 | Memorable |
| [Legendary item] | 1 | 20 | Life-changing |
### Pity System
- Guarantee rare+ after [50] drops without one
- Counter resets on any rare+ drop
## Monetization Map
| Product | Stripe Price ID | Hard Currency | Real Money | Notes |
|---------|----------------|--------------|------------|-------|
| Small bundle | price_xxx | 100 | $0.99 | Impulse buy |
| Medium bundle | price_xxx | 550 | $4.99 | Best value callout |
| Large bundle | price_xxx | 1200 | $9.99 | Whale bait |
| Premium pass | price_xxx | 500/month + perks | $4.99/mo | Recurring |
## Health Check Formulas
```
inflation_rate = total_faucets_per_day / total_sinks_per_day
// Target: 0.8 - 1.2
median_wealth = median(all_player_currency)
// Target: 2-5x average item cost
time_to_first_purchase = time_until(player.currency >= cheapest_useful_item.cost)
// Target: 5-15 minutes
```
templates/loot-table.ts
// ============================================================
// Loot Table Configuration Types
// ============================================================
export const LootRarity = {
COMMON: "common",
UNCOMMON: "uncommon",
RARE: "rare",
EPIC: "epic",
LEGENDARY: "legendary",
MYTHIC: "mythic",
} as const;
export type LootRarity = (typeof LootRarity)[keyof typeof LootRarity];
// ============================================================
// Loot Conditions
// ============================================================
export interface LootCondition {
type:
| "player_level_min"
| "player_level_max"
| "quest_completed"
| "flag_set"
| "first_kill"
| "world_event_active";
value: string | number;
}
// ============================================================
// Loot Entry
// ============================================================
export interface LootEntry {
/** Unique item identifier */
itemId: string;
/** Relative weight for drop probability (higher = more likely) */
weight: number;
/** Minimum quantity dropped */
minQuantity: number;
/** Maximum quantity dropped */
maxQuantity: number;
/** Item rarity tier */
rarity: LootRarity;
/** Conditions that must be met for this entry to be eligible */
conditions: LootCondition[];
}
// ============================================================
// Loot Table
// ============================================================
export interface LootTable {
/** Unique table identifier */
id: string;
/** Human-readable name */
name: string;
/** Number of rolls on this table per drop event */
rolls: number;
/** Whether the same item can drop multiple times in one event */
allowDuplicates: boolean;
/** Guaranteed drops (always included regardless of rolls) */
guaranteed: LootEntry[];
/** Weighted random pool */
entries: LootEntry[];
/** Global conditions: all must be met for the table to be active */
conditions: LootCondition[];
/** Nested sub-tables (rolled independently) */
subTables: LootTable[];
}
// ============================================================
// Roll Result
// ============================================================
export interface LootDrop {
itemId: string;
quantity: number;
rarity: LootRarity;
fromTable: string;
}
export type LootRollFn = (
table: LootTable,
context?: LootRollContext,
) => LootDrop[];
// ============================================================
// Roll Context (passed to the roll function)
// ============================================================
export interface LootRollContext {
playerLevel: number;
completedQuests: string[];
activeFlags: string[];
/** Luck modifier: 1.0 = normal, > 1.0 = improved drop rates for rarer items */
luckMultiplier: number;
}
// ============================================================
// Rarity Weight Multipliers (for luck-based modification)
// ============================================================
export interface RarityWeightModifiers {
[LootRarity.COMMON]: number;
[LootRarity.UNCOMMON]: number;
[LootRarity.RARE]: number;
[LootRarity.EPIC]: number;
[LootRarity.LEGENDARY]: number;
[LootRarity.MYTHIC]: number;
}
export const DEFAULT_RARITY_WEIGHTS: RarityWeightModifiers = {
[LootRarity.COMMON]: 1.0,
[LootRarity.UNCOMMON]: 0.8,
[LootRarity.RARE]: 0.5,
[LootRarity.EPIC]: 0.2,
[LootRarity.LEGENDARY]: 0.05,
[LootRarity.MYTHIC]: 0.01,
};