Apps like RealWorldGoodz combine HandCash payments, item templates, and a pool of possible rewards. This doc ties that mental model to a small reference implementation in this template.
| Concept | Typical meaning |
|---|---|
| Pool | A product the user buys or a crate they open; has metadata (name, price, how many pulls). |
| Pool entry / loot row | One reward line: item_template_id, stock (quantity in DB), optional drop weight (drop_rate as percent in RWG admin). |
| Draw | One random choice of a row (then usually decrement stock and mint that template). |
| Reveal | UX step after payment/mint: show what rolled (RWG stores shop_orders.minted_items). |
Flow sketch:
- User pays (or burns a key / crate item in other games).
- Server loads available rows (
stock > 0). - Server picks a row using weights (drop rates).
- Server atomically decrements stock for that row (compare-and-set in DB to avoid double-spend).
- Server mints the HandCash item from the chosen template.
Pure functions (no HandCash, no DB) live in:
Useful exports:
pickWeighted— one roll among{ id, weight }[].normalizePercentWeights— turn admin percents into relative weights even if they do not sum to 100.pickWeightedInStock— same, but only rows withstock > 0.rollIndependentWeighted— N gacha-style rolls with replacement (same row can win twice).pickDistinctWeighted— N pulls without replacement byid(good for “pick 3 different cosmetics” tables).createMulberry32— deterministicrandom()for tests or Monte Carlo simulations.
import { pickWeightedInStock, createMulberry32 } from "@/lib/weighted-loot-pool"
const table = [
{ id: "common-1", weight: 70, stock: 1000 },
{ id: "rare-1", weight: 25, stock: 50 },
{ id: "legendary-1", weight: 5, stock: 2 },
]
const rng = createMulberry32(12345)
const winner = pickWeightedInStock(table, rng)
// Then: decrement stock for winner.id in DB, mint template mapped from winner.idimport { rollOneLootRow } from "@/lib/weighted-loot-pool"
type Row = { id: string; dropRatePercent: number; stock: number }
const rows: Row[] = poolEntriesFromSupabase.map((e) => ({
id: e.id,
dropRatePercent: e.drop_rate,
stock: e.quantity,
}))
const chosen = rollOneLootRow(rows, Math.random)After chosen, run your existing atomic UPDATE pool_entries SET quantity = quantity - 1 WHERE id = ? AND quantity = ? pattern (see RealWorldGoodz app/api/shop/purchase/route.ts).
| Location | Role |
|---|---|
RealWorldGoodz/lib/item-pools-storage.ts |
CRUD for pools / pool_entries, dropRate on each entry. |
RealWorldGoodz/scripts/003_create_item_pools_tables.sql |
Schema: drop_rate, quantity, max_quantity. |
RealWorldGoodz/app/api/shop/purchase/route.ts |
Payment → weighted roll among in-stock rows (drop_rate percents, normalized) → atomic decrement → mint. Uses RealWorldGoodz/lib/weighted-loot-pool.ts (same API as this template’s lib/weighted-loot-pool.ts). |
RealWorldGoodz/pool_minting_specification_3a2748c8.plan.md |
Long-form design notes (Mongo-era + webhook flow; concepts still useful). |
Forest Fighters uses locked crate items + loot keys and a configured template pool in env/admin, with burn-and-mint orchestration—not the same Supabase pool tables, but the same math applies when choosing which reward template fires after a key consumes a crate.
- Weights vs probability: Any positive numbers proportional to chance work; normalize percents when admins enter “70 / 25 / 5”.
- Stock exhaustion: Filter
stock > 0before each roll; if the pool runs dry mid–multi-pull, either fail the transaction and roll back prior decrements (RWG pattern) or define pity rules. - Fairness / audits: Log
chosen.id, seed ortransactionId, and weights snapshot for support tooling. - Regulated loot: Some jurisdictions care about disclosure of odds; surface the normalized probabilities in UI.
For HandCash minting APIs, keep using handcashService / minter patterns from this template and RealWorldGoodz admin mint routes.